diff --git a/package.json b/package.json index dae65acf..81573aa6 100644 --- a/package.json +++ b/package.json @@ -117,6 +117,7 @@ "react-redux": "^4.4.0", "react-router": "^2.5.1", "react-router-redux": "^4.0.5", + "react-sidebar": "^2.2.1", "react-simple-dnd": "^0.1.2", "react-sortable": "^1.2.0", "react-toolbox": "^1.2.1", diff --git a/src/actions/globalUI.js b/src/actions/globalUI.js new file mode 100644 index 00000000..ca9e4088 --- /dev/null +++ b/src/actions/globalUI.js @@ -0,0 +1,13 @@ +export const TOGGLE_SIDEBAR = 'TOGGLE_SIDEBAR'; +export const OPEN_SIDEBAR = 'OPEN_SIDEBAR'; + +export function toggleSidebar() { + return { type: TOGGLE_SIDEBAR }; +} + +export function openSidebar(open = false) { + return { + type: OPEN_SIDEBAR, + payload: { open }, + }; +} diff --git a/src/backends/backend.js b/src/backends/backend.js index ac7f5cb2..38b93dac 100644 --- a/src/backends/backend.js +++ b/src/backends/backend.js @@ -2,8 +2,8 @@ import TestRepoBackend from './test-repo/implementation'; import GitHubBackend from './github/implementation'; import NetlifyGitBackend from './netlify-git/implementation'; import { resolveFormat } from '../formats/formats'; +import { selectListMethod, selectEntrySlug, selectEntryPath, selectAllowNewEntries } from '../reducers/collections'; import { createEntry } from '../valueObjects/Entry'; -import Collection from '../valueObjects/Collection'; class LocalStorageAuthStore { storageKey = 'nf-cms-user'; @@ -80,13 +80,12 @@ class Backend { } listEntries(collection) { - const collectionModel = new Collection(collection); - const listMethod = this.implementation[collectionModel.listMethod()]; + const listMethod = this.implementation[selectListMethod(collection)]; return listMethod.call(this.implementation, collection) .then(loadedEntries => ( loadedEntries.map(loadedEntry => createEntry( collection.get('name'), - collectionModel.entrySlug(loadedEntry.file.path), + selectEntrySlug(collection, loadedEntry.file.path), loadedEntry.file.path, { raw: loadedEntry.data, label: loadedEntry.file.label } )) @@ -99,7 +98,7 @@ class Backend { } getEntry(collection, slug) { - return this.implementation.getEntry(collection, slug, new Collection(collection).entryPath(slug)) + return this.implementation.getEntry(collection, slug, selectEntryPath(collection, slug)) .then(loadedEntry => this.entryWithFormat(collection, slug)(createEntry( collection.get('name'), slug, @@ -150,7 +149,6 @@ class Backend { } persistEntry(config, collection, entryDraft, MediaFiles, options) { - const collectionModel = new Collection(collection); const newEntry = entryDraft.getIn(['entry', 'newRecord']) || false; const parsedData = { @@ -161,11 +159,11 @@ class Backend { const entryData = entryDraft.getIn(['entry', 'data']).toJS(); let entryObj; if (newEntry) { - if (!collectionModel.allowNewEntries()) { + if (!selectAllowNewEntries(collection)) { throw (new Error('Not allowed to create new entries in this collection')); } const slug = slugFormatter(collection.get('slug'), entryDraft.getIn(['entry', 'data'])); - const path = collectionModel.entryPath(slug); + const path = selectEntryPath(collection, slug); entryObj = { path, slug, diff --git a/src/components/AppHeader/AppHeader.js b/src/components/AppHeader/AppHeader.js index 3f999d6c..96392d0e 100644 --- a/src/components/AppHeader/AppHeader.js +++ b/src/components/AppHeader/AppHeader.js @@ -17,7 +17,7 @@ export default class AppHeader extends React.Component { commands: PropTypes.array.isRequired, // eslint-disable-line defaultCommands: PropTypes.array.isRequired, // eslint-disable-line runCommand: PropTypes.func.isRequired, - toggleNavDrawer: PropTypes.func.isRequired, + toggleDrawer: PropTypes.func.isRequired, onCreateEntryClick: PropTypes.func.isRequired, onLogoutClick: PropTypes.func.isRequired, }; @@ -59,7 +59,7 @@ export default class AppHeader extends React.Component { commands, defaultCommands, runCommand, - toggleNavDrawer, + toggleDrawer, onLogoutClick, } = this.props; @@ -88,7 +88,7 @@ export default class AppHeader extends React.Component { } - onLeftIconClick={toggleNavDrawer} + onLeftIconClick={toggleDrawer} onRightIconClick={this.handleRightIconClick} > diff --git a/src/components/Cards.js b/src/components/Cards.js deleted file mode 100644 index ef5c4316..00000000 --- a/src/components/Cards.js +++ /dev/null @@ -1,11 +0,0 @@ -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 deleted file mode 100644 index f0e04a3c..00000000 --- a/src/components/Cards/AlltypeCard.css +++ /dev/null @@ -1,5 +0,0 @@ -.cardContent { - white-space: nowrap; - text-align: center; - font-weight: 500; -} diff --git a/src/components/Cards/AlltypeCard.js b/src/components/Cards/AlltypeCard.js deleted file mode 100644 index 07956790..00000000 --- a/src/components/Cards/AlltypeCard.js +++ /dev/null @@ -1,81 +0,0 @@ -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 deleted file mode 100644 index 72b8c3cf..00000000 --- a/src/components/Cards/ImageCard.css +++ /dev/null @@ -1,4 +0,0 @@ -.root { - width: 240px; - cursor: pointer; -} diff --git a/src/components/Cards/ImageCard.js b/src/components/Cards/ImageCard.js deleted file mode 100644 index 92d06fbb..00000000 --- a/src/components/Cards/ImageCard.js +++ /dev/null @@ -1,49 +0,0 @@ -import React, { PropTypes } from 'react'; -import { Card, CardMedia, CardTitle, CardText } from 'react-toolbox/lib/card'; -import styles from './ImageCard.css'; - -const ImageCard = ( - { - author, - description, - image, - text, - onClick, - onImageLoaded, - }) => ( - - - { - image && - {text} - - } - { description && { description } } - -); - -ImageCard.propTypes = { - image: PropTypes.string, - onClick: PropTypes.func, - onImageLoaded: PropTypes.func, - text: PropTypes.string.isRequired, - description: PropTypes.string, -}; - -ImageCard.defaultProps = { - onClick: () => { - }, - onImageLoaded: () => { - }, -}; - -export default ImageCard; diff --git a/src/components/Cards/ScaledLine.js b/src/components/Cards/ScaledLine.js deleted file mode 100644 index be274a6d..00000000 --- a/src/components/Cards/ScaledLine.js +++ /dev/null @@ -1,39 +0,0 @@ -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 deleted file mode 100644 index fafb1110..00000000 --- a/src/components/Cards/UnknownCard.js +++ /dev/null @@ -1,15 +0,0 @@ -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/ControlPanel/ControlPane.js b/src/components/ControlPanel/ControlPane.js index 4f8a96f3..f11114f0 100644 --- a/src/components/ControlPanel/ControlPane.js +++ b/src/components/ControlPanel/ControlPane.js @@ -3,6 +3,10 @@ import ImmutablePropTypes from 'react-immutable-proptypes'; import { resolveWidget } from '../Widgets'; import styles from './ControlPane.css'; +function isHidden(field) { + return field.get('widget') === 'hidden'; +} + export default class ControlPane extends Component { controlFor(field) { @@ -38,7 +42,7 @@ export default class ControlPane extends Component {
{ fields.map(field => -
diff --git a/src/components/EntryListing.js b/src/components/EntryListing.js deleted file mode 100644 index 27f7395d..00000000 --- a/src/components/EntryListing.js +++ /dev/null @@ -1,127 +0,0 @@ -import React, { PropTypes } from 'react'; -import ImmutablePropTypes from 'react-immutable-proptypes'; -import { Map } from 'immutable'; -import { throttle } from 'lodash'; -import bricks from 'bricks.js'; -import Waypoint from 'react-waypoint'; -import history from '../routing/history'; -import Cards from './Cards'; - -export default class EntryListing extends React.Component { - static propTypes = { - children: PropTypes.node.isRequired, - collections: PropTypes.oneOfType([ - ImmutablePropTypes.map, - ImmutablePropTypes.iterable, - ]).isRequired, - entries: ImmutablePropTypes.list, - onPaginate: PropTypes.func.isRequired, - page: PropTypes.number, - }; - - 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: '1515px', columns: 5, gutter: 15 }, - { mq: '1770px', columns: 6, gutter: 15 }, - ], - }; - - this.updateBricks = throttle(this.updateBricks.bind(this), 30); - this.handleLoadMore = this.handleLoadMore.bind(this); - } - - componentDidMount() { - this.bricksInstance = bricks({ - container: this.containerNode, - 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 cardType = collection.getIn(['card', 'type']) || 'alltype'; - const card = Cards[cardType] || Cards.unknown; - return React.createElement(card, { - key: entry.get('slug'), - collection, - description: entry.getIn(['data', collection.getIn(['card', 'description'])]), - image: entry.getIn(['data', collection.getIn(['card', 'image'])]), - link, - text: entry.get('label') ? entry.get('label') : entry.getIn(['data', collection.getIn(['card', 'text'])]), - onClick: history.push.bind(this, link), - onImageLoaded: this.updateBricks, - }); - } - - handleLoadMore() { - this.props.onPaginate(this.props.page + 1); - } - - handleRef = (node) => { - this.containerNode = node; - }; - - renderCards = () => { - const { collections, entries } = this.props; - if (Map.isMap(collections)) { - const collectionName = collections.get('name'); - return entries.map((entry) => { - const path = `/collections/${ collectionName }/entries/${ entry.get('slug') }`; - return this.cardFor(collections, entry, path); - }); - } - return entries.map((entry) => { - const collection = collections - .filter(collection => collection.get('name') === entry.get('collection')).first(); - const path = `/collections/${ collection.get('name') }/entries/${ entry.get('slug') }`; - return this.cardFor(collection, entry, path); - }); - }; - - render() { - const { children } = this.props; - return ( -
-

{children}

-
- { this.renderCards() } - -
-
- ); - } -} diff --git a/src/components/EntryListing/EntryListing.css b/src/components/EntryListing/EntryListing.css new file mode 100644 index 00000000..a640fbd0 --- /dev/null +++ b/src/components/EntryListing/EntryListing.css @@ -0,0 +1,21 @@ +.card { + overflow: hidden; + margin-bottom: 10px; + max-height: 290px; + width: 240px; + cursor: pointer; +} + +.cardImage { + width: 240px; + height: 135px; + background-position: center center; + background-size: cover; + background-repeat: no-repeat; +} + +.cardsGrid { + display: flex; + flex-flow: row wrap; + justify-content: space-around; +} diff --git a/src/components/EntryListing/EntryListing.js b/src/components/EntryListing/EntryListing.js new file mode 100644 index 00000000..776fd6ed --- /dev/null +++ b/src/components/EntryListing/EntryListing.js @@ -0,0 +1,89 @@ +import React, { PropTypes } from 'react'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import Waypoint from 'react-waypoint'; +import { Map } from 'immutable'; +import history from '../../routing/history'; +import { selectFields, selectInferedField } from '../../reducers/collections'; +import { Card } from '../UI'; +import styles from './EntryListing.css'; + +export default class EntryListing extends React.Component { + static propTypes = { + children: PropTypes.node.isRequired, + collections: PropTypes.oneOfType([ + ImmutablePropTypes.map, + ImmutablePropTypes.iterable, + ]).isRequired, + entries: ImmutablePropTypes.list, + onPaginate: PropTypes.func.isRequired, + page: PropTypes.number, + }; + + handleLoadMore = () => { + this.props.onPaginate(this.props.page + 1); + }; + + inferFields(collection) { + const titleField = selectInferedField(collection, 'title'); + const descriptionField = selectInferedField(collection, 'description'); + const imageField = selectInferedField(collection, 'image'); + const fields = selectFields(collection); + const inferedFields = [titleField, descriptionField, imageField]; + const remainingFields = fields && fields.filter(f => inferedFields.indexOf(f.get('name')) === -1); + return { titleField, descriptionField, imageField, remainingFields }; + } + + renderCard(collection, entry, inferedFields) { + const path = `/collections/${ collection.get('name') }/entries/${ entry.get('slug') }`; + const label = entry.get('label'); + const title = label || entry.getIn(['data', inferedFields.titleField]); + const image = entry.getIn(['data', inferedFields.imageField]); + return ( + + { image && +
+ } +

{title}

+ {inferedFields.descriptionField ? +

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

+ : inferedFields.remainingFields && inferedFields.remainingFields.map(f => ( +

+ {f.get('label')}: {entry.getIn(['data', f.get('name')])} +

+ )) + } + + ); + } + renderCards = () => { + const { collections, entries } = this.props; + + if (Map.isMap(collections)) { + const inferedFields = this.inferFields(collections); + return entries.map(entry => this.renderCard(collections, entry, inferedFields)); + } + return entries.map((entry) => { + const collection = collections + .filter(collection => collection.get('name') === entry.get('collection')).first(); + const inferedFields = this.inferFields(collection); + return this.renderCard(collection, entry, inferedFields); + }); + }; + + render() { + const { children } = this.props; + return ( +
+

{children}

+
+ { this.renderCards() } + +
+
+ ); + } +} diff --git a/src/components/PreviewPane/Preview.js b/src/components/PreviewPane/Preview.js index ea7d4867..1fde2335 100644 --- a/src/components/PreviewPane/Preview.js +++ b/src/components/PreviewPane/Preview.js @@ -1,13 +1,17 @@ import React, { PropTypes } from 'react'; import ImmutablePropTypes from 'react-immutable-proptypes'; +function isVisible(field) { + return field.get('widget') !== 'hidden'; +} + export default function Preview({ collection, fields, widgetFor }) { if (!collection || !fields) { return null; } return (
- {fields.map(field => widgetFor(field.get('name')))} + {fields.filter(isVisible).map(field => widgetFor(field.get('name')))}
); } diff --git a/src/components/PreviewPane/PreviewPane.js b/src/components/PreviewPane/PreviewPane.js index 1a297bef..87734a2d 100644 --- a/src/components/PreviewPane/PreviewPane.js +++ b/src/components/PreviewPane/PreviewPane.js @@ -3,13 +3,12 @@ import ReactDOM from 'react-dom'; import ImmutablePropTypes from 'react-immutable-proptypes'; import { ScrollSyncPane } from '../ScrollSync'; import registry from '../../lib/registry'; -import Collection from '../../valueObjects/Collection'; import { resolveWidget } from '../Widgets'; +import { selectTemplateName } from '../../reducers/collections'; import Preview from './Preview'; import styles from './PreviewPane.css'; export default class PreviewPane extends React.Component { - componentDidUpdate() { this.renderPreview(); } @@ -28,8 +27,8 @@ export default class PreviewPane extends React.Component { renderPreview() { const { entry, collection } = this.props; - const collectionModel = new Collection(collection); - const component = registry.getPreviewTemplate(collectionModel.templateName(entry.get('slug'))) || Preview; + const component = registry.getPreviewTemplate(selectTemplateName(collection, entry.get('slug'))) || Preview; + const previewProps = { ...this.props, widgetFor: this.widgetFor, diff --git a/src/components/UI/card/Card.css b/src/components/UI/card/Card.css index 43d7392e..2a286f7a 100644 --- a/src/components/UI/card/Card.css +++ b/src/components/UI/card/Card.css @@ -1,4 +1,4 @@ -@import "../theme.css"; +@import '../theme.css'; .card { composes: base container rounded depth; @@ -7,8 +7,8 @@ } .card > *:not(iframe, video, img, header, footer) { - margin-left: 10px; margin-right: 10px; + margin-left: 10px; } .card > *:not(iframe, video, img, header, footer):first-child { @@ -19,6 +19,16 @@ margin-bottom: 10px; } -.card > iframe, .card > video, .card > img { +.card > iframe, +.card > video, +.card > img { max-width: 100%; } + +.card h1 { + border: none; + color: var(--defaultColor); + font-size: 18px; + margin: 15px 0; + padding: 0; +} diff --git a/src/components/UnpublishedListing.css b/src/components/UnpublishedListing/UnpublishedListing.css similarity index 100% rename from src/components/UnpublishedListing.css rename to src/components/UnpublishedListing/UnpublishedListing.css diff --git a/src/components/UnpublishedListing.js b/src/components/UnpublishedListing/UnpublishedListing.js similarity index 98% rename from src/components/UnpublishedListing.js rename to src/components/UnpublishedListing/UnpublishedListing.js index baa297d1..6d41ac28 100644 --- a/src/components/UnpublishedListing.js +++ b/src/components/UnpublishedListing/UnpublishedListing.js @@ -5,7 +5,7 @@ import { Link } from 'react-router'; import moment from 'moment'; import { Card, CardTitle, CardText, CardActions } from 'react-toolbox/lib/card'; import Button from 'react-toolbox/lib/button'; -import { status, statusDescriptions } from '../constants/publishModes'; +import { status, statusDescriptions } from '../../constants/publishModes'; import styles from './UnpublishedListing.css'; class UnpublishedListing extends React.Component { diff --git a/src/components/Widgets/DateTimePreview.js b/src/components/Widgets/DateTimePreview.js index 972e068c..e009069b 100644 --- a/src/components/Widgets/DateTimePreview.js +++ b/src/components/Widgets/DateTimePreview.js @@ -1,9 +1,9 @@ import React, { PropTypes } from 'react'; -export default function StringPreview({ value }) { - return {value}; +export default function DatePreview({ value }) { + return {value ? value.toString() : null}; } -StringPreview.propTypes = { +DatePreview.propTypes = { value: PropTypes.node, }; diff --git a/src/components/Widgets/ListPreview.js b/src/components/Widgets/ListPreview.js index ed9542ca..dcfbdcae 100644 --- a/src/components/Widgets/ListPreview.js +++ b/src/components/Widgets/ListPreview.js @@ -1,7 +1,7 @@ import React, { PropTypes, Component } from 'react'; import { resolveWidget } from '../Widgets'; -export default class ObjectPreview extends Component { +export default class ListPreview extends Component { widgetFor = (field, value) => { const { getMedia } = this.props; const widget = resolveWidget(field.get('widget')); @@ -22,11 +22,11 @@ export default class ObjectPreview extends Component {
)}
) : null; } - return value ? value.join(', ') : null; + return {value ? value.join(', ') : null}; } } -ObjectPreview.propTypes = { +ListPreview.propTypes = { value: PropTypes.node, field: PropTypes.node, getMedia: PropTypes.func.isRequired, diff --git a/src/components/Widgets/NumberPreview.js b/src/components/Widgets/NumberPreview.js index 972e068c..a1c41c1c 100644 --- a/src/components/Widgets/NumberPreview.js +++ b/src/components/Widgets/NumberPreview.js @@ -1,9 +1,9 @@ import React, { PropTypes } from 'react'; -export default function StringPreview({ value }) { - return {value}; +export default function NumberPreview({ value }) { + return {value ? value.toString() : null}; } -StringPreview.propTypes = { +NumberPreview.propTypes = { value: PropTypes.node, }; diff --git a/src/components/Widgets/ObjectPreview.js b/src/components/Widgets/ObjectPreview.js index 3c541ef6..a7384b8c 100644 --- a/src/components/Widgets/ObjectPreview.js +++ b/src/components/Widgets/ObjectPreview.js @@ -17,7 +17,7 @@ export default class ObjectPreview extends Component { const { field } = this.props; const fields = field && field.get('fields'); - return
{fields && fields.map(f => this.widgetFor(f))}
; + return
{fields ? fields.map(f => this.widgetFor(f)) : null}
; } } diff --git a/src/components/Widgets/StringPreview.js b/src/components/Widgets/StringPreview.js index 972e068c..84af9b5c 100644 --- a/src/components/Widgets/StringPreview.js +++ b/src/components/Widgets/StringPreview.js @@ -1,7 +1,7 @@ import React, { PropTypes } from 'react'; export default function StringPreview({ value }) { - return {value}; + return {value ? value.toString() : null}; } StringPreview.propTypes = { diff --git a/src/components/Widgets/TextPreview.js b/src/components/Widgets/TextPreview.js index bca5e04c..71ff407b 100644 --- a/src/components/Widgets/TextPreview.js +++ b/src/components/Widgets/TextPreview.js @@ -1,4 +1,9 @@ -import StringPreview from './StringPreview'; +import React, { PropTypes } from 'react'; -export default class TextPreview extends StringPreview { +export default function TextPreview({ value }) { + return {value ? value.toString() : null}; } + +TextPreview.propTypes = { + value: PropTypes.node, +}; diff --git a/src/containers/App.css b/src/containers/App.css index 22af60c0..b5799025 100644 --- a/src/containers/App.css +++ b/src/containers/App.css @@ -1,28 +1,8 @@ -@import '../components/UI/theme.css'; - -.nav { - display: block; - padding: 1rem; - - & .heading { - border: none; - } +.root { + margin-top: 64px; } -.navDrawer { - max-width: 240px !important; - - & .drawerContent { - max-width: 240px !important; - } -} - -.notifsContainer { - position: fixed; - top: 60px; - right: 0; - bottom: 60px; - z-index: var(--topmostZindex); - width: 360px; - pointer-events: none; +.sidebar { + width: 200px; + background-color: #fff; } diff --git a/src/containers/App.js b/src/containers/App.js index 4b28381b..225842c4 100644 --- a/src/containers/App.js +++ b/src/containers/App.js @@ -2,13 +2,15 @@ import React, { PropTypes } from 'react'; import ImmutablePropTypes from 'react-immutable-proptypes'; import pluralize from 'pluralize'; import { connect } from 'react-redux'; -import { Layout, Panel, NavDrawer } from 'react-toolbox/lib/layout'; +import { Layout, Panel } from 'react-toolbox/lib/layout'; import { Navigation } from 'react-toolbox/lib/navigation'; import { Link } from 'react-toolbox/lib/link'; import { Notifs } from 'redux-notifications'; import TopBarProgress from 'react-topbar-progress-indicator'; +import Sidebar from './Sidebar'; import { loadConfig } from '../actions/config'; import { loginUser, logoutUser } from '../actions/auth'; +import { toggleSidebar } from '../actions/globalUI'; import { currentBackend } from '../backends/backend'; import { SHOW_COLLECTION, @@ -42,6 +44,7 @@ class App extends React.Component { createNewEntryInCollection: PropTypes.func.isRequired, logoutUser: PropTypes.func.isRequired, dispatch: PropTypes.func.isRequired, + toggleSidebar: PropTypes.func.isRequired, navigateToCollection: PropTypes.func.isRequired, user: ImmutablePropTypes.map, runCommand: PropTypes.func.isRequired, @@ -59,10 +62,6 @@ class App extends React.Component { ); } - state = { - navDrawerIsVisible: true, - }; - componentDidMount() { this.props.dispatch(loadConfig()); } @@ -123,19 +122,13 @@ class App extends React.Component { return { commands, defaultCommands }; } - toggleNavDrawer = () => { - this.setState({ - navDrawerIsVisible: !this.state.navDrawerIsVisible, - }); - }; - render() { - const { navDrawerIsVisible } = this.state; const { user, config, children, collections, + toggleSidebar, runCommand, navigateToCollection, createNewEntryInCollection, @@ -160,67 +153,65 @@ class App extends React.Component { } const { commands, defaultCommands } = this.generateFindBarCommands(); + const sidebarContent = ( + + ); return ( - - - - - - - - { isFetching && } -
- {children} -
-
-
+ + + + + + { isFetching && } +
+ {children} +
+
+ +
+
); } } function mapStateToProps(state) { - const { auth, config, collections, global } = state; + const { auth, config, collections, globalUI } = state; const user = auth && auth.get('user'); - const { isFetching } = global; + const isFetching = globalUI.get('isFetching'); return { auth, config, collections, user, isFetching }; } function mapDispatchToProps(dispatch) { return { dispatch, + toggleSidebar: () => dispatch(toggleSidebar()), runCommand: (type, payload) => { dispatch(runCommand(type, payload)); }, diff --git a/src/containers/CollectionPage.js b/src/containers/CollectionPage.js index 4074e18d..62199cbf 100644 --- a/src/containers/CollectionPage.js +++ b/src/containers/CollectionPage.js @@ -4,7 +4,7 @@ import { connect } from 'react-redux'; import { loadEntries } from '../actions/entries'; import { selectEntries } from '../reducers'; import { Loader } from '../components/UI'; -import EntryListing from '../components/EntryListing'; +import EntryListing from '../components/EntryListing/EntryListing'; import styles from './breakpoints.css'; class CollectionPage extends React.Component { diff --git a/src/containers/EntryPage.js b/src/containers/EntryPage.js index 235d57be..f53ee14a 100644 --- a/src/containers/EntryPage.js +++ b/src/containers/EntryPage.js @@ -11,8 +11,9 @@ import { } from '../actions/entries'; import { cancelEdit } from '../actions/editor'; import { addMedia, removeMedia } from '../actions/media'; +import { openSidebar } from '../actions/globalUI'; import { selectEntry, getMedia } from '../reducers'; -import Collection from '../valueObjects/Collection'; +import { selectFields } from '../reducers/collections'; import EntryEditor from '../components/EntryEditor/EntryEditor'; import entryPageHOC from './editorialWorkflow/EntryPageHOC'; import { Loader } from '../components/UI'; @@ -32,6 +33,7 @@ class EntryPage extends React.Component { persistEntry: PropTypes.func.isRequired, removeMedia: PropTypes.func.isRequired, cancelEdit: PropTypes.func.isRequired, + openSidebar: PropTypes.func.isRequired, fields: ImmutablePropTypes.list.isRequired, slug: PropTypes.string, newEntry: PropTypes.bool.isRequired, @@ -39,6 +41,7 @@ class EntryPage extends React.Component { componentDidMount() { const { entry, newEntry, collection, slug, loadEntry } = this.props; + this.props.openSidebar(); if (newEntry) { createEmptyDraft(collection); } else { @@ -108,9 +111,8 @@ function mapStateToProps(state, ownProps) { const { collections, entryDraft } = state; const slug = ownProps.params.slug; const collection = collections.get(ownProps.params.name); - const collectionModel = new Collection(collection); const newEntry = ownProps.route && ownProps.route.newRecord === true; - + const fields = selectFields(collection, slug); const entry = newEntry ? null : selectEntry(state, collection.get('name'), slug); const boundGetMedia = getMedia.bind(null, state); return { @@ -119,7 +121,7 @@ function mapStateToProps(state, ownProps) { newEntry, entryDraft, boundGetMedia, - fields: collectionModel.entryFields(slug), + fields, slug, entry, }; @@ -137,5 +139,6 @@ export default connect( discardDraft, persistEntry, cancelEdit, + openSidebar, } )(entryPageHOC(EntryPage)); diff --git a/src/containers/SearchPage.js b/src/containers/SearchPage.js index 05fb769c..68f440dc 100644 --- a/src/containers/SearchPage.js +++ b/src/containers/SearchPage.js @@ -4,7 +4,7 @@ import { connect } from 'react-redux'; import { selectSearchedEntries } from '../reducers'; import { searchEntries } from '../actions/entries'; import { Loader } from '../components/UI'; -import EntryListing from '../components/EntryListing'; +import EntryListing from '../components/EntryListing/EntryListing'; import styles from './breakpoints.css'; class SearchPage extends React.Component { diff --git a/src/containers/Sidebar.css b/src/containers/Sidebar.css new file mode 100644 index 00000000..ff3a9933 --- /dev/null +++ b/src/containers/Sidebar.css @@ -0,0 +1,8 @@ +.root { + margin-top: 64px; +} + +.sidebar { + width: 200px; + background-color: #fff +} diff --git a/src/containers/Sidebar.js b/src/containers/Sidebar.js new file mode 100644 index 00000000..072ca288 --- /dev/null +++ b/src/containers/Sidebar.js @@ -0,0 +1,63 @@ +import React, { PropTypes } from 'react'; +import { connect } from 'react-redux'; +import ReactSidebar from 'react-sidebar'; +import _ from 'lodash'; +import { openSidebar } from '../actions/globalUI'; +import styles from './Sidebar.css'; + +class Sidebar extends React.Component { + + static propTypes = { + children: PropTypes.node.isRequired, + content: PropTypes.node.isRequired, + sidebarIsOpen: PropTypes.bool.isRequired, + openSidebar: PropTypes.func.isRequired, + }; + + state = { sidebarDocked: false }; + + componentWillMount() { + this.mql = window.matchMedia('(min-width: 1200px)'); + this.mql.addListener(this.mediaQueryChanged); + this.setState({ sidebarDocked: this.mql.matches }); + } + + componentWillUnmount() { + this.mql.removeListener(this.mediaQueryChanged); + } + + mediaQueryChanged = _.throttle(() => { + this.setState({ sidebarDocked: this.mql.matches }); + }, 500); + + + render() { + const { + children, + content, + sidebarIsOpen, + openSidebar, + } = this.props; + + return ( + + {children} + + ); + } +} + +function mapStateToProps(state) { + const { globalUI } = state; + const sidebarIsOpen = globalUI.get('sidebarIsOpen'); + return { sidebarIsOpen }; +} + +export default connect(mapStateToProps, { openSidebar })(Sidebar); diff --git a/src/containers/editorialWorkflow/UnpublishedEntriesPanel.js b/src/containers/editorialWorkflow/UnpublishedEntriesPanel.js index 8e086873..5c3878f4 100644 --- a/src/containers/editorialWorkflow/UnpublishedEntriesPanel.js +++ b/src/containers/editorialWorkflow/UnpublishedEntriesPanel.js @@ -5,11 +5,13 @@ import { connect } from 'react-redux'; import { loadUnpublishedEntries, updateUnpublishedEntryStatus, publishUnpublishedEntry } from '../../actions/editorialWorkflow'; import { selectUnpublishedEntries } from '../../reducers'; import { EDITORIAL_WORKFLOW, status } from '../../constants/publishModes'; -import UnpublishedListing from '../../components/UnpublishedListing'; +import UnpublishedListing from '../../components/UnpublishedListing/UnpublishedListing'; +import { Loader } from '../../components/UI'; class unpublishedEntriesPanel extends Component { static propTypes = { isEditorialWorkflow: PropTypes.bool.isRequired, + isFetching: PropTypes.bool.isRequired, unpublishedEntries: ImmutablePropTypes.map, loadUnpublishedEntries: PropTypes.func.isRequired, updateUnpublishedEntryStatus: PropTypes.func.isRequired, @@ -24,9 +26,9 @@ class unpublishedEntriesPanel extends Component { } render() { - const { isEditorialWorkflow, unpublishedEntries, updateUnpublishedEntryStatus, publishUnpublishedEntry } = this.props; + const { isEditorialWorkflow, isFetching, unpublishedEntries, updateUnpublishedEntryStatus, publishUnpublishedEntry } = this.props; if (!isEditorialWorkflow) return null; - + if (isFetching) return Loading Editorial Workflow Entries; return ( { const entries = response.hits.map((hit) => { - const slug = collectionModel.entrySlug(hit.path); + const slug = selectEntrySlug(collection, hit.path); return createEntry(collection.get('name'), slug, hit.path, { data: hit.data, partial: true }); }); this.entriesCache = { collection, pagination: response.page, entries }; diff --git a/src/lib/consoleError.js b/src/lib/consoleError.js new file mode 100644 index 00000000..99986a30 --- /dev/null +++ b/src/lib/consoleError.js @@ -0,0 +1,8 @@ + +export default function consoleError(title, description) { + console.error( + `%c ⛔ ${ title }\n` + `%c${ description }\n\n`, + 'color: black; font-weight: bold; font-size: 16px; line-height: 50px;', + 'color: black;' + ); +} diff --git a/src/reducers/collections.js b/src/reducers/collections.js index 8d0bf0f6..c3a9934f 100644 --- a/src/reducers/collections.js +++ b/src/reducers/collections.js @@ -1,19 +1,22 @@ import { OrderedMap, fromJS } from 'immutable'; +import consoleError from '../lib/consoleError'; import { CONFIG_SUCCESS } from '../actions/config'; import { FILES, FOLDER } from '../constants/collectionTypes'; const hasProperty = (config, property) => ({}.hasOwnProperty.call(config, property)); const collections = (state = null, action) => { + const configCollections = action.payload && action.payload.collections; switch (action.type) { case CONFIG_SUCCESS: - const configCollections = action.payload && action.payload.collections; return OrderedMap().withMutations((map) => { (configCollections || []).forEach((configCollection) => { if (hasProperty(configCollection, 'folder')) { configCollection.type = FOLDER; // eslint-disable-line no-param-reassign } else if (hasProperty(configCollection, 'files')) { configCollection.type = FILES; // eslint-disable-line no-param-reassign + } else { + throw new Error('Unknown collection type. Collections can be either Folder based or File based. Please verify your site configuration'); } map.set(configCollection.name, fromJS(configCollection)); }); @@ -23,4 +26,126 @@ const collections = (state = null, action) => { } }; +const formatToExtension = format => ({ + markdown: 'md', + yaml: 'yml', + json: 'json', + html: 'html', +}[format]); + +const inferables = { + title: { + type: 'string', + secondaryTypes: [], + synonyms: ['title', 'name', 'label', 'headline'], + fallbackToFirstField: true, + showError: true, + }, + description: { + type: 'string', + secondaryTypes: ['text', 'markdown'], + synonyms: ['shortDescription', 'short_description', 'shortdescription', 'description', 'brief', 'body', 'content', 'biography', 'bio'], + fallbackToFirstField: false, + showError: false, + }, + image: { + type: 'image', + secondaryTypes: [], + synonyms: ['image', 'thumbnail', 'thumb', 'picture', 'avatar'], + fallbackToFirstField: false, + showError: false, + }, +}; + +const selectors = { + [FOLDER]: { + entryExtension(collection) { + return collection.get('extension') || formatToExtension(collection.get('format') || 'markdown'); + }, + fields(collection) { + return collection.get('fields'); + }, + entryPath(collection, slug) { + return `${ collection.get('folder') }/${ slug }.${ this.entryExtension(collection) }`; + }, + entrySlug(collection, path) { + return path.split('/').pop().replace(/\.[^\.]+$/, ''); + }, + listMethod() { + return 'entriesByFolder'; + }, + allowNewEntries(collection) { + return collection.get('create'); + }, + templateName(collection) { + return collection.get('name'); + }, + }, + [FILES]: { + fileForEntry(collection, slug) { + const files = collection.get('files'); + return files.filter(f => f.get('name') === slug).get(0); + }, + fields(collection, slug) { + const file = this.fileForEntry(collection, slug); + return file && file.get('fields'); + }, + entryPath(collection, slug) { + const file = this.fileForEntry(collection, slug); + return file && file.get('file'); + }, + entrySlug(collection, path) { + const file = collection.get('files').filter(f => f.get('file') === path).get(0); + return file && file.get('name'); + }, + listMethod() { + return 'entriesByFiles'; + }, + allowNewEntries() { + return false; + }, + templateName(collection, slug) { + return slug; + }, + }, +}; + +export const selectFields = (collection, slug) => selectors[collection.get('type')].fields(collection, slug); +export const selectEntryPath = (collection, slug) => selectors[collection.get('type')].entryPath(collection, slug); +export const selectEntrySlug = (collection, path) => selectors[collection.get('type')].entrySlug(collection, path); +export const selectListMethod = collection => selectors[collection.get('type')].listMethod(); +export const selectAllowNewEntries = collection => selectors[collection.get('type')].allowNewEntries(collection); +export const selectTemplateName = (collection, slug) => selectors[collection.get('type')].templateName(collection, slug); +export const selectInferedField = (collection, fieldName) => { + const inferableField = inferables[fieldName]; + const fields = collection.get('fields'); + let field; + + // If colllection has no fields or fieldName is not defined within inferables list, return null + if (!fields || !inferableField) return null; + + // Try to return a field of the specified type with one of the synonyms + const mainTypeFields = fields.filter(f => f.get('widget') === inferableField.type).map(f => f.get('name')); + field = mainTypeFields.filter(f => inferableField.synonyms.indexOf(f) !== -1); + if (field && field.size > 0) return field.first(); + + // Try to return a field for each of the specified secondary types + const secondaryTypeFields = fields.filter(f => inferableField.secondaryTypes.indexOf(f.get('widget')) !== -1).map(f => f.get('name')); + field = secondaryTypeFields.filter(f => inferableField.synonyms.indexOf(f) !== -1); + if (field && field.size > 0) return field.first(); + + // Try to return the first field of the specified type + if (inferableField.fallbackToFirstField && mainTypeFields.size > 0) return mainTypeFields.first(); + + // Coundn't infer the field. Show error and return null. + if (inferableField.showError) { + consoleError( + `The Field ${ fieldName } is missing for the collection “${ collection.get('name') }”`, + `Netlify CMS tries to infer the entry ${ fieldName } automatically, but one couldn\'t be found for entries of the collection “${ collection.get('name') }”. Please check your site configuration.` + ); + } + + return null; +}; + export default collections; diff --git a/src/reducers/global.js b/src/reducers/global.js deleted file mode 100644 index 0b67c53e..00000000 --- a/src/reducers/global.js +++ /dev/null @@ -1,17 +0,0 @@ -/* Reducer for some global UI state that we want to share between components -* Now being used for isFetching state to display global loading indicator -* */ - -const globalReducer = (state = { isFetching: false }, action) => { - if ((action.type.indexOf('REQUEST') > -1)) { - return { isFetching: true }; - } else if ( - (action.type.indexOf('SUCCESS') > -1) || - (action.type.indexOf('FAILURE') > -1) - ) { - return { isFetching: false }; - } - return state; -}; - -export default globalReducer; diff --git a/src/reducers/globalUI.js b/src/reducers/globalUI.js new file mode 100644 index 00000000..ebe8a417 --- /dev/null +++ b/src/reducers/globalUI.js @@ -0,0 +1,27 @@ +import { Map } from 'immutable'; +import { TOGGLE_SIDEBAR, OPEN_SIDEBAR } from '../actions/globalUI'; +/* +* Reducer for some global UI state that we want to share between components +* */ +const globalUI = (state = Map({ isFetching: false, sidebarIsOpen: true }), action) => { + // Generic, global loading indicator + if ((action.type.indexOf('REQUEST') > -1)) { + return state.set('isFetching', true); + } else if ( + (action.type.indexOf('SUCCESS') > -1) || + (action.type.indexOf('FAILURE') > -1) + ) { + return state.set('isFetching', false); + } + + switch (action.type) { + case TOGGLE_SIDEBAR: + return state.set('sidebarIsOpen', !state.get('sidebarIsOpen')); + case OPEN_SIDEBAR: + return state.set('sidebarIsOpen', action.payload.open); + default: + return state; + } +}; + +export default globalUI; diff --git a/src/reducers/index.js b/src/reducers/index.js index 4b3c4288..112628d1 100644 --- a/src/reducers/index.js +++ b/src/reducers/index.js @@ -7,7 +7,7 @@ import editorialWorkflow, * as fromEditorialWorkflow from './editorialWorkflow'; import entryDraft from './entryDraft'; import collections from './collections'; import medias, * as fromMedias from './medias'; -import global from './global'; +import globalUI from './globalUI'; const reducers = { auth, @@ -19,7 +19,7 @@ const reducers = { editorialWorkflow, entryDraft, medias, - global, + globalUI, }; export default reducers; diff --git a/src/valueObjects/Collection.js b/src/valueObjects/Collection.js deleted file mode 100644 index d4daaa64..00000000 --- a/src/valueObjects/Collection.js +++ /dev/null @@ -1,121 +0,0 @@ -import { FOLDER, FILES } from '../constants/collectionTypes'; - -function formatToExtension(format) { - return { - markdown: 'md', - yaml: 'yml', - json: 'json', - html: 'html', - }[format]; -} - -class FolderCollection { - constructor(collection) { - this.collection = collection; - } - - entryFields() { - return this.collection.get('fields'); - } - - entryPath(slug) { - return `${ this.collection.get('folder') }/${ slug }.${ this.entryExtension() }`; - } - - entrySlug(path) { - return path.split('/').pop().replace(/\.[^\.]+$/, ''); - } - - listMethod() { - return 'entriesByFolder'; - } - - entryExtension() { - return this.collection.get('extension') || formatToExtension(this.collection.get('format') || 'markdown'); - } - - allowNewEntries() { - return this.collection.get('create'); - } - - templateName() { - return this.collection.get('name'); - } -} - -class FilesCollection { - constructor(collection) { - this.collection = collection; - } - - entryFields(slug) { - const file = this.fileForEntry(slug); - return file && file.get('fields'); - } - - entryPath(slug) { - const file = this.fileForEntry(slug); - return file && file.get('file'); - } - - entrySlug(path) { - const file = this.collection.get('files').filter(f => f.get('file') === path).get(0); - return file && file.get('name'); - } - - fileForEntry(slug) { - const files = this.collection.get('files'); - return files.filter(f => f.get('name') === slug).get(0); - } - - listMethod() { - return 'entriesByFiles'; - } - - allowNewEntries() { - return false; - } - - templateName(slug) { - return slug; - } -} - -export default class Collection { - constructor(collection) { - switch (collection.get('type')) { - case FOLDER: - this.collection = new FolderCollection(collection); - break; - case FILES: - this.collection = new FilesCollection(collection); - break; - default: - throw ('Unknown collection type: %o', collection.get('type')); - } - } - - entryFields(slug) { - return this.collection.entryFields(slug); - } - - entryPath(slug) { - return this.collection.entryPath(slug); - } - - entrySlug(path) { - return this.collection.entrySlug(path); - } - - listMethod() { - return this.collection.listMethod(); - } - - allowNewEntries() { - return this.collection.allowNewEntries(); - } - - templateName(slug) { - return this.collection.templateName(slug); - } -} diff --git a/yarn.lock b/yarn.lock index af57bacb..b2db4520 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6961,6 +6961,10 @@ react-router@^2.5.1: loose-envify "^1.2.0" warning "^3.0.0" +react-sidebar: + version "2.2.1" + resolved "https://registry.yarnpkg.com/react-sidebar/-/react-sidebar-2.2.1.tgz#a8faf6a3c62ddc562c70680d5d016fe9741b585f" + react-simple-di@^1.2.0: version "1.2.0" resolved "https://registry.yarnpkg.com/react-simple-di/-/react-simple-di-1.2.0.tgz#dde0e5bf689f391ef2ab02c9043b213fe239c6d0"