Displays existing collection

- List all collections for a given user
- View an individual collection
- Link to a sketch from a collection
This commit is contained in:
Andrew Nicolaou 2019-07-09 10:35:24 +02:00
parent 9c36f2b2e2
commit d02a413bf3
14 changed files with 921 additions and 23 deletions

View file

@ -35,6 +35,8 @@ export const HIDE_EDIT_PROJECT_NAME = 'HIDE_EDIT_PROJECT_NAME';
export const SET_PROJECT = 'SET_PROJECT'; export const SET_PROJECT = 'SET_PROJECT';
export const SET_PROJECTS = 'SET_PROJECTS'; export const SET_PROJECTS = 'SET_PROJECTS';
export const SET_COLLECTIONS = 'SET_COLLECTIONS';
export const DELETE_PROJECT = 'DELETE_PROJECT'; export const DELETE_PROJECT = 'DELETE_PROJECT';
export const SET_SELECTED_FILE = 'SET_SELECTED_FILE'; export const SET_SELECTED_FILE = 'SET_SELECTED_FILE';

View file

@ -0,0 +1,34 @@
import axios from 'axios';
import * as ActionTypes from '../../../constants';
import { startLoader, stopLoader } from './loader';
const __process = (typeof global !== 'undefined' ? global : window).process;
const ROOT_URL = __process.env.API_URL;
// eslint-disable-next-line
export function getCollections(username) {
return (dispatch) => {
dispatch(startLoader());
let url;
if (username) {
url = `${ROOT_URL}/${username}/collections`;
} else {
url = `${ROOT_URL}/collections`;
}
axios.get(url, { withCredentials: true })
.then((response) => {
dispatch({
type: ActionTypes.SET_COLLECTIONS,
collections: response.data
});
dispatch(stopLoader());
})
.catch((response) => {
dispatch({
type: ActionTypes.ERROR,
error: response.data
});
dispatch(stopLoader());
});
};
}

View file

