UI updates (#151)

* infer card title

* Infer entry body & image

* infer image

* Better terminology: EntryListing accept a single Collection

* remove log

* Refactored Collections VO into selectors

* use selectors when showning card

* fixed size cards

* Added 'bio' and 'biography' to collection description inference synonyms

* Removed unused card file

* throw error instance

* bugfix for file based collections

* lint

* moved components with css to own folder

* Search Bugfix: More than one collection might be returned

* Changed sidebar implementation. Closes #104 & #152

* Show spinning loading for unpublished entries

* Refactored Sidebar into a separate container

* Make preview widgets more robust
This commit is contained in:
Cássio Souza
2016-11-11 17:54:58 -02:00
committed by GitHub
parent 3420273691
commit 2a2497072d
42 changed files with 490 additions and 603 deletions

View File

@ -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 {
</Menu>
</div>
}
onLeftIconClick={toggleNavDrawer}
onLeftIconClick={toggleDrawer}
onRightIconClick={this.handleRightIconClick}
>
<IndexLink to="/">

View File

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

View File

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

View File

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

@ -1,4 +0,0 @@
.root {
width: 240px;
cursor: pointer;
}

View File

@ -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,
}) => (
<Card
onClick={onClick}
className={styles.root}
>
<CardTitle
title={text}
/>
{
image && <CardMedia aspectRatio="wide">
<img
src={image}
alt={text}
onLoad={onImageLoaded}
/>
</CardMedia>
}
{ description && <CardText>{ description }</CardText> }
</Card>
);
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;

View File

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

View File

@ -1,15 +0,0 @@
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

@ -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 {
<div>
{
fields.map(field =>
<div
isHidden(field) ? null : <div
key={field.get('name')}
className={styles.widget}
>

View File

@ -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 (
<div>
<h1>{children}</h1>
<div ref={this.handleRef}>
{ this.renderCards() }
<Waypoint onEnter={this.handleLoadMore} />
</div>
</div>
);
}
}

View File

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

View File

@ -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 (
<Card
key={entry.get('slug')}
onClick={history.push.bind(this, path)} // eslint-disable-line
className={styles.card}
>
{ image &&
<header className={styles.cardImage} style={{ backgroundImage: `url(${ image })` }} />
}
<h1>{title}</h1>
{inferedFields.descriptionField ?
<p>{entry.getIn(['data', inferedFields.descriptionField])}</p>
: inferedFields.remainingFields && inferedFields.remainingFields.map(f => (
<p key={f.get('name')}>
<strong>{f.get('label')}:</strong> {entry.getIn(['data', f.get('name')])}
</p>
))
}
</Card>
);
}
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 (
<div>
<h1>{children}</h1>
<div className={styles.cardsGrid}>
{ this.renderCards() }
<Waypoint onEnter={this.handleLoadMore} />
</div>
</div>
);
}
}

View File

@ -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 (
<div>
{fields.map(field => widgetFor(field.get('name')))}
{fields.filter(isVisible).map(field => widgetFor(field.get('name')))}
</div>
);
}

View File

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

View File

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

View File

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

View File

@ -1,9 +1,9 @@
import React, { PropTypes } from 'react';
export default function StringPreview({ value }) {
return <span>{value}</span>;
export default function DatePreview({ value }) {
return <span>{value ? value.toString() : null}</span>;
}
StringPreview.propTypes = {
DatePreview.propTypes = {
value: PropTypes.node,
};

View File

@ -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 {
</div>)}</div>) : null;
}
return value ? value.join(', ') : null;
return <span>{value ? value.join(', ') : null}</span>;
}
}
ObjectPreview.propTypes = {
ListPreview.propTypes = {
value: PropTypes.node,
field: PropTypes.node,
getMedia: PropTypes.func.isRequired,

View File

@ -1,9 +1,9 @@
import React, { PropTypes } from 'react';
export default function StringPreview({ value }) {
return <span>{value}</span>;
export default function NumberPreview({ value }) {
return <span>{value ? value.toString() : null}</span>;
}
StringPreview.propTypes = {
NumberPreview.propTypes = {
value: PropTypes.node,
};

View File

@ -17,7 +17,7 @@ export default class ObjectPreview extends Component {
const { field } = this.props;
const fields = field && field.get('fields');
return <div>{fields && fields.map(f => this.widgetFor(f))}</div>;
return <div>{fields ? fields.map(f => this.widgetFor(f)) : null}</div>;
}
}

View File

@ -1,7 +1,7 @@
import React, { PropTypes } from 'react';
export default function StringPreview({ value }) {
return <span>{value}</span>;
return <span>{value ? value.toString() : null}</span>;
}
StringPreview.propTypes = {

View File

@ -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 <span>{value ? value.toString() : null}</span>;
}
TextPreview.propTypes = {
value: PropTypes.node,
};