diff --git a/client/constants.js b/client/constants.js
index ceffbf12..76a3bfe5 100644
--- a/client/constants.js
+++ b/client/constants.js
@@ -37,6 +37,10 @@ export const SET_PROJECT = 'SET_PROJECT';
export const SET_PROJECTS = 'SET_PROJECTS';
export const SET_COLLECTIONS = 'SET_COLLECTIONS';
+export const CREATE_COLLECTION = 'CREATED_COLLECTION';
+
+export const ADD_TO_COLLECTION = 'ADD_TO_COLLECTION';
+export const REMOVE_FROM_COLLECTION = 'REMOVE_FROM_COLLECTION';
export const DELETE_PROJECT = 'DELETE_PROJECT';
diff --git a/client/modules/IDE/actions/collections.js b/client/modules/IDE/actions/collections.js
index d3fffe6f..17f0b872 100644
--- a/client/modules/IDE/actions/collections.js
+++ b/client/modules/IDE/actions/collections.js
@@ -32,3 +32,80 @@ export function getCollections(username) {
});
};
}
+
+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 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/CollectionList.jsx b/client/modules/IDE/components/CollectionList.jsx
index 2b071847..9da68917 100644
--- a/client/modules/IDE/components/CollectionList.jsx
+++ b/client/modules/IDE/components/CollectionList.jsx
@@ -21,6 +21,10 @@ const arrowDown = require('../../../images/sort-arrow-down.svg');
const downFilledTriangle = require('../../../images/down-filled-triangle.svg');
class CollectionListRowBase extends React.Component {
+ static projectInCollection(project, collection) {
+ return collection.items.find(item => item.project.id === project.id) != null;
+ }
+
constructor(props) {
super(props);
this.state = {
@@ -134,6 +138,14 @@ class CollectionListRowBase extends React.Component {
}
}
+ handleCollectionAdd = () => {
+ this.props.addToCollection(this.props.collection.id, this.props.project.id);
+ }
+
+ handleCollectionRemove = () => {
+ this.props.removeFromCollection(this.props.collection.id, this.props.project.id);
+ }
+
render() {
const { collection, username } = this.props;
const { renameOpen, optionsOpen, renameValue } = this.state;
@@ -198,6 +210,8 @@ class CollectionListRowBase extends React.Component {
}
CollectionListRowBase.propTypes = {
+ addToCollection: PropTypes.func.isRequired,
+ removeFromCollection: PropTypes.func.isRequired,
collection: PropTypes.shape({
id: PropTypes.string.isRequired,
name: PropTypes.string.isRequired
@@ -215,7 +229,7 @@ CollectionListRowBase.propTypes = {
};
function mapDispatchToPropsSketchListRow(dispatch) {
- return bindActionCreators(Object.assign({}, ProjectActions, IdeActions), dispatch);
+ return bindActionCreators(Object.assign({}, CollectionsActions, ProjectActions, IdeActions), dispatch);
}
const CollectionListRow = connect(null, mapDispatchToPropsSketchListRow)(CollectionListRowBase);
@@ -279,6 +293,9 @@ class CollectionList extends React.Component {
{this.getTitle()}
+
+ New collection
+
{this._renderLoader()}
{this._renderEmptyTable()}
{this.hasCollections() &&
diff --git a/client/modules/IDE/reducers/collections.js b/client/modules/IDE/reducers/collections.js
index fbbaefcc..4c4027b1 100644
--- a/client/modules/IDE/reducers/collections.js
+++ b/client/modules/IDE/reducers/collections.js
@@ -4,6 +4,18 @@ const sketches = (state = [], action) => {
switch (action.type) {
case ActionTypes.SET_COLLECTIONS:
return action.collections;
+
+ // The API returns the complete new collection
+ // with the items added or removed
+ case ActionTypes.ADD_TO_COLLECTION:
+ case ActionTypes.REMOVE_FROM_COLLECTION:
+ return state.map((collection) => {
+ if (collection.id === action.payload.id) {
+ return action.payload;
+ }
+
+ return collection;
+ });
default:
return state;
}
diff --git a/client/modules/IDE/reducers/project.js b/client/modules/IDE/reducers/project.js
index 0779c0f5..2eb19d4d 100644
--- a/client/modules/IDE/reducers/project.js
+++ b/client/modules/IDE/reducers/project.js
@@ -1,14 +1,8 @@
-import friendlyWords from 'friendly-words';
import * as ActionTypes from '../../../constants';
-
-const generateRandomName = () => {
- const adj = friendlyWords.predicates[Math.floor(Math.random() * friendlyWords.predicates.length)];
- const obj = friendlyWords.objects[Math.floor(Math.random() * friendlyWords.objects.length)];
- return `${adj} ${obj}`;
-};
+import { generateProjectName } from '../../../utils/generateRandomName';
const initialState = () => {
- const generatedString = generateRandomName();
+ const generatedString = generateProjectName();
const generatedName = generatedString.charAt(0).toUpperCase() + generatedString.slice(1);
return {
name: generatedName,
diff --git a/client/modules/IDE/components/Collection.jsx b/client/modules/User/components/Collection.jsx
similarity index 100%
rename from client/modules/IDE/components/Collection.jsx
rename to client/modules/User/components/Collection.jsx
diff --git a/client/modules/User/components/CollectionCreate.jsx b/client/modules/User/components/CollectionCreate.jsx
new file mode 100644
index 00000000..3542e6a3
--- /dev/null
+++ b/client/modules/User/components/CollectionCreate.jsx
@@ -0,0 +1,124 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import { Helmet } from 'react-helmet';
+import { connect } from 'react-redux';
+import { browserHistory } from 'react-router';
+import { bindActionCreators } from 'redux';
+import * as CollectionsActions from '../../IDE/actions/collections';
+
+import { generateCollectionName } from '../../../utils/generateRandomName';
+
+class CollectionCreate extends React.Component {
+ constructor() {
+ super();
+
+ const name = generateCollectionName();
+
+ this.state = {
+ generatedCollectionName: name,
+ collection: {
+ name,
+ description: ''
+ }
+ };
+ }
+
+ getTitle() {
+ return 'p5.js Web Editor | Create collection';
+ }
+
+ 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 }) => {
+ browserHistory.replace(`/${owner.username}/collections/${id}`);
+ })
+ .catch((error) => {
+ console.error('Error creating collection', error);
+ this.setState({
+ creationError: error,
+ });
+ });
+ }
+
+ render() {
+ const { generatedCollectionName, creationError } = this.state;
+ 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,
+ createCollection: PropTypes.func.isRequired,
+ collection: PropTypes.shape({}).isRequired, // TODO
+ sorting: PropTypes.shape({
+ field: PropTypes.string.isRequired,
+ direction: PropTypes.string.isRequired
+ }).isRequired
+};
+
+function mapStateToProps(state, ownProps) {
+ return {
+ user: state.user,
+ };
+}
+
+function mapDispatchToProps(dispatch) {
+ return bindActionCreators(Object.assign({}, CollectionsActions), dispatch);
+}
+
+export default connect(mapStateToProps, mapDispatchToProps)(CollectionCreate);
diff --git a/client/modules/User/pages/CollectionView.jsx b/client/modules/User/pages/CollectionView.jsx
new file mode 100644
index 00000000..652e4310
--- /dev/null
+++ b/client/modules/User/pages/CollectionView.jsx
@@ -0,0 +1,114 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import { connect } from 'react-redux';
+import { bindActionCreators } from 'redux';
+import { browserHistory } from 'react-router';
+import { updateSettings, initiateVerification, createApiKey, removeApiKey } from '../actions';
+import NavBasic from '../../../components/NavBasic';
+
+import CollectionCreate from '../components/CollectionCreate';
+
+class CollectionView extends React.Component {
+ static defaultProps = {
+ user: null,
+ };
+
+ constructor(props) {
+ super(props);
+ this.closeAccountPage = this.closeAccountPage.bind(this);
+ this.gotoHomePage = this.gotoHomePage.bind(this);
+ }
+
+ componentDidMount() {
+ document.body.className = this.props.theme;
+ }
+
+ closeAccountPage() {
+ browserHistory.push(this.props.previousPath);
+ }
+
+ gotoHomePage() {
+ browserHistory.push('/');
+ }
+
+ ownerName() {
+ if (this.props.params.username) {
+ return this.props.params.username;
+ }
+
+ return this.props.user.username;
+ }
+
+ pageTitle() {
+ if (this.isCreatePage()) {
+ return 'Create collection';
+ }
+
+ return 'collection';
+ }
+
+ isOwner() {
+ return this.props.user.username === this.props.params.username;
+ }
+
+ isCreatePage() {
+ const path = this.props.location.pathname;
+ return /create$/.test(path);
+ }
+
+ renderContent() {
+ if (this.isCreatePage() && this.isOwner()) {
+ return ;
+ }
+
+ return 'collection page';
+ }
+
+ render() {
+ return (
+
+
+
+
+
+
{this.pageTitle()}
+
+
+
+ {this.renderContent()}
+
+
+
+ );
+ }
+}
+
+function mapStateToProps(state) {
+ return {
+ previousPath: state.ide.previousPath,
+ user: state.user,
+ theme: state.preferences.theme
+ };
+}
+
+function mapDispatchToProps(dispatch) {
+ return bindActionCreators({
+ updateSettings, initiateVerification, createApiKey, removeApiKey
+ }, dispatch);
+}
+
+CollectionView.propTypes = {
+ location: PropTypes.shape({
+ pathname: PropTypes.string.isRequired,
+ }).isRequired,
+ params: PropTypes.shape({
+ username: PropTypes.string.isRequired,
+ }).isRequired,
+ previousPath: PropTypes.string.isRequired,
+ theme: PropTypes.string.isRequired,
+ user: PropTypes.shape({
+ username: PropTypes.string.isRequired,
+ }),
+};
+
+export default connect(mapStateToProps, mapDispatchToProps)(CollectionView);
diff --git a/client/routes.jsx b/client/routes.jsx
index fc1eaf60..598a2dbe 100644
--- a/client/routes.jsx
+++ b/client/routes.jsx
@@ -9,6 +9,7 @@ import ResetPasswordView from './modules/User/pages/ResetPasswordView';
import EmailVerificationView from './modules/User/pages/EmailVerificationView';
import NewPasswordView from './modules/User/pages/NewPasswordView';
import AccountView from './modules/User/pages/AccountView';
+import CollectionView from './modules/User/pages/CollectionView';
import DashboardView from './modules/User/pages/DashboardView';
import createRedirectWithUsername from './components/createRedirectWithUsername';
import { getUser } from './modules/User/actions';
@@ -44,6 +45,7 @@ const routes = store => (
+
diff --git a/client/styles/base/_base.scss b/client/styles/base/_base.scss
index 2d2439a1..de49c804 100644
--- a/client/styles/base/_base.scss
+++ b/client/styles/base/_base.scss
@@ -6,13 +6,13 @@ html, body {
font-size: #{$base-font-size}px;
}
-body, input {
+body, input, textarea {
@include themify() {
color: getThemifyVariable('primary-text-color');
}
}
-body, input, button {
+body, input, textarea, button {
font-family: Montserrat, sans-serif;
}
@@ -31,7 +31,8 @@ input, button {
font-size: 1rem;
}
-input {
+input,
+textarea {
padding: #{5 / $base-font-size}rem;
border: 1px solid ;
border-radius: 2px;
diff --git a/client/styles/components/_forms.scss b/client/styles/components/_forms.scss
index 50a584fa..8bd4f055 100644
--- a/client/styles/components/_forms.scss
+++ b/client/styles/components/_forms.scss
@@ -56,6 +56,10 @@
}
}
+.form__input-flexible-height {
+ height: auto;
+}
+
.form__input::placeholder {
@include themify() {
color: getThemifyVariable('form-input-placeholder-text-color');
@@ -79,6 +83,15 @@
margin: #{39 / $base-font-size}rem 0 #{24 / $base-font-size}rem 0;
}
+.form [type="submit"][disabled] {
+ cursor: not-allowed;
+}
+
.form--inline [type="submit"] {
margin: 0 0 0 #{24 / $base-font-size}rem;
}
+
+.form [type="submit"][disabled],
+.form--inline [type="submit"][disabled] {
+ cursor: not-allowed;
+}
diff --git a/client/utils/generateRandomName.js b/client/utils/generateRandomName.js
new file mode 100644
index 00000000..101128d7
--- /dev/null
+++ b/client/utils/generateRandomName.js
@@ -0,0 +1,12 @@
+import friendlyWords from 'friendly-words';
+
+export function generateProjectName() {
+ const adj = friendlyWords.predicates[Math.floor(Math.random() * friendlyWords.predicates.length)];
+ const obj = friendlyWords.objects[Math.floor(Math.random() * friendlyWords.objects.length)];
+ return `${adj} ${obj}`;
+}
+
+export function generateCollectionName() {
+ const adj = friendlyWords.predicates[Math.floor(Math.random() * friendlyWords.predicates.length)];
+ return `My ${adj} collection`;
+}
diff --git a/server/routes/server.routes.js b/server/routes/server.routes.js
index 29877809..8222ce96 100644
--- a/server/routes/server.routes.js
+++ b/server/routes/server.routes.js
@@ -118,6 +118,18 @@ router.get('/:username/collections', (req, res) => {
));
});
+router.get('/:username/collections/create', (req, res) => {
+ userExists(req.params.username, exists => (
+ exists ? res.send(renderIndex()) : get404Sketch(html => res.send(html))
+ ));
+});
+
+router.get('/:username/sketches/:project_id/add-to-collection', (req, res) => {
+ projectForUserExists(req.params.username, req.params.project_id, 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))