@ -0,0 +1,375 @@
import format from 'date-fns/format';
import PropTypes from 'prop-types';
import React from 'react';
import { Helmet } from 'react-helmet';
import InlineSVG from 'react-inlinesvg';
import { connect } from 'react-redux';
import { Link } from 'react-router';
import { bindActionCreators } from 'redux';
import classNames from 'classnames';
import * as ProjectActions from '../actions/project';
import * as ProjectsActions from '../actions/projects';
import * as CollectionsActions from '../actions/collections';
import * as ToastActions from '../actions/toast';
import * as SortingActions from '../actions/sorting';
import * as IdeActions from '../actions/ide';
import { getCollection } from '../selectors/collections';
import Loader from '../../App/components/loader';
import Overlay from '../../App/components/Overlay';
const arrowUp = require('../../../images/sort-arrow-up.svg');
const arrowDown = require('../../../images/sort-arrow-down.svg');
const downFilledTriangle = require('../../../images/down-filled-triangle.svg');
class CollectionItemRowBase extends React.Component {
constructor(props) {
super(props);
this.state = {
optionsOpen: false,
renameOpen: false,
renameValue: props.item.project.name,
isFocused: false
};
}
onFocusComponent = () => {
this.setState({ isFocused: true });
}
onBlurComponent = () => {
this.setState({ isFocused: false });
setTimeout(() => {
if (!this.state.isFocused) {
this.closeAll();
}
}, 200);
}
openOptions = () => {
this.setState({
optionsOpen: true
});
}
closeOptions = () => {
this.setState({
optionsOpen: false
});
}
toggleOptions = () => {
if (this.state.optionsOpen) {
this.closeOptions();
} else {
this.openOptions();
}
}
openRename = () => {
this.setState({
renameOpen: true
});
}
closeRename = () => {
this.setState({
renameOpen: false
});
}
closeAll = () => {
this.setState({
renameOpen: false,
optionsOpen: false
});
}
handleRenameChange = (e) => {
this.setState({
renameValue: e.target.value
});
}
handleRenameEnter = (e) => {
if (e.key === 'Enter') {
// TODO pass this func
this.props.changeProjectName(this.props.collection.id, this.state.renameValue);
this.closeAll();
}
}
resetSketchName = () => {
this.setState({
renameValue: this.props.collection.name
});
}
handleDropdownOpen = () => {
this.closeAll();
this.openOptions();
}
handleRenameOpen = () => {
this.closeAll();
this.openRename();
}
handleSketchDownload = () => {
this.props.exportProjectAsZip(this.props.collection.id);
}
handleSketchDuplicate = () => {
this.closeAll();
this.props.cloneProject(this.props.collection.id);
}
handleSketchShare = () => {
this.closeAll();
this.props.showShareModal(this.props.collection.id, this.props.collection.name, this.props.username);
}
handleSketchDelete = () => {
this.closeAll();
if (window.confirm(`Are you sure you want to delete "${this.props.collection.name}"?`)) {
this.props.deleteProject(this.props.collection.id);
}
}
render() {
const { item, username } = this.props;
const { renameOpen, optionsOpen, renameValue } = this.state;
const sketchOwnerUsername = item.project.user.username;
const userIsOwner = this.props.user.username === sketchOwnerUsername;
const sketchUrl = `/${item.project.user.username}/sketches/${item.project.id}`;
const dropdown = (
<td className="sketch-list__dropdown-column">
<button
className="sketch-list__dropdown-button"
onClick={this.toggleOptions}
onBlur={this.onBlurComponent}
onFocus={this.onFocusComponent}
>
<InlineSVG src={downFilledTriangle} alt="Menu" />
</button>
{optionsOpen &&
<ul
className="sketch-list__action-dialogue"
>
{userIsOwner &&
<li>
<button
className="sketch-list__action-option"
onClick={this.handleSketchDelete}
onBlur={this.onBlurComponent}
onFocus={this.onFocusComponent}
>
Delete
</button>
</li>}
</ul>
}
</td>
);
return (
<tr
className="sketches-table__row"
>
<th scope="row">
<Link to={sketchUrl}>
{renameOpen ? '' : item.project.name}
</Link>
{renameOpen
&&
<input
value={renameValue}
onChange={this.handleRenameChange}
onKeyUp={this.handleRenameEnter}
onBlur={this.resetSketchName}
onClick={e => e.stopPropagation()}
/>
}
</th>
<td>{format(new Date(item.createdAt), 'MMM D, YYYY h:mm A')}</td>
<td>{sketchOwnerUsername}</td>
{/*
<td>{format(new Date(item.createdAt), 'MMM D, YYYY h:mm A')}</td>
<td>{format(new Date(itm.updatedAt), 'MMM D, YYYY h:mm A')}</td>
<td>{(collection.items || []).length}</td>
<td>{dropdown}</td>
*/}
</tr>);
}
}
CollectionItemRowBase.propTypes = {
collection: PropTypes.shape({
id: PropTypes.string.isRequired,
name: PropTypes.string.isRequired
}).isRequired,
username: PropTypes.string.isRequired,
user: PropTypes.shape({
username: PropTypes.string,
authenticated: PropTypes.bool.isRequired
}).isRequired,
deleteProject: PropTypes.func.isRequired,
showShareModal: PropTypes.func.isRequired,
cloneProject: PropTypes.func.isRequired,
exportProjectAsZip: PropTypes.func.isRequired,
changeProjectName: PropTypes.func.isRequired
};
function mapDispatchToPropsSketchListRow(dispatch) {
return bindActionCreators(Object.assign({}, ProjectActions, IdeActions), dispatch);
}
const CollectionItemRow = connect(null, mapDispatchToPropsSketchListRow)(CollectionItemRowBase);
class Collection extends React.Component {
constructor(props) {
super(props);
this.props.getCollections(this.props.username);
this.props.resetSorting();
this._renderFieldHeader = this._renderFieldHeader.bind(this);
}
getTitle() {
if (this.props.username === this.props.user.username) {
return 'p5.js Web Editor | My collections';
}
return `p5.js Web Editor | ${this.props.username}'s collections`;
}
getCollectionName() {
return this.props.collection.name;
}
hasCollection() {
return !this.props.loading && this.props.collection != null;
}
hasCollectionItems() {
return this.hasCollection() && this.props.collection.items.length > 0;
}
_renderLoader() {
if (this.props.loading) return <Loader />;
return null;
}
_renderCollectionMetadata() {
return (
<div className="collections-metadata">
<p>{this.props.collection.description}</p>
</div>
);
}
_renderEmptyTable() {
if (!this.hasCollectionItems()) {
return (<p className="sketches-table__empty">No sketches in collection.</p>);
}
return null;
}
_renderFieldHeader(fieldName, displayName) {
const { field, direction } = this.props.sorting;
const headerClass = classNames({
'sketches-table__header': true,
'sketches-table__header--selected': field === fieldName
});
return (
<th scope="col">
<button className="sketch-list__sort-button" onClick={() => this.props.toggleDirectionForField(fieldName)}>
<span className={headerClass}>{displayName}</span>
{field === fieldName && direction === SortingActions.DIRECTION.ASC &&
<InlineSVG src={arrowUp} />
}
{field === fieldName && direction === SortingActions.DIRECTION.DESC &&
<InlineSVG src={arrowDown} />
}
</button>
</th>
);
}
render() {
const username = this.props.username !== undefined ? this.props.username : this.props.user.username;
const title = this.hasCollection() ? this.getCollectionName() : null;
return (
<Overlay
ariaLabel="collection"
title={title}
previousPath={this.props.previousPath}
>
<div className="sketches-table-container">
<Helmet>
<title>{this.getTitle()}</title>
</Helmet>
{this._renderLoader()}
{this.hasCollection() && this._renderCollectionMetadata()}
{this._renderEmptyTable()}
{this.hasCollectionItems() &&
<table className="sketches-table" summary="table containing all collections">
<thead>
<tr>
{this._renderFieldHeader('name', 'Name')}
{this._renderFieldHeader('createdAt', 'Date Added')}
{this._renderFieldHeader('user', 'Owner')}
<th scope="col"></th>
</tr>
</thead>
<tbody>
{this.props.collection.items.map(item =>
(<CollectionItemRow
key={item.id}
item={item}
user={this.props.user}
username={username}
/>))}
</tbody>
</table>}
</div>
</Overlay>
);
}
}
Collection.propTypes = {
user: PropTypes.shape({
username: PropTypes.string,
authenticated: PropTypes.bool.isRequired
}).isRequired,
getCollections: PropTypes.func.isRequired,
collection: PropTypes.shape({}).isRequired, // TODO
username: PropTypes.string,
loading: PropTypes.bool.isRequired,
toggleDirectionForField: PropTypes.func.isRequired,
resetSorting: PropTypes.func.isRequired,
sorting: PropTypes.shape({
field: PropTypes.string.isRequired,
direction: PropTypes.string.isRequired
}).isRequired
};
Collection.defaultProps = {
username: undefined
};
function mapStateToProps(state, ownProps) {
return {
user: state.user,
collection: getCollection(state, ownProps.collectionId),
sorting: state.sorting,
loading: state.loading,
project: state.project
};
}
function mapDispatchToProps(dispatch) {
return bindActionCreators(Object.assign({}, CollectionsActions, ProjectsActions, ToastActions, SortingActions), dispatch);
}
export default connect(mapStateToProps, mapDispatchToProps)(Collection);

