diff --git a/client/components/Nav.jsx b/client/components/Nav.jsx
index 253146b0ce..8c8d717465 100644
--- a/client/components/Nav.jsx
+++ b/client/components/Nav.jsx
@@ -270,69 +270,81 @@ class Nav extends React.PureComponent {
New
- { __process.env.LOGIN_ENABLED && (!this.props.project.owner || this.isUserOwner()) &&
-
This is a preview version of the editor, that has not yet been officially released.
diff --git a/client/constants.js b/client/constants.js
index aaf7637c92..22d6a54095 100644
--- a/client/constants.js
+++ b/client/constants.js
@@ -32,6 +32,14 @@ export const HIDE_EDIT_PROJECT_NAME = 'HIDE_EDIT_PROJECT_NAME';
export const SET_PROJECT = 'SET_PROJECT';
export const SET_PROJECTS = 'SET_PROJECTS';
+export const SET_COLLECTIONS = 'SET_COLLECTIONS';
+export const CREATE_COLLECTION = 'CREATE_COLLECTION';
+export const UPDATE_COLLECTION = 'UPDATE_COLLECTION';
+export const DELETE_COLLECTION = 'DELETE_COLLECTION';
+
+export const ADD_TO_COLLECTION = 'ADD_TO_COLLECTION';
+export const REMOVE_FROM_COLLECTION = 'REMOVE_FROM_COLLECTION';
+
export const DELETE_PROJECT = 'DELETE_PROJECT';
export const SET_SELECTED_FILE = 'SET_SELECTED_FILE';
diff --git a/client/modules/IDE/actions/collections.js b/client/modules/IDE/actions/collections.js
new file mode 100644
index 0000000000..394b9bd2f1
--- /dev/null
+++ b/client/modules/IDE/actions/collections.js
@@ -0,0 +1,163 @@
+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());
+ });
+ };
+}
+
+export function createCollection(collection) {
+ return (dispatch) => {
+ dispatch(startLoader());
+ const url = `${ROOT_URL}/collections`;
+ return axios.post(url, collection, { withCredentials: true })
+ .then((response) => {
+ dispatch({
+ type: ActionTypes.CREATE_COLLECTION
+ });
+ dispatch(stopLoader());
+
+ return response.data;
+ })
+ .catch((response) => {
+ dispatch({
+ type: ActionTypes.ERROR,
+ error: response.data
+ });
+ dispatch(stopLoader());
+
+ return response.data;
+ });
+ };
+}
+
+export function updateCollection({ id, metadata }) {
+ return (dispatch) => {
+ dispatch(startLoader());
+ const url = `${ROOT_URL}/collections/${id}`;
+ return axios.patch(url, metadata, { withCredentials: true })
+ .then((response) => {
+ dispatch({
+ type: ActionTypes.UPDATE_COLLECTION,
+ payload: response.data
+ });
+ dispatch(stopLoader());
+
+ return response.data;
+ })
+ .catch((response) => {
+ dispatch({
+ type: ActionTypes.ERROR,
+ error: response.data
+ });
+ dispatch(stopLoader());
+
+ return response.data;
+ });
+ };
+}
+
+export function deleteCollection(collectionId) {
+ return (dispatch) => {
+ dispatch(startLoader());
+ const url = `${ROOT_URL}/collections/${collectionId}`;
+ return axios.delete(url, { withCredentials: true })
+ .then((response) => {
+ dispatch({
+ type: ActionTypes.DELETE_COLLECTION,
+ collectionId
+ });
+ dispatch(stopLoader());
+
+ return response.data;
+ })
+ .catch((response) => {
+ dispatch({
+ type: ActionTypes.ERROR,
+ error: response.data
+ });
+ dispatch(stopLoader());
+
+ return response.data;
+ });
+ };
+}
+
+export function addToCollection(collectionId, projectId) {
+ return (dispatch) => {
+ dispatch(startLoader());
+ const url = `${ROOT_URL}/collections/${collectionId}/${projectId}`;
+ return axios.post(url, { withCredentials: true })
+ .then((response) => {
+ dispatch({
+ type: ActionTypes.ADD_TO_COLLECTION,
+ payload: response.data
+ });
+ dispatch(stopLoader());
+
+ return response.data;
+ })
+ .catch((response) => {
+ dispatch({
+ type: ActionTypes.ERROR,
+ error: response.data
+ });
+ dispatch(stopLoader());
+
+ return response.data;
+ });
+ };
+}
+
+export function removeFromCollection(collectionId, projectId) {
+ return (dispatch) => {
+ dispatch(startLoader());
+ const url = `${ROOT_URL}/collections/${collectionId}/${projectId}`;
+ return axios.delete(url, { withCredentials: true })
+ .then((response) => {
+ dispatch({
+ type: ActionTypes.REMOVE_FROM_COLLECTION,
+ payload: response.data
+ });
+ dispatch(stopLoader());
+
+ return response.data;
+ })
+ .catch((response) => {
+ dispatch({
+ type: ActionTypes.ERROR,
+ error: response.data
+ });
+ dispatch(stopLoader());
+
+ return response.data;
+ });
+ };
+}
diff --git a/client/modules/IDE/components/Collection.jsx b/client/modules/IDE/components/Collection.jsx
new file mode 100644
index 0000000000..f30b73e459
--- /dev/null
+++ b/client/modules/IDE/components/Collection.jsx
@@ -0,0 +1,410 @@
+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';
+import EditableInput from './EditableInput';
+
+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 { collection, item, username } = this.props;
+ const { renameOpen, optionsOpen, renameValue } = this.state;
+ const sketchOwnerUsername = item.project.user.username;
+ const userIsSketchOwner = this.props.user.username === sketchOwnerUsername;
+ const userIsCollectionOwner = this.props.user.username === collection.owner.username;
+ const sketchUrl = `/${item.project.user.username}/sketches/${item.project.id}`;
+
+ const dropdown = (
+
+
+ {optionsOpen &&
+
+ {userIsCollectionOwner &&
+ -
+
+
}
+
+ }
+ |
+ );
+
+ return (
+
+ |
+
+ {renameOpen ? '' : item.project.name}
+
+ {renameOpen
+ &&
+ e.stopPropagation()}
+ />
+ }
+ |
+ {format(new Date(item.createdAt), 'MMM D, YYYY h:mm A')} |
+ {sketchOwnerUsername} |
+ {/*
+ {format(new Date(item.createdAt), 'MMM D, YYYY h:mm A')} |
+ {format(new Date(itm.updatedAt), 'MMM D, YYYY h:mm A')} |
+ {(collection.items || []).length} |
+ */}
+ {dropdown}
+
);
+ }
+}
+
+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;
+ }
+
+ updateMetadata = field => (value) => {
+ if (this.props.collection[field] === value) {
+ return;
+ }
+
+ if (field === "name" && value === "") {
+ return;
+ }
+
+ this.props.updateCollection({
+ id: this.props.collection.id,
+ metadata: {
+ [field]: value
+ }
+ });
+ }
+
+ _renderLoader() {
+ if (this.props.loading) return
;
+ return null;
+ }
+
+ _renderCollectionMetadata() {
+ return (
+
+ );
+ }
+
+ _renderEmptyTable() {
+ if (!this.hasCollectionItems()) {
+ return (
No sketches in collection.
);
+ }
+ 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 (
+
+
+ |
+ );
+ }
+
+ render() {
+ const username = this.props.username !== undefined ? this.props.username : this.props.user.username;
+ const title = this.hasCollection() ?
+ (
value !== ''}
+ value={this.props.collection.name}
+ />) : null;
+
+ return (
+
+
+
+ {this.getTitle()}
+
+ {this._renderLoader()}
+ {this.hasCollection() && this._renderCollectionMetadata()}
+ {this._renderEmptyTable()}
+ {this.hasCollectionItems() &&
+
+
+
+ {this._renderFieldHeader('name', 'Name')}
+ {this._renderFieldHeader('createdAt', 'Date Added')}
+ {this._renderFieldHeader('user', 'Owner')}
+ |
+
+
+
+ {this.props.collection.items.map(item =>
+ ())}
+
+
}
+
+
+ );
+ }
+}
+
+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);
diff --git a/client/modules/IDE/components/CollectionCreate.jsx b/client/modules/IDE/components/CollectionCreate.jsx
new file mode 100644
index 0000000000..3421a9c139
--- /dev/null
+++ b/client/modules/IDE/components/CollectionCreate.jsx
@@ -0,0 +1,145 @@
+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 { browserHistory } 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';
+
+import { generateCollectionName } from '../../../utils/generateRandomName';
+
+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 CollectionCreate extends React.Component {
+ state = {
+ collection: {
+ name: generateCollectionName(),
+ description: ''
+ }
+ }
+
+ 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`;
+ }
+
+ handleTextChange = field => (evt) => {
+ this.setState({
+ collection: {
+ ...this.state.collection,
+ [field]: evt.target.value,
+ }
+ });
+ }
+
+ handleCreateCollection = (event) => {
+ event.preventDefault();
+
+ this.props.createCollection(this.state.collection)
+ .then(({ id, owner }) => {
+ // Redirect to collection URL
+ console.log('Done, will redirect to collection');
+ browserHistory.replace(`/${owner.username}/collections/${id}`);
+ })
+ .catch((error) => {
+ console.error('Error creating collection', error);
+ });
+ }
+
+ render() {
+ const username = this.props.username !== undefined ? this.props.username : this.props.user.username;
+
+ const { name, description } = this.state.collection;
+
+ const invalid = name === '' || name == null;
+
+ return (
+
+
+
+ {this.getTitle()}
+
+
+
+
+ );
+ }
+}
+
+CollectionCreate.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
+};
+
+CollectionCreate.defaultProps = {
+ username: undefined
+};
+
+function mapStateToProps(state, ownProps) {
+ return {
+ user: state.user,
+ };
+}
+
+function mapDispatchToProps(dispatch) {
+ return bindActionCreators(Object.assign({}, CollectionsActions, ProjectsActions, ToastActions, SortingActions), dispatch);
+}
+
+export default connect(mapStateToProps, mapDispatchToProps)(CollectionCreate);
diff --git a/client/modules/IDE/components/CollectionList.jsx b/client/modules/IDE/components/CollectionList.jsx
new file mode 100644
index 0000000000..e32868a614
--- /dev/null
+++ b/client/modules/IDE/components/CollectionList.jsx
@@ -0,0 +1,411 @@
+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);
+ }
+
+ handleDelete = () => {
+ this.closeAll();
+ if (window.confirm(`Are you sure you want to delete "${this.props.collection.name}"?`)) {
+ this.props.deleteCollection(this.props.collection.id);
+ }
+ }
+
+ handleCollectionAdd = () => {
+ this.props.addToCollection(this.props.collection.id, this.props.project.id);
+ }
+
+ handleCollectionRemove = () => {
+ this.props.removeFromCollection(this.props.collection.id, this.props.project.id);
+ }
+
+ static projectInCollection(project, collection) {
+ return collection.items.find(item => item.project.id === project.id) != null;
+ }
+
+ render() {
+ const { collection, username, project, addMode } = this.props;
+ const { renameOpen, optionsOpen, renameValue } = this.state;
+ const userIsOwner = this.props.user.username === this.props.username;
+ let actions = null;
+
+ if (project != null && addMode === true) {
+ if (CollectionListRowBase.projectInCollection(project, collection)) {
+ actions = | ;
+ } else {
+ actions = | ;
+ }
+ } else {
+ actions = (
+
+
+ {optionsOpen &&
+
+ {userIsOwner &&
+ -
+
+
}
+
+ }
+ |
+ );
+ }
+
+ return (
+
+ |
+
+ {renameOpen ? '' : collection.name}
+
+ {renameOpen
+ &&
+ e.stopPropagation()}
+ />
+ }
+ |
+ {format(new Date(collection.createdAt), 'MMM D, YYYY h:mm A')} |
+ {format(new Date(collection.updatedAt), 'MMM D, YYYY h:mm A')} |
+ {(collection.items || []).length} |
+ {actions}
+
);
+ }
+}
+
+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({}, CollectionsActions, ProjectActions, IdeActions), dispatch);
+}
+
+const CollectionListRow = connect(null, mapDispatchToPropsSketchListRow)(CollectionListRowBase);
+
+class CollectionList extends React.Component {
+ constructor(props) {
+ super(props);
+ this.props.getCollections(this.props.username);
+
+ // If a projectId is provided, and addMode is true then we will provide
+ // the "add sketch to collection" UI
+ if (props.addMode === true && props.projectId != null) {
+ this.props.getProject(props.projectId)
+ }
+
+ 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 ;
+ return null;
+ }
+
+ _renderEmptyTable() {
+ if (!this.props.loading && this.props.collections.length === 0) {
+ return (No collections.
);
+ }
+ 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 (
+
+
+ |
+ );
+ }
+
+ render() {
+ const username = this.props.username !== undefined ? this.props.username : this.props.user.username;
+ return (
+
+
+ {this.getTitle()}
+
+
+
New collection
+
+ {this._renderLoader()}
+ {this._renderEmptyTable()}
+ {this.hasCollections() &&
+
+
+
+ {this._renderFieldHeader('name', 'Name')}
+ {this._renderFieldHeader('createdAt', 'Date Created')}
+ {this._renderFieldHeader('updatedAt', 'Date Updated')}
+ {this._renderFieldHeader('numItems', '# sketches')}
+ |
+
+
+
+ {this.props.collections.map(collection =>
+ ())}
+
+
}
+
+ );
+ }
+}
+
+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);
diff --git a/client/modules/IDE/components/EditableInput.jsx b/client/modules/IDE/components/EditableInput.jsx
new file mode 100644
index 0000000000..6315839e63
--- /dev/null
+++ b/client/modules/IDE/components/EditableInput.jsx
@@ -0,0 +1,92 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import InlineSVG from 'react-inlinesvg';
+
+const editIconUrl = require('../../../images/pencil.svg');
+
+function EditIcon() {
+ return ;
+}
+
+function EditableInput({
+ validate, value, emptyPlaceholder, InputComponent, inputProps, onChange
+}) {
+ const [isEditing, setIsEditing] = React.useState(false);
+ const [currentValue, setCurrentValue] = React.useState(value || '');
+ const displayValue = currentValue || emptyPlaceholder;
+ const classes = `editable-input editable-input--${isEditing ? 'is-editing' : 'is-not-editing'}`;
+ const inputRef = React.createRef();
+
+ React.useEffect(() => {
+ if (isEditing) {
+ inputRef.current.focus();
+ }
+ }, [isEditing]);
+
+ function beginEditing() {
+ setIsEditing(true);
+ }
+
+ function doneEditing() {
+ setIsEditing(false);
+
+ const isValid = typeof validate === 'function' && validate(currentValue);
+
+ if (isValid) {
+ onChange(currentValue);
+ } else {
+ setCurrentValue(value);
+ }
+ }
+
+ function updateValue(event) {
+ setCurrentValue(event.target.value);
+ }
+
+ function checkForKeyAction(event) {
+ if (event.key === 'Enter') {
+ doneEditing();
+ }
+ }
+
+ return (
+
+
+
+
+
+ );
+}
+
+EditableInput.defaultProps = {
+ emptyPlaceholder: 'No value',
+ InputComponent: 'input',
+ inputProps: {},
+ validate: () => true,
+ value: '',
+};
+
+EditableInput.propTypes = {
+ emptyPlaceholder: PropTypes.string,
+ InputComponent: PropTypes.elementType,
+ // eslint-disable-next-line react/forbid-prop-types
+ inputProps: PropTypes.object,
+ onChange: PropTypes.func.isRequired,
+ validate: PropTypes.func,
+ value: PropTypes.string,
+};
+
+export default EditableInput;
diff --git a/client/modules/IDE/pages/IDEView.jsx b/client/modules/IDE/pages/IDEView.jsx
index b2ae1ebb2f..d6dc99fa27 100644
--- a/client/modules/IDE/pages/IDEView.jsx
+++ b/client/modules/IDE/pages/IDEView.jsx
@@ -30,6 +30,9 @@ import * as ConsoleActions from '../actions/console';
import { getHTMLFile } from '../reducers/files';
import Overlay from '../../App/components/Overlay';
import SketchList from '../components/SketchList';
+import Collection from '../components/Collection';
+import CollectionList from '../components/CollectionList';
+import CollectionCreate from '../components/CollectionCreate';
import AssetList from '../components/AssetList';
import About from '../components/About';
import Feedback from '../components/Feedback';
@@ -316,12 +319,12 @@ class IDEView extends React.Component {
{(
(
(this.props.preferences.textOutput ||
- this.props.preferences.gridOutput ||
- this.props.preferences.soundOutput
+ this.props.preferences.gridOutput ||
+ this.props.preferences.soundOutput
) &&
- this.props.ide.isPlaying
+ this.props.ide.isPlaying
) ||
- this.props.ide.isAccessibleOutputPlaying
+ this.props.ide.isAccessibleOutputPlaying
)
}
@@ -352,20 +355,20 @@ class IDEView extends React.Component {
- { this.props.ide.modalIsVisible &&
+ {this.props.ide.modalIsVisible &&