Merge branch 'contentIntegration' into react

This commit is contained in:
Cássio Zen 2016-08-01 16:09:46 -03:00
commit 21c3d060ea
44 changed files with 734 additions and 200 deletions

View File

@ -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"

View File

@ -4,8 +4,9 @@
<meta charset="utf-8" />
<title>This is an example</title>
<link rel="stylesheet" href="https://facebook.github.io/draft-js/css/draft.css"/>
<link href='https://fonts.googleapis.com/css?family=Roboto:100,300,400,500,700' rel='stylesheet' type='text/css'>
<link href='https://fonts.googleapis.com/css?family=Roboto:300,400,500' rel='stylesheet' type='text/css'>
<link rel="stylesheet" href="/cms.css"/>
<script>
window.repoFiles = {
_posts: {
@ -16,6 +17,15 @@
_faqs: {
"what-is-netlify-cms.md": {
content: "---\ntitle: What is netlify CMS?\ndate: 2015-11-02T00:00.000Z\n---\n\n# Netlify CMS is Content Manager for Static Site Generators\n\nStatic sites are many times faster, cheaper and safer and traditional dynamic websites.\n\nModern static site generators like Jekyll, Middleman, Roots or Hugo are powerful publishing and development systems, but when we build sites for non-technical users, we need a layer on top of them.\n\nNetlify CMS is there to let your marketing team push new content to your public site, or to let technical writers work on your documentation.\n\nNetlify CMS integrates with Git and turns normal content editors into git comitters.\n\n"
},
"what-is-jam-stack.md": {
content: "---\ntitle: What is the “JAM Stack”?\ndate: 2015-11-02T00:00.000Z\n---\n\n# The JAM stack is a new way of building websites and apps that are fast, secure and simple to work with.\n\nJAM stands for JavaScript, APIs and Markup. It's the fastest growing new stack for building websites and apps: no more servers, host all your front-end on a CDN and use APIs for any moving parts.\n\n"
},
"cache-invalidation.md": {
content: "---\ntitle: What about Cache Invalidation?\ndate: 2015-11-02T00:00.000Z\n---\n\n# Netlify handles cache invalidation automatically\n\nWhen your changes go live, they go live.\n\nNo waiting for cache purges, no cumbersome varnish setup, no API calls to clean your distribution. Netlify handles cache purges within an average of 250ms from your deploy!\n\n"
},
"continuous-deployment.md": {
content: "---\ntitle: Does Netlify support Continuous Deployment?\ndate: 2015-11-02T00:00.000Z\n---\n\n# Yes, Netlify let you Integrate your site or web-app to GitHub, GitLab or BitBucket and run your build tool on our servers.\n\nAutomatically rebuild your site every time your content changes: trigger builds by pushing to git or via webhooks.\n\n"
}
},
_data: {
@ -30,7 +40,7 @@
var ONE_DAY = 60 * 60 * 24 * 1000;
for (var i= 0; i<10; i++) {
for (var i=1; i<=10; i++) {
var date = new Date();
date.setTime(date.getTime() + ONE_DAY);
@ -38,12 +48,25 @@
var slug = dateString + "-post-number-" + i + ".md";
window.repoFiles._posts[slug] = {
content: "---\ntitle: \"This is post # " + (10-i) + "\"\ndate: " + dateString + "T00:99:99.999Z\n---\n\n# The post is number " + i + "\n\nAnd this is yet another identical post body"
content: "---\ntitle: \"This is post # " + i + "\"\ndate: " + dateString + "T00:99:99.999Z\n---\n\n# The post is number " + i + "\n\nAnd this is yet another identical post body"
}
}
for (var i=1; i<=5; i++) {
var date = new Date();
date.setTime(date.getTime() + ONE_DAY);
var dateString = '' + date.getFullYear() + '-' + (date.getMonth() + 1) + '-' + date.getDate();
var slug = dateString + "-faq-number-" + i + ".md";
window.repoFiles._faqs[slug] = {
content: "---\ntitle: \"This FAQ item # " + i + "\"\ndate: " + dateString + "T00:99:99.999Z\n---\n\n# Loren ipsum dolor sit amet"
}
}
</script>
</head>
<body>
<script src='/cms.js'></script>
</body>
</html>

View File

@ -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"
}
}

View File

@ -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})`;
}

View File

@ -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))
);

View File

@ -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));

View File

@ -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 };
}

View File

@ -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) {

View File

@ -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;

View File

@ -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);
}
}

View File

@ -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();
}
}

11
src/components/Cards.js Normal file
View File

@ -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;

View File

@ -0,0 +1,5 @@
.cardContent {
white-space: nowrap;
text-align: center;
font-weight: 500;
}

View File

@ -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 doesnt 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 => (
<ScaledLine key={text.trim().replace(/[^a-z0-9]+/gi, '-')} toWidth={216}>
{text}
</ScaledLine>
));
}
render() {
const { onClick, text } = this.props;
return (
<Card onClick={onClick}>
<div className={styles.cardContent}>{this.renderInscription(text)}</div>
</Card>
);
}
}
AlltypeCard.propTypes = {
onClick: PropTypes.func,
text: PropTypes.string.isRequired
};
AlltypeCard.defaultProps = {
onClick: function() {},
};

View File

@ -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;
}

View File

@ -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 (
<Card onClick={onClick} className={styles.root}>
<img src={image} onLoad={onImageLoaded} />
<h1>{text}</h1>
{description ? <p>{description}</p> : null}
</Card>
);
}
}
ImageCard.propTypes = {
image: PropTypes.string,
onClick: PropTypes.func,
onImageLoaded: PropTypes.func,
text: PropTypes.string.isRequired,
description: PropTypes.string
};
ImageCard.defaultProps = {
onClick: function() {},
onImageLoaded: function() {}
};

View File

@ -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 (
<div ref={(c) => this._content = c} style={styles}>
<span>{children}</span>
</div>
);
}
}
ScaledLine.propTypes = {
children: PropTypes.node.isRequired,
toWidth: PropTypes.number.isRequired
};

View File

@ -0,0 +1,15 @@
import React from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes';
import { Card } from '../UI';
export default function UnknownCard({ collection }) {
return (
<Card>
<p>No card of type {collection.getIn(['card', 'type'])}.</p>
</Card>
);
}
UnknownCard.propTypes = {
collection: ImmutablePropTypes.map,
};

View File

@ -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 <div>
<h2>Listing entries!</h2>
{entries.map((entry) => {
const path = `/collections/${name}/entries/${entry.get('slug')}`;
return <Link key={entry.get('slug')} to={path}>
<h3>{entry.getIn(['data', 'title'])}</h3>
</Link>;
})}
</div>;
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 <div>
<h1>Listing {name}</h1>
<div ref={(c) => this._entries = c}>
{entries.map((entry) => {
const path = `/collections/${name}/entries/${entry.get('slug')}`;
return this.cardFor(collection, entry, path);
})}
</div>
</div>;
}
}
EntryListing.propTypes = {

View File

@ -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 {

View File

@ -1,6 +1,6 @@
import React from 'react';
import styles from './Card.css';
export default function Card({ style, className = '', children }) {
return <div className={`${styles.card} ${className}`} style={style}>{children}</div>;
export default function Card({ style, className = '', onClick, children }) {
return <div className={`${styles.card} ${className}`} style={style} onClick={onClick}>{children}</div>;
}

View File

@ -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;
}

View File

@ -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 = {

View File

@ -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);
}

View File

@ -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', () => (
<div style={styles.fixedWidth}>
<Card>
<h1>A Card</h1>
<h2>Subtitle</h2>
<p>
Margins are applied to all elements inside a card. <br/>
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.
</p>
</Card>
</div>
<Card>
<h1>A Card</h1>
<h2>Subtitle</h2>
<p>
Margins are applied to all elements inside a card. <br/>
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.
</p>
</Card>
)).add('Full width content', () => (
<div style={styles.fixedWidth}>
<Card>
<img src="https://i.ytimg.com/vi/tntOCGkgt98/maxresdefault.jpg" />
<h1>Card & cat</h1>
<p>Media Elements such as video, img (and iFrame for embeds) don't have margin</p>
</Card>
</div>
<Card>
<img src="https://i.ytimg.com/vi/tntOCGkgt98/maxresdefault.jpg" />
<h1>Card & cat</h1>
<p>Media Elements such as video, img (and iFrame for embeds) don't have margin</p>
</Card>
)).add('Footer', () => (
<div style={styles.fixedWidth}>
<Card>
<img src="http://www.top13.net/wp-content/uploads/2015/10/perfectly-timed-funny-cat-pictures-5.jpg" />
<h1>Now with footer.</h1>
<p>header and footer elements are also not subject to margin</p>
<footer style={styles.footer}>&copy; Thousand Cats Corp</footer>
</Card>
</div>
<Card>
<img src="http://www.top13.net/wp-content/uploads/2015/10/perfectly-timed-funny-cat-pictures-5.jpg" />
<h1>Now with footer.</h1>
<p>header and footer elements are also not subject to margin</p>
<footer style={styles.footer}>&copy; Thousand Cats Corp</footer>
</Card>
))

43
src/containers/App.css Normal file
View File

@ -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;
}

View File

@ -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 {
</div>;
}
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 (
<div>
<FindBar commands={[
{ id: LIST_POSTS, pattern: 'List Posts' },
{ id: LIST_FAQ, pattern: 'List FAQs' },
{ id: HELP, pattern: 'Help' },
]} />
{children}
<header>
<div className={styles.alignable}>
<FindBar
commands={commands}
defaultCommands={defaultCommands}
/>
</div>
</header>
<div className={`${styles.alignable} ${styles.main}`}>
{children}
</div>
</div>
);
}
}
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);

View File

@ -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 <div>
<h1>Dashboard</h1>
<div>
{collections.map((collection) => (
<div key={collection.get('name')}>
<Link to={`/collections/${collection.get('name')}`}>{collection.get('name')}</Link>
</div>
)).toArray()}
</div>
<div>
{entries ? <EntryListing collection={collection} entries={entries}/> : 'Loading entries...'}
</div>
{entries ? <EntryListing collection={collection} entries={entries}/> : 'Loading entries...'}
</div>;
}
}

View File

@ -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);

View File

@ -10,8 +10,8 @@
position: relative;
background-color: var(--backgroundColor);
padding: 1px 0;
margin: 4px auto;
}
.inputArea {
display: table;
width: calc(100% - 10px);

View File

@ -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),

View File

@ -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;

View File

@ -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;
}

View File

@ -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((
<Provider store={store}>
<Routes/>
<Router history={history}>
{routes}
</Router>
</Provider>
), el);

View File

@ -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;
}

View File

@ -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;

View File

@ -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);

View File

@ -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);
}
};

View File

@ -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 () => (
<Router history={browserHistory}>
<Route path="/" component={App}>
<IndexRoute component={CollectionPage}/>
<Route path="/collections/:name" component={CollectionPage}/>
<Route path="/collections/:name/entries/:slug" component={EntryPage}/>
<Route path="/search" component={SearchPage}/>
<Route path="*" component={NotFoundPage}/>
</Route>
</Router>
);

14
src/routing/history.js Normal file
View File

@ -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;

17
src/routing/routes.js Normal file
View File

@ -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 (
<Route path="/" component={App}>
<IndexRoute component={CollectionPage}/>
<Route path="/collections/:name" component={CollectionPage}/>
<Route path="/collections/:name/entries/:slug" component={EntryPage}/>
<Route path="/search" component={SearchPage}/>
<Route path="*" component={NotFoundPage}/>
</Route>
);

View File

@ -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);

View File

@ -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);
});
};

View File

@ -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'
})