View file

@ -0,0 +1,381 @@
import format from 'date-fns/format';
import PropTypes from 'prop-types';
import React from 'react';
import { Helmet } from 'react-helmet';
import InlineSVG from 'react-inlinesvg';
import { connect } from 'react-redux';
import { Link } from 'react-router';
import { bindActionCreators } from 'redux';
import classNames from 'classnames';
import * as ProjectActions from '../actions/project';
import * as ProjectsActions from '../actions/projects';
import * as CollectionsActions from '../actions/collections';
import * as ToastActions from '../actions/toast';
import * as SortingActions from '../actions/sorting';
import * as IdeActions from '../actions/ide';
import getSortedCollections from '../selectors/collections';
import Loader from '../../App/components/loader';
const arrowUp = require('../../../images/sort-arrow-up.svg');
const arrowDown = require('../../../images/sort-arrow-down.svg');
const downFilledTriangle = require('../../../images/down-filled-triangle.svg');
class CollectionListRowBase extends React.Component {
constructor(props) {
super(props);
this.state = {
optionsOpen: false,
renameOpen: false,
renameValue: props.collection.name,
isFocused: false
};
}
onFocusComponent = () => {
this.setState({ isFocused: true });
}
onBlurComponent = () => {
this.setState({ isFocused: false });
setTimeout(() => {
if (!this.state.isFocused) {
this.closeAll();
}
}, 200);
}
openOptions = () => {
this.setState({
optionsOpen: true
});
}
closeOptions = () => {
this.setState({
optionsOpen: false
});
}
toggleOptions = () => {
if (this.state.optionsOpen) {
this.closeOptions();
} else {
this.openOptions();
}
}
openRename = () => {
this.setState({
renameOpen: true
});
}
closeRename = () => {
this.setState({
renameOpen: false
});
}
closeAll = () => {
this.setState({
renameOpen: false,
optionsOpen: false
});
}
handleRenameChange = (e) => {
this.setState({
renameValue: e.target.value
});
}
handleRenameEnter = (e) => {
if (e.key === 'Enter') {
// TODO pass this func
this.props.changeProjectName(this.props.collection.id, this.state.renameValue);
this.closeAll();
}
}
resetSketchName = () => {
this.setState({
renameValue: this.props.collection.name
});
}
handleDropdownOpen = () => {
this.closeAll();
this.openOptions();
}
handleRenameOpen = () => {
this.closeAll();
this.openRename();
}
handleSketchDownload = () => {
this.props.exportProjectAsZip(this.props.collection.id);
}
handleSketchDuplicate = () => {
this.closeAll();
this.props.cloneProject(this.props.collection.id);
}
handleSketchShare = () => {
this.closeAll();
this.props.showShareModal(this.props.collection.id, this.props.collection.name, this.props.username);
}
handleSketchDelete = () => {
this.closeAll();
if (window.confirm(`Are you sure you want to delete "${this.props.collection.name}"?`)) {
this.props.deleteProject(this.props.collection.id);
}
}
render() {
const { collection, username } = this.props;
const { renameOpen, optionsOpen, renameValue } = this.state;
const userIsOwner = this.props.user.username === this.props.username;
const dropdown = (
<td className="sketch-list__dropdown-column">
<button
className="sketch-list__dropdown-button"
onClick={this.toggleOptions}
onBlur={this.onBlurComponent}
onFocus={this.onFocusComponent}
>
<InlineSVG src={downFilledTriangle} alt="Menu" />
</button>
{optionsOpen &&
<ul
className="sketch-list__action-dialogue"
>
{userIsOwner &&
<li>
<button
className="sketch-list__action-option"
onClick={this.handleSketchDelete}
onBlur={this.onBlurComponent}
onFocus={this.onFocusComponent}
>
Delete
</button>
</li>}
</ul>
}
</td>
);
return (
<tr
className="sketches-table__row"
key={collection.id}
>
<th scope="row">
<Link to={`/${username}/collections/${collection.id}`}>
{renameOpen ? '' : collection.name}
</Link>
{renameOpen
&&
<input
value={renameValue}
onChange={this.handleRenameChange}
onKeyUp={this.handleRenameEnter}
onBlur={this.resetSketchName}
onClick={e => e.stopPropagation()}
/>
}
</th>
<td>{format(new Date(collection.createdAt), 'MMM D, YYYY h:mm A')}</td>
<td>{format(new Date(collection.updatedAt), 'MMM D, YYYY h:mm A')}</td>
<td>{(collection.items || []).length}</td>
<td>{dropdown}</td>
</tr>);
}
}
CollectionListRowBase.propTypes = {
collection: PropTypes.shape({
id: PropTypes.string.isRequired,
name: PropTypes.string.isRequired
}).isRequired,
username: PropTypes.string.isRequired,
user: PropTypes.shape({
username: PropTypes.string,
authenticated: PropTypes.bool.isRequired
}).isRequired,
deleteProject: PropTypes.func.isRequired,
showShareModal: PropTypes.func.isRequired,
cloneProject: PropTypes.func.isRequired,
exportProjectAsZip: PropTypes.func.isRequired,
changeProjectName: PropTypes.func.isRequired
};
function mapDispatchToPropsSketchListRow(dispatch) {
return bindActionCreators(Object.assign({}, ProjectActions, IdeActions), dispatch);
}
const CollectionListRow = connect(null, mapDispatchToPropsSketchListRow)(CollectionListRowBase);
class CollectionList extends React.Component {
constructor(props) {
super(props);
this.props.getCollections(this.props.username);
this.props.resetSorting();
this._renderFieldHeader = this._renderFieldHeader.bind(this);
}
getTitle() {
if (this.props.username === this.props.user.username) {
return 'p5.js Web Editor | My collections';
}
return `p5.js Web Editor | ${this.props.username}'s collections`;
}
hasCollections() {
return !this.props.loading && this.props.collections.length > 0;
}
_renderLoader() {
if (this.props.loading) return <Loader />;
return null;
}
_renderEmptyTable() {
if (!this.props.loading && this.props.collections.length === 0) {
return (<p className="sketches-table__empty">No collections.</p>);
}
return null;
}
_renderFieldHeader(fieldName, displayName) {
const { field, direction } = this.props.sorting;
const headerClass = classNames({
'sketches-table__header': true,
'sketches-table__header--selected': field === fieldName
});
return (
<th scope="col">
<button className="sketch-list__sort-button" onClick={() => this.props.toggleDirectionForField(fieldName)}>
<span className={headerClass}>{displayName}</span>
{field === fieldName && direction === SortingActions.DIRECTION.ASC &&
<InlineSVG src={arrowUp} />
}
{field === fieldName && direction === SortingActions.DIRECTION.DESC &&
<InlineSVG src={arrowDown} />
}
</button>
</th>
);
}
render() {
const username = this.props.username !== undefined ? this.props.username : this.props.user.username;
return (
<div className="sketches-table-container">
<Helmet>
<title>{this.getTitle()}</title>
</Helmet>
{this._renderLoader()}
{this._renderEmptyTable()}
{this.hasCollections() &&
<table className="sketches-table" summary="table containing all collections">
<thead>
<tr>
{this._renderFieldHeader('name', 'Name')}
{this._renderFieldHeader('createdAt', 'Date Created')}
{this._renderFieldHeader('updatedAt', 'Date Updated')}
{this._renderFieldHeader('numItems', '# sketches')}
<th scope="col"></th>
</tr>
</thead>
<tbody>
{this.props.collections.map(collection =>
(<CollectionListRow
key={collection.id}
collection={collection}
user={this.props.user}
username={username}
/>))}
</tbody>
</table>}
</div>
);
}
}
const ProjectShape = PropTypes.shape({
id: PropTypes.string.isRequired,
name: PropTypes.string.isRequired,
createdAt: PropTypes.string.isRequired,
updatedAt: PropTypes.string.isRequired,
user: PropTypes.shape({
username: PropTypes.string.isRequired
}).isRequired,
});
const ItemsShape = PropTypes.shape({
createdAt: PropTypes.string.isRequired,
updatedAt: PropTypes.string.isRequired,
project: ProjectShape
});
CollectionList.propTypes = {
user: PropTypes.shape({
username: PropTypes.string,
authenticated: PropTypes.bool.isRequired
}).isRequired,
getCollections: PropTypes.func.isRequired,
collections: PropTypes.arrayOf(PropTypes.shape({
id: PropTypes.string.isRequired,
name: PropTypes.string.isRequired,
description: PropTypes.string,
createdAt: PropTypes.string.isRequired,
updatedAt: PropTypes.string.isRequired,
items: PropTypes.arrayOf(ItemsShape),
})).isRequired,
username: PropTypes.string,
loading: PropTypes.bool.isRequired,
toggleDirectionForField: PropTypes.func.isRequired,
resetSorting: PropTypes.func.isRequired,
sorting: PropTypes.shape({
field: PropTypes.string.isRequired,
direction: PropTypes.string.isRequired
}).isRequired,
project: PropTypes.shape({
id: PropTypes.string,
owner: PropTypes.shape({
id: PropTypes.string
})
})
};
CollectionList.defaultProps = {
project: {
id: undefined,
owner: undefined
},
username: undefined
};
function mapStateToProps(state) {
return {
user: state.user,
collections: getSortedCollections(state),
sorting: state.sorting,
loading: state.loading,
project: state.project
};
}
function mapDispatchToProps(dispatch) {
return bindActionCreators(
Object.assign({}, CollectionsActions, ProjectsActions, ToastActions, SortingActions),
dispatch
);
}
export default connect(mapStateToProps, mapDispatchToProps)(CollectionList);

