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.props.project.id && this.props.user.authenticated && +
  • + -
  • } - { this.props.project.id && this.props.user.authenticated && -
  • - -
  • } - { this.props.project.id && -
  • -
  • } + {this.props.project.id && +
  • + -
  • } - { this.props.project.id && -
  • -
  • } + {this.props.project.id && +
  • + -
  • } - { this.props.user.authenticated && -
  • - - Open - -
  • } - { __process.env.EXAMPLES_ENABLED && -
  • - - Examples +
  • } + {this.props.user.authenticated && +
  • + + Open -
  • } + } + {__process.env.COLLECTIONS_ENABLED && this.props.user.authenticated && this.props.project.id && +
  • + + Add to Collection + +
  • + } + {__process.env.EXAMPLES_ENABLED && +
  • + + Examples + +
  • }
  • @@ -528,7 +540,7 @@ class Nav extends React.PureComponent {
  • - { __process.env.LOGIN_ENABLED && !this.props.user.authenticated && + {__process.env.LOGIN_ENABLED && !this.props.user.authenticated && } - { __process.env.LOGIN_ENABLED && this.props.user.authenticated && + {__process.env.LOGIN_ENABLED && this.props.user.authenticated && - } + } {/*
    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 && + + } + + ); + + 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()} + +
    +

    + + +

    +

    + +