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 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 diff --git a/.storybook/config.js b/.storybook/config.js index 21edff7e..1977c69a 100644 --- a/.storybook/config.js +++ b/.storybook/config.js @@ -2,7 +2,6 @@ import { configure } from '@kadira/storybook'; import '../src/index.css'; function loadStories() { - require('../src/containers/stories/'); require('../src/components/stories/'); } diff --git a/.storybook/webpack.config.js b/.storybook/webpack.config.js index 08191f16..e62e19d4 100644 --- a/.storybook/webpack.config.js +++ b/.storybook/webpack.config.js @@ -1,34 +1 @@ -/* global module, __dirname, require */ -var webpack = require("webpack"); -const path = require("path"); - -module.exports = { - module: { - loaders: [ - { - test: /\.((png)|(eot)|(woff)|(woff2)|(ttf)|(svg)|(gif))(\?v=\d+\.\d+\.\d+)?$/, - loader: 'url-loader?limit=100000' - }, - { test: /\.json$/, loader: 'json-loader' }, - { - test: /\.css$/, - loader: 'style!css?modules&importLoaders=1!postcss' - }, - { - loader: 'babel', - test: /\.js?$/, - exclude: /(node_modules|bower_components)/, - query: { - cacheDirectory: true, - presets: ['react', 'es2015'], - plugins: ['transform-class-properties', 'transform-object-assign', 'transform-object-rest-spread'] - } - } - ] - }, - - postcss: [ - require("postcss-import")({addDependencyTo: webpack}), - require("postcss-cssnext")() - ], -}; +module.exports = require('../webpack.base.js'); diff --git a/example/example.css b/example/example.css new file mode 100644 index 00000000..3ec853c0 --- /dev/null +++ b/example/example.css @@ -0,0 +1,15 @@ +html, body { + font-family: "Helvetica Neue", Helvetica, Arial, sans-serif; + font-size: 14px; + color: #444; +} +body { + padding: 20px; +} + +h1 { + font-weight: bold; + color: #666; + font-size: 32px; + margin-top: 20px; +} diff --git a/example/index.html b/example/index.html index aa1c14e8..cbf4ef07 100644 --- a/example/index.html +++ b/example/index.html @@ -69,6 +69,26 @@ diff --git a/package.json b/package.json index f2089b1e..897a67e3 100644 --- a/package.json +++ b/package.json @@ -4,13 +4,23 @@ "description": "Netlify CMS lets content editors work on structured content stored in git", "main": "index.js", "scripts": { - "start": "webpack-dev-server -d --inline --config webpack.config.js", + "start": "webpack-dev-server --config webpack.dev.js", "test": "NODE_ENV=test mocha --recursive --compilers js:babel-register --require ./test/setup.js", "test:watch": "npm test -- --watch", "build": "webpack --config webpack.config.js", "storybook": "start-storybook -p 9001", - "storybook-build": "build-storybook -o dist" + "storybook-build": "build-storybook -o dist", + "lint": "eslint .", + "lint:fix": "npm run lint -- --fix", + "lint:staged": "lint-staged" }, + "lint-staged": { + "*.@(js|jsx)": [ + "eslint --fix", + "git add" + ] + }, + "pre-commit": "lint:staged", "keywords": [ "netlify", "cms" @@ -21,7 +31,7 @@ "@kadira/storybook": "^1.36.0", "autoprefixer": "^6.3.3", "babel-core": "^6.5.1", - "babel-eslint": "^4.1.8", + "babel-eslint": "^6.1.2", "babel-loader": "^6.2.2", "babel-plugin-lodash": "^3.2.0", "babel-plugin-transform-class-properties": "^6.5.2", @@ -32,25 +42,26 @@ "babel-register": "^6.5.2", "babel-runtime": "^6.5.0", "css-loader": "^0.23.1", - "eslint": "^1.10.3", - "eslint-loader": "^1.2.1", + "eslint": "^3.5.0", "eslint-plugin-react": "^5.1.1", "expect": "^1.20.2", "exports-loader": "^0.6.3", - "express": "^4.13.4", - "extract-text-webpack-plugin": "^1.0.1", "file-loader": "^0.8.5", "immutable": "^3.7.6", "imports-loader": "^0.6.5", "js-yaml": "^3.5.3", + "lint-staged": "^3.0.2", "mocha": "^2.4.5", "moment": "^2.11.2", + "node-sass": "^3.10.0", "normalizr": "^2.0.0", "postcss-cssnext": "^2.7.0", "postcss-import": "^8.1.2", "postcss-loader": "^0.9.1", + "pre-commit": "^1.1.3", "react": "^15.1.0", "react-dom": "^15.1.0", + "react-hot-loader": "^3.0.0-beta.2", "react-immutable-proptypes": "^1.6.0", "react-lazy-load": "^3.0.3", "react-pure-render": "^1.0.2", @@ -59,25 +70,36 @@ "react-router-redux": "^4.0.5", "redux": "^3.3.1", "redux-thunk": "^1.0.3", + "sass-loader": "^4.0.2", "style-loader": "^0.13.0", "url-loader": "^0.5.7", - "webpack": "^1.12.13", - "webpack-dev-server": "^1.14.1", + "webpack": "^1.13.2", + "webpack-dev-server": "^1.15.1", + "webpack-merge": "^0.14.1", "webpack-postcss-tools": "^1.1.1", "whatwg-fetch": "^1.0.0" }, "dependencies": { "bricks.js": "^1.7.0", + "dateformat": "^1.0.12", "fuzzy": "^0.1.1", + "immutability-helper": "^2.0.0", "js-base64": "^2.1.9", "json-loader": "^0.5.4", "localforage": "^1.4.2", "lodash": "^4.13.1", "markup-it": "git+https://github.com/cassiozen/markup-it.git", + "material-design-icons": "^3.0.1", + "normalize.css": "^4.2.0", "pluralize": "^3.0.0", "prismjs": "^1.5.1", + "react-addons-css-transition-group": "^15.3.1", + "react-datetime": "^2.6.0", "react-portal": "^2.2.1", + "react-toolbox": "^1.2.1", + "react-simple-dnd": "^0.1.2", "selection-position": "^1.0.0", + "semaphore": "^1.0.5", "slate": "^0.13.6" } } diff --git a/src/actions/config.js b/src/actions/config.js index 7a2d568f..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'; @@ -62,7 +64,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 +71,20 @@ function parseConfig(data) { } } } + + if (!('publish_mode' in config) || _.values(publishModes).indexOf(config.publish_mode) === -1) { + // Make sure there is a publish workflow mode set + config.publish_mode = publishModes.SIMPLE; + } + + 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/actions/editorialWorkflow.js b/src/actions/editorialWorkflow.js new file mode 100644 index 00000000..9b1cb70b --- /dev/null +++ b/src/actions/editorialWorkflow.js @@ -0,0 +1,179 @@ +import { currentBackend } from '../backends/backend'; +import { getMedia } from '../reducers'; +import { EDITORIAL_WORKFLOW } from '../constants/publishModes'; +/* + * Contant Declarations + */ +export const UNPUBLISHED_ENTRY_REQUEST = 'UNPUBLISHED_ENTRY_REQUEST'; +export const UNPUBLISHED_ENTRY_SUCCESS = 'UNPUBLISHED_ENTRY_SUCCESS'; + +export const UNPUBLISHED_ENTRIES_REQUEST = 'UNPUBLISHED_ENTRIES_REQUEST'; +export const UNPUBLISHED_ENTRIES_SUCCESS = 'UNPUBLISHED_ENTRIES_SUCCESS'; +export const UNPUBLISHED_ENTRIES_FAILURE = 'UNPUBLISHED_ENTRIES_FAILURE'; + +export const UNPUBLISHED_ENTRY_PERSIST_REQUEST = 'UNPUBLISHED_ENTRY_PERSIST_REQUEST'; +export const UNPUBLISHED_ENTRY_PERSIST_SUCCESS = 'UNPUBLISHED_ENTRY_PERSIST_SUCCESS'; + +export const UNPUBLISHED_ENTRY_STATUS_CHANGE_REQUEST = 'UNPUBLISHED_ENTRY_STATUS_CHANGE_REQUEST'; +export const UNPUBLISHED_ENTRY_STATUS_CHANGE_SUCCESS = 'UNPUBLISHED_ENTRY_STATUS_CHANGE_SUCCESS'; + +export const UNPUBLISHED_ENTRY_PUBLISH_REQUEST = 'UNPUBLISHED_ENTRY_PUBLISH_REQUEST'; +export const UNPUBLISHED_ENTRY_PUBLISH_SUCCESS = 'UNPUBLISHED_ENTRY_PUBLISH_SUCCESS'; + +/* + * Simple Action Creators (Internal) + */ + +function unpublishedEntryLoading(status, slug) { + return { + type: UNPUBLISHED_ENTRY_REQUEST, + payload: { status, slug } + }; +} + +function unpublishedEntryLoaded(status, entry) { + return { + type: UNPUBLISHED_ENTRY_SUCCESS, + payload: { status, entry } + }; +} + +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(), + }; +} + + +function unpublishedEntryPersisting(entry) { + return { + type: UNPUBLISHED_ENTRY_PERSIST_REQUEST, + payload: { entry } + }; +} + +function unpublishedEntryPersisted(entry) { + return { + type: UNPUBLISHED_ENTRY_PERSIST_SUCCESS, + payload: { entry } + }; +} + +function unpublishedEntryPersistedFail(error) { + return { + type: UNPUBLISHED_ENTRY_PERSIST_SUCCESS, + payload: { error } + }; +} + +function unpublishedEntryStatusChangeRequest(collection, slug, oldStatus, newStatus) { + return { + type: UNPUBLISHED_ENTRY_STATUS_CHANGE_REQUEST, + payload: { collection, slug, oldStatus, newStatus } + }; +} + +function unpublishedEntryStatusChangePersisted(collection, slug, oldStatus, newStatus) { + return { + type: UNPUBLISHED_ENTRY_STATUS_CHANGE_SUCCESS, + payload: { collection, slug, oldStatus, newStatus } + }; +} + +function unpublishedEntryPublishRequest(collection, slug, status) { + return { + type: UNPUBLISHED_ENTRY_PUBLISH_REQUEST, + payload: { collection, slug, status } + }; +} + +function unpublishedEntryPublished(collection, slug, status) { + return { + type: UNPUBLISHED_ENTRY_PUBLISH_SUCCESS, + payload: { collection, slug, status } + }; +} + +/* + * Exported Thunk Action Creators + */ + +export function loadUnpublishedEntry(collection, status, slug) { + return (dispatch, getState) => { + const state = getState(); + const backend = currentBackend(state.config); + dispatch(unpublishedEntryLoading(status, slug)); + backend.unpublishedEntry(collection, slug) + .then((entry) => dispatch(unpublishedEntryLoaded(status, entry))); + }; +} + +export function loadUnpublishedEntries() { + return (dispatch, getState) => { + const state = getState(); + if (state.config.get('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)) + ); + }; +} + +export function persistUnpublishedEntry(collection, entry) { + return (dispatch, getState) => { + const state = getState(); + const backend = currentBackend(state.config); + const MediaProxies = entry && entry.get('mediaFiles').map(path => getMedia(state, path)); + dispatch(unpublishedEntryPersisting(entry)); + backend.persistUnpublishedEntry(state.config, collection, entry, MediaProxies.toJS()).then( + () => { + dispatch(unpublishedEntryPersisted(entry)); + }, + (error) => dispatch(unpublishedEntryPersistedFail(error)) + ); + }; +} + +export function updateUnpublishedEntryStatus(collection, slug, oldStatus, newStatus) { + return (dispatch, getState) => { + const state = getState(); + const backend = currentBackend(state.config); + dispatch(unpublishedEntryStatusChangeRequest(collection, slug, oldStatus, newStatus)); + backend.updateUnpublishedEntryStatus(collection, slug, newStatus) + .then(() => { + dispatch(unpublishedEntryStatusChangePersisted(collection, slug, oldStatus, newStatus)); + }); + }; +} + +export function publishUnpublishedEntry(collection, slug, status) { + return (dispatch, getState) => { + const state = getState(); + const backend = currentBackend(state.config); + dispatch(unpublishedEntryPublishRequest(collection, slug, status)); + backend.publishUnpublishedEntry(collection, slug, status) + .then(() => { + dispatch(unpublishedEntryPublished(collection, slug, status)); + }); + }; +} diff --git a/src/actions/entries.js b/src/actions/entries.js index 0f49031a..9c9cffd6 100644 --- a/src/actions/entries.js +++ b/src/actions/entries.js @@ -13,10 +13,10 @@ 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'; - export const ENTRY_PERSIST_REQUEST = 'ENTRY_PERSIST_REQUEST'; export const ENTRY_PERSIST_SUCCESS = 'ENTRY_PERSIST_SUCCESS'; export const ENTRY_PERSIST_FAILURE = 'ENTRY_PERSIST_FAILURE'; @@ -102,6 +102,13 @@ function entryPersistFail(collection, entry, error) { }; } +function emmptyDraftCreated(entry) { + return { + type: DRAFT_CREATE_EMPTY, + payload: entry + }; +} + /* * Exported simple Action Creators */ @@ -153,14 +160,22 @@ 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(); 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/actions/findbar.js b/src/actions/findbar.js index 68db0399..dc1ea0cb 100644 --- a/src/actions/findbar.js +++ b/src/actions/findbar.js @@ -1,5 +1,5 @@ import history from '../routing/history'; -import { SEARCH } from '../containers/FindBar'; +import { SEARCH } from '../components/UI/FindBar/FindBar'; export const RUN_COMMAND = 'RUN_COMMAND'; export const SHOW_COLLECTION = 'SHOW_COLLECTION'; @@ -10,14 +10,22 @@ export function run(commandName, payload) { return { type: RUN_COMMAND, command: commandName, payload }; } +export function navigateToCollection(collectionName) { + return runCommand(SHOW_COLLECTION, { collectionName }); +} + +export function createNewEntryInCollection(collectionName) { + return runCommand(CREATE_COLLECTION, { collectionName }); +} + export function runCommand(commandName, payload) { - return (dispatch, getState) => { + return dispatch => { switch (commandName) { case SHOW_COLLECTION: history.push(`/collections/${payload.collectionName}`); break; case CREATE_COLLECTION: - window.alert(`Create a new ${payload.collectionName} - not supported yet`); + history.push(`/collections/${payload.collectionName}/entries/new`); break; case HELP: window.alert('Find Bar Help (PLACEHOLDER)\n\nLorem ipsum dolor sit amet, consectetur adipiscing elit.'); diff --git a/src/backends/backend.js b/src/backends/backend.js index e66ab9cd..1cb9bc42 100644 --- a/src/backends/backend.js +++ b/src/backends/backend.js @@ -1,6 +1,8 @@ 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'; class LocalStorageAuthStore { storageKey = 'nf-cms-user'; @@ -19,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'; } } @@ -57,9 +59,14 @@ class Backend { return this.implementation.entry(collection, slug).then(this.entryWithFormat(collection)); } - entryWithFormat(collection) { + newEntry(collection) { + const newEntry = createEntry(); + return this.entryWithFormat(collection)(newEntry); + } + + 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); } @@ -67,21 +74,88 @@ 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) + unpublishedEntries(page, perPage) { + return this.implementation.unpublishedEntries(page, perPage).then((response) => { + return { + pagination: response.pagination, + entries: response.entries.map(this.entryWithFormat('editorialWorkflow')) + }; + }); + } + + unpublishedEntry(collection, slug) { + return this.implementation.unpublishedEntry(collection, slug).then(this.entryWithFormat(collection)); + } + + 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]); + } + }); + } + + persistEntry(config, collection, entryDraft, MediaFiles, options) { + 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 commitMessage = (entryDraft.getIn(['entry', 'newRecord']) ? 'Created ' : 'Updated ') + + const entryData = entryDraft.getIn(['entry', 'data']).toJS(); + 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 }); + const mode = config.get('publish_mode'); + + const collectionName = collection.get('name'); + + return this.implementation.persistEntry(entryObj, MediaFiles, { + newEntry, parsedData, commitMessage, collectionName, mode, ...options + }); } + persistUnpublishedEntry(config, collection, entryDraft, MediaFiles) { + return this.persistEntry(config, collection, entryDraft, MediaFiles, { unpublished: true }); + } + + updateUnpublishedEntryStatus(collection, slug, newStatus) { + return this.implementation.updateUnpublishedEntryStatus(collection, slug, newStatus); + } + + publishUnpublishedEntry(collection, slug, status) { + return this.implementation.publishUnpublishedEntry(collection, slug, status); + } + + entryToRaw(collection, entry) { const format = resolveFormat(collection, entry); return format && format.toFile(entry); @@ -101,6 +175,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/API.js b/src/backends/github/API.js new file mode 100644 index 00000000..70770aed --- /dev/null +++ b/src/backends/github/API.js @@ -0,0 +1,422 @@ +import LocalForage from 'localforage'; +import MediaProxy from '../../valueObjects/MediaProxy'; +import { Base64 } from 'js-base64'; +import _ from 'lodash'; +import { SIMPLE, 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; + this.branch = branch; + this.repoURL = `/repos/${this.repo}`; + } + + 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; + }); + } + + 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 || {}); + const url = this.urlFor(path, options); + return fetch(url, { ...options, headers: headers }).then((response) => { + const contentType = response.headers.get('Content-Type'); + if (contentType && contentType.match(/json/)) { + 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)) + .then(() => { + LocalForage.setItem(`gh.meta.${key}`, { + expires: Date.now() + 300000, // In 5 minutes + data + }); + }); + }); + } + + 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)); + }); + } + + 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: branch }, + cache: false + }).then((result) => { + if (sha) { + LocalForage.setItem(`gh.${sha}`, result); + } + return result; + }); + }); + } + + listFiles(path) { + return this.request(`${this.repoURL}/contents/${path}`, { + params: { ref: this.branch } + }); + } + + 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 = {}; + const uploadPromises = []; + + const files = mediaFiles.concat(entry); + + files.forEach((file) => { + if (file.uploaded) { return; } + uploadPromises.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(uploadPromises).then(() => { + if (!options.mode || (options.mode && options.mode === SIMPLE)) { + return this.getBranch() + .then(branchData => this.updateTree(branchData.commit.sha, '/', fileTree)) + .then(changeTree => this.commit(options.commitMessage, changeTree)) + .then(response => this.patchBranch(this.branch, response.sha)); + } else if (options.mode && options.mode === EDITORIAL_WORKFLOW) { + const mediaFilesList = mediaFiles.map(file => file.path); + return this.editorialWorkflowGit(fileTree, entry, mediaFilesList, options); + } + }); + } + + editorialWorkflowGit(fileTree, entry, filesList, options) { + const contentKey = options.collectionName ? `${options.collectionName}-${entry.slug}` : entry.slug; + const branchName = `cms/${contentKey}`; + const unpublished = options.unpublished || false; + + if (!unpublished) { + // Open new editorial review workflow for this entry - Create new metadata and commit to new branch + const contentKey = options.collectionName ? `${options.collectionName}-${entry.slug}` : entry.slug; + const branchName = `cms/${contentKey}`; + + return this.getBranch() + .then(branchData => this.updateTree(branchData.commit.sha, '/', fileTree)) + .then(changeTree => this.commit(options.commitMessage, changeTree)) + .then(commitResponse => this.createBranch(branchName, commitResponse.sha)) + .then(branchResponse => this.createPR(options.commitMessage, branchName)) + .then((prResponse) => { + return this.user().then(user => { + return user.name ? user.name : user.login; + }) + .then(username => this.storeMetadata(contentKey, { + type: 'PR', + pr: { + number: prResponse.number, + head: prResponse.head && prResponse.head.sha + }, + user: username, + status: status.first(), + branch: branchName, + collection: options.collectionName, + title: options.parsedData && options.parsedData.title, + description: options.parsedData && options.parsedData.description, + objects: { + entry: entry.path, + files: filesList + }, + timeStamp: new Date().toISOString() + })); + }); + } else { + // Entry is already on editorial review workflow - just update metadata and commit to existing branch + return this.getBranch(branchName) + .then(branchData => this.updateTree(branchData.commit.sha, '/', fileTree)) + .then(changeTree => this.commit(options.commitMessage, changeTree)) + .then((response) => { + const contentKey = options.collectionName ? `${options.collectionName}-${entry.slug}` : entry.slug; + const branchName = `cms/${contentKey}`; + return this.user().then(user => { + return user.name ? user.name : user.login; + }) + .then(username => this.retrieveMetadata(contentKey)) + .then(metadata => { + let files = metadata.objects && metadata.objects.files || []; + files = files.concat(filesList); + + return { + ...metadata, + title: options.parsedData && options.parsedData.title, + description: options.parsedData && options.parsedData.description, + objects: { + entry: entry.path, + files: _.uniq(files) + }, + timeStamp: new Date().toISOString() + }; + }) + .then(updatedMetadata => this.storeMetadata(contentKey, updatedMetadata)) + .then(this.patchBranch(branchName, response.sha)); + }); + } + } + + updateUnpublishedEntryStatus(collection, slug, status) { + const contentKey = collection ? `${collection}-${slug}` : slug; + return this.retrieveMetadata(contentKey) + .then(metadata => { + return { + ...metadata, + status + }; + }) + .then(updatedMetadata => this.storeMetadata(contentKey, updatedMetadata)); + } + + publishUnpublishedEntry(collection, slug, status) { + const contentKey = collection ? `${collection}-${slug}` : slug; + return this.retrieveMetadata(contentKey) + .then(metadata => { + const headSha = metadata.pr && metadata.pr.head; + const number = metadata.pr && metadata.pr.number; + return this.mergePR(headSha, number); + }) + .then(() => this.deleteBranch(`cms/${contentKey}`)); + } + + createRef(type, name, sha) { + return this.request(`${this.repoURL}/git/refs`, { + method: 'POST', + body: JSON.stringify({ ref: `refs/${type}/${name}`, sha }), + }); + } + + patchRef(type, name, sha) { + return this.request(`${this.repoURL}/git/refs/${type}/${name}`, { + method: 'PATCH', + body: JSON.stringify({ sha }) + }); + } + + deleteRef(type, name, sha) { + return this.request(`${this.repoURL}/git/refs/${type}/${name}`, { + method: 'DELETE', + }); + } + + getBranch(branch = this.branch) { + return this.request(`${this.repoURL}/branches/${branch}`); + } + + createBranch(branchName, sha) { + return this.createRef('heads', branchName, sha); + } + + patchBranch(branchName, sha) { + return this.patchRef('heads', branchName, sha); + } + + deleteBranch(branchName) { + return this.deleteRef('heads', branchName); + } + + 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 }), + }); + } + + mergePR(headSha, number) { + return this.request(`${this.repoURL}/pulls/${number}/merge`, { + method: 'PUT', + body: JSON.stringify({ + commit_message: 'Automatically generated. Merged on Netlify CMS.', + sha: headSha + }), + }); + } + + 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/github/AuthenticationPage.js b/src/backends/github/AuthenticationPage.js index f7100604..c85dde2f 100644 --- a/src/backends/github/AuthenticationPage.js +++ b/src/backends/github/AuthenticationPage.js @@ -21,9 +21,9 @@ export default class AuthenticationPage extends React.Component { auth = new Authenticator(); } - auth.authenticate({provider: 'github', scope: 'repo'}, (err, data) => { + auth.authenticate({ provider: 'github', scope: 'repo' }, (err, data) => { if (err) { - this.setState({loginError: err.toString()}); + this.setState({ loginError: err.toString() }); return; } this.props.onLogin(data); diff --git a/src/backends/github/implementation.js b/src/backends/github/implementation.js index 67ad9535..2d270261 100644 --- a/src/backends/github/implementation.js +++ b/src/backends/github/implementation.js @@ -1,185 +1,9 @@ -import LocalForage from 'localforage'; -import MediaProxy from '../../valueObjects/MediaProxy'; +import semaphore from 'semaphore'; +import { createEntry } from '../../valueObjects/Entry'; import AuthenticationPage from './AuthenticationPage'; -import { Base64 } from 'js-base64'; +import API from './API'; -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(collection, 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) => { - return this.request(`${this.repoURL}/git/refs/heads/${this.branch}`, { - method: 'PATCH', - body: JSON.stringify({ sha: 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}`); - } - - 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 }; - }); - }); - } - -} +const MAX_CONCURRENT_DOWNLOADS = 10; export default class GitHub { constructor(config) { @@ -188,6 +12,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() { @@ -207,15 +32,22 @@ 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) => { - file.slug = file.path.split('/').pop().replace(/\.[^\.]+$/, ''); - file.raw = data; - return file; - }) - ))) - )).then((entries) => ({ + return this.api.listFiles(collection.get('folder')).then((files) => { + const sem = semaphore(MAX_CONCURRENT_DOWNLOADS); + const promises = []; + files.map((file) => { + 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(); + }).catch((err) => { + sem.leave(); + reject(err); + })); + })); + }); + return Promise.all(promises); + }).then((entries) => ({ pagination: {}, entries })); @@ -227,7 +59,51 @@ 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); + } + + unpublishedEntries() { + 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 + }; + }); + } + + unpublishedEntry(collection, slug) { + return this.unpublishedEntries().then((response) => ( + response.entries.filter((entry) => ( + entry.metaData && entry.metaData.collection === collection.get('name') && entry.slug === slug + ))[0] + )); + } + + updateUnpublishedEntryStatus(collection, slug, newStatus) { + return this.api.updateUnpublishedEntryStatus(collection, slug, newStatus); + } + + publishUnpublishedEntry(collection, slug, status) { + return this.api.publishUnpublishedEntry(collection, slug, status); } } diff --git a/src/backends/netlify-git/API.js b/src/backends/netlify-git/API.js new file mode 100644 index 00000000..886c9f0b --- /dev/null +++ b/src/backends/netlify-git/API.js @@ -0,0 +1,287 @@ +import LocalForage from 'localforage'; +import MediaProxy from '../../valueObjects/MediaProxy'; +import { Base64 } from 'js-base64'; + +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}/refs/meta/_netlify_cms`, { + 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}/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)); + }).then(() => { + LocalForage.setItem(`gh.meta.${key}`, { + expires: Date.now() + 300000, // In 5 minutes + data + }); + }); + } + + 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`, { + params: { ref: 'refs/meta/_netlify_cms' }, + headers: { 'Content-Type': 'application/vnd.netlify.raw' }, + cache: 'no-store', + }) + .then(response => JSON.parse(response)); + }); + } + + 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}/files/${path}`, { + headers: { 'Content-Type': 'application/vnd.netlify.raw' }, + params: { ref: 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 uploadPromises = []; + + const files = mediaFiles.concat(entry); + + files.forEach((file) => { + if (file.uploaded) { return; } + uploadPromises.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(uploadPromises) + .then(() => this.getBranch()) + .then(branchData => this.updateTree(branchData.commit.sha, '/', fileTree)) + .then(changeTree => this.commit(options.commitMessage, changeTree)) + .then((response) => this.patchBranch(this.branch, response.sha)); + } + + createRef(type, name, sha) { + return this.request(`${this.repoURL}/refs`, { + method: 'POST', + body: JSON.stringify({ ref: `refs/${type}/${name}`, sha }), + }); + } + + patchRef(type, name, sha) { + return this.request(`${this.repoURL}/refs/${type}/${name}`, { + method: 'PATCH', + body: JSON.stringify({ sha }) + }); + } + + deleteRef(type, name, sha) { + return this.request(`${this.repoURL}/refs/${type}/${name}`, { + method: 'DELETE', + }); + } + + getBranch(branch = this.branch) { + return this.request(`${this.repoURL}/refs/heads/${this.branch}`); + } + + createBranch(branchName, sha) { + return this.createRef('heads', branchName, sha); + } + + patchBranch(branchName, sha) { + return this.patchRef('heads', branchName, sha); + } + + deleteBranch(branchName) { + return this.deleteRef('heads', branchName); + } + + 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}/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}/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}/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}/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..74e508c5 --- /dev/null +++ b/src/backends/netlify-git/AuthenticationPage.js @@ -0,0 +1,60 @@ +import React from 'react'; + +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..cf7f21ab --- /dev/null +++ b/src/backends/netlify-git/implementation.js @@ -0,0 +1,62 @@ +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) => { + 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(); + }).catch((err) => { + sem.leave(); + reject(err); + })); + })); + }); + 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); + } +} diff --git a/src/backends/test-repo/AuthenticationPage.js b/src/backends/test-repo/AuthenticationPage.js index fd5c3ddc..ce20b0bf 100644 --- a/src/backends/test-repo/AuthenticationPage.js +++ b/src/backends/test-repo/AuthenticationPage.js @@ -7,7 +7,7 @@ export default class AuthenticationPage extends React.Component { constructor(props) { super(props); - this.state = {email: ''}; + this.state = { email: '' }; this.handleLogin = this.handleLogin.bind(this); this.handleEmailChange = this.handleEmailChange.bind(this); } @@ -18,7 +18,7 @@ export default class AuthenticationPage extends React.Component { } handleEmailChange(e) { - this.setState({email: e.target.value}); + this.setState({ email: e.target.value }); } render() { diff --git a/src/backends/test-repo/implementation.js b/src/backends/test-repo/implementation.js index c4f41080..b8cf56cc 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)); } } @@ -48,11 +45,17 @@ export default class TestRepo { )); } - persistEntry(collection, entry, mediaFiles = []) { + 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); - 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/components/ControlPane.js b/src/components/ControlPane.js index a86ecaf5..fc39041e 100644 --- a/src/components/ControlPane.js +++ b/src/components/ControlPane.js @@ -1,26 +1,29 @@ 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; - 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 - }); + const widget = resolveWidget(field.get('widget')); + 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() { 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 new file mode 100644 index 00000000..9cacb875 --- /dev/null +++ b/src/components/EntryEditor.css @@ -0,0 +1,26 @@ +.entryEditor { + display: flex; + flex-direction: column; + height: 100%; +} +.container { + display: flex; + height: 100%; +} +.footer { + background: #fff; + height: 45px; + border-top: 1px solid #e8eae8; + padding: 10px 20px; + z-index: 10; +} +.controlPane { + width: 50%; + 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 6ae93edd..fd308698 100644 --- a/src/components/EntryEditor.js +++ b/src/components/EntryEditor.js @@ -2,43 +2,60 @@ 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')}

-
-
- -
-
- -
-
- -
; -} - -const styles = { - container: { - display: 'flex' - }, - controlPane: { - width: '50%', - paddingLeft: '10px', - paddingRight: '10px' - }, - pane: { - width: '50%' +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 = { collection: 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.css b/src/components/PreviewPane.css new file mode 100644 index 00000000..af8c2001 --- /dev/null +++ b/src/components/PreviewPane.css @@ -0,0 +1,6 @@ +.frame { + width: 100%; + height: 100vh; + border: none; + background: #fff; +} diff --git a/src/components/PreviewPane.js b/src/components/PreviewPane.js index 2964ae93..d41185db 100644 --- a/src/components/PreviewPane.js +++ b/src/components/PreviewPane.js @@ -1,12 +1,15 @@ import React, { PropTypes } from 'react'; +import { render } from 'react-dom'; import ImmutablePropTypes from 'react-immutable-proptypes'; -import Widgets from './Widgets'; +import registry from '../lib/registry'; +import { resolveWidget } from './Widgets'; +import styles from './PreviewPane.css'; -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; - 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, @@ -17,13 +20,69 @@ export default class PreviewPane extends React.Component { const { collection } = this.props; if (!collection) { return null; } - return
{collection.get('fields').map((field) =>
{this.previewFor(field)}
)}
; } } +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 = resolveWidget(field.get('widget')); + return React.createElement(widget.preview, { + field: field, + value: entry.getIn(['data', field.get('name')]), + getMedia: getMedia, + }); + } + + renderPreview() { + const props = Object.assign({}, this.props, { widgetFor: this.widgetFor }); + const component = registry.getPreviewTemplate(props.collection.get('name')) || Preview; + + render(React.createElement(component, props), this.previewEl); + } + + handleIframeRef(ref) { + if (ref) { + registry.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(); + } + } + + render() { + const { collection } = this.props; + if (!collection) { return null; } + + return ; + } +} + PreviewPane.propTypes = { collection: ImmutablePropTypes.map.isRequired, entry: ImmutablePropTypes.map.isRequired, diff --git a/src/components/UI/AppHeader/AppHeader.css b/src/components/UI/AppHeader/AppHeader.css new file mode 100644 index 00000000..c1406f5d --- /dev/null +++ b/src/components/UI/AppHeader/AppHeader.css @@ -0,0 +1,17 @@ +:root { + --foregroundColor: #fff; + --backgroundColor: #272e30; + --textFieldBorderColor: #e7e7e7; + --highlightFGColor: #fff; + --highlightBGColor: #3ab7a5; +} + +.appBar { + background-color: var(--backgroundColor); +} + +.createBtn { + position: fixed; + right: 2rem; + top: 3.5rem; +} diff --git a/src/components/UI/AppHeader/AppHeader.js b/src/components/UI/AppHeader/AppHeader.js new file mode 100644 index 00000000..73759a46 --- /dev/null +++ b/src/components/UI/AppHeader/AppHeader.js @@ -0,0 +1,90 @@ +import React from 'react'; +import pluralize from 'pluralize'; +import { IndexLink } from 'react-router'; +import { Menu, MenuItem, Button, IconButton } from 'react-toolbox'; +import AppBar from 'react-toolbox/lib/app_bar'; +import FindBar from '../FindBar/FindBar'; +import styles from './AppHeader.css'; + +export default class AppHeader extends React.Component { + + state = { + createMenuActive: false + } + + handleCreatePostClick = collectionName => { + const { onCreateEntryClick } = this.props; + if (onCreateEntryClick) { + onCreateEntryClick(collectionName); + } + } + + handleCreateButtonClick = () => { + this.setState({ + createMenuActive: true + }); + } + + handleCreateMenuHide = () => { + this.setState({ + createMenuActive: false + }); + } + + render() { + const { + collections, + commands, + defaultCommands, + runCommand, + toggleNavDrawer + } = this.props; + const { createMenuActive } = this.state; + + return ( + + + + Dashboard + + + + + + ); + } +} diff --git a/src/containers/FindBar.css b/src/components/UI/FindBar/FindBar.css similarity index 95% rename from src/containers/FindBar.css rename to src/components/UI/FindBar/FindBar.css index be0abe88..ebc75482 100644 --- a/src/containers/FindBar.css +++ b/src/components/UI/FindBar/FindBar.css @@ -7,15 +7,14 @@ } .root { + flex: 1; position: relative; background-color: var(--backgroundColor); - padding: 1px 0; - margin: 4px auto; + padding: 5px; } .inputArea { display: table; - width: calc(100% - 10px); - margin: 5px; + width: 100%; color: var(--foregroundColor); background-color: #fff; border: 1px solid var(--textFieldBorderColor); diff --git a/src/containers/FindBar.js b/src/components/UI/FindBar/FindBar.js similarity index 91% rename from src/containers/FindBar.js rename to src/components/UI/FindBar/FindBar.js index ce13254d..3f03c86f 100644 --- a/src/containers/FindBar.js +++ b/src/components/UI/FindBar/FindBar.js @@ -1,9 +1,7 @@ import React, { Component, PropTypes } from 'react'; import fuzzy from 'fuzzy'; import _ from 'lodash'; -import { runCommand } from '../actions/findbar'; -import { connect } from 'react-redux'; -import { Icon } from '../components/UI'; +import { Icon } from '../index'; import styles from './FindBar.css'; export const SEARCH = 'SEARCH'; @@ -13,7 +11,12 @@ class FindBar extends Component { constructor(props) { super(props); this._compiledCommands = []; - this._searchCommand = { search: true, regexp:`(?:${SEARCH})?(.*)`, param:{ name:'searchTerm', display:'' }, token: SEARCH }; + this._searchCommand = { + search: true, + regexp: `(?:${SEARCH})?(.*)`, + param: { name: 'searchTerm', display: '' }, + token: SEARCH + }; this.state = { value: '', placeholder: PLACEHOLDER, @@ -68,7 +71,7 @@ class FindBar extends Component { if (match && match[1]) { regexp += '(.*)'; - param = { name:match[1], display:match[2] || this._camelCaseToSpace(match[1]) }; + param = { name: match[1], display: match[2] || this._camelCaseToSpace(match[1]) }; } return Object.assign({}, command, { @@ -97,13 +100,15 @@ class FindBar extends Component { const paramName = command && command.param ? command.param.name : null; const enteredParamValue = command && command.param && match[1] ? match[1].trim() : null; + console.log(this.props.runCommand); + if (command.search) { this.setState({ activeScope: SEARCH, placeholder: '' }); - enteredParamValue && this.props.dispatch(runCommand(SEARCH, { searchTerm: enteredParamValue })); + enteredParamValue && this.props.runCommand(SEARCH, { searchTerm: enteredParamValue }); } else if (command.param && !enteredParamValue) { // Partial Match // Command was partially matched: It requires a param, but param wasn't entered @@ -128,7 +133,7 @@ class FindBar extends Component { if (paramName) { payload[paramName] = enteredParamValue; } - this.props.dispatch(runCommand(command.type, payload)); + this.props.runCommand(command.type, payload); } } @@ -144,6 +149,7 @@ class FindBar extends Component { getSuggestions() { return this._getSuggestions(this.state.value, this.state.activeScope, this._compiledCommands, this.props.defaultCommands); } + // Memoized version _getSuggestions(value, scope, commands, defaultCommands) { if (scope) return []; // No autocomplete for scoped input @@ -152,7 +158,7 @@ class FindBar extends Component { .filter(command => defaultCommands.indexOf(command.id) !== -1) .map(result => ( Object.assign({}, result, { string: result.token } - ))); + ))); } const results = fuzzy.filter(value, commands, { @@ -162,8 +168,8 @@ class FindBar extends Component { }); const returnResults = results.slice(0, 4).map(result => ( - Object.assign({}, result.original, { string:result.string } - ))); + Object.assign({}, result.original, { string: result.string } + ))); returnResults.push(this._searchCommand); return returnResults; @@ -178,7 +184,7 @@ class FindBar extends Component { index = ( highlightedIndex === this.getSuggestions().length - 1 || this.state.isOpen === false - ) ? 0 : highlightedIndex + 1; + ) ? 0 : highlightedIndex + 1; this.setState({ highlightedIndex: index, isOpen: true, @@ -290,7 +296,7 @@ class FindBar extends Component { let children; if (!command.search) { children = ( - + ); } else { children = ( @@ -299,7 +305,8 @@ class FindBar extends Component { Search... : Search for: } - {this.state.value} + {this.state.value} + ); } return ( @@ -317,7 +324,7 @@ class FindBar extends Component { return commands.length === 0 ? null : (
- { commands } + {commands}
Your past searches and commands @@ -328,7 +335,7 @@ class FindBar extends Component { renderActiveScope() { if (this.state.activeScope === SEARCH) { - return
; + return
; } else { return
{this.state.activeScope}
; } @@ -358,6 +365,7 @@ class FindBar extends Component { ); } } + FindBar.propTypes = { commands: PropTypes.arrayOf(PropTypes.shape({ id: PropTypes.string.isRequired, @@ -365,8 +373,7 @@ FindBar.propTypes = { pattern: PropTypes.string.isRequired })).isRequired, defaultCommands: PropTypes.arrayOf(PropTypes.string), - dispatch: PropTypes.func.isRequired, + runCommand: PropTypes.func.isRequired, }; -export { FindBar }; -export default connect()(FindBar); +export default FindBar; diff --git a/src/components/UI/card/Card.css b/src/components/UI/card/Card.css index 592cfd1e..43d7392e 100644 --- a/src/components/UI/card/Card.css +++ b/src/components/UI/card/Card.css @@ -1,8 +1,7 @@ +@import "../theme.css"; + .card { - composes: base from "../theme.css"; - composes: container from "../theme.css"; - composes: rounded from "../theme.css"; - composes: depth from "../theme.css"; + composes: base container rounded depth; overflow: hidden; width: 240px; } diff --git a/src/components/UI/icon/Icon.js b/src/components/UI/icon/Icon.js index a084f9ec..7368cd12 100644 --- a/src/components/UI/icon/Icon.js +++ b/src/components/UI/icon/Icon.js @@ -6,7 +6,7 @@ const availableIcons = [ 'bold', 'italic', 'list', 'font', 'text-height', 'text-width', 'align-left', 'align-center', 'align-right', 'align-justify', 'indent-left', 'indent-right', 'list-bullet', 'list-numbered', 'strike', 'underline', 'table', 'superscript', 'subscript', 'header', 'h1', 'h2', 'paragraph', 'link', 'unlink', 'quote-left', 'quote-right', 'code', - 'picture','video', + 'picture', 'video', // Entypo 'note', 'note-beamed', 'music', @@ -199,7 +199,7 @@ const iconPropType = (props, propName) => { const noop = function() {}; -export default function Icon({ style, className = '', type, onClick = noop}) { +export default function Icon({ style, className = '', type, onClick = noop }) { return ; } diff --git a/src/components/UI/index.js b/src/components/UI/index.js index fae9d19b..f99eba1c 100644 --- a/src/components/UI/index.js +++ b/src/components/UI/index.js @@ -1,2 +1,5 @@ export { default as Card } from './card/Card'; +export { default as Loader } from './loader/Loader'; export { default as Icon } from './icon/Icon'; +export { default as Toast } from './toast/Toast'; +export { default as AppHeader } from './AppHeader/AppHeader'; diff --git a/src/components/UI/loader/Loader.css b/src/components/UI/loader/Loader.css new file mode 100644 index 00000000..69d3b9ad --- /dev/null +++ b/src/components/UI/loader/Loader.css @@ -0,0 +1,115 @@ +.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%; + animation: loader 0.6s linear; + 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; +} + +/*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 new file mode 100644 index 00000000..c2b8ec5d --- /dev/null +++ b/src/components/UI/loader/Loader.js @@ -0,0 +1,68 @@ +import React from 'react'; +import ReactCSSTransitionGroup from 'react-addons-css-transition-group'; +import styles from './Loader.css'; + +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); + } + + componentWillUnmount() { + if (this.interval) { + clearInterval(this.interval); + } + } + + 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/components/UI/theme.css b/src/components/UI/theme.css index b8add9c7..87c78c64 100644 --- a/src/components/UI/theme.css +++ b/src/components/UI/theme.css @@ -1,5 +1,6 @@ :root { --defaultColor: #333; + --defaultColorLight: #eee; --backgroundColor: #fff; --shadowColor: rgba(0, 0, 0, 0.117647); --successColor: #1c7; diff --git a/src/components/UI/toast/Toast.css b/src/components/UI/toast/Toast.css new file mode 100644 index 00000000..2c5bb930 --- /dev/null +++ b/src/components/UI/toast/Toast.css @@ -0,0 +1,40 @@ +@import "../theme.css"; + +.toast { + composes: base container rounded depth; + position: absolute; + top: 10px; + right: 10px; + z-index: 100; + width: 350px; + padding: 20px 10px; + font-size: 0.9rem; + text-align: center; + color: var(--defaultColorLight); + overflow: hidden; + opacity: 1; + transition: opacity .3s ease-in; +} + +.hidden { + opacity: 0; +} + +.icon { + position: absolute; + top: calc(50% - 15px); + left: 15px; + font-size: 30px; +} + +.success { + background-color: var(--successColor); +} + +.warning { + background-color: var(--warningColor); +} + +.error { + background-color: var(--errorColor); +} diff --git a/src/components/UI/toast/Toast.js b/src/components/UI/toast/Toast.js new file mode 100644 index 00000000..34671df4 --- /dev/null +++ b/src/components/UI/toast/Toast.js @@ -0,0 +1,74 @@ +import React, { PropTypes } from 'react'; +import { Icon } from '../index'; +import styles from './Toast.css'; + +export default class Toast extends React.Component { + constructor(props) { + super(props); + this.state = { + shown: false + }; + + this.autoHideTimeout = this.autoHideTimeout.bind(this); + } + + componentWillMount() { + if (this.props.show) { + this.autoHideTimeout(); + this.setState({ shown: true }); + } + } + + componentWillReceiveProps(nextProps) { + if (nextProps !== this.props) { + if (nextProps.show) this.autoHideTimeout(); + this.setState({ shown: nextProps.show }); + } + } + + componentWillUnmount() { + if (this.timeOut) { + clearTimeout(this.timeOut); + } + } + + autoHideTimeout() { + clearTimeout(this.timeOut); + this.timeOut = setTimeout(() => { + this.setState({ shown: false }); + }, 4000); + } + + render() { + const { style, type, className, children } = this.props; + const icons = { + success: 'check', + warning: 'attention', + error: 'alert' + }; + const classes = [styles.toast]; + if (className) classes.push(className); + + let icon = ''; + if (type) { + classes.push(styles[type]); + icon = ; + } + + if (!this.state.shown) { + classes.push(styles.hidden); + } + + return ( +
{icon}{children}
+ ); + } +} + +Toast.propTypes = { + style: PropTypes.object, + type: PropTypes.oneOf(['success', 'warning', 'error']).isRequired, + className: PropTypes.string, + show: PropTypes.bool, + children: PropTypes.node +}; diff --git a/src/components/UnpublishedListing.css b/src/components/UnpublishedListing.css new file mode 100644 index 00000000..ebd7bcd6 --- /dev/null +++ b/src/components/UnpublishedListing.css @@ -0,0 +1,53 @@ +.container { + display: table; + width: 100%; +} + +.column { + display: table-cell; + text-align: center; + width: 33%; + height: 100%; + transition: background-color .5s ease; + & h2 { + font-size: 16px; + } +} + +.highlighted { + background-color: #e1eeea; +} + +.column:not(:last-child) { + padding-right: 20px; +} + +.card { + width: 100% !important; + margin: 7px 0; + + & h2 { + font-size: 17px; + & small { + font-weight: normal; + } + } + + & p { + color: #555; + font-size: 12px; + margin-top: 5px; + } + + & button { + margin: 10px 10px 0 0; + float: right; + } +} + + +.clear::after { + content:""; + display:block; + clear:both; +} diff --git a/src/components/UnpublishedListing.js b/src/components/UnpublishedListing.js new file mode 100644 index 00000000..91144c2f --- /dev/null +++ b/src/components/UnpublishedListing.js @@ -0,0 +1,98 @@ +import React, { PropTypes } from 'react'; +import { DragSource, DropTarget, HTML5DragDrop } from 'react-simple-dnd'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import moment from 'moment'; +import { Card } from './UI'; +import { Link } from 'react-router'; +import { status, statusDescriptions } from '../constants/publishModes'; +import styles from './UnpublishedListing.css'; + +class UnpublishedListing extends React.Component { + constructor(props) { + super(props); + this.renderColumns = this.renderColumns.bind(this); + this.handleChangeStatus = this.handleChangeStatus.bind(this); + this.requestPublish = this.requestPublish.bind(this); + } + + handleChangeStatus(newStatus, dragProps) { + const slug = dragProps.slug; + const collection = dragProps.collection; + const oldStatus = dragProps.ownStatus; + this.props.handleChangeStatus(collection, slug, oldStatus, newStatus); + } + + requestPublish(collection, slug, ownStatus) { + if (ownStatus !== status.last()) return; + if (window.confirm('Are you sure you want to publish this entry?')) { + this.props.handlePublish(collection, slug, ownStatus); + } + } + + renderColumns(entries, column) { + if (!entries) return; + + if (!column) { + /* eslint-disable */ + return entries.entrySeq().map(([currColumn, currEntries]) => ( + + {(isOver) => ( +
+

{statusDescriptions.get(currColumn)}

+ {this.renderColumns(currEntries, currColumn)} +
+ )} +
+ /* eslint-enable */ + )); + } else { + return
+ {entries.map(entry => { + // Look for an "author" field. Fallback to username on backend implementation; + const author = entry.getIn(['data', 'author'], entry.getIn(['metaData', 'user'])); + const timeStamp = moment(entry.getIn(['metaData', 'timeStamp'])).format('llll'); + const link = `/editorialworkflow/${entry.getIn(['metaData', 'collection'])}/${entry.getIn(['metaData', 'status'])}/${entry.get('slug')}`; + const slug = entry.get('slug'); + const ownStatus = entry.getIn(['metaData', 'status']); + const collection = entry.getIn(['metaData', 'collection']); + return ( + /* eslint-disable */ + +
+ +

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

+

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

+ {(ownStatus === status.last()) && + + } +
+
+
+ /* eslint-enable */ + ); + } + )} +
; + } + } + + render() { + const columns = this.renderColumns(this.props.entries); + return ( +
+

Editorial Workflow

+
+ {columns} +
+
+ ); + } +} + +UnpublishedListing.propTypes = { + entries: ImmutablePropTypes.orderedMap, + handleChangeStatus: PropTypes.func.isRequired, + handlePublish: PropTypes.func.isRequired, +}; + +export default HTML5DragDrop(UnpublishedListing); diff --git a/src/components/Widgets.js b/src/components/Widgets.js index 0a86b6b7..e731ed78 100644 --- a/src/components/Widgets.js +++ b/src/components/Widgets.js @@ -1,30 +1,24 @@ +import registry from '../lib/registry'; 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'; import ImagePreview from './Widgets/ImagePreview'; +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); +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/components/Widgets/DateTimeControl.js b/src/components/Widgets/DateTimeControl.js new file mode 100644 index 00000000..7f49868a --- /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.object, +}; 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, +}; diff --git a/src/components/Widgets/ImageControl.js b/src/components/Widgets/ImageControl.js index 12b250e4..5c680599 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/MarkdownControl.js b/src/components/Widgets/MarkdownControl.js index a315cb80..483be419 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 && } - { this.renderEditor() } + {this.renderEditor()}
); } } -export default MarkdownControl; - MarkdownControl.propTypes = { editor: PropTypes.object.isRequired, onChange: PropTypes.func.isRequired, diff --git a/src/components/Widgets/MarkdownControlElements/VisualEditor/index.js b/src/components/Widgets/MarkdownControlElements/VisualEditor/index.js index b7d57822..0f9eb696 100644 --- a/src/components/Widgets/MarkdownControlElements/VisualEditor/index.js +++ b/src/components/Widgets/MarkdownControlElements/VisualEditor/index.js @@ -42,7 +42,8 @@ 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)); + console.log('md: %o', content); + rawJson = SlateUtils.encode(content, null, ['mediaproxy'].concat(getPlugins().map(plugin => plugin.id))); } else { rawJson = emptyParagraphBlock; } @@ -253,7 +254,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/components/Widgets/MarkdownControlElements/VisualEditor/schema.js b/src/components/Widgets/MarkdownControlElements/VisualEditor/schema.js index ea00ade2..412dae5e 100644 --- a/src/components/Widgets/MarkdownControlElements/VisualEditor/schema.js +++ b/src/components/Widgets/MarkdownControlElements/VisualEditor/schema.js @@ -62,4 +62,4 @@ export const SCHEMA = { borderRadius: '4px' } } -} +}; diff --git a/src/plugins/index.js b/src/components/Widgets/MarkdownControlElements/plugins.js similarity index 52% rename from src/plugins/index.js rename to src/components/Widgets/MarkdownControlElements/plugins.js index 485a0c4c..fa0e0d34 100644 --- a/src/plugins/index.js +++ b/src/components/Widgets/MarkdownControlElements/plugins.js @@ -16,23 +16,6 @@ const EditorComponent = Record({ toPreview: function(attributes) { return 'Plugin'; } }); -function CMS() { - this.registerEditorComponent = (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) - }); - - 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/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/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