View file

@ -314,12 +314,12 @@ class IDEView extends React.Component {
{( {(
( (
(this.props.preferences.textOutput || (this.props.preferences.textOutput ||
this.props.preferences.gridOutput || this.props.preferences.gridOutput ||
this.props.preferences.soundOutput this.props.preferences.soundOutput
) && ) &&
this.props.ide.isPlaying this.props.ide.isPlaying
) || ) ||
this.props.ide.isAccessibleOutputPlaying this.props.ide.isAccessibleOutputPlaying
) )
} }
</div> </div>
@ -350,14 +350,14 @@ class IDEView extends React.Component {
</SplitPane> </SplitPane>
</SplitPane> </SplitPane>
</div> </div>
{ this.props.ide.modalIsVisible && {this.props.ide.modalIsVisible &&
<NewFileModal <NewFileModal
canUploadMedia={this.props.user.authenticated} canUploadMedia={this.props.user.authenticated}
closeModal={this.props.closeNewFileModal} closeModal={this.props.closeNewFileModal}
createFile={this.props.createFile} createFile={this.props.createFile}
/> />
} }
{ this.props.ide.newFolderModalVisible && {this.props.ide.newFolderModalVisible &&
<NewFolderModal <NewFolderModal
closeModal={this.props.closeNewFolderModal} closeModal={this.props.closeNewFolderModal}
createFolder={this.props.createFolder} createFolder={this.props.createFolder}
@ -372,7 +372,7 @@ class IDEView extends React.Component {
<About previousPath={this.props.ide.previousPath} /> <About previousPath={this.props.ide.previousPath} />
</Overlay> </Overlay>
} }
{ this.props.location.pathname === '/feedback' && {this.props.location.pathname === '/feedback' &&
<Overlay <Overlay
previousPath={this.props.ide.previousPath} previousPath={this.props.ide.previousPath}
title="Submit Feedback" title="Submit Feedback"
@ -381,7 +381,7 @@ class IDEView extends React.Component {
<Feedback previousPath={this.props.ide.previousPath} /> <Feedback previousPath={this.props.ide.previousPath} />
</Overlay> </Overlay>
} }
{ this.props.ide.shareModalVisible && {this.props.ide.shareModalVisible &&
<Overlay <Overlay
title="Share This Sketch" title="Share This Sketch"
ariaLabel="share" ariaLabel="share"
@ -394,7 +394,7 @@ class IDEView extends React.Component {
/> />
</Overlay> </Overlay>
} }
{ this.props.ide.keyboardShortcutVisible && {this.props.ide.keyboardShortcutVisible &&
<Overlay <Overlay
title="Keyboard Shortcuts" title="Keyboard Shortcuts"
ariaLabel="keyboard shortcuts" ariaLabel="keyboard shortcuts"
@ -403,7 +403,7 @@ class IDEView extends React.Component {
<KeyboardShortcutModal /> <KeyboardShortcutModal />
</Overlay> </Overlay>
} }
{ this.props.ide.errorType && {this.props.ide.errorType &&
<Overlay <Overlay
title="Error" title="Error"
ariaLabel="error" ariaLabel="error"
@ -415,7 +415,7 @@ class IDEView extends React.Component {
/> />
</Overlay> </Overlay>
} }
{ this.props.ide.helpType && {this.props.ide.helpType &&
<Overlay <Overlay
title="Serve over HTTPS" title="Serve over HTTPS"
closeOverlay={this.props.hideHelpModal} closeOverlay={this.props.hideHelpModal}

View file

@ -0,0 +1,12 @@
import * as ActionTypes from '../../../constants';
const sketches = (state = [], action) => {
switch (action.type) {
case ActionTypes.SET_COLLECTIONS:
return action.collections;
default:
return state;
}
};
export default sketches;

View file

@ -0,0 +1,37 @@
import { createSelector } from 'reselect';
import differenceInMilliseconds from 'date-fns/difference_in_milliseconds';
import find from 'lodash/find';
import orderBy from 'lodash/orderBy';
import { DIRECTION } from '../actions/sorting';
const getCollections = state => state.collections;
const getField = state => state.sorting.field;
const getDirection = state => state.sorting.direction;
const getSortedCollections = createSelector(
getCollections,
getField,
getDirection,
(collections, field, direction) => {
if (field === 'name') {
if (direction === DIRECTION.DESC) {
return orderBy(collections, 'name', 'desc');
}
return orderBy(collections, 'name', 'asc');
}
const sortedCollections = [...collections].sort((a, b) => {
const result =
direction === DIRECTION.ASC
? differenceInMilliseconds(new Date(a[field]), new Date(b[field]))
: differenceInMilliseconds(new Date(b[field]), new Date(a[field]));
return result;
});
return sortedCollections;
}
);
export function getCollection(state, id) {
return find(getCollections(state), { id });
}
export default getSortedCollections;

View file

@ -4,6 +4,7 @@ import { Link } from 'react-router';
const TabKey = { const TabKey = {
assets: 'assets', assets: 'assets',
collections: 'collections',
sketches: 'sketches', sketches: 'sketches',
}; };
@ -30,8 +31,9 @@ Tab.propTypes = {
const DashboardTabSwitcher = ({ currentTab, isOwner, username }) => ( const DashboardTabSwitcher = ({ currentTab, isOwner, username }) => (
<ul className="dashboard-header__switcher"> <ul className="dashboard-header__switcher">
<div className="dashboard-header__tabs"> <div className="dashboard-header__tabs">
<Tab to={`/${username}/sketches`} isSelected={currentTab === 'sketches'}>Sketches</Tab> <Tab to={`/${username}/sketches`} isSelected={currentTab === TabKey.sketches}>Sketches</Tab>
{isOwner && <Tab to={`/${username}/assets`} isSelected={currentTab === 'assets'}>Assets</Tab>} <Tab to={`/${username}/collections`} isSelected={currentTab === TabKey.collections}>Collections</Tab>
{isOwner && <Tab to={`/${username}/assets`} isSelected={currentTab === TabKey.assets}>Assets</Tab>}
</div> </div>
</ul> </ul>
); );

View file

@ -8,6 +8,7 @@ import { updateSettings, initiateVerification, createApiKey, removeApiKey } from
import NavBasic from '../../../components/NavBasic'; import NavBasic from '../../../components/NavBasic';
import AssetList from '../../IDE/components/AssetList'; import AssetList from '../../IDE/components/AssetList';
import CollectionList from '../../IDE/components/CollectionList';
import SketchList from '../../IDE/components/SketchList'; import SketchList from '../../IDE/components/SketchList';
import DashboardTabSwitcher, { TabKey } from '../components/DashboardTabSwitcher'; import DashboardTabSwitcher, { TabKey } from '../components/DashboardTabSwitcher';
@ -35,11 +36,13 @@ class DashboardView extends React.Component {
browserHistory.push('/'); browserHistory.push('/');
} }
selectedTabName() { selectedTabKey() {
const path = this.props.location.pathname; const path = this.props.location.pathname;
if (/assets/.test(path)) { if (/assets/.test(path)) {
return TabKey.assets; return TabKey.assets;
} else if (/collections/.test(path)) {
return TabKey.collections;
} }
return TabKey.sketches; return TabKey.sketches;
@ -57,12 +60,20 @@ class DashboardView extends React.Component {
return this.props.user.username === this.props.params.username; return this.props.user.username === this.props.params.username;
} }
navigationItem() { renderContent(tabKey, username) {
switch (tabKey) {
case TabKey.assets:
return <AssetList username={username} />;
case TabKey.collections:
return <CollectionList username={username} />;
case TabKey.sketches:
default:
return <SketchList username={username} />;
}
} }
render() { render() {
const currentTab = this.selectedTabName(); const currentTab = this.selectedTabKey();
const isOwner = this.isOwner(); const isOwner = this.isOwner();
const { username } = this.props.params; const { username } = this.props.params;
@ -82,9 +93,7 @@ class DashboardView extends React.Component {
</div> </div>
<div className="dashboard-content"> <div className="dashboard-content">
{ {this.renderContent(currentTab, username)}
currentTab === TabKey.sketches ? <SketchList username={username} /> : <AssetList username={username} />
}
</div> </div>
</section> </section>
</div> </div>

View file

@ -12,6 +12,7 @@ import console from './modules/IDE/reducers/console';
import assets from './modules/IDE/reducers/assets'; import assets from './modules/IDE/reducers/assets';
import sorting from './modules/IDE/reducers/sorting'; import sorting from './modules/IDE/reducers/sorting';
import loading from './modules/IDE/reducers/loading'; import loading from './modules/IDE/reducers/loading';
import collections from './modules/IDE/reducers/collections';
const rootReducer = combineReducers({ const rootReducer = combineReducers({
form, form,
@ -26,7 +27,8 @@ const rootReducer = combineReducers({
toast, toast,
console, console,
assets, assets,
loading loading,
collections
}); });
export default rootReducer; export default rootReducer;

View file

@ -41,8 +41,9 @@ const routes = store => (
<Route path="/assets" component={createRedirectWithUsername('/:username/assets')} /> <Route path="/assets" component={createRedirectWithUsername('/:username/assets')} />
<Route path="/account" component={AccountView} /> <Route path="/account" component={AccountView} />
<Route path="/:username/sketches/:project_id" component={IDEView} /> <Route path="/:username/sketches/:project_id" component={IDEView} />
<Route path="/:username/sketches" component={DashboardView} /> <Route path="/:username/sketches" component={IDEView} />
<Route path="/:username/assets" component={DashboardView} /> <Route path="/:username/collections" component={DashboardView} />
<Route path="/:username/collections/:collection_id" component={DashboardView} />
<Route path="/about" component={IDEView} /> <Route path="/about" component={IDEView} />
<Route path="/feedback" component={IDEView} /> <Route path="/feedback" component={IDEView} />
</Route> </Route>

View file

@ -0,0 +1,29 @@
import Collection from '../../models/collection';
import User from '../../models/user';
export default function collectionForUserExists(username, collectionId, callback) {
function sendFailure() {
callback(false);
}
function sendSuccess(collection) {
callback(collection != null);
}
function findUser() {
return User.findOne({ username });
}
function findCollection(owner) {
if (owner == null) {
throw new Error('User not found');
}
return Collection.findOne({ _id: collectionId, owner });
}
return findUser()
.then(findCollection)
.then(sendSuccess)
.catch(sendFailure);
}

View file

@ -1,4 +1,5 @@
export { default as addProjectToCollection } from './addProjectToCollection'; export { default as addProjectToCollection } from './addProjectToCollection';
export { default as collectionForUserExists } from './collectionForUserExists';
export { default as createCollection } from './createCollection'; export { default as createCollection } from './createCollection';
export { default as listCollections } from './listCollections'; export { default as listCollections } from './listCollections';
export { default as removeCollection } from './removeCollection'; export { default as removeCollection } from './removeCollection';

View file

@ -3,6 +3,7 @@ import { renderIndex } from '../views/index';
import { get404Sketch } from '../views/404Page'; import { get404Sketch } from '../views/404Page';
import { userExists } from '../controllers/user.controller'; import { userExists } from '../controllers/user.controller';
import { projectExists, projectForUserExists } from '../controllers/project.controller'; import { projectExists, projectForUserExists } from '../controllers/project.controller';
import { collectionForUserExists } from '../controllers/collection.controller';
const router = new Router(); const router = new Router();
@ -111,4 +112,16 @@ router.get('/:username/sketches', (req, res) => {
)); ));
}); });
router.get('/:username/collections', (req, res) => {
userExists(req.params.username, exists => (
exists ? res.send(renderIndex()) : get404Sketch(html => res.send(html))
));
});
router.get('/:username/collections/:id', (req, res) => {
collectionForUserExists(req.params.username, req.params.id, exists => (
exists ? res.send(renderIndex()) : get404Sketch(html => res.send(html))
));
});
export default router; export default router;