diff --git a/example/config.yml b/example/config.yml index 1e549f22..a930e59b 100644 --- a/example/config.yml +++ b/example/config.yml @@ -17,6 +17,7 @@ collections: # A list of collections the CMS should be able to edit meta: - {label: "Publish Date", name: "date", widget: "datetime", format: "YYYY-MM-DD hh:mma"} - {label: "SEO Description", name: "description", widget: "text"} + card: {type: "image", image: "image", text: "title"} - name: "faq" # Used in routes, ie.: /admin/collections/:slug/edit label: "FAQ" # Used in the UI, ie.: "New Post" @@ -25,6 +26,7 @@ collections: # A list of collections the CMS should be able to edit fields: # The fields each document in this collection have - {label: "Question", name: "title", widget: "string", tagname: "h1"} - {label: "Answer", name: "body", widget: "markdown"} + card: {type: "alltype", text: "title"} - name: "settings" label: "Settings" diff --git a/example/index.html b/example/index.html index ea2ae7f7..2d910b39 100644 --- a/example/index.html +++ b/example/index.html @@ -4,8 +4,9 @@ This is an example - - + + + + diff --git a/package.json b/package.json index 450678d0..b6f2bec3 100644 --- a/package.json +++ b/package.json @@ -36,6 +36,7 @@ "eslint-loader": "^1.2.1", "eslint-plugin-react": "^5.1.1", "exports-loader": "^0.6.3", + "extract-text-webpack-plugin": "^1.0.1", "express": "^4.13.4", "file-loader": "^0.8.5", "immutable": "^3.7.6", @@ -53,8 +54,8 @@ "react-lazy-load": "^3.0.3", "react-pure-render": "^1.0.2", "react-redux": "^4.4.0", - "react-router": "^2.0.0", - "react-router-redux": "^3.0.0", + "react-router": "^2.5.1", + "react-router-redux": "^4.0.5", "redux": "^3.3.1", "redux-thunk": "^1.0.3", "style-loader": "^0.13.0", @@ -65,14 +66,17 @@ "whatwg-fetch": "^1.0.0" }, "dependencies": { + "bricks.js": "^1.7.0", "commonmark": "^0.24.0", "commonmark-react-renderer": "^4.1.2", "draft-js": "^0.7.0", "draft-js-export-markdown": "^0.2.0", "draft-js-import-markdown": "^0.1.6", "fuzzy": "^0.1.1", + "js-base64": "^2.1.9", "json-loader": "^0.5.4", "localforage": "^1.4.2", - "lodash": "^4.13.1" + "lodash": "^4.13.1", + "pluralize": "^3.0.0" } } diff --git a/src/actions/config.js b/src/actions/config.js index c0148f3e..7a2d568f 100644 --- a/src/actions/config.js +++ b/src/actions/config.js @@ -43,7 +43,7 @@ export function loadConfig(config) { return (dispatch, getState) => { dispatch(configLoading()); - fetch('/config.yml').then((response) => { + fetch('config.yml').then((response) => { if (response.status !== 200) { throw `Failed to load config.yml (${response.status})`; } diff --git a/src/actions/entries.js b/src/actions/entries.js index 329c7318..0f49031a 100644 --- a/src/actions/entries.js +++ b/src/actions/entries.js @@ -1,4 +1,5 @@ import { currentBackend } from '../backends/backend'; +import { getMedia } from '../reducers'; /* * Contant Declarations @@ -11,7 +12,7 @@ export const ENTRIES_REQUEST = 'ENTRIES_REQUEST'; export const ENTRIES_SUCCESS = 'ENTRIES_SUCCESS'; export const ENTRIES_FAILURE = 'ENTRIES_FAILURE'; -export const DRAFT_CREATE = 'DRAFT_CREATE'; +export const DRAFT_CREATE_FROM_ENTRY = 'DRAFT_CREATE_FROM_ENTRY'; export const DRAFT_DISCARD = 'DRAFT_DISCARD'; export const DRAFT_CHANGE = 'DRAFT_CHANGE'; @@ -69,7 +70,7 @@ function entriesFailed(collection, error) { type: ENTRIES_FAILURE, error: 'Failed to load entries', payload: error.toString(), - meta: {collection: collection.get('name')} + meta: { collection: collection.get('name') } }; } @@ -83,12 +84,12 @@ function entryPersisting(collection, entry) { }; } -function entryPersisted(persistedEntry, persistedMediaFiles) { +function entryPersisted(collection, entry) { return { type: ENTRY_PERSIST_SUCCESS, payload: { - persistedEntry: persistedEntry, - persistedMediaFiles: persistedMediaFiles + collection: collection, + entry: entry } }; } @@ -104,9 +105,9 @@ function entryPersistFail(collection, entry, error) { /* * Exported simple Action Creators */ -export function createDraft(entry) { +export function createDraftFromEntry(entry) { return { - type: DRAFT_CREATE, + type: DRAFT_CREATE_FROM_ENTRY, payload: entry }; } @@ -152,14 +153,16 @@ export function loadEntries(collection) { }; } -export function persist(collection, entry, mediaFiles) { +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.persist(collection, entry, mediaFiles).then( - ({persistedEntry, persistedMediaFiles}) => { - dispatch(entryPersisted(persistedEntry, persistedMediaFiles)); + backend.persistEntry(collection, entry, MediaProxies.toJS()).then( + () => { + dispatch(entryPersisted(collection, entry)); }, (error) => dispatch(entryPersistFail(collection, entry, error)) ); diff --git a/src/actions/findbar.js b/src/actions/findbar.js index b231b46f..68db0399 100644 --- a/src/actions/findbar.js +++ b/src/actions/findbar.js @@ -1,30 +1,29 @@ -import { browserHistory } from 'react-router'; +import history from '../routing/history'; import { SEARCH } from '../containers/FindBar'; export const RUN_COMMAND = 'RUN_COMMAND'; -export const LIST_POSTS = 'LIST_POSTS'; -export const LIST_FAQ = 'LIST_FAQ'; +export const SHOW_COLLECTION = 'SHOW_COLLECTION'; +export const CREATE_COLLECTION = 'CREATE_COLLECTION'; export const HELP = 'HELP'; export function run(commandName, payload) { return { type: RUN_COMMAND, command: commandName, payload }; } - export function runCommand(commandName, payload) { return (dispatch, getState) => { switch (commandName) { - case LIST_POSTS: - browserHistory.push('/collections/posts'); + case SHOW_COLLECTION: + history.push(`/collections/${payload.collectionName}`); break; - case LIST_FAQ: - browserHistory.push('/collections/faq'); + case CREATE_COLLECTION: + window.alert(`Create a new ${payload.collectionName} - not supported yet`); break; case HELP: window.alert('Find Bar Help (PLACEHOLDER)\n\nLorem ipsum dolor sit amet, consectetur adipiscing elit.'); break; case SEARCH: - browserHistory.push('/search'); + history.push('/search'); break; } dispatch(run(commandName, payload)); diff --git a/src/actions/media.js b/src/actions/media.js index 88f822ff..1e5bd81f 100644 --- a/src/actions/media.js +++ b/src/actions/media.js @@ -2,9 +2,9 @@ export const ADD_MEDIA = 'ADD_MEDIA'; export const REMOVE_MEDIA = 'REMOVE_MEDIA'; export function addMedia(mediaProxy) { - return {type: ADD_MEDIA, payload: mediaProxy}; + return { type: ADD_MEDIA, payload: mediaProxy }; } -export function removeMedia(uri) { - return {type: REMOVE_MEDIA, payload: uri}; +export function removeMedia(path) { + return { type: REMOVE_MEDIA, payload: path }; } diff --git a/src/backends/backend.js b/src/backends/backend.js index ee639f0c..e66ab9cd 100644 --- a/src/backends/backend.js +++ b/src/backends/backend.js @@ -67,19 +67,19 @@ class Backend { }; } - persist(collection, entryDraft) { + 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) }; - return this.implementation.persist(collection, entryObj, entryDraft.get('mediaFiles').toJS()).then( - (response) => ({ - persistedEntry: this.entryWithFormat(collection)(response.persistedEntry), - persistedMediaFiles:response.persistedMediaFiles - }) - ); + + const commitMessage = (entryDraft.getIn(['entry', 'newRecord']) ? 'Created ' : 'Updated ') + + collection.get('label') + ' “' + + entryDraft.getIn(['entry', 'data', 'title']) + '”'; + + return this.implementation.persistEntry(collection, entryObj, MediaFiles, { commitMessage }); } entryToRaw(collection, entry) { diff --git a/src/backends/github/AuthenticationPage.js b/src/backends/github/AuthenticationPage.js index b28c5d47..f7100604 100644 --- a/src/backends/github/AuthenticationPage.js +++ b/src/backends/github/AuthenticationPage.js @@ -14,9 +14,14 @@ export default class AuthenticationPage extends React.Component { handleLogin(e) { e.preventDefault(); + let auth; + if (document.location.host.split(':')[0] === 'localhost') { + auth = new Authenticator({ site_id: 'cms.netlify.com' }); + } else { + auth = new Authenticator(); + } - const auth = new Authenticator({site_id: 'cms.netlify.com'}); - auth.authenticate({provider: 'github', scope: 'user'}, (err, data) => { + auth.authenticate({provider: 'github', scope: 'repo'}, (err, data) => { if (err) { this.setState({loginError: err.toString()}); return; diff --git a/src/backends/github/implementation.js b/src/backends/github/implementation.js index 96790bd6..67ad9535 100644 --- a/src/backends/github/implementation.js +++ b/src/backends/github/implementation.js @@ -1,5 +1,7 @@ import LocalForage from 'localforage'; +import MediaProxy from '../../valueObjects/MediaProxy'; import AuthenticationPage from './AuthenticationPage'; +import { Base64 } from 'js-base64'; const API_ROOT = 'https://api.github.com'; @@ -8,7 +10,7 @@ class API { this.token = token; this.repo = repo; this.branch = branch; - this.baseURL = API_ROOT + `/repos/${this.repo}`; + this.repoURL = `/repos/${this.repo}`; } user() { @@ -20,9 +22,9 @@ class API { return cache.then((cached) => { if (cached) { return cached; } - return this.request(`/contents/${path}`, { - headers: {Accept: 'application/vnd.github.VERSION.raw'}, - data: {ref: this.branch}, + return this.request(`${this.repoURL}/contents/${path}`, { + headers: { Accept: 'application/vnd.github.VERSION.raw' }, + body: { ref: this.branch }, cache: false }).then((result) => { if (sha) { @@ -35,11 +37,48 @@ class API { } listFiles(path) { - return this.request(`/contents/${path}`, { - data: {ref: this.branch} + 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}`, @@ -60,7 +99,7 @@ class API { request(path, options = {}) { const headers = this.requestHeaders(options.headers || {}); - return fetch(this.baseURL + path, {...options, headers: headers}).then((response) => { + return fetch(API_ROOT + path, { ...options, headers: headers }).then((response) => { if (response.headers.get('Content-Type').match(/json/)) { return this.parseJsonResponse(response); } @@ -68,6 +107,78 @@ class API { 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 }; + }); + }); + } + } export default class GitHub { @@ -115,4 +226,8 @@ export default class GitHub { response.entries.filter((entry) => entry.slug === slug)[0] )); } + + persistEntry(collection, entry, mediaFiles = [], options = {}) { + return this.api.persistFiles(collection, entry, mediaFiles, options); + } } diff --git a/src/backends/test-repo/implementation.js b/src/backends/test-repo/implementation.js index 2afc719b..c4f41080 100644 --- a/src/backends/test-repo/implementation.js +++ b/src/backends/test-repo/implementation.js @@ -20,7 +20,7 @@ export default class TestRepo { } authenticate(state) { - return Promise.resolve({email: state.email}); + return Promise.resolve({ email: state.email }); } entries(collection) { @@ -48,10 +48,11 @@ export default class TestRepo { )); } - persist(collection, entry, mediaFiles = []) { - const folder = collection.get('folder'); + persistEntry(collection, entry, mediaFiles = []) { + 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; - return Promise.resolve({persistedEntry:entry, persistedMediaFiles:[]}); + mediaFiles.forEach(media => media.uploaded = true); + return Promise.resolve(); } } diff --git a/src/components/Cards.js b/src/components/Cards.js new file mode 100644 index 00000000..ab903e2b --- /dev/null +++ b/src/components/Cards.js @@ -0,0 +1,11 @@ +import UnknownCard from './Cards/UnknownCard'; +import ImageCard from './Cards/ImageCard'; +import AlltypeCard from './Cards/AlltypeCard'; + +const Cards = { + _unknown: UnknownCard, + image: ImageCard, + alltype: AlltypeCard +}; + +export default Cards; diff --git a/src/components/Cards/AlltypeCard.css b/src/components/Cards/AlltypeCard.css new file mode 100644 index 00000000..f0e04a3c --- /dev/null +++ b/src/components/Cards/AlltypeCard.css @@ -0,0 +1,5 @@ +.cardContent { + white-space: nowrap; + text-align: center; + font-weight: 500; +} diff --git a/src/components/Cards/AlltypeCard.js b/src/components/Cards/AlltypeCard.js new file mode 100644 index 00000000..07956790 --- /dev/null +++ b/src/components/Cards/AlltypeCard.js @@ -0,0 +1,81 @@ +import React, { PropTypes } from 'react'; +import { Card } from '../UI'; +import ScaledLine from './ScaledLine'; +import styles from './AlltypeCard.css'; + +export default class AlltypeCard extends React.Component { + + // Based on the Slabtype Algorithm by Erik Loyer + // http://erikloyer.com/index.php/blog/the_slabtype_algorithm_part_1_background/ + renderInscription(inscription) { + + const idealCharPerLine = 22; + + // segment the text into lines + const words = inscription.split(' '); + let preText, postText, finalText; + let preDiff, postDiff; + let wordIndex = 0; + const lineText = []; + + // while we still have words left, build the next line + while (wordIndex < words.length) { + postText = ''; + + // build two strings (preText and postText) word by word, with one + // string always one word behind the other, until + // the length of one string is less than the ideal number of characters + // per line, while the length of the other is greater than that ideal + while (postText.length < idealCharPerLine) { + preText = postText; + postText += words[wordIndex] + ' '; + wordIndex++; + if (wordIndex >= words.length) { + break; + } + } + + // calculate the character difference between the two strings and the + // ideal number of characters per line + preDiff = idealCharPerLine - preText.length; + postDiff = postText.length - idealCharPerLine; + + // if the smaller string is closer to the length of the ideal than + // the longer string, and doesn’t contain just a single space, then + // use that one for the line + if ((preDiff < postDiff) && (preText.length > 2)) { + finalText = preText; + wordIndex--; + + // otherwise, use the longer string for the line + } else { + finalText = postText; + } + + lineText.push(finalText.substr(0, finalText.length - 1)); + } + return lineText.map(text => ( + + {text} + + )); + } + + render() { + const { onClick, text } = this.props; + return ( + +
{this.renderInscription(text)}
+
+ ); + } +} + +AlltypeCard.propTypes = { + onClick: PropTypes.func, + text: PropTypes.string.isRequired +}; + +AlltypeCard.defaultProps = { + onClick: function() {}, +}; diff --git a/src/components/Cards/ImageCard.css b/src/components/Cards/ImageCard.css new file mode 100644 index 00000000..b2020b48 --- /dev/null +++ b/src/components/Cards/ImageCard.css @@ -0,0 +1,18 @@ +.root { + cursor: pointer; +} + +.root h1 { + font-size: 17px; +} + +.root h2 { + font-weight: 400; + font-size: 15px; +} + +.root p { + color: #555; + font-size: 14px; + margin-top: 5px; +} diff --git a/src/components/Cards/ImageCard.js b/src/components/Cards/ImageCard.js new file mode 100644 index 00000000..307b63a9 --- /dev/null +++ b/src/components/Cards/ImageCard.js @@ -0,0 +1,31 @@ +import React, { PropTypes } from 'react'; +import { Card } from '../UI'; +import styles from './ImageCard.css'; + +export default class ImageCard extends React.Component { + + render() { + const { onClick, onImageLoaded, image, text, description } = this.props; + return ( + + +

{text}

+ + {description ?

{description}

: null} +
+ ); + } +} + +ImageCard.propTypes = { + image: PropTypes.string, + onClick: PropTypes.func, + onImageLoaded: PropTypes.func, + text: PropTypes.string.isRequired, + description: PropTypes.string +}; + +ImageCard.defaultProps = { + onClick: function() {}, + onImageLoaded: function() {} +}; diff --git a/src/components/Cards/ScaledLine.js b/src/components/Cards/ScaledLine.js new file mode 100644 index 00000000..be274a6d --- /dev/null +++ b/src/components/Cards/ScaledLine.js @@ -0,0 +1,39 @@ +import React, { PropTypes } from 'react'; + +export default class ScaledLine extends React.Component { + constructor(props) { + super(props); + this._content = null; + this.state = { + ratio: 1, + }; + } + + componentDidMount() { + const actualContent = this._content.children[0]; + + this.setState({ + ratio: this.props.toWidth / actualContent.offsetWidth, + }); + } + + render() { + const { ratio } = this.state; + const { children } = this.props; + + const styles = { + fontSize: ratio.toFixed(3) + 'em' + }; + + return ( +
this._content = c} style={styles}> + {children} +
+ ); + } +} + +ScaledLine.propTypes = { + children: PropTypes.node.isRequired, + toWidth: PropTypes.number.isRequired +}; diff --git a/src/components/Cards/UnknownCard.js b/src/components/Cards/UnknownCard.js new file mode 100644 index 00000000..fafb1110 --- /dev/null +++ b/src/components/Cards/UnknownCard.js @@ -0,0 +1,15 @@ +import React from 'react'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import { Card } from '../UI'; + +export default function UnknownCard({ collection }) { + return ( + +

No card of type “{collection.getIn(['card', 'type'])}”.

+
+ ); +} + +UnknownCard.propTypes = { + collection: ImmutablePropTypes.map, +}; diff --git a/src/components/EntryListing.js b/src/components/EntryListing.js index 311af02a..28baa927 100644 --- a/src/components/EntryListing.js +++ b/src/components/EntryListing.js @@ -1,18 +1,91 @@ import React from 'react'; import ImmutablePropTypes from 'react-immutable-proptypes'; -import { Link } from 'react-router'; +import Bricks from 'bricks.js'; +import history from '../routing/history'; +import Cards from './Cards'; +import _ from 'lodash'; -export default function EntryListing({ collection, entries }) { - const name = collection.get('name'); - return
-

Listing entries!

- {entries.map((entry) => { - const path = `/collections/${name}/entries/${entry.get('slug')}`; - return -

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

- ; - })} -
; +export default class EntryListing extends React.Component { + constructor(props) { + super(props); + this.bricksInstance = null; + + this.bricksConfig = { + packed: 'data-packed', + sizes: [ + { columns: 1, gutter: 15 }, + { mq: '495px', columns: 2, gutter: 15 }, + { mq: '750px', columns: 3, gutter: 15 }, + { mq: '1005px', columns: 4, gutter: 15 }, + { mq: '1260px', columns: 5, gutter: 15 }, + { mq: '1515px', columns: 6, gutter: 15 }, + { mq: '1770px', columns: 7, gutter: 15 }, + ] + }; + + this.updateBricks = _.throttle(this.updateBricks.bind(this), 30); + } + + componentDidMount() { + this.bricksInstance = Bricks({ + container: this._entries, + packed: this.bricksConfig.packed, + sizes: this.bricksConfig.sizes + }); + + this.bricksInstance.resize(true); + + if (this.props.entries && this.props.entries.size > 0) { + setTimeout(() => { + this.bricksInstance.pack(); + }, 0); + } + } + + componentDidUpdate(prevProps) { + if ((prevProps.entries === undefined || prevProps.entries.size === 0) && this.props.entries.size === 0) { + return; + } + + this.bricksInstance.pack(); + } + + componengWillUnmount() { + this.bricksInstance.resize(false); + } + + updateBricks() { + this.bricksInstance.pack(); + } + + cardFor(collection, entry, link) { + //const { entry, getMedia, onChange, onAddMedia, onRemoveMedia } = this.props; + const card = Cards[collection.getIn(['card', 'type'])] || Cards._unknown; + return React.createElement(card, { + key: entry.get('slug'), + collection: collection, + onClick: history.push.bind(this, link), + onImageLoaded: this.updateBricks, + text: entry.getIn(['data', collection.getIn(['card', 'text'])]), + description: entry.getIn(['data', collection.getIn(['card', 'description'])]), + image: entry.getIn(['data', collection.getIn(['card', 'image'])]), + }); + } + + render() { + const { collection, entries } = this.props; + const name = collection.get('name'); + + return
+

Listing {name}

+
this._entries = c}> + {entries.map((entry) => { + const path = `/collections/${name}/entries/${entry.get('slug')}`; + return this.cardFor(collection, entry, path); + })} +
+
; + } } EntryListing.propTypes = { diff --git a/src/components/UI/card/Card.css b/src/components/UI/card/Card.css index 7d93192c..592cfd1e 100644 --- a/src/components/UI/card/Card.css +++ b/src/components/UI/card/Card.css @@ -4,10 +4,12 @@ composes: rounded from "../theme.css"; composes: depth from "../theme.css"; overflow: hidden; + width: 240px; } .card > *:not(iframe, video, img, header, footer) { - margin: 0 10px; + margin-left: 10px; + margin-right: 10px; } .card > *:not(iframe, video, img, header, footer):first-child { diff --git a/src/components/UI/card/Card.js b/src/components/UI/card/Card.js index 6ed4d3fd..cdbd0785 100644 --- a/src/components/UI/card/Card.js +++ b/src/components/UI/card/Card.js @@ -1,6 +1,6 @@ import React from 'react'; import styles from './Card.css'; -export default function Card({ style, className = '', children }) { - return
{children}
; +export default function Card({ style, className = '', onClick, children }) { + return
{children}
; } diff --git a/src/components/UI/theme.css b/src/components/UI/theme.css index e39e702b..b8add9c7 100644 --- a/src/components/UI/theme.css +++ b/src/components/UI/theme.css @@ -1,7 +1,7 @@ :root { --defaultColor: #333; --backgroundColor: #fff; - --shadowColor: rgba(0, 0, 0, 0.25); + --shadowColor: rgba(0, 0, 0, 0.117647); --successColor: #1c7; --warningColor: #fa0; --errorColor: #f52; @@ -12,15 +12,14 @@ } .container { - margin: 10px; color: var(--defaultColor); background-color: var(--backgroundColor); } .rounded { - border-radius: 6px; + border-radius: 2px; } .depth { - box-shadow: 0px 1px 2px 0px var(--shadowColor); + box-shadow: var(--shadowColor) 0px 1px 6px, var(--shadowColor) 0px 1px 4px; } diff --git a/src/components/Widgets.js b/src/components/Widgets.js index 9ffc220d..0a86b6b7 100644 --- a/src/components/Widgets.js +++ b/src/components/Widgets.js @@ -1,11 +1,11 @@ -import UnknownControl from './widgets/UnknownControl'; -import UnknownPreview from './widgets/UnknownPreview'; -import StringControl from './widgets/StringControl'; -import StringPreview from './widgets/StringPreview'; -import MarkdownControl from './widgets/MarkdownControl'; -import MarkdownPreview from './widgets/MarkdownPreview'; -import ImageControl from './widgets/ImageControl'; -import ImagePreview from './widgets/ImagePreview'; +import UnknownControl from './Widgets/UnknownControl'; +import UnknownPreview from './Widgets/UnknownPreview'; +import StringControl from './Widgets/StringControl'; +import StringPreview from './Widgets/StringPreview'; +import MarkdownControl from './Widgets/MarkdownControl'; +import MarkdownPreview from './Widgets/MarkdownPreview'; +import ImageControl from './Widgets/ImageControl'; +import ImagePreview from './Widgets/ImagePreview'; const Widgets = { diff --git a/src/components/Widgets/ImageControl.js b/src/components/Widgets/ImageControl.js index 01f91757..d80cd80e 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.uri); + this.props.onChange(mediaProxy.path); } else { this.props.onChange(null); } @@ -63,7 +63,7 @@ export default class ImageControl extends React.Component { renderImageName() { if (!this.props.value) return null; if (this.value instanceof MediaProxy) { - return truncateMiddle(this.props.value.uri, MAX_DISPLAY_LENGTH); + return truncateMiddle(this.props.value.path, MAX_DISPLAY_LENGTH); } else { return truncateMiddle(this.props.value, MAX_DISPLAY_LENGTH); } diff --git a/src/components/stories/Card.js b/src/components/stories/Card.js index a791f268..95eeabe7 100644 --- a/src/components/stories/Card.js +++ b/src/components/stories/Card.js @@ -3,7 +3,6 @@ import { Card } from '../UI'; import { storiesOf } from '@kadira/storybook'; const styles = { - fixedWidth: { width: 280 }, footer: { color: '#aaa', backgroundColor: '#555', @@ -11,40 +10,33 @@ const styles = { marginTop: 5, padding: 10 } - -} +}; storiesOf('Card', module) .add('Default View', () => ( -
- -

A Card

-

Subtitle

-

- Margins are applied to all elements inside a card.
- Lorem ipsum dolor sit amet, consectetur adipiscing elit. lobortis vel. Nulla porttitor enim at tellus eget - malesuada eleifend. Nunc tellus turpis, tincidunt sed felis facilisis, lacinia condimentum quam. Cras quis - tortor fermentum, aliquam tortor eu, consequat ligula. Nulla eget nulla act odio varius ullamcorper turpis. - In consequat egestas nulla condimentum faucibus. Donec scelerisque convallis est nec fringila. Suspendisse - non lorem non erat congue consequat. -

-
-
+ +

A Card

+

Subtitle

+

+ Margins are applied to all elements inside a card.
+ Lorem ipsum dolor sit amet, consectetur adipiscing elit. lobortis vel. Nulla porttitor enim at tellus eget + malesuada eleifend. Nunc tellus turpis, tincidunt sed felis facilisis, lacinia condimentum quam. Cras quis + tortor fermentum, aliquam tortor eu, consequat ligula. Nulla eget nulla act odio varius ullamcorper turpis. + In consequat egestas nulla condimentum faucibus. Donec scelerisque convallis est nec fringila. Suspendisse + non lorem non erat congue consequat. +

+
)).add('Full width content', () => ( -
- - -

Card & cat

-

Media Elements such as video, img (and iFrame for embeds) don't have margin

-
-
+ + +

Card & cat

+

Media Elements such as video, img (and iFrame for embeds) don't have margin

+
)).add('Footer', () => ( -
- - -

Now with footer.

-

header and footer elements are also not subject to margin

- -
-
+ + +

Now with footer.

+

header and footer elements are also not subject to margin

+ +
)) diff --git a/src/containers/App.css b/src/containers/App.css new file mode 100644 index 00000000..ad6bef11 --- /dev/null +++ b/src/containers/App.css @@ -0,0 +1,43 @@ +.alignable { + margin: 0px auto; +} + +@media (max-width: 749px) and (min-width: 495px) { + .alignable { + width: 495px; + } +} + +@media (max-width: 1004px) and (min-width: 750px) { + .alignable { + width: 750px; + } +} + +@media (max-width: 1259px) and (min-width: 1005px) { + .alignable { + width: 1005px; + } +} + +@media (max-width: 1514px) and (min-width: 1260px) { + .alignable { + width: 1260px; + } +} + +@media (max-width: 1769px) and (min-width: 1515px) { + .alignable { + width: 1515px; + } +} + +@media (min-width: 1770px) { + .alignable { + width: 1770px; + } +} + +.main { + padding-top: 60px; +} diff --git a/src/containers/App.js b/src/containers/App.js index 290a6d4a..052af2c1 100644 --- a/src/containers/App.js +++ b/src/containers/App.js @@ -3,8 +3,10 @@ import { connect } from 'react-redux'; import { loadConfig } from '../actions/config'; import { loginUser } from '../actions/auth'; import { currentBackend } from '../backends/backend'; -import { LIST_POSTS, LIST_FAQ, HELP, MORE_COMMANDS } from '../actions/findbar'; +import { SHOW_COLLECTION, CREATE_COLLECTION, HELP } from '../actions/findbar'; import FindBar from './FindBar'; +import styles from './App.css'; +import pluralize from 'pluralize'; class App extends React.Component { componentDidMount() { @@ -49,6 +51,37 @@ class App extends React.Component { ; } + generateFindBarCommands() { + // Generate command list + const commands = []; + const defaultCommands = []; + + this.props.collections.forEach(collection => { + commands.push({ + id: `show_${collection.get('name')}`, + pattern: `Show ${pluralize(collection.get('label'))}`, + type: SHOW_COLLECTION, + payload: { collectionName:collection.get('name') } + }); + + if (defaultCommands.length < 5) defaultCommands.push(`show_${collection.get('name')}`); + + if (collection.get('create') === true) { + commands.push({ + id: `create_${collection.get('name')}`, + pattern: `Create new ${pluralize(collection.get('label'), 1)}(:itemName as ${pluralize(collection.get('label'), 1)} Name)`, + type: CREATE_COLLECTION, + payload: { collectionName:collection.get('name') } + }); + } + }); + + commands.push({ id: HELP, type: HELP, pattern: 'Help' }); + defaultCommands.push(HELP); + + return { commands, defaultCommands }; + } + render() { const { user, config, children } = this.props; @@ -68,24 +101,31 @@ class App extends React.Component { return this.authenticating(); } + const { commands, defaultCommands } = this.generateFindBarCommands(); + return (
- - {children} +
+
+ +
+
+
+ {children} +
); } } function mapStateToProps(state) { - const { auth, config } = state; + const { auth, config, collections } = state; const user = auth && auth.get('user'); - return { auth, config, user }; + return { auth, config, collections, user }; } export default connect(mapStateToProps)(App); diff --git a/src/containers/CollectionPage.js b/src/containers/CollectionPage.js index 8d8b12b4..8c10703e 100644 --- a/src/containers/CollectionPage.js +++ b/src/containers/CollectionPage.js @@ -1,6 +1,5 @@ import React, { PropTypes } from 'react'; import ImmutablePropTypes from 'react-immutable-proptypes'; -import { Link } from 'react-router'; import { connect } from 'react-redux'; import { loadEntries } from '../actions/entries'; import { selectEntries } from '../reducers'; @@ -29,17 +28,7 @@ class DashboardPage extends React.Component { } return
-

Dashboard

-
- {collections.map((collection) => ( -
- {collection.get('name')} -
- )).toArray()} -
-
- {entries ? : 'Loading entries...'} -
+ {entries ? : 'Loading entries...'}
; } } diff --git a/src/containers/EntryPage.js b/src/containers/EntryPage.js index d03b744b..79784c0e 100644 --- a/src/containers/EntryPage.js +++ b/src/containers/EntryPage.js @@ -3,10 +3,10 @@ import ImmutablePropTypes from 'react-immutable-proptypes'; import { connect } from 'react-redux'; import { loadEntry, - createDraft, + createDraftFromEntry, discardDraft, changeDraft, - persist + persistEntry } from '../actions/entries'; import { addMedia, removeMedia } from '../actions/media'; import { selectEntry, getMedia } from '../reducers'; @@ -16,18 +16,18 @@ class EntryPage extends React.Component { constructor(props) { super(props); this.props.loadEntry(props.collection, props.slug); - this.handlePersist = this.handlePersist.bind(this); + this.handlePersistEntry = this.handlePersistEntry.bind(this); } componentDidMount() { if (this.props.entry) { - this.props.createDraft(this.props.entry); + this.props.createDraftFromEntry(this.props.entry); } } componentWillReceiveProps(nextProps) { if (this.props.entry !== nextProps.entry && !nextProps.entry.get('isFetching')) { - this.props.createDraft(nextProps.entry); + this.props.createDraftFromEntry(nextProps.entry); } } @@ -35,8 +35,8 @@ class EntryPage extends React.Component { this.props.discardDraft(); } - handlePersist() { - this.props.persist(this.props.collection, this.props.entryDraft); + handlePersistEntry() { + this.props.persistEntry(this.props.collection, this.props.entryDraft); } render() { @@ -56,7 +56,7 @@ class EntryPage extends React.Component { onChange={changeDraft} onAddMedia={addMedia} onRemoveMedia={removeMedia} - onPersist={this.handlePersist} + onPersist={this.handlePersistEntry} /> ); } @@ -67,12 +67,12 @@ EntryPage.propTypes = { boundGetMedia: PropTypes.func.isRequired, changeDraft: PropTypes.func.isRequired, collection: ImmutablePropTypes.map.isRequired, - createDraft: PropTypes.func.isRequired, + createDraftFromEntry: PropTypes.func.isRequired, discardDraft: PropTypes.func.isRequired, entry: ImmutablePropTypes.map.isRequired, entryDraft: ImmutablePropTypes.map.isRequired, loadEntry: PropTypes.func.isRequired, - persist: PropTypes.func.isRequired, + persistEntry: PropTypes.func.isRequired, removeMedia: PropTypes.func.isRequired, slug: PropTypes.string.isRequired, }; @@ -93,8 +93,8 @@ export default connect( addMedia, removeMedia, loadEntry, - createDraft, + createDraftFromEntry, discardDraft, - persist + persistEntry } )(EntryPage); diff --git a/src/containers/FindBar.css b/src/containers/FindBar.css index e19a35da..5f31c520 100644 --- a/src/containers/FindBar.css +++ b/src/containers/FindBar.css @@ -10,8 +10,8 @@ position: relative; background-color: var(--backgroundColor); padding: 1px 0; + margin: 4px auto; } - .inputArea { display: table; width: calc(100% - 10px); diff --git a/src/containers/FindBar.js b/src/containers/FindBar.js index 829e9d5f..ce13254d 100644 --- a/src/containers/FindBar.js +++ b/src/containers/FindBar.js @@ -124,8 +124,11 @@ class FindBar extends Component { }, () => { this._input.blur(); }); - const payload = paramName ? { [paramName]: enteredParamValue } : null; - this.props.dispatch(runCommand(command.id, payload)); + const payload = command.payload || {}; + if (paramName) { + payload[paramName] = enteredParamValue; + } + this.props.dispatch(runCommand(command.type, payload)); } } @@ -358,6 +361,7 @@ class FindBar extends Component { FindBar.propTypes = { commands: PropTypes.arrayOf(PropTypes.shape({ id: PropTypes.string.isRequired, + type: PropTypes.string.isRequired, pattern: PropTypes.string.isRequired })).isRequired, defaultCommands: PropTypes.arrayOf(PropTypes.string), diff --git a/src/formats/yaml.js b/src/formats/yaml.js index 944f3e9e..9fd73c07 100644 --- a/src/formats/yaml.js +++ b/src/formats/yaml.js @@ -19,7 +19,7 @@ const ImageType = new yaml.Type('image', { kind: 'scalar', instanceOf: MediaProxy, represent: function(value) { - return `${value.uri}`; + return `${value.path}`; }, resolve: function(value) { if (value === null) return false; diff --git a/src/index.css b/src/index.css index ee57887e..c13be2b0 100644 --- a/src/index.css +++ b/src/index.css @@ -2,6 +2,7 @@ html { box-sizing: border-box; -ms-text-size-adjust: 100%; -webkit-text-size-adjust: 100%; + margin: 0; } *, *:before, *:after { box-sizing: inherit; @@ -10,13 +11,23 @@ html { body { font-family: 'Roboto', sans-serif; height: 100%; + background-color: #fafafa; + margin: 0; +} + +header { + background-color: #fff; + box-shadow: 0 1px 2px 0 rgba(0,0,0,0.22); + height: 54px; + position: fixed; + width: 100%; + z-index: 999; } :global #root, :global #root > * { height: 100%; } -h1, h2, h3, h4, h5, h6, p, blockquote, figure, dl, ol, ul { +h1, h2, h3, h4, h5, h6, p { margin: 0; - padding: 0; } diff --git a/src/index.js b/src/index.js index 40da4d74..508b38f0 100644 --- a/src/index.js +++ b/src/index.js @@ -1,19 +1,26 @@ import React from 'react'; import { render } from 'react-dom'; import { Provider } from 'react-redux'; +import { Router } from 'react-router'; import configureStore from './store/configureStore'; -import Routes from './routes/routes'; +import routes from './routing/routes'; +import history, { syncHistory } from './routing/history'; import 'file?name=index.html!../example/index.html'; import './index.css'; const store = configureStore(); +// Create an enhanced history that syncs navigation events with the store +syncHistory(store); + const el = document.createElement('div'); el.id = 'root'; document.body.appendChild(el); render(( - + + {routes} + ), el); diff --git a/src/lib/netlify-auth.js b/src/lib/netlify-auth.js index 54a75283..b22b8c6b 100644 --- a/src/lib/netlify-auth.js +++ b/src/lib/netlify-auth.js @@ -29,8 +29,8 @@ const PROVIDERS = { }; class Authenticator { - constructor(config) { - this.site_id = config.site_id; + constructor(config = {}) { + this.site_id = config.site_id || null; this.base_url = config.base_url || NETLIFY_API; } diff --git a/src/reducers/entryDraft.js b/src/reducers/entryDraft.js index 801f3ea0..8f23e8c9 100644 --- a/src/reducers/entryDraft.js +++ b/src/reducers/entryDraft.js @@ -1,12 +1,12 @@ import { Map, List } from 'immutable'; -import { DRAFT_CREATE, DRAFT_DISCARD, DRAFT_CHANGE } from '../actions/entries'; +import { DRAFT_CREATE_FROM_ENTRY, DRAFT_DISCARD, DRAFT_CHANGE } from '../actions/entries'; import { ADD_MEDIA, REMOVE_MEDIA } from '../actions/media'; const initialState = Map({ entry: Map(), mediaFiles: List() }); const entryDraft = (state = Map(), action) => { switch (action.type) { - case DRAFT_CREATE: + case DRAFT_CREATE_FROM_ENTRY: if (!action.payload) { // New entry return initialState; @@ -14,6 +14,7 @@ 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_DISCARD: @@ -22,9 +23,9 @@ const entryDraft = (state = Map(), action) => { return state.set('entry', action.payload); case ADD_MEDIA: - return state.update('mediaFiles', (list) => list.push(action.payload.uri)); + return state.update('mediaFiles', (list) => list.push(action.payload.path)); case REMOVE_MEDIA: - return state.update('mediaFiles', (list) => list.filterNot((uri) => uri === action.payload)); + return state.update('mediaFiles', (list) => list.filterNot((path) => path === action.payload)); default: return state; diff --git a/src/reducers/index.js b/src/reducers/index.js index 29a7cdf3..ecdac295 100644 --- a/src/reducers/index.js +++ b/src/reducers/index.js @@ -23,5 +23,5 @@ export const selectEntry = (state, collection, slug) => export const selectEntries = (state, collection) => fromEntries.selectEntries(state.entries, collection); -export const getMedia = (state, uri) => - fromMedias.getMedia(state.medias, uri); +export const getMedia = (state, path) => + fromMedias.getMedia(state.medias, path); diff --git a/src/reducers/medias.js b/src/reducers/medias.js index e80d24dc..c0cfaa34 100644 --- a/src/reducers/medias.js +++ b/src/reducers/medias.js @@ -1,19 +1,13 @@ import { Map } from 'immutable'; import { ADD_MEDIA, REMOVE_MEDIA } from '../actions/media'; -import { ENTRY_PERSIST_SUCCESS } from '../actions/entries'; import MediaProxy from '../valueObjects/MediaProxy'; const medias = (state = Map(), action) => { switch (action.type) { case ADD_MEDIA: - return state.set(action.payload.uri, action.payload); + return state.set(action.payload.path, action.payload); case REMOVE_MEDIA: return state.delete(action.payload); - case ENTRY_PERSIST_SUCCESS: - return state.map((media, uri) => { - if (action.payload.persistedMediaFiles.indexOf(uri) > -1) media.uploaded = true; - return media; - }); default: return state; @@ -22,10 +16,10 @@ const medias = (state = Map(), action) => { export default medias; -export const getMedia = (state, uri) => { - if (state.has(uri)) { - return state.get(uri); +export const getMedia = (state, path) => { + if (state.has(path)) { + return state.get(path); } else { - return new MediaProxy(uri, null, true); + return new MediaProxy(path, null, true); } }; diff --git a/src/routes/routes.js b/src/routes/routes.js deleted file mode 100644 index 50a798d6..00000000 --- a/src/routes/routes.js +++ /dev/null @@ -1,19 +0,0 @@ -import React from 'react'; -import { Router, Route, IndexRoute, browserHistory } from 'react-router'; -import App from '../containers/App'; -import CollectionPage from '../containers/CollectionPage'; -import EntryPage from '../containers/EntryPage'; -import SearchPage from '../containers/SearchPage'; -import NotFoundPage from '../containers/NotFoundPage'; - -export default () => ( - - - - - - - - - -); diff --git a/src/routing/history.js b/src/routing/history.js new file mode 100644 index 00000000..a7ec5dbf --- /dev/null +++ b/src/routing/history.js @@ -0,0 +1,14 @@ +import { createHashHistory } from 'history'; +import { useRouterHistory } from 'react-router'; +import { syncHistoryWithStore } from 'react-router-redux'; + +let history = useRouterHistory(createHashHistory)({ + queryKey: false +}); + +const syncHistory = (store) => { + history = syncHistoryWithStore(history, store); +}; + +export { syncHistory }; +export default history; diff --git a/src/routing/routes.js b/src/routing/routes.js new file mode 100644 index 00000000..39f1bf81 --- /dev/null +++ b/src/routing/routes.js @@ -0,0 +1,17 @@ +import React from 'react'; +import { Route, IndexRoute } from 'react-router'; +import App from '../containers/App'; +import CollectionPage from '../containers/CollectionPage'; +import EntryPage from '../containers/EntryPage'; +import SearchPage from '../containers/SearchPage'; +import NotFoundPage from '../containers/NotFoundPage'; + +export default ( + + + + + + + +); diff --git a/src/store/configureStore.js b/src/store/configureStore.js index 3107dba0..d7552c00 100644 --- a/src/store/configureStore.js +++ b/src/store/configureStore.js @@ -1,16 +1,15 @@ import { createStore, applyMiddleware, combineReducers, compose } from 'redux'; import thunkMiddleware from 'redux-thunk'; -import { browserHistory } from 'react-router'; -import { syncHistory, routeReducer } from 'react-router-redux'; +import { routerReducer } from 'react-router-redux'; import reducers from '../reducers'; const reducer = combineReducers({ ...reducers, - router: routeReducer + routing: routerReducer }); const createStoreWithMiddleware = compose( - applyMiddleware(thunkMiddleware, syncHistory(browserHistory)), + applyMiddleware(thunkMiddleware), window.devToolsExtension ? window.devToolsExtension() : (f) => f )(createStore); diff --git a/src/valueObjects/MediaProxy.js b/src/valueObjects/MediaProxy.js index 7fd93ca0..c02e39ca 100644 --- a/src/valueObjects/MediaProxy.js +++ b/src/valueObjects/MediaProxy.js @@ -7,8 +7,22 @@ export default function MediaProxy(value, file, uploaded = false) { this.value = value; this.file = file; this.uploaded = uploaded; - this.uri = config.media_folder && !uploaded ? config.media_folder + '/' + value : value; - this.toString = function() { - return this.uploaded ? this.uri : window.URL.createObjectURL(this.file, { oneTimeOnly: true }); - }; + this.sha = null; + this.path = config.media_folder && !uploaded ? config.media_folder + '/' + value : value; } + +MediaProxy.prototype.toString = function() { + return this.uploaded ? this.path : window.URL.createObjectURL(this.file, { oneTimeOnly: true }); +}; + +MediaProxy.prototype.toBase64 = function() { + return new Promise((resolve, reject) => { + const fr = new FileReader(); + fr.onload = (readerEvt) => { + const binaryString = readerEvt.target.result; + + resolve(binaryString.split('base64,')[1]); + }; + fr.readAsDataURL(this.file); + }); +}; diff --git a/webpack.config.js b/webpack.config.js index 66e20d7e..b312cf01 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -1,5 +1,6 @@ /* global module, __dirname, require */ var webpack = require('webpack'); +var ExtractTextPlugin = require('extract-text-webpack-plugin'); var path = require('path'); module.exports = { @@ -12,7 +13,7 @@ module.exports = { { test: /\.json$/, loader: 'json-loader' }, { test: /\.css$/, - loader: 'style!css?modules&importLoaders=1!postcss' + loader: ExtractTextPlugin.extract("style", "css?modules&importLoaders=1!postcss"), }, { loader: 'babel', @@ -33,6 +34,7 @@ module.exports = { ], plugins: [ + new ExtractTextPlugin('cms.css', { allChunks: true }), new webpack.ProvidePlugin({ 'fetch': 'imports?this=>global!exports?global.fetch!whatwg-fetch' })