diff --git a/client/constants.js b/client/constants.js index c4dc80ae..4dd57213 100644 --- a/client/constants.js +++ b/client/constants.js @@ -23,6 +23,7 @@ export const API_KEY_CREATED = 'API_KEY_CREATED'; export const API_KEY_REMOVED = 'API_KEY_REMOVED'; export const SET_PROJECT_NAME = 'SET_PROJECT_NAME'; +export const RENAME_PROJECT = 'RENAME_PROJECT'; export const PROJECT_SAVE_SUCCESS = 'PROJECT_SAVE_SUCCESS'; export const PROJECT_SAVE_FAIL = 'PROJECT_SAVE_FAIL'; diff --git a/client/modules/App/components/Overlay.jsx b/client/modules/App/components/Overlay.jsx index 486225f4..3a70242d 100644 --- a/client/modules/App/components/Overlay.jsx +++ b/client/modules/App/components/Overlay.jsx @@ -33,7 +33,7 @@ class Overlay extends React.Component { return; } - this.handleClickOutside(); + this.handleClickOutside(e); } handleClickOutside() { @@ -49,6 +49,10 @@ class Overlay extends React.Component { } close() { + // Only close if it is the last (and therefore the topmost overlay) + const overlays = document.getElementsByClassName('overlay'); + if (this.node.parentElement.parentElement !== overlays[overlays.length - 1]) return; + if (!this.props.closeOverlay) { browserHistory.push(this.props.previousPath); } else { diff --git a/client/modules/IDE/actions/ide.js b/client/modules/IDE/actions/ide.js index 64226b73..1d7c2998 100644 --- a/client/modules/IDE/actions/ide.js +++ b/client/modules/IDE/actions/ide.js @@ -134,9 +134,17 @@ export function closeNewFolderModal() { }; } -export function showShareModal() { - return { - type: ActionTypes.SHOW_SHARE_MODAL +export function showShareModal(projectId, projectName, ownerUsername) { + return (dispatch, getState) => { + const { project, user } = getState(); + dispatch({ + type: ActionTypes.SHOW_SHARE_MODAL, + payload: { + shareModalProjectId: projectId || project.id, + shareModalProjectName: projectName || project.name, + shareModalProjectUsername: ownerUsername || user.username + } + }); }; } diff --git a/client/modules/IDE/actions/project.js b/client/modules/IDE/actions/project.js index 0fa2d344..e4abb9d5 100644 --- a/client/modules/IDE/actions/project.js +++ b/client/modules/IDE/actions/project.js @@ -9,7 +9,8 @@ import { setUnsavedChanges, justOpenedProject, resetJustOpenedProject, - showErrorModal + showErrorModal, + setPreviousPath } from './ide'; import { clearState, saveState } from '../../../persistState'; @@ -246,47 +247,61 @@ function generateNewIdsForChildren(file, files) { file.children = newChildren; // eslint-disable-line } -export function cloneProject() { +export function cloneProject(id) { return (dispatch, getState) => { dispatch(setUnsavedChanges(false)); - const state = getState(); - const newFiles = state.files.map((file) => { // eslint-disable-line - return { ...file }; - }); - - // generate new IDS for all files - const rootFile = newFiles.find(file => file.name === 'root'); - const newRootFileId = objectID().toHexString(); - rootFile.id = newRootFileId; - rootFile._id = newRootFileId; - generateNewIdsForChildren(rootFile, newFiles); - - // duplicate all files hosted on S3 - each(newFiles, (file, callback) => { - if (file.url && file.url.includes('amazonaws')) { - const formParams = { - url: file.url - }; - axios.post(`${ROOT_URL}/S3/copy`, formParams, { withCredentials: true }) - .then((response) => { - file.url = response.data.url; - callback(null); - }); + new Promise((resolve, reject) => { + if (!id) { + resolve(getState()); } else { - callback(null); + fetch(`${ROOT_URL}/projects/${id}`) + .then(res => res.json()) + .then(data => resolve({ + files: data.files, + project: { + name: data.name + } + })); } - }, (err) => { - // if not errors in duplicating the files on S3, then duplicate it - const formParams = Object.assign({}, { name: `${state.project.name} copy` }, { files: newFiles }); - axios.post(`${ROOT_URL}/projects`, formParams, { withCredentials: true }) - .then((response) => { - browserHistory.push(`/${response.data.user.username}/sketches/${response.data.id}`); - dispatch(setNewProject(response.data)); - }) - .catch(response => dispatch({ - type: ActionTypes.PROJECT_SAVE_FAIL, - error: response.data - })); + }).then((state) => { + const newFiles = state.files.map((file) => { // eslint-disable-line + return { ...file }; + }); + + // generate new IDS for all files + const rootFile = newFiles.find(file => file.name === 'root'); + const newRootFileId = objectID().toHexString(); + rootFile.id = newRootFileId; + rootFile._id = newRootFileId; + generateNewIdsForChildren(rootFile, newFiles); + + // duplicate all files hosted on S3 + each(newFiles, (file, callback) => { + if (file.url && file.url.includes('amazonaws')) { + const formParams = { + url: file.url + }; + axios.post(`${ROOT_URL}/S3/copy`, formParams, { withCredentials: true }) + .then((response) => { + file.url = response.data.url; + callback(null); + }); + } else { + callback(null); + } + }, (err) => { + // if not errors in duplicating the files on S3, then duplicate it + const formParams = Object.assign({}, { name: `${state.project.name} copy` }, { files: newFiles }); + axios.post(`${ROOT_URL}/projects`, formParams, { withCredentials: true }) + .then((response) => { + browserHistory.push(`/${response.data.user.username}/sketches/${response.data.id}`); + dispatch(setNewProject(response.data)); + }) + .catch(response => dispatch({ + type: ActionTypes.PROJECT_SAVE_FAIL, + error: response.data + })); + }); }); }; } @@ -309,3 +324,58 @@ export function setProjectSavedTime(updatedAt) { value: updatedAt }; } + +export function changeProjectName(id, newName) { + return (dispatch, getState) => { + const state = getState(); + axios.put(`${ROOT_URL}/projects/${id}`, { name: newName }, { withCredentials: true }) + .then((response) => { + if (response.status === 200) { + dispatch({ + type: ActionTypes.RENAME_PROJECT, + payload: { id: response.data.id, name: response.data.name } + }); + if (state.project.id === response.data.id) { + dispatch({ + type: ActionTypes.SET_PROJECT_NAME, + name: response.data.name + }); + } + } + }) + .catch((response) => { + console.log(response); + dispatch({ + type: ActionTypes.PROJECT_SAVE_FAIL, + error: response.data + }); + }); + }; +} + +export function deleteProject(id) { + return (dispatch, getState) => { + axios.delete(`${ROOT_URL}/projects/${id}`, { withCredentials: true }) + .then(() => { + const state = getState(); + if (id === state.project.id) { + dispatch(resetProject()); + dispatch(setPreviousPath('/')); + } + dispatch({ + type: ActionTypes.DELETE_PROJECT, + id + }); + }) + .catch((response) => { + if (response.status === 403) { + dispatch(showErrorModal('staleSession')); + } else { + dispatch({ + type: ActionTypes.ERROR, + error: response.data + }); + } + }); + }; +} diff --git a/client/modules/IDE/actions/projects.js b/client/modules/IDE/actions/projects.js index 77dfbe52..446c50cc 100644 --- a/client/modules/IDE/actions/projects.js +++ b/client/modules/IDE/actions/projects.js @@ -1,12 +1,11 @@ import axios from 'axios'; import * as ActionTypes from '../../../constants'; -import { showErrorModal, setPreviousPath } from './ide'; -import { resetProject } from './project'; 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 getProjects(username) { return (dispatch) => { dispatch(startLoader()); @@ -33,30 +32,3 @@ export function getProjects(username) { }); }; } - -export function deleteProject(id) { - return (dispatch, getState) => { - axios.delete(`${ROOT_URL}/projects/${id}`, { withCredentials: true }) - .then(() => { - const state = getState(); - if (id === state.project.id) { - dispatch(resetProject()); - dispatch(setPreviousPath('/')); - } - dispatch({ - type: ActionTypes.DELETE_PROJECT, - id - }); - }) - .catch((response) => { - if (response.status === 403) { - dispatch(showErrorModal('staleSession')); - } else { - dispatch({ - type: ActionTypes.ERROR, - error: response.data - }); - } - }); - }; -} diff --git a/client/modules/IDE/components/AssetList.jsx b/client/modules/IDE/components/AssetList.jsx index 42513fba..ab548322 100644 --- a/client/modules/IDE/components/AssetList.jsx +++ b/client/modules/IDE/components/AssetList.jsx @@ -17,13 +17,14 @@ class AssetList extends React.Component { } getAssetsTitle() { - if (this.props.username === this.props.user.username) { + if (!this.props.username || this.props.username === this.props.user.username) { return 'p5.js Web Editor | My assets'; } return `p5.js Web Editor | ${this.props.username}'s assets`; } render() { + const username = this.props.username !== undefined ? this.props.username : this.props.user.username; return (
@@ -49,7 +50,7 @@ class AssetList extends React.Component { {asset.name} {prettyBytes(asset.size)} View - {asset.sketchName} + {asset.sketchName} ))} diff --git a/client/modules/IDE/components/SketchList.jsx b/client/modules/IDE/components/SketchList.jsx index f51a815a..bcfdf15f 100644 --- a/client/modules/IDE/components/SketchList.jsx +++ b/client/modules/IDE/components/SketchList.jsx @@ -4,19 +4,255 @@ import React from 'react'; import { Helmet } from 'react-helmet'; import InlineSVG from 'react-inlinesvg'; import { connect } from 'react-redux'; -import { browserHistory, Link } from 'react-router'; +import { Link } from 'react-router'; import { bindActionCreators } from 'redux'; import classNames from 'classnames'; import * as ProjectActions from '../actions/project'; -import * as SketchActions from '../actions/projects'; +import * as ProjectsActions from '../actions/projects'; import * as ToastActions from '../actions/toast'; import * as SortingActions from '../actions/sorting'; +import * as IdeActions from '../actions/ide'; import getSortedSketches from '../selectors/projects'; import Loader from '../../App/components/loader'; -const trashCan = require('../../../images/trash-can.svg'); 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 SketchListRowBase extends React.Component { + constructor(props) { + super(props); + this.state = { + optionsOpen: false, + renameOpen: false, + renameValue: props.sketch.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.sketch.id, this.state.renameValue); + this.closeAll(); + } + } + + resetSketchName = () => { + this.setState({ + renameValue: this.props.sketch.name + }); + } + + handleDropdownOpen = () => { + this.closeAll(); + this.openOptions(); + } + + handleRenameOpen = () => { + this.closeAll(); + this.openRename(); + } + + handleSketchDownload = () => { + this.props.exportProjectAsZip(this.props.sketch.id); + } + + handleSketchDuplicate = () => { + this.closeAll(); + this.props.cloneProject(this.props.sketch.id); + } + + handleSketchShare = () => { + this.closeAll(); + this.props.showShareModal(this.props.sketch.id, this.props.sketch.name, this.props.username); + } + + handleSketchDelete = () => { + this.closeAll(); + if (window.confirm(`Are you sure you want to delete "${this.props.sketch.name}"?`)) { + this.props.deleteProject(this.props.sketch.id); + } + } + + render() { + const { sketch, username } = this.props; + const { renameOpen, optionsOpen, renameValue } = this.state; + const userIsOwner = this.props.user.username === this.props.username; + return ( + + + + {renameOpen ? '' : sketch.name} + + {renameOpen + && + e.stopPropagation()} + /> + } + + {format(new Date(sketch.createdAt), 'MMM D, YYYY h:mm A')} + {format(new Date(sketch.updatedAt), 'MMM D, YYYY h:mm A')} + + + {optionsOpen && + } + + ); + } +} + +SketchListRowBase.propTypes = { + sketch: 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 SketchListRow = connect(null, mapDispatchToPropsSketchListRow)(SketchListRowBase); class SketchList extends React.Component { constructor(props) { @@ -83,43 +319,20 @@ class SketchList extends React.Component { - {this._renderFieldHeader('name', 'Sketch')} {this._renderFieldHeader('createdAt', 'Date Created')} {this._renderFieldHeader('updatedAt', 'Date Updated')} + {this.props.sketches.map(sketch => - // eslint-disable-next-line - browserHistory.push(`/${username}/sketches/${sketch.id}`)} - > - - - - - )} + sketch={sketch} + user={this.props.user} + username={username} + />))}
- {(() => { // eslint-disable-line - if (this.props.username === this.props.user.username || this.props.username === undefined) { - return ( - - ); - } - })()} - {sketch.name}{format(new Date(sketch.createdAt), 'MMM D, YYYY h:mm A')}{format(new Date(sketch.updatedAt), 'MMM D, YYYY h:mm A')}
}
@@ -129,7 +342,8 @@ class SketchList extends React.Component { SketchList.propTypes = { user: PropTypes.shape({ - username: PropTypes.string + username: PropTypes.string, + authenticated: PropTypes.bool.isRequired }).isRequired, getProjects: PropTypes.func.isRequired, sketches: PropTypes.arrayOf(PropTypes.shape({ @@ -140,16 +354,25 @@ SketchList.propTypes = { })).isRequired, username: PropTypes.string, loading: PropTypes.bool.isRequired, - deleteProject: PropTypes.func.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 + }) + }) }; SketchList.defaultProps = { + project: { + id: undefined, + owner: undefined + }, username: undefined }; @@ -158,12 +381,13 @@ function mapStateToProps(state) { user: state.user, sketches: getSortedSketches(state), sorting: state.sorting, - loading: state.loading + loading: state.loading, + project: state.project }; } function mapDispatchToProps(dispatch) { - return bindActionCreators(Object.assign({}, SketchActions, ProjectActions, ToastActions, SortingActions), dispatch); + return bindActionCreators(Object.assign({}, ProjectsActions, ToastActions, SortingActions), dispatch); } export default connect(mapStateToProps, mapDispatchToProps)(SketchList); diff --git a/client/modules/IDE/pages/IDEView.jsx b/client/modules/IDE/pages/IDEView.jsx index ed19bb8b..b2ae1ebb 100644 --- a/client/modules/IDE/pages/IDEView.jsx +++ b/client/modules/IDE/pages/IDEView.jsx @@ -414,9 +414,9 @@ class IDEView extends React.Component { closeOverlay={this.props.closeShareModal} > } @@ -481,6 +481,9 @@ IDEView.propTypes = { projectOptionsVisible: PropTypes.bool.isRequired, newFolderModalVisible: PropTypes.bool.isRequired, shareModalVisible: PropTypes.bool.isRequired, + shareModalProjectId: PropTypes.string.isRequired, + shareModalProjectName: PropTypes.string.isRequired, + shareModalProjectUsername: PropTypes.string.isRequired, editorOptionsVisible: PropTypes.bool.isRequired, keyboardShortcutVisible: PropTypes.bool.isRequired, unsavedChanges: PropTypes.bool.isRequired, diff --git a/client/modules/IDE/reducers/ide.js b/client/modules/IDE/reducers/ide.js index 379237bc..f9559234 100644 --- a/client/modules/IDE/reducers/ide.js +++ b/client/modules/IDE/reducers/ide.js @@ -10,6 +10,9 @@ const initialState = { projectOptionsVisible: false, newFolderModalVisible: false, shareModalVisible: false, + shareModalProjectId: null, + shareModalProjectName: null, + shareModalProjectUsername: null, editorOptionsVisible: false, keyboardShortcutVisible: false, unsavedChanges: false, @@ -61,7 +64,12 @@ const ide = (state = initialState, action) => { case ActionTypes.CLOSE_NEW_FOLDER_MODAL: return Object.assign({}, state, { newFolderModalVisible: false }); case ActionTypes.SHOW_SHARE_MODAL: - return Object.assign({}, state, { shareModalVisible: true }); + return Object.assign({}, state, { + shareModalVisible: true, + shareModalProjectId: action.payload.shareModalProjectId, + shareModalProjectName: action.payload.shareModalProjectName, + shareModalProjectUsername: action.payload.shareModalProjectUsername, + }); case ActionTypes.CLOSE_SHARE_MODAL: return Object.assign({}, state, { shareModalVisible: false }); case ActionTypes.SHOW_EDITOR_OPTIONS: diff --git a/client/modules/IDE/reducers/projects.js b/client/modules/IDE/reducers/projects.js index ba3bb4c9..ff06c5c7 100644 --- a/client/modules/IDE/reducers/projects.js +++ b/client/modules/IDE/reducers/projects.js @@ -7,6 +7,14 @@ const sketches = (state = [], action) => { case ActionTypes.DELETE_PROJECT: return state.filter(sketch => sketch.id !== action.id); + case ActionTypes.RENAME_PROJECT: { + return state.map((sketch) => { + if (sketch.id === action.payload.id) { + return { ...sketch, name: action.payload.name }; + } + return { ...sketch }; + }); + } default: return state; } diff --git a/client/styles/abstracts/_placeholders.scss b/client/styles/abstracts/_placeholders.scss index d6bc73f4..23b03f0e 100644 --- a/client/styles/abstracts/_placeholders.scss +++ b/client/styles/abstracts/_placeholders.scss @@ -193,6 +193,9 @@ height: auto; z-index: 9999; border-radius: #{6 / $base-font-size}rem; + & li:first-child { + border-radius: #{5 / $base-font-size}rem #{5 / $base-font-size}rem 0 0; + } & li:last-child { border-radius: 0 0 #{5 / $base-font-size}rem #{5 / $base-font-size}rem; } @@ -227,17 +230,9 @@ %dropdown-open-left { @extend %dropdown-open; left: 0; - border-top-left-radius: 0px; - & li:first-child { - border-radius: 0 #{5 / $base-font-size}rem 0 0; - } } %dropdown-open-right { @extend %dropdown-open; right: 0; - border-top-right-radius: 0px; - & li:first-child { - border-radius: #{5 / $base-font-size}rem 0 0 0; - } } diff --git a/client/styles/components/_sidebar.scss b/client/styles/components/_sidebar.scss index 9c852e9d..345d0b40 100644 --- a/client/styles/components/_sidebar.scss +++ b/client/styles/components/_sidebar.scss @@ -25,6 +25,8 @@ } .sidebar__add { + width: #{20 / $base-font-size}rem; + height: #{20 / $base-font-size}rem; @include icon(); .sidebar--contracted & { display: none; @@ -121,10 +123,11 @@ } .sidebar__file-item-show-options { + width: #{20 / $base-font-size}rem; + height: #{20 / $base-font-size}rem; @include icon(); @include themify() { - padding: #{4 / $base-font-size}rem 0; - padding-right: #{6 / $base-font-size}rem; + margin-right: #{5 / $base-font-size}rem; } display: none; position: absolute; diff --git a/client/styles/components/_sketch-list.scss b/client/styles/components/_sketch-list.scss index 328b07b2..dca1d759 100644 --- a/client/styles/components/_sketch-list.scss +++ b/client/styles/components/_sketch-list.scss @@ -10,8 +10,9 @@ padding: #{10 / $base-font-size}rem #{20 / $base-font-size}rem; max-height: 100%; border-spacing: 0; - & .sketch-list__trash-column { - width: #{23 / $base-font-size}rem; + & .sketch-list__dropdown-column { + width: #{60 / $base-font-size}rem; + position: relative; } } @@ -44,11 +45,14 @@ } } +.sketches-table thead th:nth-child(1){ + padding-left: #{12 / $base-font-size}rem; +} + .sketches-table__row { margin: #{10 / $base-font-size}rem; height: #{72 / $base-font-size}rem; font-size: #{16 / $base-font-size}rem; - cursor: pointer; } .sketches-table__row:nth-child(odd) { @@ -57,6 +61,10 @@ } } +.sketches-table__row > th:nth-child(1) { + padding-left: #{12 / $base-font-size}rem; +} + .sketches-table__row a { @include themify() { color: getThemifyVariable('primary-text-color'); @@ -75,28 +83,26 @@ font-weight: normal; } -.visibility-toggle .sketch-list__trash-button { - @extend %hidden-element; - width:#{20 / $base-font-size}rem; - height:#{20 / $base-font-size}rem; -} - -.visibility-toggle:hover .sketch-list__trash-button { - @include themify() { - background-color: transparent; - border: none; - cursor: pointer; - padding: 0; - position: initial; - left: 0; - top: 0; - & g { - opacity: 1; - fill: getThemifyVariable('icon-hover-color'); +.sketch-list__dropdown-button { + width:#{25 / $base-font-size}rem; + height:#{25 / $base-font-size}rem; + @include themify() { + & polygon { + fill: getThemifyVariable('dropdown-color'); } } } +.sketch-list__action-dialogue { + @extend %dropdown-open-right; + top: 63%; + right: calc(100% - 26px); +} + +.sketch-list__action-option { + +} + .sketches-table__empty { text-align: center; font-size: #{16 / $base-font-size}rem; diff --git a/server/controllers/project.controller.js b/server/controllers/project.controller.js index 527ac64a..ae029918 100644 --- a/server/controllers/project.controller.js +++ b/server/controllers/project.controller.js @@ -40,7 +40,7 @@ export function updateProject(req, res) { res.json({ success: false }); return; } - if (updatedProject.files.length !== req.body.files.length) { + if (req.body.files && updatedProject.files.length !== req.body.files.length) { const oldFileIds = updatedProject.files.map(file => file.id); const newFileIds = req.body.files.map(file => file.id); const staleIds = oldFileIds.filter(id => newFileIds.indexOf(id) === -1);