diff --git a/.env.example b/.env.example index 7680fb90a6..3a07031751 100644 --- a/.env.example +++ b/.env.example @@ -1,7 +1,8 @@ -API_URL=/api +API_URL=/editor AWS_ACCESS_KEY= AWS_REGION= AWS_SECRET_KEY= +CORS_ALLOW_LOCALHOST=true EMAIL_SENDER= EMAIL_VERIFY_SECRET_TOKEN=whatever_you_want_this_to_be_it_only_matters_for_production EXAMPLE_USER_EMAIL=examples@p5js.org @@ -23,3 +24,5 @@ PORT=8000 S3_BUCKET= S3_BUCKET_URL_BASE= SESSION_SECRET=whatever_you_want_this_to_be_it_only_matters_for_production +UI_ACCESS_TOKEN_ENABLED=false +UPLOAD_LIMIT=250000000 diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index 578af53b77..fc813e0cde 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -15,8 +15,6 @@ Hello! We welcome community contributions to the p5.js Web Editor. Contributing - [Issue Search and Tagging](#issue-search-and-tagging) - [Beginning Work](#beginning-work) - [Contribution Guides](#contribution-guides) - - [Writing Commit Messages](#writing-commit-messages) - - [Tips](#tips) ## Code of Conduct @@ -33,6 +31,8 @@ Don't know where to begin? Here are some suggestions to get started: - Front end: React/Redux, CSS/Sass, CodeMirror - Back end: Node, Express, MongoDB, Jest, AWS - DevOps: Travis CI, Jest, Docker, Kubernetes, AWS + - Documentation + - Translations: Application and documentation * Use the [p5.js Web Editor](https://editor.p5js.org)! Find a bug? Think of something you think would add to the project? Open an issue. * Expand an existing issue. Sometimes issues are missing steps to reproduce, or need suggestions for potential solutions. Sometimes they need another voice saying, "this is really important!" * Try getting the project running locally on your computer by following the [installation steps](./../developer_docs/installation.md). @@ -60,45 +60,9 @@ If you feel like an issue is tagged incorrectly (e.g. it's low priority and you If you'd like to work on an issue, please comment on it to let the maintainers know, so that they can assign it to you. If someone else has already commented and taken up that issue, please refrain from working on it and submitting a PR without asking the maintainers as it leads to unnecessary duplication of effort. -Then, follow the [installation guide](https://github.com/processing/p5.js-web-editor/blob/master/developer_docs/installation.md) to get the project building and working on your computer. +Then, look at the [development guide](https://github.com/processing/p5.js-web-editor/blob/master/developer_docs/development.md) for instructions on how to install the project locally and follow the right development workflow. ### Contribution Guides * [https://guides.github.com/activities/hello-world/](https://guides.github.com/activities/hello-world/) * [https://guides.github.com/activities/forking/](https://guides.github.com/activities/forking/) - -## Writing Commit Messages - -Good commit messages serve at least three important purposes: - -* They speed up the reviewing process. -* They help us write good release notes. -* They help future maintainers understand your change and the reasons behind it. - -Structure your commit message like this: - - ``` - Short (50 chars or less) summary of changes ( involving Fixes #Issue-number keyword ) - - More detailed explanatory text, if necessary. Wrap it to about 72 - characters or so. In some contexts, the first line is treated as the - subject of an email and the rest of the text as the body. The blank - line separating the summary from the body is critical (unless you omit - the body entirely); tools like rebase can get confused if you run the - two together. - - Further paragraphs come after blank lines. - - - Bullet points are okay, too - - - Typically a hyphen or asterisk is used for the bullet, preceded by a - single space, with blank lines in between, but conventions vary here - ``` - -* Write the summary line and description of what you have done in the imperative mode, that is as if you were commanding someone. Start the line with "Fix", "Add", "Change" instead of "Fixed", "Added", "Changed". -* Always leave the second line blank. -* Be as descriptive as possible in the description. It helps reasoning about the intention of commits and gives more context about why changes happened. - -## Tips - -* If it seems difficult to summarize what your commit does, it may be because it includes several logical changes or bug fixes, and are better split up into several commits using `git add -p`. \ No newline at end of file diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 0000000000..82c461c760 --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1 @@ +custom: https://processingfoundation.org/support diff --git a/.github/config.yml b/.github/config.yml index b4ec5b1a19..ab6fa5c5ad 100644 --- a/.github/config.yml +++ b/.github/config.yml @@ -10,7 +10,7 @@ newIssueWelcomeComment: > # Comment to be posted to on PRs from first time contributors in your repository newPRWelcomeComment: > - ๐ŸŽ‰ Thanks for opening this pull request! Please check out our [contributing guidelines](https://github.com/processing/p5.js-web-editor/blob/master/CONTRIBUTING.md) if you haven't already. + ๐ŸŽ‰ Thanks for opening this pull request! Please check out our [contributing guidelines](https://github.com/processing/p5.js-web-editor/blob/master/.github/CONTRIBUTING.md) if you haven't already. # Configuration for first-pr-merge - https://github.com/behaviorbot/first-pr-merge diff --git a/.travis.yml b/.travis.yml index da57c24aca..1628743435 100644 --- a/.travis.yml +++ b/.travis.yml @@ -37,12 +37,13 @@ deploy: script: ./deploy.sh skip_cleanup: true on: - branch: master + branch: release + tags: true - provider: script script: ./deploy_staging.sh skip_cleanup: true on: - branch: feature/public-api + branch: develop env: global: diff --git a/README.md b/README.md index 63efbb8d31..84e9427523 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,12 @@ # [p5.js Web Editor](https://editor.p5js.org) + +Documentation is also available in the following languages: + +[ํ•œ๊ตญ์–ด](https://github.com/processing/p5.js-web-editor/blob/master/translations/ko) + +## Welcome! ๐Ÿ‘‹๐Ÿ‘‹๐Ÿฟ๐Ÿ‘‹๐Ÿฝ๐Ÿ‘‹๐Ÿป๐Ÿ‘‹๐Ÿพ๐Ÿ‘‹๐Ÿผ + The p5.js Web Editor is a platform for creative coding, with a focus on making coding accessible for as many people as possible, including artists, designers, educators, beginners, and anyone who wants to learn. Simply by opening the website you can get started writing p5.js sketches without downloading or configuring anything. The editor is designed with simplicity in mind by limiting features and frills. We strive to listen to the community to drive the editorโ€™s development, and to be intentional with every change. The editor is free and open-source. We also strive to give the community as much ownership and control as possible. You can download your sketches so that you can edit them locally or host them elsewhere. You can also host your own version of the editor, giving you control over its data. diff --git a/app.json b/app.json index a588258e41..3eff82c9fb 100644 --- a/app.json +++ b/app.json @@ -16,7 +16,7 @@ ], "env": { "API_URL": { - "value": "/api" + "value": "/editor" }, "AWS_ACCESS_KEY": { "description": "AWS Access Key", diff --git a/client/components/AddRemoveButton.jsx b/client/components/AddRemoveButton.jsx new file mode 100644 index 0000000000..7350c177e7 --- /dev/null +++ b/client/components/AddRemoveButton.jsx @@ -0,0 +1,27 @@ +import React from 'react'; +import PropTypes from 'prop-types'; + +import AddIcon from '../images/plus.svg'; +import RemoveIcon from '../images/minus.svg'; + +const AddRemoveButton = ({ type, onClick }) => { + const alt = type === 'add' ? 'Add to collection' : 'Remove from collection'; + const Icon = type === 'add' ? AddIcon : RemoveIcon; + + return ( + + ); +}; + +AddRemoveButton.propTypes = { + type: PropTypes.oneOf(['add', 'remove']).isRequired, + onClick: PropTypes.func.isRequired, +}; + +export default AddRemoveButton; diff --git a/client/components/Nav.jsx b/client/components/Nav.jsx index 617f6883ce..22305b3ae4 100644 --- a/client/components/Nav.jsx +++ b/client/components/Nav.jsx @@ -3,7 +3,6 @@ import React from 'react'; import { connect } from 'react-redux'; import { withRouter } from 'react-router'; import { Link } from 'react-router'; -import InlineSVG from 'react-inlinesvg'; import classNames from 'classnames'; import * as IDEActions from '../modules/IDE/actions/ide'; import * as toastActions from '../modules/IDE/actions/toast'; @@ -13,8 +12,9 @@ import { logoutUser } from '../modules/User/actions'; import { metaKeyName, } from '../utils/metaKey'; -const triangleUrl = require('../images/down-filled-triangle.svg'); -const logoUrl = require('../images/p5js-logo-small.svg'); +import CaretLeftIcon from '../images/left-arrow.svg'; +import TriangleIcon from '../images/down-filled-triangle.svg'; +import LogoIcon from '../images/p5js-logo-small.svg'; const __process = (typeof global !== 'undefined' ? global : window).process; @@ -92,11 +92,12 @@ class Nav extends React.PureComponent { } handleNew() { - if (!this.props.unsavedChanges) { + const { unsavedChanges, warnIfUnsavedChanges } = this.props; + if (!unsavedChanges) { this.props.showToast(1500); this.props.setToastText('Opened new sketch.'); this.props.newProject(); - } else if (this.props.warnIfUnsavedChanges()) { + } else if (warnIfUnsavedChanges && warnIfUnsavedChanges()) { this.props.showToast(1500); this.props.setToastText('Opened new sketch.'); this.props.newProject(); @@ -180,7 +181,8 @@ class Nav extends React.PureComponent { } handleShare() { - this.props.showShareModal(); + const { username } = this.props.params; + this.props.showShareModal(this.props.project.id, this.props.project.name, username); this.setDropdown('none'); } @@ -222,397 +224,470 @@ class Nav extends React.PureComponent { this.timer = setTimeout(this.setDropdown.bind(this, 'none'), 10); } - render() { - const navDropdownState = { - file: classNames({ - 'nav__item': true, - 'nav__item--open': this.state.dropdownOpen === 'file' - }), - edit: classNames({ - 'nav__item': true, - 'nav__item--open': this.state.dropdownOpen === 'edit' - }), - sketch: classNames({ - 'nav__item': true, - 'nav__item--open': this.state.dropdownOpen === 'sketch' - }), - help: classNames({ - 'nav__item': true, - 'nav__item--open': this.state.dropdownOpen === 'help' - }), - account: classNames({ - 'nav__item': true, - 'nav__item--open': this.state.dropdownOpen === 'account' - }) - }; + renderDashboardMenu(navDropdownState) { return ( - + + + + ); + } + + renderUserMenu(navDropdownState) { + const isLoginEnabled = __process.env.LOGIN_ENABLED; + const isAuthenticated = this.props.user.authenticated; + + if (isLoginEnabled && isAuthenticated) { + return this.renderAuthenticatedUserMenu(navDropdownState); + } else if (isLoginEnabled && !isAuthenticated) { + return this.renderUnauthenticatedUserMenu(navDropdownState); + } + + return null; + } + + renderLeftLayout(navDropdownState) { + switch (this.props.layout) { + case 'dashboard': + return this.renderDashboardMenu(navDropdownState); + case 'project': + default: + return this.renderProjectMenu(navDropdownState); + } + } + + render() { + const navDropdownState = { + file: classNames({ + 'nav__item': true, + 'nav__item--open': this.state.dropdownOpen === 'file' + }), + edit: classNames({ + 'nav__item': true, + 'nav__item--open': this.state.dropdownOpen === 'edit' + }), + sketch: classNames({ + 'nav__item': true, + 'nav__item--open': this.state.dropdownOpen === 'sketch' + }), + help: classNames({ + 'nav__item': true, + 'nav__item--open': this.state.dropdownOpen === 'help' + }), + account: classNames({ + 'nav__item': true, + 'nav__item--open': this.state.dropdownOpen === 'account' + }) + }; + + return ( +
+ +
); } } @@ -631,6 +706,7 @@ Nav.propTypes = { }).isRequired, project: PropTypes.shape({ id: PropTypes.string, + name: PropTypes.string, owner: PropTypes.shape({ id: PropTypes.string }) @@ -639,7 +715,7 @@ Nav.propTypes = { showShareModal: PropTypes.func.isRequired, showErrorModal: PropTypes.func.isRequired, unsavedChanges: PropTypes.bool.isRequired, - warnIfUnsavedChanges: PropTypes.func.isRequired, + warnIfUnsavedChanges: PropTypes.func, showKeyboardShortcutModal: PropTypes.func.isRequired, cmController: PropTypes.shape({ tidyCode: PropTypes.func, @@ -653,9 +729,13 @@ Nav.propTypes = { setAllAccessibleOutput: PropTypes.func.isRequired, newFile: PropTypes.func.isRequired, newFolder: PropTypes.func.isRequired, + layout: PropTypes.oneOf(['dashboard', 'project']), rootFile: PropTypes.shape({ id: PropTypes.string.isRequired - }).isRequired + }).isRequired, + params: PropTypes.shape({ + username: PropTypes.string + }) }; Nav.defaultProps = { @@ -663,7 +743,12 @@ Nav.defaultProps = { id: undefined, owner: undefined }, - cmController: {} + cmController: {}, + layout: 'project', + warnIfUnsavedChanges: undefined, + params: { + username: undefined + } }; function mapStateToProps(state) { diff --git a/client/components/NavBasic.jsx b/client/components/NavBasic.jsx new file mode 100644 index 0000000000..411329d69c --- /dev/null +++ b/client/components/NavBasic.jsx @@ -0,0 +1,39 @@ +import PropTypes from 'prop-types'; +import React from 'react'; + +import LogoIcon from '../images/p5js-logo-small.svg'; +import ArrowIcon from '../images/triangle-arrow-left.svg'; + +class NavBasic extends React.PureComponent { + static defaultProps = { + onBack: null + } + + render() { + return ( + + ); + } +} + +NavBasic.propTypes = { + onBack: PropTypes.func, +}; + +export default NavBasic; diff --git a/client/components/PreviewNav.jsx b/client/components/PreviewNav.jsx index 7169621dd9..6a983e19f0 100644 --- a/client/components/PreviewNav.jsx +++ b/client/components/PreviewNav.jsx @@ -1,24 +1,23 @@ import PropTypes from 'prop-types'; import React from 'react'; import { Link } from 'react-router'; -import InlineSVG from 'react-inlinesvg'; -const logoUrl = require('../images/p5js-logo-small.svg'); -const editorUrl = require('../images/code.svg'); +import LogoIcon from '../images/p5js-logo-small.svg'; +import CodeIcon from '../images/code.svg'; const PreviewNav = ({ owner, project }) => ( diff --git a/client/components/__test__/FileNode.test.jsx b/client/components/__test__/FileNode.test.jsx new file mode 100644 index 0000000000..b78d6ab26d --- /dev/null +++ b/client/components/__test__/FileNode.test.jsx @@ -0,0 +1,183 @@ +import React from 'react'; +import { shallow } from 'enzyme'; +import { FileNode } from '../../modules/IDE/components/FileNode'; + +describe('', () => { + let component; + let props = {}; + let input; + let renameTriggerButton; + const changeName = (newFileName) => { + renameTriggerButton.simulate('click'); + input.simulate('change', { target: { value: newFileName } }); + input.simulate('blur'); + }; + const getState = () => component.state(); + const getUpdatedName = () => getState().updatedName; + + describe('with valid props, regardless of filetype', () => { + ['folder', 'file'].forEach((fileType) => { + beforeEach(() => { + props = { + ...props, + id: '0', + name: 'test.jsx', + fileType, + canEdit: true, + children: [], + authenticated: false, + setSelectedFile: jest.fn(), + deleteFile: jest.fn(), + updateFileName: jest.fn(), + resetSelectedFile: jest.fn(), + newFile: jest.fn(), + newFolder: jest.fn(), + showFolderChildren: jest.fn(), + hideFolderChildren: jest.fn(), + openUploadFileModal: jest.fn(), + setProjectName: jest.fn(), + }; + component = shallow(); + }); + + describe('when changing name', () => { + beforeEach(() => { + input = component.find('.sidebar__file-item-input'); + renameTriggerButton = component + .find('.sidebar__file-item-option') + .first(); + component.setState({ isEditing: true }); + }); + + describe('to an empty name', () => { + const newName = ''; + beforeEach(() => changeName(newName)); + + it('should not save', () => expect(props.updateFileName).not.toHaveBeenCalled()); + it('should reset name', () => expect(getUpdatedName()).toEqual(props.name)); + }); + }); + }); + }); + + describe('as file with valid props', () => { + beforeEach(() => { + props = { + ...props, + id: '0', + name: 'test.jsx', + fileType: 'file', + canEdit: true, + children: [], + authenticated: false, + setSelectedFile: jest.fn(), + deleteFile: jest.fn(), + updateFileName: jest.fn(), + resetSelectedFile: jest.fn(), + newFile: jest.fn(), + newFolder: jest.fn(), + showFolderChildren: jest.fn(), + hideFolderChildren: jest.fn(), + openUploadFileModal: jest.fn() + }; + component = shallow(); + }); + + describe('when changing name', () => { + beforeEach(() => { + input = component.find('.sidebar__file-item-input'); + renameTriggerButton = component + .find('.sidebar__file-item-option') + .first(); + component.setState({ isEditing: true }); + }); + it('should render', () => expect(component).toBeDefined()); + + // it('should debug', () => console.log(component.debug())); + + describe('to a valid filename', () => { + const newName = 'newname.jsx'; + beforeEach(() => changeName(newName)); + + it('should save the name', () => { + expect(props.updateFileName).toBeCalledWith(props.id, newName); + }); + }); + + // Failure Scenarios + + describe('to an extensionless filename', () => { + const newName = 'extensionless'; + beforeEach(() => changeName(newName)); + }); + it('should not save', () => expect(props.setProjectName).not.toHaveBeenCalled()); + it('should reset name', () => expect(getUpdatedName()).toEqual(props.name)); + describe('to different extension', () => { + const newName = 'name.gif'; + beforeEach(() => changeName(newName)); + + it('should not save', () => expect(props.setProjectName).not.toHaveBeenCalled()); + it('should reset name', () => expect(getUpdatedName()).toEqual(props.name)); + }); + + describe('to just an extension', () => { + const newName = '.jsx'; + beforeEach(() => changeName(newName)); + + it('should not save', () => expect(props.updateFileName).not.toHaveBeenCalled()); + it('should reset name', () => expect(getUpdatedName()).toEqual(props.name)); + }); + }); + }); + + + describe('as folder with valid props', () => { + beforeEach(() => { + props = { + ...props, + id: '0', + children: [], + name: 'filename', + fileType: 'folder', + canEdit: true, + authenticated: false, + setSelectedFile: jest.fn(), + deleteFile: jest.fn(), + updateFileName: jest.fn(), + resetSelectedFile: jest.fn(), + newFile: jest.fn(), + newFolder: jest.fn(), + showFolderChildren: jest.fn(), + hideFolderChildren: jest.fn(), + openUploadFileModal: jest.fn() + }; + component = shallow(); + }); + + describe('when changing name', () => { + beforeEach(() => { + input = component.find('.sidebar__file-item-input'); + renameTriggerButton = component + .find('.sidebar__file-item-option') + .first(); + component.setState({ isEditing: true }); + }); + + describe('to a foldername', () => { + const newName = 'newfoldername'; + beforeEach(() => changeName(newName)); + + it('should save', () => expect(props.updateFileName).toBeCalledWith(props.id, newName)); + it('should update name', () => expect(getUpdatedName()).toEqual(newName)); + }); + + describe('to a filename', () => { + const newName = 'filename.jsx'; + beforeEach(() => changeName(newName)); + + it('should not save', () => expect(props.updateFileName).not.toHaveBeenCalled()); + it('should reset name', () => expect(getUpdatedName()).toEqual(props.name)); + }); + }); + }); +}); diff --git a/client/components/__test__/Toolbar.test.jsx b/client/components/__test__/Toolbar.test.jsx new file mode 100644 index 0000000000..a64f6f2d5e --- /dev/null +++ b/client/components/__test__/Toolbar.test.jsx @@ -0,0 +1,105 @@ +import React from 'react'; +import { shallow } from 'enzyme'; +import { ToolbarComponent } from '../../modules/IDE/components/Toolbar'; + + +const initialProps = { + isPlaying: false, + preferencesIsVisible: false, + stopSketch: jest.fn(), + setProjectName: jest.fn(), + openPreferences: jest.fn(), + showEditProjectName: jest.fn(), + hideEditProjectName: jest.fn(), + infiniteLoop: false, + autorefresh: false, + setAutorefresh: jest.fn(), + setTextOutput: jest.fn(), + setGridOutput: jest.fn(), + startSketch: jest.fn(), + startAccessibleSketch: jest.fn(), + saveProject: jest.fn(), + currentUser: 'me', + originalProjectName: 'testname', + + owner: { + username: 'me' + }, + project: { + name: 'testname', + isEditingName: false, + id: 'id', + }, +}; + + +describe('', () => { + let component; + let props = initialProps; + let input; + let renameTriggerButton; + const changeName = (newFileName) => { + component.find('.toolbar__project-name').simulate('click', { preventDefault: jest.fn() }); + input = component.find('.toolbar__project-name-input'); + renameTriggerButton = component.find('.toolbar__edit-name-button'); + renameTriggerButton.simulate('click'); + input.simulate('change', { target: { value: newFileName } }); + input.simulate('blur'); + }; + const setProps = (additionalProps) => { + props = { + ...props, + ...additionalProps, + + project: { + ...props.project, + ...(additionalProps || {}).project + }, + }; + }; + + // Test Cases + + describe('with valid props', () => { + beforeEach(() => { + setProps(); + component = shallow(); + }); + it('renders', () => expect(component).toBeDefined()); + + describe('when use owns sketch', () => { + beforeEach(() => setProps({ currentUser: props.owner.username })); + + describe('when changing sketch name', () => { + beforeEach(() => { + setProps({ + project: { isEditingName: true, name: 'testname' }, + setProjectName: jest.fn(name => component.setProps({ project: { name } })), + }); + component = shallow(); + }); + + describe('to a valid name', () => { + beforeEach(() => changeName('hello')); + it('should save', () => expect(props.setProjectName).toBeCalledWith('hello')); + }); + + + describe('to an empty name', () => { + beforeEach(() => changeName('')); + it('should set name to empty', () => expect(props.setProjectName).toBeCalledWith('')); + it( + 'should detect empty name and revert to original', + () => expect(props.setProjectName).toHaveBeenLastCalledWith(initialProps.project.name) + ); + }); + }); + }); + + describe('when user does not own sketch', () => { + beforeEach(() => setProps({ currentUser: 'not-the-owner' })); + + it('should disable edition', () => expect(component.find('.toolbar__edit-name-button')).toEqual({})); + }); + }); +}); diff --git a/client/components/__test__/__snapshots__/Nav.test.jsx.snap b/client/components/__test__/__snapshots__/Nav.test.jsx.snap index 5922659f29..592fc282f0 100644 --- a/client/components/__test__/__snapshots__/Nav.test.jsx.snap +++ b/client/components/__test__/__snapshots__/Nav.test.jsx.snap @@ -1,334 +1,346 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`Nav renders correctly 1`] = ` - + `; diff --git a/client/components/createRedirectWithUsername.jsx b/client/components/createRedirectWithUsername.jsx new file mode 100644 index 0000000000..fe76b5cd20 --- /dev/null +++ b/client/components/createRedirectWithUsername.jsx @@ -0,0 +1,27 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import { browserHistory } from 'react-router'; + +const RedirectToUser = ({ username, url = '/:username/sketches' }) => { + React.useEffect(() => { + if (username == null) { + return; + } + + browserHistory.replace(url.replace(':username', username)); + }, [username]); + + return null; +}; + +function mapStateToProps(state) { + return { + username: state.user ? state.user.username : null, + }; +} + +const ConnectedRedirectToUser = connect(mapStateToProps)(RedirectToUser); + +const createRedirectWithUsername = url => props => ; + +export default createRedirectWithUsername; diff --git a/client/constants.js b/client/constants.js index d4a4504b25..477409fce5 100644 --- a/client/constants.js +++ b/client/constants.js @@ -20,6 +20,9 @@ export const AUTH_ERROR = 'AUTH_ERROR'; export const SETTINGS_UPDATED = 'SETTINGS_UPDATED'; +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'; @@ -33,6 +36,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 = 'CREATED_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 EDIT_COLLECTION = 'EDIT_COLLECTION'; + export const DELETE_PROJECT = 'DELETE_PROJECT'; export const SET_SELECTED_FILE = 'SET_SELECTED_FILE'; @@ -69,6 +80,8 @@ export const SHOW_NEW_FOLDER_MODAL = 'SHOW_NEW_FOLDER_MODAL'; export const CLOSE_NEW_FOLDER_MODAL = 'CLOSE_NEW_FOLDER_MODAL'; export const SHOW_FOLDER_CHILDREN = 'SHOW_FOLDER_CHILDREN'; export const HIDE_FOLDER_CHILDREN = 'HIDE_FOLDER_CHILDREN'; +export const OPEN_UPLOAD_FILE_MODAL = 'OPEN_UPLOAD_FILE_MODAL'; +export const CLOSE_UPLOAD_FILE_MODAL = 'CLOSE_UPLOAD_FILE_MODAL'; export const SHOW_SHARE_MODAL = 'SHOW_SHARE_MODAL'; export const CLOSE_SHARE_MODAL = 'CLOSE_SHARE_MODAL'; @@ -116,6 +129,7 @@ export const CLEAR_PERSISTED_STATE = 'CLEAR_PERSISTED_STATE'; export const HIDE_RUNTIME_ERROR_WARNING = 'HIDE_RUNTIME_ERROR_WARNING'; export const SHOW_RUNTIME_ERROR_WARNING = 'SHOW_RUNTIME_ERROR_WARNING'; export const SET_ASSETS = 'SET_ASSETS'; +export const DELETE_ASSET = 'DELETE_ASSET'; export const TOGGLE_DIRECTION = 'TOGGLE_DIRECTION'; export const SET_SORTING = 'SET_SORTING'; diff --git a/client/images/check.svg b/client/images/check.svg new file mode 100644 index 0000000000..b8da10eb9a --- /dev/null +++ b/client/images/check.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/client/images/check_encircled.svg b/client/images/check_encircled.svg new file mode 100644 index 0000000000..63ddbf51f9 --- /dev/null +++ b/client/images/check_encircled.svg @@ -0,0 +1,11 @@ + + + + + + + diff --git a/client/images/close.svg b/client/images/close.svg new file mode 100644 index 0000000000..b6516ed988 --- /dev/null +++ b/client/images/close.svg @@ -0,0 +1,12 @@ + + + + + + + diff --git a/client/images/console-debug-contrast.svg b/client/images/console-debug-contrast.svg new file mode 100644 index 0000000000..76906983d7 --- /dev/null +++ b/client/images/console-debug-contrast.svg @@ -0,0 +1,54 @@ + + + + + + image/svg+xml + + + + + + + + diff --git a/client/images/console-debug-light.svg b/client/images/console-debug-light.svg index 8bdc7ae393..0e03934266 100644 --- a/client/images/console-debug-light.svg +++ b/client/images/console-debug-light.svg @@ -50,5 +50,5 @@ + fill="#0071AD" /> diff --git a/client/images/console-error-contrast.svg b/client/images/console-error-contrast.svg new file mode 100644 index 0000000000..b455509b89 --- /dev/null +++ b/client/images/console-error-contrast.svg @@ -0,0 +1,54 @@ + + + + + + image/svg+xml + + + + + + + + diff --git a/client/images/console-info-contrast.svg b/client/images/console-info-contrast.svg new file mode 100644 index 0000000000..7c37d67327 --- /dev/null +++ b/client/images/console-info-contrast.svg @@ -0,0 +1,54 @@ + + + + + + image/svg+xml + + + + + + + + diff --git a/client/images/console-info-dark.svg b/client/images/console-info-dark.svg index c116d093cd..869795c59c 100644 --- a/client/images/console-info-dark.svg +++ b/client/images/console-info-dark.svg @@ -50,5 +50,5 @@ + fill="#D9D9D9" /> diff --git a/client/images/console-info-light.svg b/client/images/console-info-light.svg index 24b7426313..eaf8be4daa 100644 --- a/client/images/console-info-light.svg +++ b/client/images/console-info-light.svg @@ -50,5 +50,5 @@ + fill="#4D4D4D" /> diff --git a/client/images/console-warn-contrast.svg b/client/images/console-warn-contrast.svg new file mode 100644 index 0000000000..6811e3d4fe --- /dev/null +++ b/client/images/console-warn-contrast.svg @@ -0,0 +1,54 @@ + + + + + + image/svg+xml + + + + + + + + diff --git a/client/images/console-warn-light.svg b/client/images/console-warn-light.svg index 6e76d14abc..548ee94690 100644 --- a/client/images/console-warn-light.svg +++ b/client/images/console-warn-light.svg @@ -50,5 +50,5 @@ + fill="#996B00" /> diff --git a/client/images/down-arrow.svg b/client/images/down-arrow.svg index 8ea28980b2..a1dc41d5d1 100644 --- a/client/images/down-arrow.svg +++ b/client/images/down-arrow.svg @@ -1,11 +1,10 @@ - arrow shape copy 2 - - + + diff --git a/client/images/exit.svg b/client/images/exit.svg index dd19e998fe..b6c0cc7463 100644 --- a/client/images/exit.svg +++ b/client/images/exit.svg @@ -5,7 +5,7 @@ - + diff --git a/client/images/left-arrow.svg b/client/images/left-arrow.svg index ea692ff9f0..ab89e77b94 100644 --- a/client/images/left-arrow.svg +++ b/client/images/left-arrow.svg @@ -1,7 +1,6 @@ - arrow shape copy diff --git a/client/images/right-arrow.svg b/client/images/right-arrow.svg index ba720dde29..199db8caec 100644 --- a/client/images/right-arrow.svg +++ b/client/images/right-arrow.svg @@ -1,7 +1,6 @@ - arrow shape copy diff --git a/client/images/triangle-arrow-down-white.svg b/client/images/triangle-arrow-down-white.svg index a66e9ff63f..75fd556753 100644 --- a/client/images/triangle-arrow-down-white.svg +++ b/client/images/triangle-arrow-down-white.svg @@ -3,7 +3,7 @@ Created with Sketch. - + diff --git a/client/images/triangle-arrow-down.svg b/client/images/triangle-arrow-down.svg index a66e9ff63f..47ab5d101d 100644 --- a/client/images/triangle-arrow-down.svg +++ b/client/images/triangle-arrow-down.svg @@ -3,7 +3,7 @@ Created with Sketch. - + diff --git a/client/images/triangle-arrow-left.svg b/client/images/triangle-arrow-left.svg new file mode 100644 index 0000000000..dcc159dfb6 --- /dev/null +++ b/client/images/triangle-arrow-left.svg @@ -0,0 +1,14 @@ + + Left Arrow + Created with Sketch. + + + + + + + + + diff --git a/client/images/triangle-arrow-right.svg b/client/images/triangle-arrow-right.svg index a99148b2ca..222020a8a3 100644 --- a/client/images/triangle-arrow-right.svg +++ b/client/images/triangle-arrow-right.svg @@ -3,7 +3,7 @@ Created with Sketch. - + diff --git a/client/images/up-arrow.svg b/client/images/up-arrow.svg index 1545520a69..c4a90f1dfc 100644 --- a/client/images/up-arrow.svg +++ b/client/images/up-arrow.svg @@ -1,11 +1,10 @@ - arrow shape copy - - + + diff --git a/client/modules/App/App.jsx b/client/modules/App/App.jsx index 654610e488..c9d9b3a169 100644 --- a/client/modules/App/App.jsx +++ b/client/modules/App/App.jsx @@ -18,7 +18,10 @@ class App extends React.Component { } componentWillReceiveProps(nextProps) { - if (nextProps.location !== this.props.location) { + const locationWillChange = nextProps.location !== this.props.location; + const shouldSkipRemembering = nextProps.location.state && nextProps.location.state.skipSavingPath === true; + + if (locationWillChange && !shouldSkipRemembering) { this.props.setPreviousPath(this.props.location.pathname); } } @@ -42,7 +45,10 @@ class App extends React.Component { App.propTypes = { children: PropTypes.element, location: PropTypes.shape({ - pathname: PropTypes.string + pathname: PropTypes.string, + state: PropTypes.shape({ + skipSavingPath: PropTypes.bool, + }), }).isRequired, setPreviousPath: PropTypes.func.isRequired, theme: PropTypes.string, diff --git a/client/modules/App/components/Overlay.jsx b/client/modules/App/components/Overlay.jsx index 3a70242d11..4e54b615f3 100644 --- a/client/modules/App/components/Overlay.jsx +++ b/client/modules/App/components/Overlay.jsx @@ -1,9 +1,8 @@ import PropTypes from 'prop-types'; import React from 'react'; -import InlineSVG from 'react-inlinesvg'; import { browserHistory } from 'react-router'; -const exitUrl = require('../../../images/exit.svg'); +import ExitIcon from '../../../images/exit.svg'; class Overlay extends React.Component { constructor(props) { @@ -64,10 +63,12 @@ class Overlay extends React.Component { const { ariaLabel, title, - children + children, + actions, + isFixedHeight, } = this.props; return ( -
+

{title}

- +
+ {actions} + +
{children}
@@ -91,18 +95,22 @@ class Overlay extends React.Component { Overlay.propTypes = { children: PropTypes.element, + actions: PropTypes.element, closeOverlay: PropTypes.func, title: PropTypes.string, ariaLabel: PropTypes.string, - previousPath: PropTypes.string + previousPath: PropTypes.string, + isFixedHeight: PropTypes.bool, }; Overlay.defaultProps = { children: null, + actions: null, title: 'Modal', closeOverlay: null, ariaLabel: 'modal', - previousPath: '/' + previousPath: '/', + isFixedHeight: false, }; export default Overlay; diff --git a/client/modules/App/components/loader.jsx b/client/modules/App/components/loader.jsx index 1561387e59..3730642711 100644 --- a/client/modules/App/components/loader.jsx +++ b/client/modules/App/components/loader.jsx @@ -1,9 +1,11 @@ import React from 'react'; const Loader = () => ( -
-
-
+
+
+
+
+
); export default Loader; diff --git a/client/modules/IDE/actions/assets.js b/client/modules/IDE/actions/assets.js index e2b49cf64d..483e6d4e34 100644 --- a/client/modules/IDE/actions/assets.js +++ b/client/modules/IDE/actions/assets.js @@ -30,8 +30,23 @@ export function getAssets() { }; } -export function deleteAsset(assetKey, userId) { +export function deleteAsset(assetKey) { return { - type: 'PLACEHOLDER' + type: ActionTypes.DELETE_ASSET, + key: assetKey + }; +} + +export function deleteAssetRequest(assetKey) { + return (dispatch) => { + axios.delete(`${ROOT_URL}/S3/${assetKey}`, { withCredentials: true }) + .then((response) => { + dispatch(deleteAsset(assetKey)); + }) + .catch(() => { + dispatch({ + type: ActionTypes.ERROR + }); + }); }; } diff --git a/client/modules/IDE/actions/collections.js b/client/modules/IDE/actions/collections.js new file mode 100644 index 0000000000..03cf2a647b --- /dev/null +++ b/client/modules/IDE/actions/collections.js @@ -0,0 +1,182 @@ +import axios from 'axios'; +import { browserHistory } from 'react-router'; +import * as ActionTypes from '../../../constants'; +import { startLoader, stopLoader } from './loader'; +import { setToastText, showToast } from './toast'; + +const __process = (typeof global !== 'undefined' ? global : window).process; +const ROOT_URL = __process.env.API_URL; + +const TOAST_DISPLAY_TIME_MS = 1500; + +// 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((error) => { + const { response } = error; + 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()); + + const newCollection = response.data; + dispatch(setToastText(`Created "${newCollection.name}"`)); + dispatch(showToast(TOAST_DISPLAY_TIME_MS)); + + const pathname = `/${newCollection.owner.username}/collections/${newCollection.id}`; + const location = { pathname, state: { skipSavingPath: true } }; + + browserHistory.push(location); + }) + .catch((error) => { + const { response } = error; + console.error('Error creating collection', response.data); + dispatch({ + type: ActionTypes.ERROR, + error: response.data + }); + dispatch(stopLoader()); + }); + }; +} + +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()); + + const collectionName = response.data.name; + + dispatch(setToastText(`Added to "${collectionName}`)); + dispatch(showToast(TOAST_DISPLAY_TIME_MS)); + + return response.data; + }) + .catch((error) => { + const { response } = error; + 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()); + + const collectionName = response.data.name; + + dispatch(setToastText(`Removed from "${collectionName}`)); + dispatch(showToast(TOAST_DISPLAY_TIME_MS)); + + return response.data; + }) + .catch((error) => { + const { response } = error; + dispatch({ + type: ActionTypes.ERROR, + error: response.data + }); + dispatch(stopLoader()); + + return response.data; + }); + }; +} + +export function editCollection(collectionId, { name, description }) { + return (dispatch) => { + const url = `${ROOT_URL}/collections/${collectionId}`; + return axios.patch(url, { name, description }, { withCredentials: true }) + .then((response) => { + dispatch({ + type: ActionTypes.EDIT_COLLECTION, + payload: response.data + }); + return response.data; + }) + .catch((error) => { + const { response } = error; + dispatch({ + type: ActionTypes.ERROR, + error: response.data + }); + + return response.data; + }); + }; +} + +export function deleteCollection(collectionId) { + return (dispatch) => { + const url = `${ROOT_URL}/collections/${collectionId}`; + return axios.delete(url, { withCredentials: true }) + .then((response) => { + dispatch({ + type: ActionTypes.DELETE_COLLECTION, + payload: response.data, + collectionId, + }); + return response.data; + }) + .catch((error) => { + const { response } = error; + dispatch({ + type: ActionTypes.ERROR, + error: response.data + }); + + return response.data; + }); + }; +} diff --git a/client/modules/IDE/actions/files.js b/client/modules/IDE/actions/files.js index 0bbf292dc1..004600b224 100644 --- a/client/modules/IDE/actions/files.js +++ b/client/modules/IDE/actions/files.js @@ -3,7 +3,7 @@ import objectID from 'bson-objectid'; import blobUtil from 'blob-util'; import { reset } from 'redux-form'; import * as ActionTypes from '../../../constants'; -import { setUnsavedChanges } from './ide'; +import { setUnsavedChanges, closeNewFolderModal, closeNewFileModal } from './ide'; import { setProjectSavedTime } from './project'; const __process = (typeof global !== 'undefined' ? global : window).process; @@ -58,16 +58,20 @@ export function createFile(formProps) { parentId }); dispatch(setProjectSavedTime(response.data.project.updatedAt)); + dispatch(closeNewFileModal()); dispatch(reset('new-file')); // dispatch({ // type: ActionTypes.HIDE_MODAL // }); dispatch(setUnsavedChanges(true)); }) - .catch(response => dispatch({ - type: ActionTypes.ERROR, - error: response.data - })); + .catch((error) => { + const { response } = error; + dispatch({ + type: ActionTypes.ERROR, + error: response.data + }); + }); } else { const id = objectID().toHexString(); dispatch({ @@ -85,6 +89,7 @@ export function createFile(formProps) { // type: ActionTypes.HIDE_MODAL // }); dispatch(setUnsavedChanges(true)); + dispatch(closeNewFileModal()); } }; } @@ -109,14 +114,15 @@ export function createFolder(formProps) { parentId }); dispatch(setProjectSavedTime(response.data.project.updatedAt)); + dispatch(closeNewFolderModal()); + }) + .catch((error) => { + const { response } = error; dispatch({ - type: ActionTypes.CLOSE_NEW_FOLDER_MODAL + type: ActionTypes.ERROR, + error: response.data }); - }) - .catch(response => dispatch({ - type: ActionTypes.ERROR, - error: response.data - })); + }); } else { const id = objectID().toHexString(); dispatch({ @@ -130,18 +136,19 @@ export function createFolder(formProps) { fileType: 'folder', children: [] }); - dispatch({ - type: ActionTypes.CLOSE_NEW_FOLDER_MODAL - }); + dispatch(closeNewFolderModal()); } }; } export function updateFileName(id, name) { - return { - type: ActionTypes.UPDATE_FILE_NAME, - id, - name + return (dispatch) => { + dispatch(setUnsavedChanges(true)); + dispatch({ + type: ActionTypes.UPDATE_FILE_NAME, + id, + name + }); }; } @@ -162,7 +169,8 @@ export function deleteFile(id, parentId) { parentId }); }) - .catch((response) => { + .catch((error) => { + const { response } = error; dispatch({ type: ActionTypes.ERROR, error: response.data diff --git a/client/modules/IDE/actions/ide.js b/client/modules/IDE/actions/ide.js index cf573573b0..239dc6c757 100644 --- a/client/modules/IDE/actions/ide.js +++ b/client/modules/IDE/actions/ide.js @@ -75,6 +75,19 @@ export function closeNewFileModal() { }; } +export function openUploadFileModal(parentId) { + return { + type: ActionTypes.OPEN_UPLOAD_FILE_MODAL, + parentId + }; +} + +export function closeUploadFileModal() { + return { + type: ActionTypes.CLOSE_UPLOAD_FILE_MODAL + }; +} + export function expandSidebar() { return { type: ActionTypes.EXPAND_SIDEBAR diff --git a/client/modules/IDE/actions/preferences.js b/client/modules/IDE/actions/preferences.js index ae8e243889..01ba077ccd 100644 --- a/client/modules/IDE/actions/preferences.js +++ b/client/modules/IDE/actions/preferences.js @@ -8,10 +8,13 @@ function updatePreferences(formParams, dispatch) { axios.put(`${ROOT_URL}/preferences`, formParams, { withCredentials: true }) .then(() => { }) - .catch(response => dispatch({ - type: ActionTypes.ERROR, - error: response.data - })); + .catch((error) => { + const { response } = error; + dispatch({ + type: ActionTypes.ERROR, + error: response.data + }); + }); } export function setFontSize(value) { diff --git a/client/modules/IDE/actions/project.js b/client/modules/IDE/actions/project.js index e4abb9d5ff..192e55ddff 100644 --- a/client/modules/IDE/actions/project.js +++ b/client/modules/IDE/actions/project.js @@ -57,10 +57,13 @@ export function getProject(id) { dispatch(setProject(response.data)); dispatch(setUnsavedChanges(false)); }) - .catch(response => dispatch({ - type: ActionTypes.ERROR, - error: response.data - })); + .catch((error) => { + const { response } = error; + dispatch({ + type: ActionTypes.ERROR, + error: response.data + }); + }); }; } @@ -133,6 +136,7 @@ export function saveProject(selectedFile = null, autosave = false) { } const formParams = Object.assign({}, state.project); formParams.files = [...state.files]; + if (selectedFile) { const fileToUpdate = formParams.files.find(file => file.id === selectedFile.id); fileToUpdate.content = selectedFile.content; @@ -160,7 +164,8 @@ export function saveProject(selectedFile = null, autosave = false) { } } }) - .catch((response) => { + .catch((error) => { + const { response } = error; dispatch(endSavingProject()); if (response.status === 403) { dispatch(showErrorModal('staleSession')); @@ -199,7 +204,8 @@ export function saveProject(selectedFile = null, autosave = false) { } } }) - .catch((response) => { + .catch((error) => { + const { response } = error; dispatch(endSavingProject()); if (response.status === 403) { dispatch(showErrorModal('staleSession')); @@ -297,10 +303,13 @@ export function cloneProject(id) { 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 - })); + .catch((error) => { + const { response } = error; + dispatch({ + type: ActionTypes.PROJECT_SAVE_FAIL, + error: response.data + }); + }); }); }); }; @@ -343,8 +352,8 @@ export function changeProjectName(id, newName) { } } }) - .catch((response) => { - console.log(response); + .catch((error) => { + const { response } = error; dispatch({ type: ActionTypes.PROJECT_SAVE_FAIL, error: response.data @@ -367,7 +376,8 @@ export function deleteProject(id) { id }); }) - .catch((response) => { + .catch((error) => { + const { response } = error; if (response.status === 403) { dispatch(showErrorModal('staleSession')); } else { diff --git a/client/modules/IDE/actions/projects.js b/client/modules/IDE/actions/projects.js index 446c50ccc0..eb653ec8d7 100644 --- a/client/modules/IDE/actions/projects.js +++ b/client/modules/IDE/actions/projects.js @@ -23,7 +23,8 @@ export function getProjects(username) { }); dispatch(stopLoader()); }) - .catch((response) => { + .catch((error) => { + const { response } = error; dispatch({ type: ActionTypes.ERROR, error: response.data diff --git a/client/modules/IDE/actions/sorting.js b/client/modules/IDE/actions/sorting.js index 8feb9b123b..f28cf325f1 100644 --- a/client/modules/IDE/actions/sorting.js +++ b/client/modules/IDE/actions/sorting.js @@ -26,13 +26,14 @@ export function toggleDirectionForField(field) { }; } -export function setSearchTerm(searchTerm) { +export function setSearchTerm(scope, searchTerm) { return { type: ActionTypes.SET_SEARCH_TERM, - query: searchTerm + query: searchTerm, + scope, }; } -export function resetSearchTerm() { - return setSearchTerm(''); +export function resetSearchTerm(scope) { + return setSearchTerm(scope, ''); } diff --git a/client/modules/IDE/actions/uploader.js b/client/modules/IDE/actions/uploader.js index f2f12585fd..c7a0139f82 100644 --- a/client/modules/IDE/actions/uploader.js +++ b/client/modules/IDE/actions/uploader.js @@ -65,12 +65,13 @@ export function dropzoneAcceptCallback(userId, file, done) { file.previewTemplate.className += ' uploading'; // eslint-disable-line done(); }) - .catch((response) => { - file.custom_status = 'rejected'; // eslint-disable-line - if (response.data.responseText && response.data.responseText.message) { + .catch((error) => { + const { response } = error; + file.custom_status = 'rejected'; // eslint-disable-line + if (response.data && response.data.responseText && response.data.responseText.message) { done(response.data.responseText.message); } - done('error preparing the upload'); + done('Error: Reached upload limit.'); }); } }; diff --git a/client/modules/IDE/components/About.jsx b/client/modules/IDE/components/About.jsx index a8133e9875..213a1f464f 100644 --- a/client/modules/IDE/components/About.jsx +++ b/client/modules/IDE/components/About.jsx @@ -1,10 +1,9 @@ import React from 'react'; -import InlineSVG from 'react-inlinesvg'; import { Helmet } from 'react-helmet'; -const squareLogoUrl = require('../../../images/p5js-square-logo.svg'); -// const playUrl = require('../../../images/play.svg'); -const asteriskUrl = require('../../../images/p5-asterisk.svg'); +import SquareLogoIcon from '../../../images/p5js-square-logo.svg'; +// import PlayIcon from '../../../images/play.svg'; +import AsteriskIcon from '../../../images/p5-asterisk.svg'; function About(props) { return ( @@ -13,7 +12,7 @@ function About(props) { p5.js Web Editor | About
- + {/* Video button to hello p5 video page */} {/*

- + Play hello! video

*/}
@@ -33,7 +32,7 @@ function About(props) { target="_blank" rel="noopener noreferrer" > - +
diff --git a/client/modules/IDE/components/FileNode.jsx b/client/modules/IDE/components/FileNode.jsx index 12496b38b6..bd077874de 100644 --- a/client/modules/IDE/components/FileNode.jsx +++ b/client/modules/IDE/components/FileNode.jsx @@ -2,43 +2,32 @@ import PropTypes from 'prop-types'; import React from 'react'; import { bindActionCreators } from 'redux'; import { connect } from 'react-redux'; -import InlineSVG from 'react-inlinesvg'; import classNames from 'classnames'; import * as IDEActions from '../actions/ide'; import * as FileActions from '../actions/files'; - -const downArrowUrl = require('../../../images/down-filled-triangle.svg'); -const folderRightUrl = require('../../../images/triangle-arrow-right.svg'); -const folderDownUrl = require('../../../images/triangle-arrow-down.svg'); -const fileUrl = require('../../../images/file.svg'); +import DownArrowIcon from '../../../images/down-filled-triangle.svg'; +import FolderRightIcon from '../../../images/triangle-arrow-right.svg'; +import FolderDownIcon from '../../../images/triangle-arrow-down.svg'; +import FileIcon from '../../../images/file.svg'; export class FileNode extends React.Component { constructor(props) { super(props); - this.renderChild = this.renderChild.bind(this); - this.handleKeyPress = this.handleKeyPress.bind(this); - this.handleFileNameChange = this.handleFileNameChange.bind(this); - this.validateFileName = this.validateFileName.bind(this); - this.handleFileClick = this.handleFileClick.bind(this); - this.toggleFileOptions = this.toggleFileOptions.bind(this); - this.hideFileOptions = this.hideFileOptions.bind(this); - this.showEditFileName = this.showEditFileName.bind(this); - this.hideEditFileName = this.hideEditFileName.bind(this); - this.onBlurComponent = this.onBlurComponent.bind(this); - this.onFocusComponent = this.onFocusComponent.bind(this); this.state = { isOptionsOpen: false, isEditingName: false, isFocused: false, + isDeleting: false, + updatedName: this.props.name }; } - onFocusComponent() { + onFocusComponent = () => { this.setState({ isFocused: true }); } - onBlurComponent() { + onBlurComponent = () => { this.setState({ isFocused: false }); setTimeout(() => { if (!this.state.isFocused) { @@ -47,41 +36,96 @@ export class FileNode extends React.Component { }, 200); } - handleFileClick(e) { - e.stopPropagation(); - if (this.props.name !== 'root' && !this.isDeleting) { - this.props.setSelectedFile(this.props.id); + + setUpdatedName = (updatedName) => { + this.setState({ updatedName }); + } + + saveUpdatedFileName = () => { + const { updatedName } = this.state; + const { name, updateFileName, id } = this.props; + + if (updatedName !== name) { + updateFileName(id, updatedName); + } + } + + handleFileClick = (event) => { + event.stopPropagation(); + const { isDeleting } = this.state; + const { id, setSelectedFile, name } = this.props; + if (name !== 'root' && !isDeleting) { + setSelectedFile(id); } } - handleFileNameChange(event) { - this.props.updateFileName(this.props.id, event.target.value); + handleFileNameChange = (event) => { + const newName = event.target.value; + this.setUpdatedName(newName); + } + + handleFileNameBlur = () => { + this.validateFileName(); + this.hideEditFileName(); } - handleKeyPress(event) { + handleClickRename = () => { + this.setUpdatedName(this.props.name); + this.showEditFileName(); + setTimeout(() => this.fileNameInput.focus(), 0); + setTimeout(() => this.hideFileOptions(), 0); + } + + handleClickAddFile = () => { + this.props.newFile(this.props.id); + setTimeout(() => this.hideFileOptions(), 0); + } + + handleClickAddFolder = () => { + this.props.newFolder(this.props.id); + setTimeout(() => this.hideFileOptions(), 0); + } + + handleClickUploadFile = () => { + this.props.openUploadFileModal(this.props.id); + setTimeout(this.hideFileOptions, 0); + } + + handleClickDelete = () => { + if (window.confirm(`Are you sure you want to delete ${this.props.name}?`)) { + this.setState({ isDeleting: true }); + this.props.resetSelectedFile(this.props.id); + setTimeout(() => this.props.deleteFile(this.props.id, this.props.parentId), 100); + } + } + + handleKeyPress = (event) => { if (event.key === 'Enter') { this.hideEditFileName(); } } - validateFileName() { - const oldFileExtension = this.originalFileName.match(/\.[0-9a-z]+$/i); - const newFileExtension = this.props.name.match(/\.[0-9a-z]+$/i); - const hasPeriod = this.props.name.match(/\.+/); - const newFileName = this.props.name; + validateFileName = () => { + const currentName = this.props.name; + const { updatedName } = this.state; + const oldFileExtension = currentName.match(/\.[0-9a-z]+$/i); + const newFileExtension = updatedName.match(/\.[0-9a-z]+$/i); + const hasPeriod = updatedName.match(/\.+/); const hasNoExtension = oldFileExtension && !newFileExtension; const hasExtensionIfFolder = this.props.fileType === 'folder' && hasPeriod; const notSameExtension = oldFileExtension && newFileExtension && oldFileExtension[0].toLowerCase() !== newFileExtension[0].toLowerCase(); - const hasEmptyFilename = newFileName === ''; - const hasOnlyExtension = newFileExtension && newFileName === newFileExtension[0]; + const hasEmptyFilename = updatedName.trim() === ''; + const hasOnlyExtension = newFileExtension && updatedName.trim() === newFileExtension[0]; if (hasEmptyFilename || hasNoExtension || notSameExtension || hasOnlyExtension || hasExtensionIfFolder) { - this.props.updateFileName(this.props.id, this.originalFileName); + this.setUpdatedName(currentName); + } else { + this.saveUpdatedFileName(); } } - toggleFileOptions(e) { - e.preventDefault(); + toggleFileOptions = (event) => { + event.preventDefault(); if (!this.props.canEdit) { return; } @@ -93,26 +137,32 @@ export class FileNode extends React.Component { } } - hideFileOptions() { + hideFileOptions = () => { this.setState({ isOptionsOpen: false }); } - showEditFileName() { + showEditFileName = () => { this.setState({ isEditingName: true }); } - hideEditFileName() { + hideEditFileName = () => { this.setState({ isEditingName: false }); } - renderChild(childId) { - return ( -
  • - -
  • - ); + showFolderChildren = () => { + this.props.showFolderChildren(this.props.id); + } + + hideFolderChildren = () => { + this.props.hideFolderChildren(this.props.id); } + renderChild = childId => ( +
  • + +
  • + ) + render() { const itemClass = classNames({ 'sidebar__root-item': this.props.name === 'root', @@ -123,150 +173,134 @@ export class FileNode extends React.Component { 'sidebar__file-item--closed': this.props.isFolderClosed }); + const isFile = this.props.fileType === 'file'; + const isFolder = this.props.fileType === 'folder'; + const isRoot = this.props.name === 'root'; + return (
    - {(() => { // eslint-disable-line - if (this.props.name !== 'root') { - return ( -
    - - {(() => { // eslint-disable-line - if (this.props.fileType === 'file') { - return ( - - - - ); - } - return ( -
    - - -
    - ); - })()} - - { this.fileNameInput = element; }} - onBlur={() => { - this.validateFileName(); - this.hideEditFileName(); - }} - onKeyPress={this.handleKeyPress} - /> + { !isRoot && +
    + + { isFile && + + + } + { isFolder && +
    -
    -
      - {(() => { // eslint-disable-line - if (this.props.fileType === 'folder') { - return ( -
    • - -
    • - ); - } - })()} - {(() => { // eslint-disable-line - if (this.props.fileType === 'folder') { - return ( -
    • - -
    • - ); - } - })()} + +
    + } + + { this.fileNameInput = element; }} + onBlur={this.handleFileNameBlur} + onKeyPress={this.handleKeyPress} + /> + +
    +
      + { isFolder && +
    • -
    -
    -
    - ); - } - })()} - {(() => { // eslint-disable-line - if (this.props.children) { - return ( -
      - {this.props.children.map(this.renderChild)} + { this.props.authenticated && +
    • + +
    • + } + + } +
    • + +
    • +
    • + +
    - ); - } - })()} +
    +
    + } + { this.props.children && +
      + {this.props.children.map(this.renderChild)} +
    + }
    ); } @@ -288,18 +322,21 @@ FileNode.propTypes = { newFolder: PropTypes.func.isRequired, showFolderChildren: PropTypes.func.isRequired, hideFolderChildren: PropTypes.func.isRequired, - canEdit: PropTypes.bool.isRequired + canEdit: PropTypes.bool.isRequired, + openUploadFileModal: PropTypes.func.isRequired, + authenticated: PropTypes.bool.isRequired }; FileNode.defaultProps = { parentId: '0', isSelectedFile: false, - isFolderClosed: false + isFolderClosed: false, }; function mapStateToProps(state, ownProps) { // this is a hack, state is updated before ownProps - return state.files.find(file => file.id === ownProps.id) || { name: 'test', fileType: 'file' }; + const fileNode = state.files.find(file => file.id === ownProps.id) || { name: 'test', fileType: 'file' }; + return Object.assign({}, fileNode, { authenticated: state.user.authenticated }); } function mapDispatchToProps(dispatch) { diff --git a/client/modules/IDE/components/FileUploader.jsx b/client/modules/IDE/components/FileUploader.jsx index 50fe6062fe..c9515f5cf8 100644 --- a/client/modules/IDE/components/FileUploader.jsx +++ b/client/modules/IDE/components/FileUploader.jsx @@ -30,7 +30,7 @@ class FileUploader extends React.Component { thumbnailWidth: 200, thumbnailHeight: 200, acceptedFiles: fileExtensionsAndMimeTypes, - dictDefaultMessage: 'Drop files here to upload or click to use the file browser', + dictDefaultMessage: 'Drop files here or click to use the file browser', accept: this.props.dropzoneAcceptCallback.bind(this, userId), sending: this.props.dropzoneSendingCallback, complete: this.props.dropzoneCompleteCallback diff --git a/client/modules/IDE/components/KeyboardShortcutModal.jsx b/client/modules/IDE/components/KeyboardShortcutModal.jsx index fbb180b9e4..ce8a364752 100644 --- a/client/modules/IDE/components/KeyboardShortcutModal.jsx +++ b/client/modules/IDE/components/KeyboardShortcutModal.jsx @@ -3,78 +3,83 @@ import { metaKeyName, } from '../../../utils/metaKey'; function KeyboardShortcutModal() { return ( -
      -
    • - {'\u21E7'} + Tab - Tidy -
    • -
    • - - {metaKeyName} + S - - Save -
    • -
    • - - {metaKeyName} + F - - Find Text -
    • -
    • - - {metaKeyName} + G - - Find Next Text Match -
    • -
    • - - {metaKeyName} + {'\u21E7'} + G - - Find Previous Text Match -
    • -
    • - - {metaKeyName} + [ - - Indent Code Left -
    • -
    • - - {metaKeyName} + ] - - Indent Code Right -
    • -
    • - - {metaKeyName} + / - - Comment Line -
    • -
    • - - {metaKeyName} + Enter - - Start Sketch -
    • -
    • - - {metaKeyName} + {'\u21E7'} + Enter - - Stop Sketch -
    • -
    • - - {metaKeyName} + {'\u21E7'} + 1 - - Turn on Accessible Output -
    • -
    • - - {metaKeyName} + {'\u21E7'} + 2 - - Turn off Accessible Output -
    • -
    +
    +
    + Note: our keyboard shortcuts follow Sublime Text shortcuts +
    +
      +
    • + {'\u21E7'} + Tab + Tidy +
    • +
    • + + {metaKeyName} + S + + Save +
    • +
    • + + {metaKeyName} + F + + Find Text +
    • +
    • + + {metaKeyName} + G + + Find Next Text Match +
    • +
    • + + {metaKeyName} + {'\u21E7'} + G + + Find Previous Text Match +
    • +
    • + + {metaKeyName} + [ + + Indent Code Left +
    • +
    • + + {metaKeyName} + ] + + Indent Code Right +
    • +
    • + + {metaKeyName} + / + + Comment Line +
    • +
    • + + {metaKeyName} + Enter + + Start Sketch +
    • +
    • + + {metaKeyName} + {'\u21E7'} + Enter + + Stop Sketch +
    • +
    • + + {metaKeyName} + {'\u21E7'} + 1 + + Turn on Accessible Output +
    • +
    • + + {metaKeyName} + {'\u21E7'} + 2 + + Turn off Accessible Output +
    • +
    +
    ); } diff --git a/client/modules/IDE/components/NewFileForm.jsx b/client/modules/IDE/components/NewFileForm.jsx index d0a6b8ab61..aa173a506c 100644 --- a/client/modules/IDE/components/NewFileForm.jsx +++ b/client/modules/IDE/components/NewFileForm.jsx @@ -22,16 +22,24 @@ class NewFileForm extends React.Component { handleSubmit(this.createFile)(data); }} > - - { this.fileName = element; }} - /> - +
    + + { this.fileName = element; }} + /> + +
    {name.touched && name.error && {name.error}} ); diff --git a/client/modules/IDE/components/NewFileModal.jsx b/client/modules/IDE/components/NewFileModal.jsx index 433c3daae5..412d989501 100644 --- a/client/modules/IDE/components/NewFileModal.jsx +++ b/client/modules/IDE/components/NewFileModal.jsx @@ -1,13 +1,14 @@ import PropTypes from 'prop-types'; import React from 'react'; +import { connect } from 'react-redux'; +import { bindActionCreators, compose } from 'redux'; import { reduxForm } from 'redux-form'; -import classNames from 'classnames'; -import InlineSVG from 'react-inlinesvg'; import NewFileForm from './NewFileForm'; -import FileUploader from './FileUploader'; +import { closeNewFileModal } from '../actions/ide'; +import { createFile } from '../actions/files'; import { CREATE_FILE_REGEX } from '../../../../server/utils/fileUtils'; -const exitUrl = require('../../../images/exit.svg'); +import ExitIcon from '../../../images/exit.svg'; // At some point this will probably be generalized to a generic modal @@ -28,34 +29,23 @@ class NewFileModal extends React.Component { } render() { - const modalClass = classNames({ - 'modal': true, - 'modal--reduced': !this.props.canUploadMedia - }); return ( -
    { this.modal = element; }}> +
    { this.modal = element; }}>
    -

    Add File

    -
    - {(() => { - if (this.props.canUploadMedia) { - return ( -
    -

    OR

    - -
    - ); - } - return ''; - })()}
    ); @@ -63,8 +53,8 @@ class NewFileModal extends React.Component { } NewFileModal.propTypes = { - closeModal: PropTypes.func.isRequired, - canUploadMedia: PropTypes.bool.isRequired + createFile: PropTypes.func.isRequired, + closeNewFileModal: PropTypes.func.isRequired }; function validate(formProps) { @@ -79,9 +69,19 @@ function validate(formProps) { return errors; } +function mapStateToProps() { + return {}; +} + +function mapDispatchToProps(dispatch) { + return bindActionCreators({ createFile, closeNewFileModal }, dispatch); +} -export default reduxForm({ - form: 'new-file', - fields: ['name'], - validate -})(NewFileModal); +export default compose( + connect(mapStateToProps, mapDispatchToProps), + reduxForm({ + form: 'new-file', + fields: ['name'], + validate + }) +)(NewFileModal); diff --git a/client/modules/IDE/components/NewFolderForm.jsx b/client/modules/IDE/components/NewFolderForm.jsx index 3490de648c..765ebd0668 100644 --- a/client/modules/IDE/components/NewFolderForm.jsx +++ b/client/modules/IDE/components/NewFolderForm.jsx @@ -20,21 +20,27 @@ class NewFolderForm extends React.Component {
    { - if (handleSubmit(this.createFolder)(data)) { - this.props.closeModal(); - } + handleSubmit(this.createFolder)(data); }} > - - { this.fileName = element; }} - {...domOnlyProps(name)} - /> - +
    + + { this.fileName = element; }} + {...domOnlyProps(name)} + /> + +
    {name.touched && name.error && {name.error}}
    ); diff --git a/client/modules/IDE/components/NewFolderModal.jsx b/client/modules/IDE/components/NewFolderModal.jsx index 60483ce8b1..13029d6c1e 100644 --- a/client/modules/IDE/components/NewFolderModal.jsx +++ b/client/modules/IDE/components/NewFolderModal.jsx @@ -1,10 +1,9 @@ import PropTypes from 'prop-types'; import React from 'react'; import { reduxForm } from 'redux-form'; -import InlineSVG from 'react-inlinesvg'; import NewFolderForm from './NewFolderForm'; -const exitUrl = require('../../../images/exit.svg'); +import ExitIcon from '../../../images/exit.svg'; class NewFolderModal extends React.Component { componentDidMount() { @@ -16,9 +15,13 @@ class NewFolderModal extends React.Component {
    { this.newFolderModal = element; }} >
    -

    Add Folder

    -
    diff --git a/client/modules/IDE/components/Preferences.jsx b/client/modules/IDE/components/Preferences.jsx index 7ca5a21653..1b5c342b30 100644 --- a/client/modules/IDE/components/Preferences.jsx +++ b/client/modules/IDE/components/Preferences.jsx @@ -1,15 +1,14 @@ import PropTypes from 'prop-types'; import React from 'react'; -import InlineSVG from 'react-inlinesvg'; import { Helmet } from 'react-helmet'; import { Tab, Tabs, TabList, TabPanel } from 'react-tabs'; // import { bindActionCreators } from 'redux'; // import { connect } from 'react-redux'; // import * as PreferencesActions from '../actions/preferences'; -const plusUrl = require('../../../images/plus.svg'); -const minusUrl = require('../../../images/minus.svg'); -const beepUrl = require('../../../sounds/audioAlert.mp3'); +import PlusIcon from '../../../images/plus.svg'; +import MinusIcon from '../../../images/minus.svg'; +import beepUrl from '../../../sounds/audioAlert.mp3'; class Preferences extends React.Component { constructor(props) { @@ -98,9 +97,9 @@ class Preferences extends React.Component { -
    -

    General Settings

    -

    Accessibility

    +
    +

    General Settings

    +

    Accessibility

    @@ -150,7 +149,7 @@ class Preferences extends React.Component { aria-label="decrease font size" disabled={this.state.fontSize <= 8} > - +
    diff --git a/client/modules/IDE/components/QuickAddList/Icons.jsx b/client/modules/IDE/components/QuickAddList/Icons.jsx new file mode 100644 index 0000000000..ae59c7cf7e --- /dev/null +++ b/client/modules/IDE/components/QuickAddList/Icons.jsx @@ -0,0 +1,26 @@ +import React from 'react'; +import PropTypes from 'prop-types'; + +import CheckIcon from '../../../../images/check_encircled.svg'; +import CloseIcon from '../../../../images/close.svg'; + +const Icons = ({ isAdded }) => { + const classes = [ + 'quick-add__icon', + isAdded ? 'quick-add__icon--in-collection' : 'quick-add__icon--not-in-collection' + ].join(' '); + + return ( +
    + + + +
    + ); +}; + +Icons.propTypes = { + isAdded: PropTypes.bool.isRequired, +}; + +export default Icons; diff --git a/client/modules/IDE/components/QuickAddList/QuickAddList.jsx b/client/modules/IDE/components/QuickAddList/QuickAddList.jsx new file mode 100644 index 0000000000..be7a5ac3a4 --- /dev/null +++ b/client/modules/IDE/components/QuickAddList/QuickAddList.jsx @@ -0,0 +1,75 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { Link } from 'react-router'; + +import Icons from './Icons'; + +const Item = ({ + isAdded, onSelect, name, url +}) => { + const buttonLabel = isAdded ? 'Remove from collection' : 'Add to collection'; + return ( +
  • { /* eslint-disable-line */ } + + {name} + e.stopPropogation()} + > + View + +
  • + ); +}; + +const ItemType = PropTypes.shape({ + name: PropTypes.string.isRequired, + url: PropTypes.string.isRequired, + isAdded: PropTypes.bool.isRequired, +}); + +Item.propTypes = { + name: PropTypes.string.isRequired, + url: PropTypes.string.isRequired, + isAdded: PropTypes.bool.isRequired, + onSelect: PropTypes.func.isRequired, +}; + +const QuickAddList = ({ + items, onAdd, onRemove +}) => { + const handleAction = (item) => { + if (item.isAdded) { + onRemove(item); + } else { + onAdd(item); + } + }; + + return ( +
      {items.map(item => ( { + event.stopPropagation(); + event.currentTarget.blur(); + handleAction(item); + } + } + />))} +
    + ); +}; + +QuickAddList.propTypes = { + items: PropTypes.arrayOf(ItemType).isRequired, + onAdd: PropTypes.func.isRequired, + onRemove: PropTypes.func.isRequired, +}; + +export default QuickAddList; diff --git a/client/modules/IDE/components/QuickAddList/index.js b/client/modules/IDE/components/QuickAddList/index.js new file mode 100644 index 0000000000..4503fca590 --- /dev/null +++ b/client/modules/IDE/components/QuickAddList/index.js @@ -0,0 +1 @@ +export { default } from './QuickAddList.jsx'; diff --git a/client/modules/IDE/components/Searchbar/Collection.jsx b/client/modules/IDE/components/Searchbar/Collection.jsx new file mode 100644 index 0000000000..c566ced2a6 --- /dev/null +++ b/client/modules/IDE/components/Searchbar/Collection.jsx @@ -0,0 +1,24 @@ +import { bindActionCreators } from 'redux'; +import { connect } from 'react-redux'; +import * as SortingActions from '../../actions/sorting'; + +import Searchbar from './Searchbar'; + +const scope = 'collection'; + +function mapStateToProps(state) { + return { + searchLabel: 'Search collections...', + searchTerm: state.search[`${scope}SearchTerm`], + }; +} + +function mapDispatchToProps(dispatch) { + const actions = { + setSearchTerm: term => SortingActions.setSearchTerm(scope, term), + resetSearchTerm: () => SortingActions.resetSearchTerm(scope), + }; + return bindActionCreators(Object.assign({}, actions), dispatch); +} + +export default connect(mapStateToProps, mapDispatchToProps)(Searchbar); diff --git a/client/modules/IDE/components/Searchbar.jsx b/client/modules/IDE/components/Searchbar/Searchbar.jsx similarity index 56% rename from client/modules/IDE/components/Searchbar.jsx rename to client/modules/IDE/components/Searchbar/Searchbar.jsx index f3047b913c..b81b3beab6 100644 --- a/client/modules/IDE/components/Searchbar.jsx +++ b/client/modules/IDE/components/Searchbar/Searchbar.jsx @@ -1,12 +1,8 @@ import PropTypes from 'prop-types'; import React from 'react'; -import InlineSVG from 'react-inlinesvg'; -import { bindActionCreators } from 'redux'; -import { connect } from 'react-redux'; import { throttle } from 'lodash'; -import * as SortingActions from '../actions/sorting'; -const searchIcon = require('../../../images/magnifyingglass.svg'); +import SearchIcon from '../../../../images/magnifyingglass.svg'; class Searchbar extends React.Component { constructor(props) { @@ -29,12 +25,13 @@ class Searchbar extends React.Component { handleSearchEnter = (e) => { if (e.key === 'Enter') { - this.props.setSearchTerm(this.state.searchValue); + this.searchChange(); } } - searchChange = (value) => { - this.props.setSearchTerm(this.state.searchValue); + searchChange = () => { + if (this.state.searchValue.trim().length === 0) return; + this.props.setSearchTerm(this.state.searchValue.trim()); }; handleSearchChange = (e) => { @@ -46,19 +43,15 @@ class Searchbar extends React.Component { render() { const { searchValue } = this.state; return ( -
    - +
    +
    +
    @@ -75,17 +68,12 @@ class Searchbar extends React.Component { Searchbar.propTypes = { searchTerm: PropTypes.string.isRequired, setSearchTerm: PropTypes.func.isRequired, - resetSearchTerm: PropTypes.func.isRequired + resetSearchTerm: PropTypes.func.isRequired, + searchLabel: PropTypes.string, }; -function mapStateToProps(state) { - return { - searchTerm: state.search.searchTerm - }; -} - -function mapDispatchToProps(dispatch) { - return bindActionCreators(Object.assign({}, SortingActions), dispatch); -} +Searchbar.defaultProps = { + searchLabel: 'Search sketches...', +}; -export default connect(mapStateToProps, mapDispatchToProps)(Searchbar); +export default Searchbar; diff --git a/client/modules/IDE/components/Searchbar/Sketch.jsx b/client/modules/IDE/components/Searchbar/Sketch.jsx new file mode 100644 index 0000000000..bc12854e35 --- /dev/null +++ b/client/modules/IDE/components/Searchbar/Sketch.jsx @@ -0,0 +1,23 @@ +import { bindActionCreators } from 'redux'; +import { connect } from 'react-redux'; +import * as SortingActions from '../../actions/sorting'; + +import Searchbar from './Searchbar'; + +const scope = 'sketch'; + +function mapStateToProps(state) { + return { + searchTerm: state.search[`${scope}SearchTerm`], + }; +} + +function mapDispatchToProps(dispatch) { + const actions = { + setSearchTerm: term => SortingActions.setSearchTerm(scope, term), + resetSearchTerm: () => SortingActions.resetSearchTerm(scope), + }; + return bindActionCreators(Object.assign({}, actions), dispatch); +} + +export default connect(mapStateToProps, mapDispatchToProps)(Searchbar); diff --git a/client/modules/IDE/components/Searchbar/index.js b/client/modules/IDE/components/Searchbar/index.js new file mode 100644 index 0000000000..3a063f6830 --- /dev/null +++ b/client/modules/IDE/components/Searchbar/index.js @@ -0,0 +1,2 @@ +export { default as CollectionSearchbar } from './Collection.jsx'; +export { default as SketchSearchbar } from './Sketch.jsx'; diff --git a/client/modules/IDE/components/Sidebar.jsx b/client/modules/IDE/components/Sidebar.jsx index 2ea492c25a..97c8c0ecc1 100644 --- a/client/modules/IDE/components/Sidebar.jsx +++ b/client/modules/IDE/components/Sidebar.jsx @@ -1,10 +1,9 @@ import PropTypes from 'prop-types'; import React from 'react'; import classNames from 'classnames'; -import InlineSVG from 'react-inlinesvg'; import ConnectedFileNode from './FileNode'; -const downArrowUrl = require('../../../images/down-filled-triangle.svg'); +import DownArrowIcon from '../../../images/down-filled-triangle.svg'; class Sidebar extends React.Component { constructor(props) { @@ -69,14 +68,14 @@ class Sidebar extends React.Component { const rootFile = this.props.files.filter(file => file.name === 'root')[0]; return ( - +
    ); } } @@ -137,6 +152,7 @@ Sidebar.propTypes = { openProjectOptions: PropTypes.func.isRequired, closeProjectOptions: PropTypes.func.isRequired, newFolder: PropTypes.func.isRequired, + openUploadFileModal: PropTypes.func.isRequired, owner: PropTypes.shape({ id: PropTypes.string }), diff --git a/client/modules/IDE/components/SketchList.jsx b/client/modules/IDE/components/SketchList.jsx index 89d5cb1024..bbece87a16 100644 --- a/client/modules/IDE/components/SketchList.jsx +++ b/client/modules/IDE/components/SketchList.jsx @@ -2,7 +2,6 @@ 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'; @@ -10,15 +9,18 @@ import classNames from 'classnames'; import slugify from 'slugify'; 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 getSortedSketches from '../selectors/projects'; import Loader from '../../App/components/loader'; +import Overlay from '../../App/components/Overlay'; +import AddToCollectionList from './AddToCollectionList'; -const arrowUp = require('../../../images/sort-arrow-up.svg'); -const arrowDown = require('../../../images/sort-arrow-down.svg'); -const downFilledTriangle = require('../../../images/down-filled-triangle.svg'); +import ArrowUpIcon from '../../../images/sort-arrow-up.svg'; +import ArrowDownIcon from '../../../images/sort-arrow-down.svg'; +import DownFilledTriangleIcon from '../../../images/down-filled-triangle.svg'; class SketchListRowBase extends React.Component { constructor(props) { @@ -27,9 +29,11 @@ class SketchListRowBase extends React.Component { optionsOpen: false, renameOpen: false, renameValue: props.sketch.name, - isFocused: false + isFocused: false, }; + this.renameInput = React.createRef(); } + onFocusComponent = () => { this.setState({ isFocused: true }); } @@ -65,8 +69,9 @@ class SketchListRowBase extends React.Component { openRename = () => { this.setState({ - renameOpen: true - }); + renameOpen: true, + renameValue: this.props.sketch.name + }, () => this.renameInput.current.focus()); } closeRename = () => { @@ -90,15 +95,27 @@ class SketchListRowBase extends React.Component { handleRenameEnter = (e) => { if (e.key === 'Enter') { - // TODO pass this func - this.props.changeProjectName(this.props.sketch.id, this.state.renameValue); + this.updateName(); this.closeAll(); } } + handleRenameBlur = () => { + this.updateName(); + this.closeAll(); + } + + updateName = () => { + const isValid = this.state.renameValue.trim().length !== 0; + if (isValid) { + this.props.changeProjectName(this.props.sketch.id, this.state.renameValue.trim()); + } + } + resetSketchName = () => { this.setState({ - renameValue: this.props.sketch.name + renameValue: this.props.sketch.name, + renameOpen: false }); } @@ -133,105 +150,148 @@ class SketchListRowBase extends React.Component { } } - render() { - const { sketch, username } = this.props; - const { renameOpen, optionsOpen, renameValue } = this.state; + renderViewButton = sketchURL => ( + + View + + ) + + renderDropdown = () => { + const { optionsOpen } = this.state; const userIsOwner = this.props.user.username === this.props.username; - let url = `/${username}/sketches/${sketch.id}`; - if (username === 'p5') { - url = `/${username}/sketches/${slugify(sketch.name, '_')}`; - } + 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 && +
      - - - {optionsOpen && -
        - {userIsOwner && + {userIsOwner && +
      • + +
      • } +
      • + +
      • + {this.props.user.authenticated && +
      • + +
      • } + {this.props.user.authenticated &&
      • } -
      • - -
      • - {this.props.user.authenticated && -
      • - -
      • } - { /*
      • - -
      • */ } - {userIsOwner && -
      • - -
      • } -
      } - - ); + { /*
    • + +
    • */ } + {userIsOwner && +
    • + +
    • } +
    } + + ); + } + + render() { + const { + sketch, + username, + } = this.props; + const { renameOpen, renameValue } = this.state; + let url = `/${username}/sketches/${sketch.id}`; + if (username === 'p5') { + url = `/${username}/sketches/${slugify(sketch.name, '_')}`; + } + + const name = ( + + + {renameOpen ? '' : sketch.name} + + {renameOpen + && + e.stopPropagation()} + ref={this.renameInput} + /> + } + + ); + + return ( + + + + {name} + + {format(new Date(sketch.createdAt), 'MMM D, YYYY h:mm A')} + {format(new Date(sketch.updatedAt), 'MMM D, YYYY h:mm A')} + {this.renderDropdown()} + + ); } } @@ -251,7 +311,8 @@ SketchListRowBase.propTypes = { showShareModal: PropTypes.func.isRequired, cloneProject: PropTypes.func.isRequired, exportProjectAsZip: PropTypes.func.isRequired, - changeProjectName: PropTypes.func.isRequired + changeProjectName: PropTypes.func.isRequired, + onAddToCollection: PropTypes.func.isRequired }; function mapDispatchToPropsSketchListRow(dispatch) { @@ -265,7 +326,19 @@ class SketchList extends React.Component { super(props); this.props.getProjects(this.props.username); this.props.resetSorting(); - this._renderFieldHeader = this._renderFieldHeader.bind(this); + + this.state = { + isInitialDataLoad: true, + }; + } + + componentDidUpdate(prevProps) { + if (this.props.sketches !== prevProps.sketches && Array.isArray(this.props.sketches)) { + // eslint-disable-next-line react/no-did-update-set-state + this.setState({ + isInitialDataLoad: false, + }); + } } getSketchesTitle() { @@ -276,36 +349,62 @@ class SketchList extends React.Component { } hasSketches() { - return !this.props.loading && this.props.sketches.length > 0; + return !this.isLoading() && this.props.sketches.length > 0; + } + + isLoading() { + return this.props.loading && this.state.isInitialDataLoad; } _renderLoader() { - if (this.props.loading) return ; + if (this.isLoading()) return ; return null; } _renderEmptyTable() { - if (!this.props.loading && this.props.sketches.length === 0) { + if (!this.isLoading() && this.props.sketches.length === 0) { return (

    No sketches.

    ); } return null; } - _renderFieldHeader(fieldName, displayName) { + _getButtonLabel = (fieldName, displayName) => { + const { field, direction } = this.props.sorting; + let buttonLabel; + if (field !== fieldName) { + if (field === 'name') { + buttonLabel = `Sort by ${displayName} ascending.`; + } else { + buttonLabel = `Sort by ${displayName} descending.`; + } + } else if (direction === SortingActions.DIRECTION.ASC) { + buttonLabel = `Sort by ${displayName} descending.`; + } else { + buttonLabel = `Sort by ${displayName} ascending.`; + } + return buttonLabel; + } + + _renderFieldHeader = (fieldName, displayName) => { const { field, direction } = this.props.sorting; const headerClass = classNames({ 'sketches-table__header': true, 'sketches-table__header--selected': field === fieldName }); + const buttonLabel = this._getButtonLabel(fieldName, displayName); return ( - @@ -315,7 +414,7 @@ class SketchList extends React.Component { render() { const username = this.props.username !== undefined ? this.props.username : this.props.user.username; return ( -
    +
    {this.getSketchesTitle()} @@ -338,10 +437,27 @@ class SketchList extends React.Component { sketch={sketch} user={this.props.user} username={username} + onAddToCollection={() => { + this.setState({ sketchToAddToCollection: sketch }); + }} />))} } -
    + { + this.state.sketchToAddToCollection && + this.setState({ sketchToAddToCollection: null })} + > + + + } + ); } } @@ -366,19 +482,9 @@ SketchList.propTypes = { 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 }; @@ -393,7 +499,10 @@ function mapStateToProps(state) { } function mapDispatchToProps(dispatch) { - return bindActionCreators(Object.assign({}, ProjectsActions, ToastActions, SortingActions), dispatch); + return bindActionCreators( + Object.assign({}, ProjectsActions, CollectionsActions, ToastActions, SortingActions), + dispatch + ); } export default connect(mapStateToProps, mapDispatchToProps)(SketchList); diff --git a/client/modules/IDE/components/Toast.jsx b/client/modules/IDE/components/Toast.jsx index 81440e80be..2b9a0d58bb 100644 --- a/client/modules/IDE/components/Toast.jsx +++ b/client/modules/IDE/components/Toast.jsx @@ -2,10 +2,9 @@ import PropTypes from 'prop-types'; import React from 'react'; import { bindActionCreators } from 'redux'; import { connect } from 'react-redux'; -import InlineSVG from 'react-inlinesvg'; import * as ToastActions from '../actions/toast'; -const exitUrl = require('../../../images/exit.svg'); +import ExitIcon from '../../../images/exit.svg'; function Toast(props) { return ( @@ -13,8 +12,8 @@ function Toast(props) {

    {props.text}

    -
    ); diff --git a/client/modules/IDE/components/Toolbar.jsx b/client/modules/IDE/components/Toolbar.jsx index b331e10eae..b3d13364ae 100644 --- a/client/modules/IDE/components/Toolbar.jsx +++ b/client/modules/IDE/components/Toolbar.jsx @@ -3,16 +3,14 @@ import React from 'react'; import { connect } from 'react-redux'; import { Link } from 'react-router'; import classNames from 'classnames'; -import InlineSVG from 'react-inlinesvg'; - import * as IDEActions from '../actions/ide'; import * as preferenceActions from '../actions/preferences'; import * as projectActions from '../actions/project'; -const playUrl = require('../../../images/play.svg'); -const stopUrl = require('../../../images/stop.svg'); -const preferencesUrl = require('../../../images/preferences.svg'); -const editProjectNameUrl = require('../../../images/pencil.svg'); +import PlayIcon from '../../../images/play.svg'; +import StopIcon from '../../../images/stop.svg'; +import PreferencesIcon from '../../../images/preferences.svg'; +import EditProjectNameIcon from '../../../images/pencil.svg'; class Toolbar extends React.Component { constructor(props) { @@ -32,7 +30,7 @@ class Toolbar extends React.Component { } validateProjectName() { - if (this.props.project.name === '') { + if ((this.props.project.name.trim()).length === 0) { this.props.setProjectName(this.originalProjectName); } } @@ -61,6 +59,8 @@ class Toolbar extends React.Component { 'toolbar__project-name-container--editing': this.props.project.isEditingName }); + const canEditProjectName = this.canEditProjectName(); + return (
    ); @@ -210,4 +214,5 @@ const mapDispatchToProps = { ...projectActions, }; +export const ToolbarComponent = Toolbar; export default connect(mapStateToProps, mapDispatchToProps)(Toolbar); diff --git a/client/modules/IDE/components/UploadFileModal.jsx b/client/modules/IDE/components/UploadFileModal.jsx new file mode 100644 index 0000000000..27fa7c6f32 --- /dev/null +++ b/client/modules/IDE/components/UploadFileModal.jsx @@ -0,0 +1,71 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { connect } from 'react-redux'; +import { Link } from 'react-router'; +import prettyBytes from 'pretty-bytes'; +import FileUploader from './FileUploader'; +import { getreachedTotalSizeLimit } from '../selectors/users'; +import ExitIcon from '../../../images/exit.svg'; + +const __process = (typeof global !== 'undefined' ? global : window).process; +const limit = __process.env.UPLOAD_LIMIT || 250000000; +const limitText = prettyBytes(limit); + +class UploadFileModal extends React.Component { + propTypes = { + reachedTotalSizeLimit: PropTypes.bool.isRequired, + closeModal: PropTypes.func.isRequired + } + + componentDidMount() { + this.focusOnModal(); + } + + focusOnModal = () => { + this.modal.focus(); + } + + + render() { + return ( +
    { this.modal = element; }}> +
    +
    +

    Upload File

    + +
    + { this.props.reachedTotalSizeLimit && +

    + { + `Error: You cannot upload any more files. You have reached the total size limit of ${limitText}. + If you would like to upload more, please remove the ones you aren't using anymore by + in your ` + } + assets + . +

    + } + { !this.props.reachedTotalSizeLimit && +
    + +
    + } +
    +
    + ); + } +} + +function mapStateToProps(state) { + return { + reachedTotalSizeLimit: getreachedTotalSizeLimit(state) + }; +} + +export default connect(mapStateToProps)(UploadFileModal); diff --git a/client/modules/IDE/pages/FullView.jsx b/client/modules/IDE/pages/FullView.jsx index 6a18566f69..cbdc89d690 100644 --- a/client/modules/IDE/pages/FullView.jsx +++ b/client/modules/IDE/pages/FullView.jsx @@ -25,7 +25,7 @@ class FullView extends React.Component { owner={{ username: this.props.project.owner ? `${this.props.project.owner.username}` : '' }} project={{ name: this.props.project.name, id: this.props.params.project_id }} /> -
    +
    -
    +
    ); } diff --git a/client/modules/IDE/pages/IDEView.jsx b/client/modules/IDE/pages/IDEView.jsx index d9a906d9fb..14ac255c8c 100644 --- a/client/modules/IDE/pages/IDEView.jsx +++ b/client/modules/IDE/pages/IDEView.jsx @@ -12,6 +12,7 @@ import Toolbar from '../components/Toolbar'; import Preferences from '../components/Preferences'; import NewFileModal from '../components/NewFileModal'; import NewFolderModal from '../components/NewFolderModal'; +import UploadFileModal from '../components/UploadFileModal'; import ShareModal from '../components/ShareModal'; import KeyboardShortcutModal from '../components/KeyboardShortcutModal'; import ErrorModal from '../components/ErrorModal'; @@ -28,11 +29,10 @@ import * as ToastActions from '../actions/toast'; import * as ConsoleActions from '../actions/console'; import { getHTMLFile } from '../reducers/files'; import Overlay from '../../App/components/Overlay'; -import SketchList from '../components/SketchList'; -import Searchbar from '../components/Searchbar'; -import AssetList from '../components/AssetList'; import About from '../components/About'; +import AddToCollectionList from '../components/AddToCollectionList'; import Feedback from '../components/Feedback'; +import { CollectionSearchbar } from '../components/Searchbar'; class IDEView extends React.Component { constructor(props) { @@ -156,6 +156,28 @@ class IDEView extends React.Component { } else if (e.keyCode === 49 && ((e.metaKey && this.isMac) || (e.ctrlKey && !this.isMac)) && e.shiftKey) { e.preventDefault(); this.props.setAllAccessibleOutput(true); + } else if (e.keyCode === 66 && ((e.metaKey && this.isMac) || (e.ctrlKey && !this.isMac))) { + e.preventDefault(); + if (!this.props.ide.sidebarIsExpanded) { + this.props.expandSidebar(); + } else { + this.props.collapseSidebar(); + } + } else if (e.keyCode === 192 && e.ctrlKey) { + e.preventDefault(); + if (this.props.ide.consoleIsExpanded) { + this.props.collapseConsole(); + } else { + this.props.expandConsole(); + } + } else if (e.keyCode === 27) { + if (this.props.ide.newFolderModalVisible) { + this.props.closeNewFolderModal(); + } else if (this.props.ide.uploadFileModalVisible) { + this.props.closeUploadFileModal(); + } else if (this.props.ide.modalIsVisible) { + this.props.closeNewFileModal(); + } } } @@ -217,7 +239,7 @@ class IDEView extends React.Component { /> } -
    +
    -
    +

    Preview

    @@ -314,12 +338,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 ) }
    @@ -346,47 +370,23 @@ class IDEView extends React.Component { cmController={this.cmController} />
    -
    + -
    + { this.props.ide.modalIsVisible && - + } - { this.props.ide.newFolderModalVisible && + {this.props.ide.newFolderModalVisible && } - { this.props.location.pathname.match(/sketches$/) && - - - - - } - { this.props.location.pathname.match(/assets$/) && - - - + {this.props.ide.uploadFileModalVisible && + } { this.props.location.pathname === '/about' && } - { this.props.location.pathname === '/feedback' && + {this.props.location.pathname === '/feedback' && } - { this.props.ide.shareModalVisible && + {this.props.location.pathname.match(/add-to-collection$/) && + } + isFixedHeight + > + + + } + {this.props.ide.shareModalVisible && } - { this.props.ide.keyboardShortcutVisible && + {this.props.ide.keyboardShortcutVisible && } - { this.props.ide.errorType && + {this.props.ide.errorType && { switch (action.type) { case ActionTypes.SET_ASSETS: return { list: action.assets, totalSize: action.totalSize }; + case ActionTypes.DELETE_ASSET: + return { list: state.list.filter(asset => asset.key !== action.key) }; default: return state; } diff --git a/client/modules/IDE/reducers/collections.js b/client/modules/IDE/reducers/collections.js new file mode 100644 index 0000000000..c7017c29a7 --- /dev/null +++ b/client/modules/IDE/reducers/collections.js @@ -0,0 +1,28 @@ +import * as ActionTypes from '../../../constants'; + +const sketches = (state = [], action) => { + switch (action.type) { + case ActionTypes.SET_COLLECTIONS: + return action.collections; + + case ActionTypes.DELETE_COLLECTION: + return state.filter(({ id }) => action.collectionId !== id); + + // The API returns the complete new edited collection + // with any items added or removed + case ActionTypes.EDIT_COLLECTION: + 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; + } +}; + +export default sketches; diff --git a/client/modules/IDE/reducers/ide.js b/client/modules/IDE/reducers/ide.js index 070c2371fa..db2c31e6b2 100644 --- a/client/modules/IDE/reducers/ide.js +++ b/client/modules/IDE/reducers/ide.js @@ -9,11 +9,11 @@ const initialState = { preferencesIsVisible: false, projectOptionsVisible: false, newFolderModalVisible: false, + uploadFileModalVisible: false, shareModalVisible: false, shareModalProjectId: 'abcd', shareModalProjectName: 'My Cute Sketch', shareModalProjectUsername: 'p5_user', - sketchlistModalVisible: false, editorOptionsVisible: false, keyboardShortcutVisible: false, unsavedChanges: false, @@ -106,6 +106,10 @@ const ide = (state = initialState, action) => { return Object.assign({}, state, { runtimeErrorWarningVisible: false }); case ActionTypes.SHOW_RUNTIME_ERROR_WARNING: return Object.assign({}, state, { runtimeErrorWarningVisible: true }); + case ActionTypes.OPEN_UPLOAD_FILE_MODAL: + return Object.assign({}, state, { uploadFileModalVisible: true, parentId: action.parentId }); + case ActionTypes.CLOSE_UPLOAD_FILE_MODAL: + return Object.assign({}, state, { uploadFileModalVisible: false }); default: return state; } diff --git a/client/modules/IDE/reducers/project.js b/client/modules/IDE/reducers/project.js index 0779c0f584..2eb19d4de2 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/reducers/search.js b/client/modules/IDE/reducers/search.js index 1e2ff8d40d..67d352433e 100644 --- a/client/modules/IDE/reducers/search.js +++ b/client/modules/IDE/reducers/search.js @@ -1,13 +1,14 @@ import * as ActionTypes from '../../../constants'; const initialState = { - searchTerm: '' + collectionSearchTerm: '', + sketchSearchTerm: '' }; export default (state = initialState, action) => { switch (action.type) { case ActionTypes.SET_SEARCH_TERM: - return { ...state, searchTerm: action.query }; + return { ...state, [`${action.scope}SearchTerm`]: action.query }; default: return state; } diff --git a/client/modules/IDE/selectors/collections.js b/client/modules/IDE/selectors/collections.js new file mode 100644 index 0000000000..f212103a96 --- /dev/null +++ b/client/modules/IDE/selectors/collections.js @@ -0,0 +1,56 @@ +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 getSearchTerm = state => state.search.collectionSearchTerm; + +const getFilteredCollections = createSelector( + getCollections, + getSearchTerm, + (collections, search) => { + if (search) { + const searchStrings = collections.map((collection) => { + const smallCollection = { + name: collection.name + }; + return { ...collection, searchString: Object.values(smallCollection).join(' ').toLowerCase() }; + }); + return searchStrings.filter(collection => collection.searchString.includes(search.toLowerCase())); + } + return collections; + } +); + + +const getSortedCollections = createSelector( + getFilteredCollections, + 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; diff --git a/client/modules/IDE/selectors/projects.js b/client/modules/IDE/selectors/projects.js index b223082612..14d65ea0f2 100644 --- a/client/modules/IDE/selectors/projects.js +++ b/client/modules/IDE/selectors/projects.js @@ -6,7 +6,7 @@ import { DIRECTION } from '../actions/sorting'; const getSketches = state => state.sketches; const getField = state => state.sorting.field; const getDirection = state => state.sorting.direction; -const getSearchTerm = state => state.search.searchTerm; +const getSearchTerm = state => state.search.sketchSearchTerm; const getFilteredSketches = createSelector( getSketches, diff --git a/client/modules/IDE/selectors/users.js b/client/modules/IDE/selectors/users.js new file mode 100644 index 0000000000..556d99dd13 --- /dev/null +++ b/client/modules/IDE/selectors/users.js @@ -0,0 +1,30 @@ +import { createSelector } from 'reselect'; + +const __process = (typeof global !== 'undefined' ? global : window).process; +const getAuthenticated = state => state.user.authenticated; +const getTotalSize = state => state.user.totalSize; +const getAssetsTotalSize = state => state.assets.totalSize; +const limit = __process.env.UPLOAD_LIMIT || 250000000; + +export const getCanUploadMedia = createSelector( + getAuthenticated, + getTotalSize, + (authenticated, totalSize) => { + if (!authenticated) return false; + // eventually do the same thing for verified when + // email verification actually works + if (totalSize > limit) return false; + return true; + } +); + +export const getreachedTotalSizeLimit = createSelector( + getTotalSize, + getAssetsTotalSize, + (totalSize, assetsTotalSize) => { + const currentSize = totalSize || assetsTotalSize; + if (currentSize && currentSize > limit) return true; + // if (totalSize > 1000) return true; + return false; + } +); diff --git a/client/modules/User/actions.js b/client/modules/User/actions.js index 86c366c437..1992854910 100644 --- a/client/modules/User/actions.js +++ b/client/modules/User/actions.js @@ -26,7 +26,10 @@ export function signUpUser(previousPath, formValues) { dispatch(justOpenedProject()); browserHistory.push(previousPath); }) - .catch(response => dispatch(authError(response.data.error))); + .catch((error) => { + const { response } = error; + dispatch(authError(response.data.error)); + }); }; } @@ -82,7 +85,8 @@ export function getUser() { preferences: response.data.preferences }); }) - .catch((response) => { + .catch((error) => { + const { response } = error; const message = response.message || response.data.error; dispatch(authError(message)); }); @@ -98,7 +102,8 @@ export function validateSession() { dispatch(showErrorModal('staleSession')); } }) - .catch((response) => { + .catch((error) => { + const { response } = error; if (response.status === 404) { dispatch(showErrorModal('staleSession')); } @@ -114,7 +119,10 @@ export function logoutUser() { type: ActionTypes.UNAUTH_USER }); }) - .catch(response => dispatch(authError(response.data.error))); + .catch((error) => { + const { response } = error; + dispatch(authError(response.data.error)); + }); }; } @@ -127,10 +135,13 @@ export function initiateResetPassword(formValues) { .then(() => { // do nothing }) - .catch(response => dispatch({ - type: ActionTypes.ERROR, - message: response.data - })); + .catch((error) => { + const { response } = error; + dispatch({ + type: ActionTypes.ERROR, + message: response.data + }); + }); }; } @@ -143,10 +154,13 @@ export function initiateVerification() { .then(() => { // do nothing }) - .catch(response => dispatch({ - type: ActionTypes.ERROR, - message: response.data - })); + .catch((error) => { + const { response } = error; + dispatch({ + type: ActionTypes.ERROR, + message: response.data + }); + }); }; } @@ -161,10 +175,13 @@ export function verifyEmailConfirmation(token) { type: ActionTypes.EMAIL_VERIFICATION_VERIFIED, message: response.data, })) - .catch(response => dispatch({ - type: ActionTypes.EMAIL_VERIFICATION_INVALID, - message: response.data - })); + .catch((error) => { + const { response } = error; + dispatch({ + type: ActionTypes.EMAIL_VERIFICATION_INVALID, + message: response.data + }); + }); }; } @@ -216,5 +233,42 @@ export function updateSettings(formValues) { dispatch(showToast(5500)); dispatch(setToastText('Settings saved.')); }) - .catch(response => Promise.reject(new Error(response.data.error))); + .catch((error) => { + const { response } = error; + Promise.reject(new Error(response.data.error)); + }); +} + +export function createApiKeySuccess(user) { + return { + type: ActionTypes.API_KEY_CREATED, + user + }; +} + +export function createApiKey(label) { + return dispatch => + axios.post(`${ROOT_URL}/account/api-keys`, { label }, { withCredentials: true }) + .then((response) => { + dispatch(createApiKeySuccess(response.data)); + }) + .catch((error) => { + const { response } = error; + Promise.reject(new Error(response.data.error)); + }); +} + +export function removeApiKey(keyId) { + return dispatch => + axios.delete(`${ROOT_URL}/account/api-keys/${keyId}`, { withCredentials: true }) + .then((response) => { + dispatch({ + type: ActionTypes.API_KEY_REMOVED, + user: response.data + }); + }) + .catch((error) => { + const { response } = error; + Promise.reject(new Error(response.data.error)); + }); } diff --git a/client/modules/User/components/APIKeyForm.jsx b/client/modules/User/components/APIKeyForm.jsx new file mode 100644 index 0000000000..459efe312d --- /dev/null +++ b/client/modules/User/components/APIKeyForm.jsx @@ -0,0 +1,122 @@ +import PropTypes from 'prop-types'; +import React from 'react'; + +import CopyableInput from '../../IDE/components/CopyableInput'; + +import APIKeyList from './APIKeyList'; + +import PlusIcon from '../../../images/plus-icon.svg'; + +export const APIKeyPropType = PropTypes.shape({ + id: PropTypes.object.isRequired, + token: PropTypes.object, + label: PropTypes.string.isRequired, + createdAt: PropTypes.string.isRequired, + lastUsedAt: PropTypes.string, +}); + +class APIKeyForm extends React.Component { + constructor(props) { + super(props); + this.state = { keyLabel: '' }; + + this.addKey = this.addKey.bind(this); + this.removeKey = this.removeKey.bind(this); + this.renderApiKeys = this.renderApiKeys.bind(this); + } + + addKey(event) { + event.preventDefault(); + const { keyLabel } = this.state; + + this.setState({ + keyLabel: '' + }); + + this.props.createApiKey(keyLabel); + + return false; + } + + removeKey(key) { + const message = `Are you sure you want to delete "${key.label}"?`; + + if (window.confirm(message)) { + this.props.removeApiKey(key.id); + } + } + + renderApiKeys() { + const hasApiKeys = this.props.apiKeys && this.props.apiKeys.length > 0; + + if (hasApiKeys) { + return ( + + ); + } + return

    You have no exsiting tokens.

    ; + } + + render() { + const keyWithToken = this.props.apiKeys.find(k => !!k.token); + + return ( +
    +

    + Personal Access Tokens act like your password to allow automated + scripts to access the Editor API. Create a token for each script + that needs access. +

    + +
    +

    Create new token

    + + + { this.setState({ keyLabel: event.target.value }); }} + placeholder="What is this token for? e.g. Example import script" + type="text" + value={this.state.keyLabel} + /> + + + + { + keyWithToken && ( +
    +

    Your new access token

    +

    + Make sure to copy your new personal access token now. + You wonโ€™t be able to see it again! +

    + +
    + ) + } +
    + +
    +

    Existing tokens

    + {this.renderApiKeys()} +
    +
    + ); + } +} + +APIKeyForm.propTypes = { + apiKeys: PropTypes.arrayOf(PropTypes.shape(APIKeyPropType)).isRequired, + createApiKey: PropTypes.func.isRequired, + removeApiKey: PropTypes.func.isRequired, +}; + +export default APIKeyForm; diff --git a/client/modules/User/components/APIKeyList.jsx b/client/modules/User/components/APIKeyList.jsx new file mode 100644 index 0000000000..9201aa4b6d --- /dev/null +++ b/client/modules/User/components/APIKeyList.jsx @@ -0,0 +1,55 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import format from 'date-fns/format'; +import distanceInWordsToNow from 'date-fns/distance_in_words_to_now'; +import orderBy from 'lodash/orderBy'; + +import { APIKeyPropType } from './APIKeyForm'; + +import TrashCanIcon from '../../../images/trash-can.svg'; + +function APIKeyList({ apiKeys, onRemove }) { + return ( + + + + + + + + + + + {orderBy(apiKeys, ['createdAt'], ['desc']).map((key) => { + const lastUsed = key.lastUsedAt ? + distanceInWordsToNow(new Date(key.lastUsedAt), { addSuffix: true }) : + 'Never'; + + return ( + + + + + + + ); + })} + +
    NameCreated onLast usedActions
    {key.label}{format(new Date(key.createdAt), 'MMM D, YYYY h:mm A')}{lastUsed} + +
    + ); +} + +APIKeyList.propTypes = { + apiKeys: PropTypes.arrayOf(PropTypes.shape(APIKeyPropType)).isRequired, + onRemove: PropTypes.func.isRequired, +}; + +export default APIKeyList; diff --git a/client/modules/User/components/Collection.jsx b/client/modules/User/components/Collection.jsx new file mode 100644 index 0000000000..a5a043c54a --- /dev/null +++ b/client/modules/User/components/Collection.jsx @@ -0,0 +1,456 @@ +import format from 'date-fns/format'; +import PropTypes from 'prop-types'; +import React, { useState, useRef, useEffect } from 'react'; +import { Helmet } from 'react-helmet'; +import { connect } from 'react-redux'; +import { Link } from 'react-router'; +import { bindActionCreators } from 'redux'; +import classNames from 'classnames'; +import * as ProjectActions from '../../IDE/actions/project'; +import * as ProjectsActions from '../../IDE/actions/projects'; +import * as CollectionsActions from '../../IDE/actions/collections'; +import * as ToastActions from '../../IDE/actions/toast'; +import * as SortingActions from '../../IDE/actions/sorting'; +import * as IdeActions from '../../IDE/actions/ide'; +import { getCollection } from '../../IDE/selectors/collections'; +import Loader from '../../App/components/loader'; +import EditableInput from '../../IDE/components/EditableInput'; +import Overlay from '../../App/components/Overlay'; +import AddToCollectionSketchList from '../../IDE/components/AddToCollectionSketchList'; +import CopyableInput from '../../IDE/components/CopyableInput'; +import { SketchSearchbar } from '../../IDE/components/Searchbar'; + +import DropdownArrowIcon from '../../../images/down-arrow.svg'; +import ArrowUpIcon from '../../../images/sort-arrow-up.svg'; +import ArrowDownIcon from '../../../images/sort-arrow-down.svg'; +import RemoveIcon from '../../../images/close.svg'; + +const ShareURL = ({ value }) => { + const [showURL, setShowURL] = useState(false); + const node = useRef(); + + const handleClickOutside = (e) => { + if (node.current.contains(e.target)) { + return; + } + setShowURL(false); + }; + + useEffect(() => { + if (showURL) { + document.addEventListener('mousedown', handleClickOutside); + } else { + document.removeEventListener('mousedown', handleClickOutside); + } + + return () => { + document.removeEventListener('mousedown', handleClickOutside); + }; + }, [showURL]); + + return ( +
    + + { showURL && +
    + +
    + } +
    + ); +}; + +ShareURL.propTypes = { + value: PropTypes.string.isRequired, +}; + +class CollectionItemRowBase extends React.Component { + handleSketchRemove = () => { + if (window.confirm(`Are you sure you want to remove "${this.props.item.project.name}" from this collection?`)) { + this.props.removeFromCollection(this.props.collection.id, this.props.item.project.id); + } + } + + render() { + const { item } = this.props; + const sketchOwnerUsername = item.project.user.username; + const sketchUrl = `/${item.project.user.username}/sketches/${item.project.id}`; + + return ( + + + + {item.project.name} + + + {format(new Date(item.createdAt), 'MMM D, YYYY h:mm A')} + {sketchOwnerUsername} + + + + ); + } +} + +CollectionItemRowBase.propTypes = { + collection: PropTypes.shape({ + id: PropTypes.string.isRequired, + name: PropTypes.string.isRequired + }).isRequired, + item: PropTypes.shape({ + createdAt: PropTypes.string.isRequired, + project: PropTypes.shape({ + id: PropTypes.string.isRequired, + name: PropTypes.string.isRequired, + user: PropTypes.shape({ + username: PropTypes.string.isRequired + }) + }).isRequired, + }).isRequired, + user: PropTypes.shape({ + username: PropTypes.string, + authenticated: PropTypes.bool.isRequired + }).isRequired, + removeFromCollection: PropTypes.func.isRequired +}; + +function mapDispatchToPropsSketchListRow(dispatch) { + return bindActionCreators(Object.assign({}, CollectionsActions, 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); + this.showAddSketches = this.showAddSketches.bind(this); + this.hideAddSketches = this.hideAddSketches.bind(this); + + this.state = { + isAddingSketches: false, + }; + } + + 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`; + } + + getUsername() { + return this.props.username !== undefined ? this.props.username : this.props.user.username; + } + + getCollectionName() { + return this.props.collection.name; + } + + isOwner() { + let isOwner = false; + + if (this.props.user != null && + this.props.user.username && + this.props.collection.owner.username === this.props.user.username) { + isOwner = true; + } + + return isOwner; + } + + 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 ; + return null; + } + + _renderCollectionMetadata() { + const { + id, name, description, items, owner + } = this.props.collection; + + const hostname = window.location.origin; + const { username } = this.props; + + const baseURL = `${hostname}/${username}/collections/`; + + const handleEditCollectionName = (value) => { + if (value === name) { + return; + } + + this.props.editCollection(id, { name: value }); + }; + + const handleEditCollectionDescription = (value) => { + if (value === description) { + return; + } + + this.props.editCollection(id, { description: value }); + }; + + // + // TODO: Implement UI for editing slug + // + // const handleEditCollectionSlug = (value) => { + // if (value === slug) { + // return; + // } + // + // this.props.editCollection(id, { slug: value }); + // }; + + return ( +
    +
    +
    +

    + { + this.isOwner() ? + value !== ''} /> : + name + } +

    + +

    + { + this.isOwner() ? + : + description + } +

    + +

    Collection by{' '} + {owner.username} +

    + +

    {items.length} sketch{items.length === 1 ? '' : 'es'}

    +
    + +
    +

    + +

    + { + this.isOwner() && + + } +
    +
    +
    + ); + } + + showAddSketches() { + this.setState({ + isAddingSketches: true, + }); + } + + hideAddSketches() { + this.setState({ + isAddingSketches: false, + }); + } + + _renderEmptyTable() { + const isLoading = this.props.loading; + const hasCollectionItems = this.props.collection != null && + this.props.collection.items.length > 0; + + if (!isLoading && !hasCollectionItems) { + return (

    No sketches in collection

    ); + } + return null; + } + + _getButtonLabel = (fieldName, displayName) => { + const { field, direction } = this.props.sorting; + let buttonLabel; + if (field !== fieldName) { + if (field === 'name') { + buttonLabel = `Sort by ${displayName} ascending.`; + } else { + buttonLabel = `Sort by ${displayName} descending.`; + } + } else if (direction === SortingActions.DIRECTION.ASC) { + buttonLabel = `Sort by ${displayName} descending.`; + } else { + buttonLabel = `Sort by ${displayName} ascending.`; + } + return buttonLabel; + } + + _renderFieldHeader(fieldName, displayName) { + const { field, direction } = this.props.sorting; + const headerClass = classNames({ + 'sketches-table__header': true, + 'sketches-table__header--selected': field === fieldName + }); + const buttonLabel = this._getButtonLabel(fieldName, displayName); + return ( + + + + ); + } + + render() { + const title = this.hasCollection() ? this.getCollectionName() : 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 => + ())} + +
    + } + { + this.state.isAddingSketches && ( + } + closeOverlay={this.hideAddSketches} + isFixedHeight + > +
    + +
    +
    + ) + } +
    +
    +
    +
    + ); + } +} + +Collection.propTypes = { + user: PropTypes.shape({ + username: PropTypes.string, + authenticated: PropTypes.bool.isRequired + }).isRequired, + getCollections: PropTypes.func.isRequired, + collection: PropTypes.shape({ + id: PropTypes.string, + name: PropTypes.string, + slug: PropTypes.string, + description: PropTypes.string, + owner: PropTypes.shape({ + username: PropTypes.string, + }).isRequired, + items: PropTypes.arrayOf(PropTypes.shape({})), + }), + username: PropTypes.string, + loading: PropTypes.bool.isRequired, + toggleDirectionForField: PropTypes.func.isRequired, + editCollection: PropTypes.func.isRequired, + resetSorting: PropTypes.func.isRequired, + sorting: PropTypes.shape({ + field: PropTypes.string.isRequired, + direction: PropTypes.string.isRequired + }).isRequired +}; + +Collection.defaultProps = { + username: undefined, + collection: { + id: undefined, + items: [], + owner: { + 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/User/components/CollectionCreate.jsx b/client/modules/User/components/CollectionCreate.jsx new file mode 100644 index 0000000000..b58212232b --- /dev/null +++ b/client/modules/User/components/CollectionCreate.jsx @@ -0,0 +1,110 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import { Helmet } from 'react-helmet'; +import { connect } from 'react-redux'; +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); + } + + render() { + const { generatedCollectionName, creationError } = this.state; + const { name, description } = this.state.collection; + + const invalid = name === '' || name == null; + + return ( +
    + + {this.getTitle()} + +
    +
    + {creationError && Couldn't create collection} +

    + + + {invalid && Collection name is required} +

    +

    + +