diff --git a/README.md b/README.md index 4a3b76fd..6c69e2c7 100644 --- a/README.md +++ b/README.md @@ -55,6 +55,10 @@ The automatic redirection to HTTPS is turned off by default in development. If y S3_BUCKET= GITHUB_ID= GITHUB_SECRET= + EMAIL_SENDER= + MAILGUN_KEY= + MAILGUN_DOMAIN= + EMAIL_VERIFY_SECRET_TOKEN=whatever_you_want_this_to_be_it_only_matters_for_production ``` For production, you will need to have real Github and Amazon credentions. Refer to [this gist](https://gist.github.com/catarak/70c9301f0fd1ac2d6b58de03f61997e3) for creating an S3 bucket for testing. diff --git a/client/components/Nav.jsx b/client/components/Nav.jsx index 181c0e64..0a861e97 100644 --- a/client/components/Nav.jsx +++ b/client/components/Nav.jsx @@ -21,7 +21,10 @@ class Nav extends React.PureComponent { {(() => { // eslint-disable-line - if (!this.props.project.owner || (this.props.project.owner && this.props.project.owner.id === this.props.user.id)) { + if ( + !this.props.project.owner || + (this.props.project.owner && this.props.project.owner.id === this.props.user.id) + ) { return (
  • +
  • + + My assets + +
  • My account @@ -153,7 +164,11 @@ class Nav extends React.PureComponent {
    This is a preview version of the editor, that has not yet been officially released. - It is in development, you can report bugs here. + It is in development, you can report bugs here. Please use with caution.
    @@ -178,7 +193,6 @@ Nav.propTypes = { }) }), logoutUser: PropTypes.func.isRequired, - stopSketch: PropTypes.func.isRequired, showShareModal: PropTypes.func.isRequired, showErrorModal: PropTypes.func.isRequired, unsavedChanges: PropTypes.bool.isRequired, diff --git a/client/constants.js b/client/constants.js index 52e6f709..7ef4983c 100644 --- a/client/constants.js +++ b/client/constants.js @@ -1,11 +1,13 @@ +// TODO Organize this file by reducer type, ot break this apart into +// multiple files export const UPDATE_FILE_CONTENT = 'UPDATE_FILE_CONTENT'; export const TOGGLE_SKETCH = 'TOGGLE_SKETCH'; export const START_SKETCH = 'START_SKETCH'; export const STOP_SKETCH = 'STOP_SKETCH'; -export const START_TEXT_OUTPUT = 'START_TEXT_OUTPUT'; -export const STOP_TEXT_OUTPUT = 'STOP_TEXT_OUTPUT'; +export const START_ACCESSIBLE_OUTPUT = 'START_ACCESSIBLE_OUTPUT'; +export const STOP_ACCESSIBLE_OUTPUT = 'STOP_ACCESSIBLE_OUTPUT'; export const OPEN_PREFERENCES = 'OPEN_PREFERENCES'; export const CLOSE_PREFERENCES = 'CLOSE_PREFERENCES'; @@ -69,6 +71,8 @@ export const SET_AUTOSAVE = 'SET_AUTOSAVE'; export const SET_LINT_WARNING = 'SET_LINT_WARNING'; export const SET_PREFERENCES = 'SET_PREFERENCES'; export const SET_TEXT_OUTPUT = 'SET_TEXT_OUTPUT'; +export const SET_GRID_OUTPUT = 'SET_GRID_OUTPUT'; +export const SET_SOUND_OUTPUT = 'SET_SOUND_OUTPUT'; export const OPEN_PROJECT_OPTIONS = 'OPEN_PROJECT_OPTIONS'; export const CLOSE_PROJECT_OPTIONS = 'CLOSE_PROJECT_OPTIONS'; @@ -100,6 +104,11 @@ export const RESET_PASSWORD_INITIATE = 'RESET_PASSWORD_INITIATE'; export const RESET_PASSWORD_RESET = 'RESET_PASSWORD_RESET'; export const INVALID_RESET_PASSWORD_TOKEN = 'INVALID_RESET_PASSWORD_TOKEN'; +export const EMAIL_VERIFICATION_INITIATE = 'EMAIL_VERIFICATION_INITIATE'; +export const EMAIL_VERIFICATION_VERIFY = 'EMAIL_VERIFICATION_VERIFY'; +export const EMAIL_VERIFICATION_VERIFIED = 'EMAIL_VERIFICATION_VERIFIED'; +export const EMAIL_VERIFICATION_INVALID = 'EMAIL_VERIFICATION_INVALID'; + // eventually, handle errors more specifically and better export const ERROR = 'ERROR'; @@ -120,3 +129,4 @@ export const HIDE_HELP_MODAL = 'HIDE_HELP_MODAL'; 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'; diff --git a/client/modules/App/components/Overlay.jsx b/client/modules/App/components/Overlay.jsx index 6331d017..8951df64 100644 --- a/client/modules/App/components/Overlay.jsx +++ b/client/modules/App/components/Overlay.jsx @@ -1,21 +1,70 @@ import React, { PropTypes } from 'react'; +import InlineSVG from 'react-inlinesvg'; +import { browserHistory } from 'react-router'; -function Overlay(props) { - return ( -
    -
    - {props.children} +const exitUrl = require('../../../images/exit.svg'); + +class Overlay extends React.Component { + constructor(props) { + super(props); + this.close = this.close.bind(this); + } + + componentDidMount() { + this.overlay.focus(); + } + + close() { + if (!this.props.closeOverlay) { + browserHistory.push(this.props.previousPath); + } else { + this.props.closeOverlay(); + } + } + + render() { + const { + ariaLabel, + title, + children + } = this.props; + return ( +
    +
    +
    { this.overlay = element; }} + className="overlay__body" + > +
    +

    {title}

    + +
    + {children} +
    +
    -
    - ); + ); + } } Overlay.propTypes = { - children: PropTypes.element + children: PropTypes.element, + closeOverlay: PropTypes.func, + title: PropTypes.string, + ariaLabel: PropTypes.string, + previousPath: PropTypes.string.isRequired }; Overlay.defaultProps = { - children: null + children: null, + title: 'Modal', + closeOverlay: null, + ariaLabel: 'modal' }; export default Overlay; diff --git a/client/modules/IDE/actions/assets.js b/client/modules/IDE/actions/assets.js new file mode 100644 index 00000000..8cceb91e --- /dev/null +++ b/client/modules/IDE/actions/assets.js @@ -0,0 +1,30 @@ +import axios from 'axios'; + +import * as ActionTypes from '../../../constants'; + +const ROOT_URL = process.env.API_URL; + +function setAssets(assets) { + return { + type: ActionTypes.SET_ASSETS, + assets + }; +} + +export function getAssets(username) { + return (dispatch, getState) => { + axios.get(`${ROOT_URL}/S3/${username}/objects`, { withCredentials: true }) + .then((response) => { + dispatch(setAssets(response.data.assets)); + }) + .catch(response => dispatch({ + type: ActionTypes.ERROR + })); + }; +} + +export function deleteAsset(assetKey, userId) { + return { + type: 'PLACEHOLDER' + }; +} diff --git a/client/modules/IDE/actions/ide.js b/client/modules/IDE/actions/ide.js index 691d08dc..66146f00 100644 --- a/client/modules/IDE/actions/ide.js +++ b/client/modules/IDE/actions/ide.js @@ -31,15 +31,15 @@ export function endSketchRefresh() { }; } -export function startTextOutput() { +export function startAccessibleOutput() { return { - type: ActionTypes.START_TEXT_OUTPUT + type: ActionTypes.START_ACCESSIBLE_OUTPUT }; } -export function stopTextOutput() { +export function stopAccessibleOutput() { return { - type: ActionTypes.STOP_TEXT_OUTPUT + type: ActionTypes.STOP_ACCESSIBLE_OUTPUT }; } diff --git a/client/modules/IDE/actions/preferences.js b/client/modules/IDE/actions/preferences.js index bdc40f76..4f735280 100644 --- a/client/modules/IDE/actions/preferences.js +++ b/client/modules/IDE/actions/preferences.js @@ -137,6 +137,42 @@ export function setTextOutput(value) { }; } +export function setGridOutput(value) { + return (dispatch, getState) => { + dispatch({ + type: ActionTypes.SET_GRID_OUTPUT, + value + }); + const state = getState(); + if (state.user.authenticated) { + const formParams = { + preferences: { + gridOutput: value + } + }; + updatePreferences(formParams, dispatch); + } + }; +} + +export function setSoundOutput(value) { + return (dispatch, getState) => { + dispatch({ + type: ActionTypes.SET_SOUND_OUTPUT, + value + }); + const state = getState(); + if (state.user.authenticated) { + const formParams = { + preferences: { + soundOutput: value + } + }; + updatePreferences(formParams, dispatch); + } + }; +} + export function setTheme(value) { // return { // type: ActionTypes.SET_THEME, @@ -180,4 +216,3 @@ export function setAutorefresh(value) { } }; } - diff --git a/client/modules/IDE/components/About.jsx b/client/modules/IDE/components/About.jsx index 7fa2bd39..aa734c7c 100644 --- a/client/modules/IDE/components/About.jsx +++ b/client/modules/IDE/components/About.jsx @@ -1,131 +1,101 @@ -import React, { PropTypes } from 'react'; +import React from 'react'; import InlineSVG from 'react-inlinesvg'; -import { browserHistory } from 'react-router'; -const exitUrl = require('../../../images/exit.svg'); const squareLogoUrl = require('../../../images/p5js-square-logo.svg'); const playUrl = require('../../../images/play.svg'); const asteriskUrl = require('../../../images/p5-asterisk.svg'); -class About extends React.Component { - constructor(props) { - super(props); - this.closeAboutModal = this.closeAboutModal.bind(this); - } - - componentDidMount() { - this.aboutSection.focus(); - } - - closeAboutModal() { - browserHistory.push(this.props.previousPath); - } - - render() { - return ( -
    { this.aboutSection = element; }} tabIndex="0"> -
    -

    Welcome

    - -
    -
    - -
    -

    New to p5.js?

    -

    - - - Examples -

    -

    - - - Tutorials -

    -
    -
    -

    Resources

    -

    - - - Libraries -

    -

    - - - Reference -

    -

    - - - Forum -

    -
    -
    -
    -

    - Contribute -

    -

    - Report a bug -

    -

    - Twitter -

    - -
    -
    - ); - } +function About(props) { + return ( +
    + +
    +

    New to p5.js?

    +

    + + + Examples +

    +

    + + + Tutorials +

    +
    +
    +

    Resources

    +

    + + + Libraries +

    +

    + + + Reference +

    +

    + + + Forum +

    +
    +
    +

    + Contribute +

    +

    + Report a bug +

    +

    + Twitter +

    +
    +
    + ); } -About.propTypes = { - previousPath: PropTypes.string.isRequired -}; - export default About; diff --git a/client/modules/IDE/components/AccessibleOutput.jsx b/client/modules/IDE/components/AccessibleOutput.jsx new file mode 100644 index 00000000..a3688bd7 --- /dev/null +++ b/client/modules/IDE/components/AccessibleOutput.jsx @@ -0,0 +1,51 @@ +import React, { PropTypes } from 'react'; +import GridOutput from '../components/GridOutput'; +import TextOutput from '../components/TextOutput'; + +class AccessibleOutput extends React.Component { + componentDidMount() { + this.accessibleOutputModal.focus(); + } + componentDidUpdate(prevProps) { + // if the user explicitly clicks on the play button, want to refocus on the text output + if (this.props.isPlaying && this.props.previewIsRefreshing) { + this.accessibleOutputModal.focus(); + } + } + render() { + return ( +
    { this.accessibleOutputModal = element; }} + tabIndex="0" + aria-label="accessible-output" + title="canvas text output" + > + {(() => { // eslint-disable-line + if (this.props.textOutput) { + return ( + + ); + } + })()} + {(() => { // eslint-disable-line + if (this.props.gridOutput) { + return ( + + ); + } + })()} +
    + ); + } +} + +AccessibleOutput.propTypes = { + isPlaying: PropTypes.bool.isRequired, + previewIsRefreshing: PropTypes.bool.isRequired, + textOutput: PropTypes.bool.isRequired, + gridOutput: PropTypes.bool.isRequired +}; + +export default AccessibleOutput; diff --git a/client/modules/IDE/components/AssetList.jsx b/client/modules/IDE/components/AssetList.jsx new file mode 100644 index 00000000..e78de28c --- /dev/null +++ b/client/modules/IDE/components/AssetList.jsx @@ -0,0 +1,71 @@ +import React, { PropTypes } from 'react'; +import { connect } from 'react-redux'; +import { bindActionCreators } from 'redux'; +import { Link } from 'react-router'; +import prettyBytes from 'pretty-bytes'; + +import * as AssetActions from '../actions/assets'; + + +class AssetList extends React.Component { + constructor(props) { + super(props); + this.props.getAssets(this.props.username); + } + + render() { + return ( +
    + {this.props.assets.length === 0 && +

    No uploaded assets.

    + } + {this.props.assets.length > 0 && + + + + + + + + + + + {this.props.assets.map(asset => + + + + + + + )} + +
    NameSizeViewSketch
    {asset.name}{prettyBytes(asset.size)}View{asset.sketchName}
    } +
    + ); + } +} + +AssetList.propTypes = { + username: PropTypes.string.isRequired, + assets: PropTypes.arrayOf(PropTypes.shape({ + key: PropTypes.string.isRequired, + name: PropTypes.string.isRequired, + url: PropTypes.string.isRequired, + sketchName: PropTypes.string.isRequired, + sketchId: PropTypes.string.isRequired + })).isRequired, + getAssets: PropTypes.func.isRequired, +}; + +function mapStateToProps(state) { + return { + user: state.user, + assets: state.assets + }; +} + +function mapDispatchToProps(dispatch) { + return bindActionCreators(Object.assign({}, AssetActions), dispatch); +} + +export default connect(mapStateToProps, mapDispatchToProps)(AssetList); diff --git a/client/modules/IDE/components/Console.jsx b/client/modules/IDE/components/Console.jsx index d3f2eabb..6fb3e143 100644 --- a/client/modules/IDE/components/Console.jsx +++ b/client/modules/IDE/components/Console.jsx @@ -24,7 +24,11 @@ class Console extends React.Component { - - -
    - {(() => { // eslint-disable-line - if (this.props.type === 'forceAuthentication') { - return this.forceAuthentication(); - } else if (this.props.type === 'staleSession') { - return this.staleSession(); - } else if (this.props.type === 'staleProject') { - return this.staleProject(); - } - })()} -
    - +
    + {(() => { // eslint-disable-line + if (this.props.type === 'forceAuthentication') { + return this.forceAuthentication(); + } else if (this.props.type === 'staleSession') { + return this.staleSession(); + } else if (this.props.type === 'staleProject') { + return this.staleProject(); + } + })()} +
    ); } } diff --git a/client/modules/IDE/components/FileNode.jsx b/client/modules/IDE/components/FileNode.jsx index 4368d2e7..d2afc08a 100644 --- a/client/modules/IDE/components/FileNode.jsx +++ b/client/modules/IDE/components/FileNode.jsx @@ -45,7 +45,11 @@ export class FileNode extends React.Component { if (oldFileExtension && !newFileExtension) { this.props.updateFileName(this.props.id, this.originalFileName); } - if (oldFileExtension && newFileExtension && oldFileExtension[0].toLowerCase() !== newFileExtension[0].toLowerCase()) { + if ( + oldFileExtension && + newFileExtension && + oldFileExtension[0].toLowerCase() !== newFileExtension[0].toLowerCase() + ) { this.props.updateFileName(this.props.id, this.originalFileName); } } diff --git a/client/modules/IDE/components/GridOutput.jsx b/client/modules/IDE/components/GridOutput.jsx new file mode 100644 index 00000000..f1e855d9 --- /dev/null +++ b/client/modules/IDE/components/GridOutput.jsx @@ -0,0 +1,38 @@ +import React from 'react'; + +class GridOutput extends React.Component { + componentDidMount() { + this.GridOutputModal.focus(); + } + render() { + return ( +
    { this.GridOutputModal = element; }} + > +

    Grid Output

    +

    +

    + +
    +
    +
    +
    + ); + } +} + +export default GridOutput; diff --git a/client/modules/IDE/components/KeyboardShortcutModal.jsx b/client/modules/IDE/components/KeyboardShortcutModal.jsx index fe2cb5ad..0eb399ca 100644 --- a/client/modules/IDE/components/KeyboardShortcutModal.jsx +++ b/client/modules/IDE/components/KeyboardShortcutModal.jsx @@ -1,82 +1,84 @@ -import React, { PropTypes } from 'react'; -import InlineSVG from 'react-inlinesvg'; +import React from 'react'; -const exitUrl = require('../../../images/exit.svg'); +import { + metaKeyName, +} from '../../../utils/metaKey'; -class KeyboardShortcutModal extends React.Component { - componentDidMount() { - this.isMac = navigator.userAgent.toLowerCase().indexOf('mac') !== -1; - } - - render() { - return ( -
    -
    -

    Keyboard Shortcuts

    - -
    -
      -
    • - Shift + Tab - Tidy -
    • -
    • - - {this.isMac ? 'Command + S' : 'Control + S'} - - Save -
    • -
    • - - {this.isMac ? 'Command + [' : 'Control + ['} - - Indent Code Left -
    • -
    • - - {this.isMac ? 'Command + ]' : 'Control + ]'} - - Indent Code Right -
    • -
    • - - {this.isMac ? 'Command + /' : 'Control + /'} - - Comment Line -
    • -
    • - - {this.isMac ? 'Command + Enter' : 'Control + Enter'} - Start Sketch -
    • -
    • - - {this.isMac ? 'Command + Shift + Enter' : 'Control + Shift + Enter'} - - Stop Sketch -
    • -
    • - - {this.isMac ? 'Command + Shift + 1' : 'Control + Shift + 1'} - - Toggle Text-based Canvas -
    • -
    • - - {this.isMac ? 'Command + Shift + 2' : 'Control + Shift + 2'} - - Turn Off Text-based Canvas -
    • -
    -
    - ); - } +function KeyboardShortcutModal() { + return ( +
      +
    • + Shift + Tab + Tidy +
    • +
    • + + {metaKeyName} + S + + Save +
    • +
    • + + {metaKeyName} + F + + Find Text +
    • +
    • + + {metaKeyName} + G + + Find Next Text Match +
    • +
    • + + {metaKeyName} + Shift + G + + Find Previous Text Match +
    • +
    • + + {metaKeyName} + [ + + Indent Code Left +
    • +
    • + + {metaKeyName} + ] + + Indent Code Right +
    • +
    • + + {metaKeyName} + / + + Comment Line +
    • +
    • + + {metaKeyName} + Enter + + Start Sketch +
    • +
    • + + {metaKeyName} + Shift + Enter + + Stop Sketch +
    • +
    • + + {metaKeyName} + Shift + 1 + + Toggle Text-based Canvas +
    • +
    • + + {metaKeyName} + Shift + 2 + + Turn Off Text-based Canvas +
    • +
    + ); } -KeyboardShortcutModal.propTypes = { - closeModal: PropTypes.func.isRequired -}; - export default KeyboardShortcutModal; diff --git a/client/modules/IDE/components/Preferences.jsx b/client/modules/IDE/components/Preferences.jsx index cb549a13..24203322 100644 --- a/client/modules/IDE/components/Preferences.jsx +++ b/client/modules/IDE/components/Preferences.jsx @@ -261,50 +261,41 @@ class Preferences extends React.Component {
    this.props.setTextOutput(1)} + type="checkbox" + onChange={(event) => { + this.props.setTextOutput(event.target.checked); + }} aria-label="text output on" name="text output" id="text-output-on" - className="preference__radio-button" value="On" - checked={Boolean(this.props.textOutput === 1)} + checked={(this.props.textOutput)} /> this.props.setTextOutput(2)} - aria-label="table text output on" - name="table text output" - id="grid-output-on" - className="preference__radio-button" - value="Grid On" - checked={Boolean(this.props.textOutput === 2)} + type="checkbox" + onChange={(event) => { + this.props.setGridOutput(event.target.checked); + }} + aria-label="table output on" + name="table output" + id="table-output-on" + value="On" + checked={(this.props.gridOutput)} /> - + this.props.setTextOutput(3)} + type="checkbox" + onChange={(event) => { + this.props.setSoundOutput(event.target.checked); + }} aria-label="sound output on" name="sound output" id="sound-output-on" - className="preference__radio-button" value="On" - checked={Boolean(this.props.textOutput === 3)} + checked={(this.props.soundOutput)} /> - this.props.setTextOutput(0)} - aria-label="text output off" - name="text output" - id="text-output-off" - className="preference__radio-button" - value="Off" - checked={!(this.props.textOutput)} - /> - -
    @@ -324,8 +315,12 @@ Preferences.propTypes = { setFontSize: PropTypes.func.isRequired, autosave: PropTypes.bool.isRequired, setAutosave: PropTypes.func.isRequired, - textOutput: PropTypes.number.isRequired, + textOutput: PropTypes.bool.isRequired, + gridOutput: PropTypes.bool.isRequired, + soundOutput: PropTypes.bool.isRequired, setTextOutput: PropTypes.func.isRequired, + setGridOutput: PropTypes.func.isRequired, + setSoundOutput: PropTypes.func.isRequired, lintWarning: PropTypes.bool.isRequired, setLintWarning: PropTypes.func.isRequired, theme: PropTypes.string.isRequired, diff --git a/client/modules/IDE/components/PreviewFrame.jsx b/client/modules/IDE/components/PreviewFrame.jsx index 33af7507..c29d2e54 100644 --- a/client/modules/IDE/components/PreviewFrame.jsx +++ b/client/modules/IDE/components/PreviewFrame.jsx @@ -7,8 +7,12 @@ import loopProtect from 'loop-protect'; import { getBlobUrl } from '../actions/files'; import { resolvePathToFile } from '../../../../server/utils/filePath'; +const decomment = require('decomment'); + const startTag = '@fs-'; +// eslint-disable-next-line max-len const MEDIA_FILE_REGEX = /^('|")(?!(http:\/\/|https:\/\/)).*\.(png|jpg|jpeg|gif|bmp|mp3|wav|aiff|ogg|json|txt|csv|svg|obj|mp4|ogg|webm|mov|otf|ttf|m4a)('|")$/i; +// eslint-disable-next-line max-len const MEDIA_FILE_REGEX_NO_QUOTES = /^(?!(http:\/\/|https:\/\/)).*\.(png|jpg|jpeg|gif|bmp|mp3|wav|aiff|ogg|json|txt|csv|svg|obj|mp4|ogg|webm|mov|otf|ttf|m4a)$/i; const STRING_REGEX = /(['"])((\\\1|.)*?)\1/gm; const TEXT_FILE_REGEX = /(.+\.json$|.+\.txt$|.+\.csv$)/i; @@ -54,20 +58,17 @@ function hijackConsoleErrorsScript(offs) { } return [line - l, file]; } - // catch reference errors, via http://stackoverflow.com/a/12747364/2994108 window.onerror = function (msg, url, lineNumber, columnNo, error) { var string = msg.toLowerCase(); var substring = "script error"; var data = {}; - if (string.indexOf(substring) !== -1){ data = 'Script Error: See Browser Console for Detail'; } else { var fileInfo = getScriptOff(lineNumber); data = msg + ' (' + fileInfo[1] + ': line ' + fileInfo[0] + ')'; } - window.parent.postMessage([{ method: 'error', arguments: data, @@ -114,7 +115,7 @@ class PreviewFrame extends React.Component { } // if user switches textoutput preferences - if (this.props.isTextOutputPlaying !== prevProps.isTextOutputPlaying) { + if (this.props.isAccessibleOutputPlaying !== prevProps.isAccessibleOutputPlaying) { this.renderSketch(); return; } @@ -124,6 +125,16 @@ class PreviewFrame extends React.Component { return; } + if (this.props.gridOutput !== prevProps.gridOutput) { + this.renderSketch(); + return; + } + + if (this.props.soundOutput !== prevProps.soundOutput) { + this.renderSketch(); + return; + } + if (this.props.fullView && this.props.files[0].id !== prevProps.files[0].id) { this.renderSketch(); } @@ -165,38 +176,43 @@ class PreviewFrame extends React.Component { '/loop-protect.min.js', '/hijackConsole.js' ]; - if (this.props.isTextOutputPlaying || (this.props.textOutput !== 0 && this.props.isPlaying)) { + if ( + this.props.isAccessibleOutputPlaying || + ((this.props.textOutput || this.props.gridOutput || this.props.soundOutput) && this.props.isPlaying)) { let interceptorScripts = []; - if (this.props.textOutput === 0) { - this.props.setTextOutput(1); + interceptorScripts = [ + '/p5-interceptor/registry.js', + '/p5-interceptor/loadData.js', + '/p5-interceptor/interceptorHelperFunctions.js', + '/p5-interceptor/baseInterceptor.js', + '/p5-interceptor/entities/entity.min.js', + '/p5-interceptor/ntc.min.js' + ]; + if (!this.props.textOutput && !this.props.gridOutput && !this.props.soundOutput) { + this.props.setTextOutput(true); } - if (this.props.textOutput === 1) { - interceptorScripts = [ - '/p5-interceptor/registry.js', - '/p5-interceptor/loadData.js', - '/p5-interceptor/interceptorHelperFunctions.js', - '/p5-interceptor/baseInterceptor.js', - '/p5-interceptor/entities/entity.min.js', + if (this.props.textOutput) { + let textInterceptorScripts = []; + textInterceptorScripts = [ '/p5-interceptor/textInterceptor/interceptorFunctions.js', - '/p5-interceptor/textInterceptor/interceptorP5.js', - '/p5-interceptor/ntc.min.js' + '/p5-interceptor/textInterceptor/interceptorP5.js' ]; - } else if (this.props.textOutput === 2) { - interceptorScripts = [ - '/p5-interceptor/registry.js', - '/p5-interceptor/loadData.js', - '/p5-interceptor/interceptorHelperFunctions.js', - '/p5-interceptor/baseInterceptor.js', - '/p5-interceptor/entities/entity.min.js', + interceptorScripts = interceptorScripts.concat(textInterceptorScripts); + } + if (this.props.gridOutput) { + let gridInterceptorScripts = []; + gridInterceptorScripts = [ '/p5-interceptor/gridInterceptor/interceptorFunctions.js', - '/p5-interceptor/gridInterceptor/interceptorP5.js', - '/p5-interceptor/ntc.min.js' + '/p5-interceptor/gridInterceptor/interceptorP5.js' ]; - } else if (this.props.textOutput === 3) { - interceptorScripts = [ - '/p5-interceptor/loadData.js', + interceptorScripts = interceptorScripts.concat(gridInterceptorScripts); + } + if (this.props.soundOutput) { + let soundInterceptorScripts = []; + soundInterceptorScripts = [ '/p5-interceptor/soundInterceptor/interceptorP5.js' ]; + interceptorScripts = interceptorScripts.concat(soundInterceptorScripts); } scriptsToInject = scriptsToInject.concat(interceptorScripts); } @@ -264,6 +280,7 @@ class PreviewFrame extends React.Component { } } }); + newContent = decomment(newContent, { ignore: /noprotect/g }); newContent = loopProtect(newContent); return newContent; } @@ -373,8 +390,10 @@ class PreviewFrame extends React.Component { PreviewFrame.propTypes = { isPlaying: PropTypes.bool.isRequired, - isTextOutputPlaying: PropTypes.bool.isRequired, - textOutput: PropTypes.number.isRequired, + isAccessibleOutputPlaying: PropTypes.bool.isRequired, + textOutput: PropTypes.bool.isRequired, + gridOutput: PropTypes.bool.isRequired, + soundOutput: PropTypes.bool.isRequired, setTextOutput: PropTypes.func.isRequired, htmlFile: PropTypes.shape({ content: PropTypes.string.isRequired diff --git a/client/modules/IDE/components/ShareModal.jsx b/client/modules/IDE/components/ShareModal.jsx index ad8c030e..3b398e1b 100644 --- a/client/modules/IDE/components/ShareModal.jsx +++ b/client/modules/IDE/components/ShareModal.jsx @@ -1,57 +1,46 @@ import React, { PropTypes } from 'react'; -import InlineSVG from 'react-inlinesvg'; -const exitUrl = require('../../../images/exit.svg'); - -class ShareModal extends React.Component { - componentDidMount() { - this.shareModal.focus(); - } - render() { - const hostname = window.location.origin; - return ( -
    { this.shareModal = element; }} tabIndex="0"> -
    -

    Share Sketch

    - -
    -
    - - `} - /> -
    -
    - - -
    -
    - - -
    -
    - ); - } +function ShareModal(props) { + const { + projectId, + ownerUsername + } = props; + const hostname = window.location.origin; + return ( +
    +
    + + `} + /> +
    +
    + + +
    +
    + + +
    +
    + ); } ShareModal.propTypes = { projectId: PropTypes.string.isRequired, - closeShareModal: PropTypes.func.isRequired, ownerUsername: PropTypes.string.isRequired }; diff --git a/client/modules/IDE/components/SketchList.jsx b/client/modules/IDE/components/SketchList.jsx index a60a1add..478bc265 100644 --- a/client/modules/IDE/components/SketchList.jsx +++ b/client/modules/IDE/components/SketchList.jsx @@ -8,35 +8,22 @@ import * as SketchActions from '../actions/projects'; import * as ProjectActions from '../actions/project'; import * as ToastActions from '../actions/toast'; -const exitUrl = require('../../../images/exit.svg'); const trashCan = require('../../../images/trash-can.svg'); class SketchList extends React.Component { constructor(props) { super(props); - this.closeSketchList = this.closeSketchList.bind(this); this.props.getProjects(this.props.username); } - componentDidMount() { - document.getElementById('sketchlist').focus(); - } - - closeSketchList() { - browserHistory.push(this.props.previousPath); - } - render() { const username = this.props.username !== undefined ? this.props.username : this.props.user.username; return ( -
    -
    -

    Open a Sketch

    - -
    -
    +
    + { this.props.sketches.length === 0 && +

    No sketches.

    + } + { this.props.sketches.length > 0 && @@ -79,9 +66,8 @@ class SketchList extends React.Component { )} -
    -
    -
    + } + ); } } @@ -98,8 +84,7 @@ SketchList.propTypes = { updatedAt: PropTypes.string.isRequired })).isRequired, username: PropTypes.string, - deleteProject: PropTypes.func.isRequired, - previousPath: PropTypes.string.isRequired, + deleteProject: PropTypes.func.isRequired }; SketchList.defaultProps = { diff --git a/client/modules/IDE/components/TextOutput.jsx b/client/modules/IDE/components/TextOutput.jsx index 67d67db6..f9b528e0 100644 --- a/client/modules/IDE/components/TextOutput.jsx +++ b/client/modules/IDE/components/TextOutput.jsx @@ -1,28 +1,16 @@ -import React, { PropTypes } from 'react'; +import React from 'react'; class TextOutput extends React.Component { componentDidMount() { - this.canvasTextOutput.focus(); - } - componentDidUpdate(prevProps) { - // if the user explicitly clicks on the play button, want to refocus on the text output - if (this.props.isPlaying && this.props.previewIsRefreshing) { - this.canvasTextOutput.focus(); - } + this.TextOutputModal.focus(); } render() { return (
    { this.canvasTextOutput = element; }} - tabIndex="0" - aria-label="text-output" - title="canvas text output" + id="textOutput-content" + ref={(element) => { this.TextOutputModal = element; }} > -

    Output

    -
    -
    +

    Text Output

    { this.props.clearConsole(); - this.props.startTextOutput(); + this.props.startAccessibleOutput(); this.props.startSketchAndRefresh(); }} aria-label="play sketch" @@ -85,7 +85,7 @@ class Toolbar extends React.Component {
    {(() => { - if ((this.props.preferences.textOutput && this.props.ide.isPlaying) || this.props.ide.isTextOutputPlaying) { + if ( + ( + (this.props.preferences.textOutput || + this.props.preferences.gridOutput || + this.props.preferences.soundOutput + ) && this.props.ide.isPlaying + ) || this.props.ide.isAccessibleOutputPlaying) { return ( - ); } @@ -366,9 +386,13 @@ class IDEView extends React.Component { files={this.props.files} content={this.props.selectedFile.content} isPlaying={this.props.ide.isPlaying} - isTextOutputPlaying={this.props.ide.isTextOutputPlaying} + isAccessibleOutputPlaying={this.props.ide.isAccessibleOutputPlaying} textOutput={this.props.preferences.textOutput} + gridOutput={this.props.preferences.gridOutput} + soundOutput={this.props.preferences.soundOutput} setTextOutput={this.props.setTextOutput} + setGridOutput={this.props.setGridOutput} + setSoundOutput={this.props.setSoundOutput} dispatchConsoleEvent={this.props.dispatchConsoleEvent} autorefresh={this.props.preferences.autorefresh} previewIsRefreshing={this.props.ide.previewIsRefreshing} @@ -407,10 +431,30 @@ class IDEView extends React.Component { {(() => { // eslint-disable-line if (this.props.location.pathname.match(/sketches$/)) { return ( - + + + ); + } + })()} + {(() => { // eslint-disable-line + if (this.props.location.pathname.match(/assets$/)) { + return ( + + ); @@ -419,7 +463,11 @@ class IDEView extends React.Component { {(() => { // eslint-disable-line if (this.props.location.pathname === '/about') { return ( - + ); @@ -428,10 +476,13 @@ class IDEView extends React.Component { {(() => { // eslint-disable-line if (this.props.ide.shareModalVisible) { return ( - + @@ -441,10 +492,12 @@ class IDEView extends React.Component { {(() => { // eslint-disable-line if (this.props.ide.keyboardShortcutVisible) { return ( - - + + ); } @@ -452,10 +505,13 @@ class IDEView extends React.Component { {(() => { // eslint-disable-line if (this.props.ide.errorType) { return ( - + ); @@ -498,7 +554,7 @@ IDEView.propTypes = { saveProject: PropTypes.func.isRequired, ide: PropTypes.shape({ isPlaying: PropTypes.bool.isRequired, - isTextOutputPlaying: PropTypes.bool.isRequired, + isAccessibleOutputPlaying: PropTypes.bool.isRequired, consoleEvent: PropTypes.array, modalIsVisible: PropTypes.bool.isRequired, sidebarIsExpanded: PropTypes.bool.isRequired, @@ -513,7 +569,7 @@ IDEView.propTypes = { infiniteLoop: PropTypes.bool.isRequired, previewIsRefreshing: PropTypes.bool.isRequired, infiniteLoopMessage: PropTypes.string.isRequired, - projectSavedTime: PropTypes.string.isRequired, + projectSavedTime: PropTypes.string, previousPath: PropTypes.string.isRequired, justOpenedProject: PropTypes.bool.isRequired, errorType: PropTypes.string, @@ -521,8 +577,8 @@ IDEView.propTypes = { runtimeErrorWarningVisible: PropTypes.bool.isRequired, }).isRequired, stopSketch: PropTypes.func.isRequired, - startTextOutput: PropTypes.func.isRequired, - stopTextOutput: PropTypes.func.isRequired, + startAccessibleOutput: PropTypes.func.isRequired, + stopAccessibleOutput: PropTypes.func.isRequired, project: PropTypes.shape({ id: PropTypes.string, name: PropTypes.string.isRequired, @@ -547,7 +603,9 @@ IDEView.propTypes = { isTabIndent: PropTypes.bool.isRequired, autosave: PropTypes.bool.isRequired, lintWarning: PropTypes.bool.isRequired, - textOutput: PropTypes.number.isRequired, + textOutput: PropTypes.bool.isRequired, + gridOutput: PropTypes.bool.isRequired, + soundOutput: PropTypes.bool.isRequired, theme: PropTypes.string.isRequired, autorefresh: PropTypes.bool.isRequired }).isRequired, @@ -559,6 +617,8 @@ IDEView.propTypes = { setAutosave: PropTypes.func.isRequired, setLintWarning: PropTypes.func.isRequired, setTextOutput: PropTypes.func.isRequired, + setGridOutput: PropTypes.func.isRequired, + setSoundOutput: PropTypes.func.isRequired, files: PropTypes.arrayOf(PropTypes.shape({ id: PropTypes.string.isRequired, name: PropTypes.string.isRequired, diff --git a/client/modules/IDE/reducers/assets.js b/client/modules/IDE/reducers/assets.js new file mode 100644 index 00000000..260660f2 --- /dev/null +++ b/client/modules/IDE/reducers/assets.js @@ -0,0 +1,12 @@ +import * as ActionTypes from '../../../constants'; + +const assets = (state = [], action) => { + switch (action.type) { + case ActionTypes.SET_ASSETS: + return action.assets; + default: + return state; + } +}; + +export default assets; diff --git a/client/modules/IDE/reducers/files.js b/client/modules/IDE/reducers/files.js index 28e44a9a..0449a277 100644 --- a/client/modules/IDE/reducers/files.js +++ b/client/modules/IDE/reducers/files.js @@ -13,9 +13,9 @@ const defaultHTML = ` - - - + + + diff --git a/client/modules/IDE/reducers/ide.js b/client/modules/IDE/reducers/ide.js index 0ee02b4b..379237bc 100644 --- a/client/modules/IDE/reducers/ide.js +++ b/client/modules/IDE/reducers/ide.js @@ -2,7 +2,7 @@ import * as ActionTypes from '../../../constants'; const initialState = { isPlaying: false, - isTextOutputPlaying: false, + isAccessibleOutputPlaying: false, modalIsVisible: false, sidebarIsExpanded: false, consoleIsExpanded: true, @@ -28,10 +28,10 @@ const ide = (state = initialState, action) => { return Object.assign({}, state, { isPlaying: true }); case ActionTypes.STOP_SKETCH: return Object.assign({}, state, { isPlaying: false }); - case ActionTypes.START_TEXT_OUTPUT: - return Object.assign({}, state, { isTextOutputPlaying: true }); - case ActionTypes.STOP_TEXT_OUTPUT: - return Object.assign({}, state, { isTextOutputPlaying: false }); + case ActionTypes.START_ACCESSIBLE_OUTPUT: + return Object.assign({}, state, { isAccessibleOutputPlaying: true }); + case ActionTypes.STOP_ACCESSIBLE_OUTPUT: + return Object.assign({}, state, { isAccessibleOutputPlaying: false }); case ActionTypes.CONSOLE_EVENT: return Object.assign({}, state, { consoleEvent: action.event }); case ActionTypes.SHOW_MODAL: diff --git a/client/modules/IDE/reducers/preferences.js b/client/modules/IDE/reducers/preferences.js index f4161670..8441f042 100644 --- a/client/modules/IDE/reducers/preferences.js +++ b/client/modules/IDE/reducers/preferences.js @@ -6,7 +6,9 @@ const initialState = { isTabIndent: true, autosave: true, lintWarning: false, - textOutput: 0, + textOutput: false, + gridOutput: false, + soundOutput: false, theme: 'light', autorefresh: false }; @@ -31,6 +33,10 @@ const preferences = (state = initialState, action) => { return Object.assign({}, state, { lintWarning: action.value }); case ActionTypes.SET_TEXT_OUTPUT: return Object.assign({}, state, { textOutput: action.value }); + case ActionTypes.SET_GRID_OUTPUT: + return Object.assign({}, state, { gridOutput: action.value }); + case ActionTypes.SET_SOUND_OUTPUT: + return Object.assign({}, state, { soundOutput: action.value }); case ActionTypes.SET_PREFERENCES: return action.preferences; case ActionTypes.SET_THEME: diff --git a/client/modules/IDE/reducers/project.js b/client/modules/IDE/reducers/project.js index eb1de631..c3572381 100644 --- a/client/modules/IDE/reducers/project.js +++ b/client/modules/IDE/reducers/project.js @@ -8,6 +8,7 @@ const initialState = () => { return { name: generatedName, serveSecure: isSecurePage(), + updatedAt: '' }; }; diff --git a/client/modules/User/actions.js b/client/modules/User/actions.js index 2bcb7d1c..3150da4b 100644 --- a/client/modules/User/actions.js +++ b/client/modules/User/actions.js @@ -130,6 +130,41 @@ export function initiateResetPassword(formValues) { }; } +export function initiateVerification() { + return (dispatch) => { + dispatch({ + type: ActionTypes.EMAIL_VERIFICATION_INITIATE + }); + axios.post(`${ROOT_URL}/verify/send`, {}, { withCredentials: true }) + .then(() => { + // do nothing + }) + .catch(response => dispatch({ + type: ActionTypes.ERROR, + message: response.data + })); + }; +} + +export function verifyEmailConfirmation(token) { + return (dispatch) => { + dispatch({ + type: ActionTypes.EMAIL_VERIFICATION_VERIFY, + state: 'checking', + }); + return axios.get(`${ROOT_URL}/verify?t=${token}`, {}, { withCredentials: true }) + .then(response => dispatch({ + type: ActionTypes.EMAIL_VERIFICATION_VERIFIED, + message: response.data, + })) + .catch(response => dispatch({ + type: ActionTypes.EMAIL_VERIFICATION_INVALID, + message: response.data + })); + }; +} + + export function resetPasswordReset() { return { type: ActionTypes.RESET_PASSWORD_RESET diff --git a/client/modules/User/components/AccountForm.jsx b/client/modules/User/components/AccountForm.jsx index b8980d9f..e504bc9b 100644 --- a/client/modules/User/components/AccountForm.jsx +++ b/client/modules/User/components/AccountForm.jsx @@ -4,11 +4,19 @@ import { domOnlyProps } from '../../../utils/reduxFormUtils'; function AccountForm(props) { const { fields: { username, email, currentPassword, newPassword }, + user, handleSubmit, + initiateVerification, submitting, invalid, pristine } = props; + + const handleInitiateVerification = (evt) => { + evt.preventDefault(); + initiateVerification(); + }; + return (

    @@ -22,6 +30,26 @@ function AccountForm(props) { /> {email.touched && email.error && {email.error}}

    + { + user.verified !== 'verified' && + ( +

    + Unconfirmed. + { + user.emailVerificationInitiate === true ? + ( + Confirmation sent, check your email. + ) : + ( + + ) + } +

    + ) + }

    - {currentPassword.touched && currentPassword.error && {currentPassword.error}} + { + currentPassword.touched && + currentPassword.error && + {currentPassword.error} + }

    @@ -56,7 +88,12 @@ function AccountForm(props) { /> {newPassword.touched && newPassword.error && {newPassword.error}}

    - +
    ); } @@ -66,9 +103,14 @@ AccountForm.propTypes = { username: PropTypes.object.isRequired, email: PropTypes.object.isRequired, currentPassword: PropTypes.object.isRequired, - newPassword: PropTypes.object.isRequired + newPassword: PropTypes.object.isRequired, + }).isRequired, + user: PropTypes.shape({ + verified: PropTypes.number.isRequired, + emailVerificationInitiate: PropTypes.bool.isRequired, }).isRequired, handleSubmit: PropTypes.func.isRequired, + initiateVerification: PropTypes.func.isRequired, updateSettings: PropTypes.func.isRequired, submitting: PropTypes.bool, invalid: PropTypes.bool, diff --git a/client/modules/User/components/NewPasswordForm.jsx b/client/modules/User/components/NewPasswordForm.jsx index 6e28de49..22c2839f 100644 --- a/client/modules/User/components/NewPasswordForm.jsx +++ b/client/modules/User/components/NewPasswordForm.jsx @@ -25,7 +25,11 @@ function NewPasswordForm(props) { id="confirm password" {...domOnlyProps(confirmPassword)} /> - {confirmPassword.touched && confirmPassword.error && {confirmPassword.error}} + { + confirmPassword.touched && + confirmPassword.error && + {confirmPassword.error} + }

    diff --git a/client/modules/User/components/ResetPasswordForm.jsx b/client/modules/User/components/ResetPasswordForm.jsx index 2c3dd4c9..ba59cd4a 100644 --- a/client/modules/User/components/ResetPasswordForm.jsx +++ b/client/modules/User/components/ResetPasswordForm.jsx @@ -15,7 +15,12 @@ function ResetPasswordForm(props) { {...domOnlyProps(email)} />

    - + ); } diff --git a/client/modules/User/components/SignupForm.jsx b/client/modules/User/components/SignupForm.jsx index 58513d1e..8f5a99b3 100644 --- a/client/modules/User/components/SignupForm.jsx +++ b/client/modules/User/components/SignupForm.jsx @@ -47,7 +47,11 @@ function SignupForm(props) { id="confirm password" {...domOnlyProps(confirmPassword)} /> - {confirmPassword.touched && confirmPassword.error && {confirmPassword.error}} + { + confirmPassword.touched && + confirmPassword.error && + {confirmPassword.error} + }

    diff --git a/client/modules/User/pages/AccountView.jsx b/client/modules/User/pages/AccountView.jsx index 81f56e11..515e2b79 100644 --- a/client/modules/User/pages/AccountView.jsx +++ b/client/modules/User/pages/AccountView.jsx @@ -4,7 +4,7 @@ import { bindActionCreators } from 'redux'; import { browserHistory } from 'react-router'; import InlineSVG from 'react-inlinesvg'; import axios from 'axios'; -import { updateSettings } from '../actions'; +import { updateSettings, initiateVerification } from '../actions'; import AccountForm from '../components/AccountForm'; import { validateSettings } from '../../../utils/reduxFormUtils'; import GithubButton from '../components/GithubButton'; @@ -59,7 +59,7 @@ function mapStateToProps(state) { } function mapDispatchToProps(dispatch) { - return bindActionCreators({ updateSettings }, dispatch); + return bindActionCreators({ updateSettings, initiateVerification }, dispatch); } function asyncValidate(formProps, dispatch, props) { @@ -81,7 +81,7 @@ function asyncValidate(formProps, dispatch, props) { } AccountView.propTypes = { - previousPath: PropTypes.string.isRequired + previousPath: PropTypes.string.isRequired, }; export default reduxForm({ diff --git a/client/modules/User/pages/EmailVerificationView.jsx b/client/modules/User/pages/EmailVerificationView.jsx new file mode 100644 index 00000000..228bce2f --- /dev/null +++ b/client/modules/User/pages/EmailVerificationView.jsx @@ -0,0 +1,110 @@ +import React, { PropTypes } from 'react'; +import { connect } from 'react-redux'; +import { bindActionCreators } from 'redux'; +import { browserHistory } from 'react-router'; +import InlineSVG from 'react-inlinesvg'; +import get from 'lodash/get'; +import { verifyEmailConfirmation } from '../actions'; + +const exitUrl = require('../../../images/exit.svg'); +const logoUrl = require('../../../images/p5js-logo.svg'); + + +class EmailVerificationView extends React.Component { + static defaultProps = { + emailVerificationTokenState: null, + } + + constructor(props) { + super(props); + this.closeLoginPage = this.closeLoginPage.bind(this); + this.gotoHomePage = this.gotoHomePage.bind(this); + + this.state = { + error: null, + }; + } + + componentWillMount() { + const verificationToken = this.verificationToken(); + if (verificationToken != null) { + this.props.verifyEmailConfirmation(verificationToken); + } + } + + verificationToken = () => get(this.props, 'location.query.t', null); + + closeLoginPage() { + browserHistory.push(this.props.previousPath); + } + + gotoHomePage() { + browserHistory.push('/'); + } + + render() { + let status = null; + const { + emailVerificationTokenState, + } = this.props; + + if (this.verificationToken() == null) { + status = ( +

    That link is invalid

    + ); + } else if (emailVerificationTokenState === 'checking') { + status = ( +

    Validating token, please wait...

    + ); + } else if (emailVerificationTokenState === 'verified') { + status = ( +

    All done, your email address has been verified.

    + ); + } else if (emailVerificationTokenState === 'invalid') { + status = ( +

    Something went wrong.

    + ); + } + + return ( +
    +
    + + +
    +
    +

    Verify your email

    + {status} +
    +
    + ); + } +} + +function mapStateToProps(state) { + return { + emailVerificationTokenState: state.user.emailVerificationTokenState, + previousPath: state.ide.previousPath + }; +} + +function mapDispatchToProps(dispatch) { + return bindActionCreators({ + verifyEmailConfirmation, + }, dispatch); +} + + +EmailVerificationView.propTypes = { + previousPath: PropTypes.string.isRequired, + emailVerificationTokenState: PropTypes.oneOf([ + 'checking', 'verified', 'invalid' + ]), + verifyEmailConfirmation: PropTypes.func.isRequired, +}; + +export default connect(mapStateToProps, mapDispatchToProps)(EmailVerificationView); diff --git a/client/modules/User/reducers.js b/client/modules/User/reducers.js index 5554a11f..6989bfc0 100644 --- a/client/modules/User/reducers.js +++ b/client/modules/User/reducers.js @@ -19,6 +19,14 @@ const user = (state = { authenticated: false }, action) => { return Object.assign({}, state, { resetPasswordInitiate: false }); case ActionTypes.INVALID_RESET_PASSWORD_TOKEN: return Object.assign({}, state, { resetPasswordInvalid: true }); + case ActionTypes.EMAIL_VERIFICATION_INITIATE: + return Object.assign({}, state, { emailVerificationInitiate: true }); + case ActionTypes.EMAIL_VERIFICATION_VERIFY: + return Object.assign({}, state, { emailVerificationTokenState: 'checking' }); + case ActionTypes.EMAIL_VERIFICATION_VERIFIED: + return Object.assign({}, state, { emailVerificationTokenState: 'verified' }); + case ActionTypes.EMAIL_VERIFICATION_INVALID: + return Object.assign({}, state, { emailVerificationTokenState: 'invalid' }); case ActionTypes.SETTINGS_UPDATED: return { ...state, ...action.user }; default: diff --git a/client/reducers.js b/client/reducers.js index 4505623a..c508c8ea 100644 --- a/client/reducers.js +++ b/client/reducers.js @@ -9,6 +9,7 @@ import user from './modules/User/reducers'; import sketches from './modules/IDE/reducers/projects'; import toast from './modules/IDE/reducers/toast'; import console from './modules/IDE/reducers/console'; +import assets from './modules/IDE/reducers/assets'; const rootReducer = combineReducers({ form, @@ -20,7 +21,8 @@ const rootReducer = combineReducers({ sketches, editorAccessibility, toast, - console + console, + assets }); export default rootReducer; diff --git a/client/routes.jsx b/client/routes.jsx index b4bdf6a8..556d9970 100644 --- a/client/routes.jsx +++ b/client/routes.jsx @@ -7,15 +7,21 @@ import FullView from './modules/IDE/pages/FullView'; import LoginView from './modules/User/pages/LoginView'; import SignupView from './modules/User/pages/SignupView'; import ResetPasswordView from './modules/User/pages/ResetPasswordView'; +import EmailVerificationView from './modules/User/pages/EmailVerificationView'; import NewPasswordView from './modules/User/pages/NewPasswordView'; import AccountView from './modules/User/pages/AccountView'; // import SketchListView from './modules/Sketch/pages/SketchListView'; import { getUser } from './modules/User/actions'; +import { stopSketch } from './modules/IDE/actions/ide'; const checkAuth = (store) => { store.dispatch(getUser()); }; +const onRouteChange = (store) => { + store.dispatch(stopSketch()); +}; + const routes = (store) => { const sourceProtocol = findSourceProtocol(store.getState()); @@ -28,11 +34,12 @@ const routes = (store) => { }); return ( - + { onRouteChange(store); }}> + { + diff --git a/client/styles/abstracts/_mixins.scss b/client/styles/abstracts/_mixins.scss index 54b836ec..1e6f2c52 100644 --- a/client/styles/abstracts/_mixins.scss +++ b/client/styles/abstracts/_mixins.scss @@ -16,4 +16,24 @@ $theme-map: null !global; } } +} + +@mixin icon() { + @include themify() { + color: getThemifyVariable('icon-color'); + & g { + fill: getThemifyVariable('icon-color'); + } + &:hover { + color: getThemifyVariable('icon-hover-color'); + & g { + opacity: 1; + fill: getThemifyVariable('icon-hover-color'); + } + } + } + background-color: transparent; + border: none; + cursor: pointer; + padding: 0; } \ No newline at end of file diff --git a/client/styles/abstracts/_placeholders.scss b/client/styles/abstracts/_placeholders.scss index 8c6ce582..9f3a2038 100644 --- a/client/styles/abstracts/_placeholders.scss +++ b/client/styles/abstracts/_placeholders.scss @@ -31,26 +31,6 @@ } } -%icon { - @include themify() { - color: getThemifyVariable('icon-color'); - & g { - fill: getThemifyVariable('icon-color'); - } - &:hover { - color: getThemifyVariable('icon-hover-color'); - & g { - opacity: 1; - fill: getThemifyVariable('icon-hover-color'); - } - } - } - background-color: transparent; - border: none; - cursor: pointer; - padding: 0; -} - %icon-toast{ @include themify() { color: $toast-text-color @@ -206,4 +186,4 @@ color: getThemifyVariable('primary-text-color'); } } -} \ No newline at end of file +} diff --git a/client/styles/abstracts/_variables.scss b/client/styles/abstracts/_variables.scss index 6fc8c9cf..122c9df8 100644 --- a/client/styles/abstracts/_variables.scss +++ b/client/styles/abstracts/_variables.scss @@ -44,6 +44,7 @@ $themes: ( input-text-color: #333, input-border-color: #b5b5b5, about-list-text-color: #4a4a4a, + search-background-color: #ebebeb ), dark: ( logo-color: $p5js-pink, @@ -79,6 +80,7 @@ $themes: ( input-text-color: #333, input-border-color: #b5b5b5, about-list-text-color: #f4f4f4, + search-background-color: #ebebeb ), contrast: ( logo-color: $yellow, @@ -113,6 +115,7 @@ $themes: ( input-text-color: #333, input-border-color: #b5b5b5, about-list-text-color: #f4f4f4, + search-background-color: $white ) ); diff --git a/client/styles/components/_about.scss b/client/styles/components/_about.scss index 4cd4ea4e..25e916ea 100644 --- a/client/styles/components/_about.scss +++ b/client/styles/components/_about.scss @@ -1,35 +1,3 @@ -.about { - @extend %modal; - display: flex; - flex-wrap: wrap; - flex-flow: column; - width: #{720 / $base-font-size}rem; - outline: none; - & a { - color: $form-navigation-options-color; - } -} - -.about__header { - display: flex; - justify-content: space-between; - padding-top: #{12 / $base-font-size}rem; - padding-right: #{14 / $base-font-size}rem; - padding-bottom: #{20 / $base-font-size}rem; - padding-left: #{21 / $base-font-size}rem; -} - -.about__header-title { - font-size: #{38 / $base-font-size}rem; - font-weight: normal; -} - -.about__exit-button { - @include themify() { - @extend %icon; - } -} - .about__logo { @include themify() { & path { @@ -69,10 +37,15 @@ display: flex; flex-direction: row; justify-content: space-between; + flex-wrap: wrap; padding-top: #{17 / $base-font-size}rem; padding-right: #{78 / $base-font-size}rem; padding-bottom: #{20 / $base-font-size}rem; padding-left: #{20 / $base-font-size}rem; + width: #{720 / $base-font-size}rem; + & a { + color: $form-navigation-options-color; + } } .about__content-column { @@ -112,6 +85,7 @@ padding-right: #{20 / $base-font-size}rem; padding-bottom: #{21 / $base-font-size}rem; padding-left: #{291 / $base-font-size}rem; + width: 100%; } .about__footer-list { diff --git a/client/styles/components/_asset-list.scss b/client/styles/components/_asset-list.scss new file mode 100644 index 00000000..76013deb --- /dev/null +++ b/client/styles/components/_asset-list.scss @@ -0,0 +1,56 @@ +.asset-table-container { + // flex: 1 1 0%; + overflow-y: scroll; + max-width: 100%; + width: #{1000 / $base-font-size}rem; + min-height: #{400 / $base-font-size}rem; +} + +.asset-table { + width: 100%; + padding: #{10 / $base-font-size}rem #{20 / $base-font-size}rem; + max-height: 100%; + border-spacing: 0; + & .asset-list__delete-column { + width: #{23 / $base-font-size}rem; + } + + & thead { + font-size: #{12 / $base-font-size}rem; + @include themify() { + color: getThemifyVariable('inactive-text-color') + } + } + + & th { + height: #{32 / $base-font-size}rem; + font-weight: normal; + } +} + +.asset-table__row { + margin: #{10 / $base-font-size}rem; + height: #{72 / $base-font-size}rem; + font-size: #{16 / $base-font-size}rem; + + &:nth-child(odd) { + @include themify() { + background: getThemifyVariable('console-header-background-color'); + } + } + + & a { + @include themify() { + color: getThemifyVariable('primary-text-color'); + } + } + + & td:first-child { + padding-left: #{10 / $base-font-size}rem; + } +} + +.asset-table__empty { + text-align: center; + font-size: #{16 / $base-font-size}rem; +} diff --git a/client/styles/components/_console.scss b/client/styles/components/_console.scss index 93a55dcd..9ed29fc7 100644 --- a/client/styles/components/_console.scss +++ b/client/styles/components/_console.scss @@ -72,9 +72,7 @@ .preview-console__collapse { padding-top: #{3 / $base-font-size}rem; - @include themify() { - @extend %icon; - } + @include icon(); .preview-console--collapsed & { display: none; } @@ -82,9 +80,7 @@ .preview-console__expand { padding-top: #{3 / $base-font-size}rem; - @include themify() { - @extend %icon; - } + @include icon(); display: none; .preview-console--collapsed & { display: inline-block; diff --git a/client/styles/components/_editor.scss b/client/styles/components/_editor.scss index 0d244103..780b19a2 100644 --- a/client/styles/components/_editor.scss +++ b/client/styles/components/_editor.scss @@ -105,6 +105,169 @@ width: #{48 / $base-font-size}rem; } +/* + Search dialog +*/ + +.CodeMirror-dialog { + position: fixed; + top: 0; + left: 50%; + margin-left: - #{365/2/$base-font-size}rem; + + z-index: 10; + + width: 100%; + max-width: #{365 / $base-font-size}rem; + + font-family: Montserrat, sans-serif; + + padding: #{14 / $base-font-size}rem #{20 / $base-font-size}rem #{14 / $base-font-size}rem #{18 / $base-font-size}rem; + + border-radius: 2px; + + @include themify() { + background-color: getThemifyVariable('modal-background-color'); + box-shadow: 0 12px 12px 0 getThemifyVariable('shadow-color'); + border: solid 0.5px getThemifyVariable('modal-border-color'); + } +} + +.CodeMirror-search-title { + display: block; + margin-bottom: #{12 / $base-font-size}rem; + + font-size: #{21 / $base-font-size}rem; + font-weight: bold; +} + +.CodeMirror-search-field { + display: block; + width: 100%; + margin-bottom: #{12 / $base-font-size}rem; + @include themify() { + background-color: getThemifyVariable('search-background-color'); + border: solid 0.5px getThemifyVariable('button-border-color'); + } + &:focus { + background-color: $white; + } +} + +.CodeMirror-search-count { + display: block; + height: #{20 / $base-font-size}rem; + text-align: right; +} + +.CodeMirror-search-actions { + display: flex; + justify-content: space-between; +} + +/* + +*/ +.CodeMirror-search-modifiers { + display: flex; + justify-content: flex-end; +} + +.CodeMirror-regexp-button, +.CodeMirror-case-button, +.CodeMirror-word-button { + width: 20px; + height: 20px; + + margin-left: #{10 / $base-font-size}rem; + + word-break: keep-all; + white-space: nowrap; + + @include themify() { + color: getThemifyVariable('inactive-text-color'); + } +} + +.CodeMirror-regexp-button .label, +.CodeMirror-case-button .label, +.CodeMirror-word-button .label { + @extend %hidden-element; +} + +[aria-checked="true"] { + @include themify() { + color: getThemifyVariable('primary-text-color'); + } +} + +/* + Previous / Next buttons +*/ + +// Visually hide button text +.CodeMirror-search-button .label { + @extend %hidden-element; +} + +.CodeMirror-search-button { + margin-right: #{10 / $base-font-size}rem; +} + +.CodeMirror-search-button::after { + display: block; + content: ' '; + + width: 14px; + height: 14px; + + @include icon(); + + background-repeat: no-repeat; + background-position: center; +} + +// Previous button +.CodeMirror-search-button.prev::after { + background-image: url(../images/up-arrow.svg) +} + +// Next button +.CodeMirror-search-button.next::after { + background-image: url(../images/down-arrow.svg) +} + +/* + Close button +*/ +.CodeMirror-close-button { + position: absolute; + top: #{14 / $base-font-size}rem; + right: #{18 / $base-font-size}rem; + + display: flex; + flex-direction: row; +} + +// Visually hide button text +.CodeMirror-close-button .label { + @extend %hidden-element; +} + +.CodeMirror-close-button:after { + display: block; + content: ' '; + + width: 16px; + height: 16px; + + margin-left: #{8 / $base-font-size}rem; + + @include icon(); + + background: transparent url(../images/exit.svg) no-repeat; +} + .editor-holder { height: calc(100% - #{29 / $base-font-size}rem); width: 100%; @@ -116,9 +279,7 @@ } .editor__options-button { - @include themify() { - @extend %icon; - } + @include icon(); position: absolute; top: #{10 / $base-font-size}rem; right: #{2 / $base-font-size}rem; diff --git a/client/styles/components/_error-modal.scss b/client/styles/components/_error-modal.scss index 35eb940f..f20f05a5 100644 --- a/client/styles/components/_error-modal.scss +++ b/client/styles/components/_error-modal.scss @@ -12,9 +12,7 @@ } .error-modal__exit-button { - @include themify() { - @extend %icon; - } + @include icon(); } .error-modal__content { diff --git a/client/styles/components/_forms.scss b/client/styles/components/_forms.scss index 88193b26..ea839663 100644 --- a/client/styles/components/_forms.scss +++ b/client/styles/components/_forms.scss @@ -33,6 +33,15 @@ border-color: $secondary-form-title-color; } +.form__context { + text-align: left; + margin-top: #{15 / $base-font-size}rem; +} + +.form__status { + color: $form-navigation-options-color; +} + .form input[type="submit"] { @extend %forms-button; } diff --git a/client/styles/components/_keyboard-shortcuts.scss b/client/styles/components/_keyboard-shortcuts.scss new file mode 100644 index 00000000..66963e91 --- /dev/null +++ b/client/styles/components/_keyboard-shortcuts.scss @@ -0,0 +1,20 @@ +.keyboard-shortcuts { + padding: #{20 / $base-font-size}rem; + padding-bottom: #{40 / $base-font-size}rem; + width: #{450 / $base-font-size}rem; +} + +.keyboard-shortcut-item { + display: flex; + & + & { + margin-top: #{10 / $base-font-size}rem; + } + align-items: baseline; +} + +.keyboard-shortcut__command { + width: 50%; + font-weight: bold; + text-align: right; + padding-right: #{10 / $base-font-size}rem; +} diff --git a/client/styles/components/_modal.scss b/client/styles/components/_modal.scss index 0999c89c..06830684 100644 --- a/client/styles/components/_modal.scss +++ b/client/styles/components/_modal.scss @@ -22,9 +22,7 @@ } .modal__exit-button { - @include themify() { - @extend %icon; - } + @include icon(); } .modal__header { @@ -50,68 +48,3 @@ text-align: center; margin: #{20 / $base-font-size}rem 0; } - -.uploader { - min-height: #{200 / $base-font-size}rem; - width: 100%; - text-align: center; -} - -.share-modal { - @extend %modal; - padding: #{20 / $base-font-size}rem; - width: #{500 / $base-font-size}rem; -} - -.share-modal__header { - display: flex; - justify-content: space-between; -} - -.share-modal__section { - width: 100%; - display: flex; - align-items: center; - padding: #{10 / $base-font-size}rem 0; -} - -.share-modal__label { - width: #{86 / $base-font-size}rem; -} - -.share-modal__input { - flex: 1; -} - -.keyboard-shortcuts { - @extend %modal; - padding: #{20 / $base-font-size}rem; - width: #{450 / $base-font-size}rem; -} - -.keyboard-shortcuts__header { - display: flex; - justify-content: space-between; - margin-bottom: #{20 / $base-font-size}rem; -} - -.keyboard-shortcuts__close { - @include themify() { - @extend %icon; - } -} - -.keyboard-shortcut-item { - display: flex; - & + & { - margin-top: #{10 / $base-font-size}rem; - } - align-items: baseline; -} - -.keyboard-shortcut__command { - width: 50%; - font-weight: bold; - text-align: right; - padding-right: #{10 / $base-font-size}rem; -} diff --git a/client/styles/components/_overlay.scss b/client/styles/components/_overlay.scss index 9d952969..690d1c7e 100644 --- a/client/styles/components/_overlay.scss +++ b/client/styles/components/_overlay.scss @@ -9,10 +9,31 @@ overflow-y: hidden; } -.overlay-content { +.overlay__content { height: 100%; width: 100%; display: flex; justify-content: center; align-items: center; +} + +.overlay__body { + @extend %modal; + display: flex; + flex-wrap: wrap; + flex-flow: column; + max-height: 80%; + max-width: 65%; +} + +.overlay__header { + display: flex; + justify-content: space-between; + padding: #{40 / $base-font-size}rem #{20 / $base-font-size}rem #{30 / $base-font-size}rem #{20 / $base-font-size}rem; + flex: 1 0 auto; +} + +.overlay__close-button { + @include icon(); + padding: #{12 / $base-font-size}rem #{16 / $base-font-size}rem; } \ No newline at end of file diff --git a/client/styles/components/_preferences.scss b/client/styles/components/_preferences.scss index e684cd96..d45adebb 100644 --- a/client/styles/components/_preferences.scss +++ b/client/styles/components/_preferences.scss @@ -17,9 +17,7 @@ } .preferences__exit-button { - @include themify() { - @extend %icon; - } + @include icon(); padding-top: #{5 / $base-font-size}rem; margin-right: #{-6 / $base-font-size}rem; } @@ -126,7 +124,7 @@ } .preference__option:last-child { - padding-right: 0; + padding-right: 0; } .preference__preview-button { @@ -151,5 +149,5 @@ } .preference__option.preference__canvas:not(:last-child) { - padding-right: #{20 / $base-font-size}rem; -} \ No newline at end of file + padding-right: #{14 / $base-font-size}rem; +} diff --git a/client/styles/components/_share.scss b/client/styles/components/_share.scss new file mode 100644 index 00000000..a9b72c7e --- /dev/null +++ b/client/styles/components/_share.scss @@ -0,0 +1,24 @@ +.share-modal { + padding: #{20 / $base-font-size}rem; + width: #{500 / $base-font-size}rem; +} + +.share-modal__header { + display: flex; + justify-content: space-between; +} + +.share-modal__section { + width: 100%; + display: flex; + align-items: center; + padding: #{10 / $base-font-size}rem 0; +} + +.share-modal__label { + width: #{86 / $base-font-size}rem; +} + +.share-modal__input { + flex: 1; +} \ No newline at end of file diff --git a/client/styles/components/_sidebar.scss b/client/styles/components/_sidebar.scss index 86efd929..dba607e0 100644 --- a/client/styles/components/_sidebar.scss +++ b/client/styles/components/_sidebar.scss @@ -25,9 +25,7 @@ } .sidebar__add { - @include themify() { - @extend %icon; - } + @include icon(); .sidebar--contracted & { display: none; } @@ -124,8 +122,8 @@ } .sidebar__file-item-show-options { + @include icon(); @include themify() { - @extend %icon; padding: #{4 / $base-font-size}rem 0; background-color: map-get($theme-map, 'file-selected-color'); padding-right: #{6 / $base-font-size}rem; @@ -183,9 +181,7 @@ } .sidebar__expand { - @include themify() { - @extend %icon; - } + @include icon(); position: absolute; top: #{7 / $base-font-size}rem; left: #{1 / $base-font-size}rem; @@ -200,9 +196,7 @@ } .sidebar__contract { - @include themify() { - @extend %icon; - } + @include icon(); position: absolute; top: #{7 / $base-font-size}rem; left: #{34 / $base-font-size}rem; diff --git a/client/styles/components/_sketch-list.scss b/client/styles/components/_sketch-list.scss index 5cc15dd8..1369699c 100644 --- a/client/styles/components/_sketch-list.scss +++ b/client/styles/components/_sketch-list.scss @@ -1,25 +1,8 @@ -.sketch-list { - @extend %modal; - display: flex; - flex-wrap: wrap; - flex-flow: column; - width: #{1000 / $base-font-size}rem; - height: 80%; - outline: none; -} - -.sketch-list__header { - display: flex; - justify-content: space-between; -} - -.sketch-list__header-title { - padding: #{40 / $base-font-size}rem #{16 / $base-font-size}rem #{12 / $base-font-size}rem #{21 / $base-font-size}rem; -} - .sketches-table-container { - flex: 1 0 0%; overflow-y: scroll; + max-width: 100%; + width: #{1000 / $base-font-size}rem; + min-height: #{400 / $base-font-size}rem; } .sketches-table { @@ -67,13 +50,6 @@ font-weight: normal; } -.sketch-list__exit-button { - @include themify() { - @extend %icon; - } - margin: #{12 / $base-font-size}rem #{16 / $base-font-size}rem; -} - .visibility-toggle .sketch-list__trash-button { @extend %hidden-element; width:#{20 / $base-font-size}rem; @@ -95,3 +71,8 @@ } } } + +.sketches-table__empty { + text-align: center; + font-size: #{16 / $base-font-size}rem; +} diff --git a/client/styles/components/_uploader.scss b/client/styles/components/_uploader.scss index e6d8bcee..695e84da 100644 --- a/client/styles/components/_uploader.scss +++ b/client/styles/components/_uploader.scss @@ -1,3 +1,9 @@ .dropzone { color: $primary-text-color; +} + +.uploader { + min-height: #{200 / $base-font-size}rem; + width: 100%; + text-align: center; } \ No newline at end of file diff --git a/client/styles/layout/_ide.scss b/client/styles/layout/_ide.scss index 87e3724c..cb446951 100644 --- a/client/styles/layout/_ide.scss +++ b/client/styles/layout/_ide.scss @@ -31,7 +31,7 @@ @extend %hidden-element; } -.text-output { +.accessible-output { @extend %hidden-element; } diff --git a/client/styles/main.scss b/client/styles/main.scss index fe97261e..abe5a93b 100644 --- a/client/styles/main.scss +++ b/client/styles/main.scss @@ -34,6 +34,9 @@ @import 'components/error-modal'; @import 'components/preview-frame'; @import 'components/help-modal'; +@import 'components/share'; +@import 'components/asset-list'; +@import 'components/keyboard-shortcuts'; @import 'layout/ide'; @import 'layout/fullscreen'; diff --git a/client/utils/codemirror-search.js b/client/utils/codemirror-search.js new file mode 100644 index 00000000..32c52a63 --- /dev/null +++ b/client/utils/codemirror-search.js @@ -0,0 +1,433 @@ +/* eslint-disable */ +// CodeMirror, copyright (c) by Marijn Haverbeke and others +// Distributed under an MIT license: http://codemirror.net/LICENSE + +// Define search commands. Depends on dialog.js or another +// implementation of the openDialog method. + +// Replace works a little oddly -- it will do the replace on the next +// Ctrl-G (or whatever is bound to findNext) press. You prevent a +// replace by making sure the match is no longer selected when hitting +// Ctrl-G. + +export default function(CodeMirror) { + "use strict"; + + function searchOverlay(query) { + return {token: function(stream) { + query.lastIndex = stream.pos; + var match = query.exec(stream.string); + if (match && match.index == stream.pos) { + stream.pos += match[0].length || 1; + return "searching"; + } else if (match) { + stream.pos = match.index; + } else { + stream.skipToEnd(); + } + }}; + } + + function SearchState() { + this.posFrom = this.posTo = this.lastQuery = this.query = null; + this.overlay = null; + this.regexp = false; + this.caseInsensitive = true; + this.wholeWord = false; + } + + function getSearchState(cm) { + return cm.state.search || (cm.state.search = new SearchState()); + } + + function getSearchCursor(cm, query, pos) { + return cm.getSearchCursor(query, pos, getSearchState(cm).caseInsensitive); + } + + function persistentDialog(cm, text, deflt, onEnter, onKeyDown) { + var searchField = document.getElementsByClassName("CodeMirror-search-field")[0]; + if (!searchField) { + cm.openDialog(text, onEnter, { + value: deflt, + selectValueOnOpen: true, + closeOnEnter: false, + onClose: function () { + clearSearch(cm); + }, + onKeyDown: onKeyDown, + closeOnBlur: false + }); + + searchField = document.getElementsByClassName("CodeMirror-search-field")[0]; + + var dialog = document.getElementsByClassName("CodeMirror-dialog")[0]; + var closeButton = dialog.getElementsByClassName("close")[0]; + + var state = getSearchState(cm); + + CodeMirror.on(searchField, "keyup", function (e) { + if (e.keyCode !== 13 && searchField.value.length > 1) { // not enter and more than 1 character to search + startSearch(cm, getSearchState(cm), searchField.value); + } + }); + + CodeMirror.on(closeButton, "click", function () { + clearSearch(cm); + dialog.parentNode.removeChild(dialog); + cm.focus(); + }); + + var upArrow = dialog.getElementsByClassName("up-arrow")[0]; + CodeMirror.on(upArrow, "click", function () { + CodeMirror.commands.findPrev(cm); + searchField.blur(); + cm.focus(); + }); + + var downArrow = dialog.getElementsByClassName("down-arrow")[0]; + CodeMirror.on(downArrow, "click", function () { + CodeMirror.commands.findNext(cm); + searchField.blur(); + cm.focus(); + }); + + var regexpButton = dialog.getElementsByClassName("CodeMirror-regexp-button")[0]; + CodeMirror.on(regexpButton, "click", function () { + var state = getSearchState(cm); + state.regexp = toggle(regexpButton); + startSearch(cm, getSearchState(cm), searchField.value); + }); + + toggle(regexpButton, state.regexp); + + var caseSensitiveButton = dialog.getElementsByClassName("CodeMirror-case-button")[0]; + CodeMirror.on(caseSensitiveButton, "click", function () { + var state = getSearchState(cm); + state.caseInsensitive = !toggle(caseSensitiveButton); + startSearch(cm, getSearchState(cm), searchField.value); + }); + + toggle(caseSensitiveButton, !state.caseInsensitive); + + var wholeWordButton = dialog.getElementsByClassName("CodeMirror-word-button")[0]; + CodeMirror.on(wholeWordButton, "click", function () { + var state = getSearchState(cm); + state.wholeWord = toggle(wholeWordButton); + startSearch(cm, getSearchState(cm), searchField.value); + }); + + toggle(wholeWordButton, state.wholeWord); + + function toggle(el, initialState) { + var currentState, nextState; + + if (initialState == null) { + currentState = el.getAttribute('aria-checked') === 'true'; + nextState = !currentState; + } else { + nextState = initialState; + } + + el.setAttribute('aria-checked', nextState); + return nextState; + } + } else { + searchField.focus(); + } + } + + function dialog(cm, text, shortText, deflt, f) { + if (cm.openDialog) cm.openDialog(text, f, {value: deflt, selectValueOnOpen: true}); + else f(prompt(shortText, deflt)); + } + + var lastSelectedIndex = 0; + function confirmDialog(cm, text, shortText, fs) { + if (cm.openConfirm) cm.openConfirm(text, fs); + else if (confirm(shortText)) fs[0](); + + var dialog = document.getElementsByClassName("CodeMirror-dialog")[0]; + var buttons = dialog.getElementsByTagName("button"); + buttons[lastSelectedIndex].focus(); + for (var i = 0; i < buttons.length; i += 1) { + (function (index) { + var button = buttons[index]; + button.addEventListener("focus", function (e) { + lastSelectedIndex = index === buttons.length - 1 ? 0 : index; + }); + button.addEventListener("keyup", function (e) { + if (e.keyCode === 37) { // arrow left + var prevButton = index === 0 ? buttons.length - 1 : index - 1; + buttons[prevButton].focus(); + } + if (e.keyCode === 39) { // arrow right + var nextButton = index === buttons.length - 1 ? 0 : index + 1; + buttons[nextButton].focus(); + } + if (e.keyCode === 27) { // esc + cm.focus(); + } + }); + button.addEventListener("click", function () { + if (index === buttons.length - 1) { // "done" + lastSelectedIndex = 0; + } + }) + })(i); + } + } + + function parseString(string) { + return string.replace(/\\(.)/g, function(_, ch) { + if (ch == "n") return "\n" + if (ch == "r") return "\r" + return ch + }) + } + + /* + Parses the query text and state and returns + a RegExp ready for searching + */ + function parseQuery(query, state) { + var emptyQuery = 'x^'; // matches nothing + + if (query === '') { // empty string matches nothing + query = emptyQuery; + } else { + if (state.regexp === false) { + query = parseString(query); + query = query.replace(/[\-\[\]\/\{\}\(\)\*\+\?\.\\\^\$\|]/g, "\\$&"); + } + + if (state.wholeWord) { + query += '\\b'; + } + } + + var regexp; + + try { + regexp = new RegExp(query, state.caseInsensitive ? "gi" : "g"); + } catch (e) { + regexp = new RegExp(emptyQuery, 'g'); + } + + // If the resulting regexp will match everything, do not use it + if (regexp.test('')) { + return new RegExp(emptyQuery, 'g'); + } + + return regexp; + } + + var queryDialog = ` +

    Find

    + +
    +
    + + + +
    +
    + + +
    +
    + + `; + + function startSearch(cm, state, originalQuery) { + state.queryText = originalQuery; + state.query = parseQuery(originalQuery, state); + + cm.removeOverlay(state.overlay, state.caseInsensitive); + state.overlay = searchOverlay(state.query); + cm.addOverlay(state.overlay); + if (cm.showMatchesOnScrollbar) { + if (state.annotate) { state.annotate.clear(); state.annotate = null; } + state.annotate = cm.showMatchesOnScrollbar(state.query, state.caseInsensitive); + } + } + + function doSearch(cm, rev, persistent, immediate, ignoreQuery) { + var state = getSearchState(cm); + if (!ignoreQuery && state.query) return findNext(cm, rev); + var q = cm.getSelection() || state.lastQuery; + if (persistent && cm.openDialog) { + var hiding = null + var searchNext = function(query, event) { + CodeMirror.e_stop(event); + if (!query) return; + if (query != state.queryText) { + startSearch(cm, state, query); + state.posFrom = state.posTo = cm.getCursor(); + } + if (hiding) hiding.style.opacity = 1 + findNext(cm, event.shiftKey, function(_, to) { + var dialog + if (to.line < 3 && document.querySelector && + (dialog = cm.display.wrapper.querySelector(".CodeMirror-dialog")) && + dialog.getBoundingClientRect().bottom - 4 > cm.cursorCoords(to, "window").top) + (hiding = dialog).style.opacity = .4 + }) + }; + persistentDialog(cm, queryDialog, q, searchNext, function(event, query) { + var keyName = CodeMirror.keyName(event) + var cmd = CodeMirror.keyMap[cm.getOption("keyMap")][keyName] + if (!cmd) cmd = cm.getOption('extraKeys')[keyName] + if (cmd == "findNext" || cmd == "findPrev" || + cmd == "findPersistentNext" || cmd == "findPersistentPrev") { + CodeMirror.e_stop(event); + startSearch(cm, getSearchState(cm), query); + cm.execCommand(cmd); + } else if (cmd == "find" || cmd == "findPersistent") { + CodeMirror.e_stop(event); + searchNext(query, event); + } + }); + if (immediate && q) { + startSearch(cm, state, q); + findNext(cm, rev); + } + } else { + dialog(cm, queryDialog, "Search for:", q, function(query) { + if (query && !state.query) cm.operation(function() { + startSearch(cm, state, query); + state.posFrom = state.posTo = cm.getCursor(); + findNext(cm, rev); + }); + }); + } + } + + function findNext(cm, rev, callback) {cm.operation(function() { + var state = getSearchState(cm); + var cursor = getSearchCursor(cm, state.query, rev ? state.posFrom : state.posTo); + if (!cursor.find(rev)) { + cursor = getSearchCursor(cm, state.query, rev ? CodeMirror.Pos(cm.lastLine()) : CodeMirror.Pos(cm.firstLine(), 0)); + if (!cursor.find(rev)) return; + } + cm.setSelection(cursor.from(), cursor.to()); + cm.scrollIntoView({from: cursor.from(), to: cursor.to()}, 60); + state.posFrom = cursor.from(); state.posTo = cursor.to(); + if (callback) callback(cursor.from(), cursor.to()) + });} + + function clearSearch(cm) {cm.operation(function() { + var state = getSearchState(cm); + state.lastQuery = state.queryText; + if (!state.query) return; + state.query = state.queryText = null; + cm.removeOverlay(state.overlay); + if (state.annotate) { state.annotate.clear(); state.annotate = null; } + });} + + var replaceQueryDialog = + '
    '; + + var replacementQueryDialog = 'With: '; + var doReplaceConfirm = "Replace? "; + + function replaceAll(cm, query, text) { + cm.operation(function() { + for (var cursor = getSearchCursor(cm, query); cursor.findNext();) { + if (typeof query != "string") { + var match = cm.getRange(cursor.from(), cursor.to()).match(query); + cursor.replace(text.replace(/\$(\d)/g, function(_, i) {return match[i];})); + } else cursor.replace(text); + } + }); + } + + // TODO: This will need updating if replace is implemented + function replace(cm, all) { + var prevDialog = document.getElementsByClassName("CodeMirror-dialog")[0]; + if (prevDialog) { + clearSearch(cm); + prevDialog.parentNode.removeChild(prevDialog); + cm.focus(); + } + if (cm.getOption("readOnly")) return; + var query = cm.getSelection() || getSearchState(cm).lastQuery; + var dialogText = all ? "Replace all:" : "Replace:" + dialog(cm, dialogText + replaceQueryDialog, dialogText, query, function(query) { + if (!query) return; + query = parseQuery(query); + dialog(cm, replacementQueryDialog, "Replace with:", "", function(text) { + text = parseString(text) + if (all) { + replaceAll(cm, query, text) + } else { + clearSearch(cm); + var cursor = getSearchCursor(cm, query, cm.getCursor("from")); + var advance = function() { + var start = cursor.from(), match; + if (!(match = cursor.findNext())) { + cursor = getSearchCursor(cm, query); + if (!(match = cursor.findNext()) || + (start && cursor.from().line == start.line && cursor.from().ch == start.ch)) return; + } + cm.setSelection(cursor.from(), cursor.to()); + cm.scrollIntoView({from: cursor.from(), to: cursor.to()}, 60); + confirmDialog(cm, doReplaceConfirm, "Replace?", + [function() {doReplace(match);}, advance, + function() {replaceAll(cm, query, text)}]); + }; + var doReplace = function(match) { + cursor.replace(typeof query == "string" ? text : + text.replace(/\$(\d)/g, function(_, i) {return match[i];})); + advance(); + }; + advance(); + } + }); + }); + } + + CodeMirror.commands.find = function(cm) {doSearch(cm);}; + CodeMirror.commands.findPersistent = function(cm) { doSearch(cm, false, true, false, true);}; + CodeMirror.commands.findPersistentNext = function(cm) {doSearch(cm, false, true, true);}; + CodeMirror.commands.findPersistentPrev = function(cm) {doSearch(cm, true, true, true);}; + CodeMirror.commands.findNext = doSearch; + CodeMirror.commands.findPrev = function(cm) {doSearch(cm, true);}; + CodeMirror.commands.clearSearch = clearSearch; + // CodeMirror.commands.replace = replace; + // CodeMirror.commands.replaceAll = function(cm) {replace(cm, true);}; +}; diff --git a/client/utils/metaKey.js b/client/utils/metaKey.js new file mode 100644 index 00000000..e88052e1 --- /dev/null +++ b/client/utils/metaKey.js @@ -0,0 +1,16 @@ +const metaKey = (() => { + if (navigator != null && navigator.platform != null) { + return /^MAC/i.test(navigator.platform) ? + 'Cmd' : + 'Ctrl'; + } + + return 'Ctrl'; +})(); + +const metaKeyName = metaKey === 'Cmd' ? 'Command' : 'Control'; + +export { + metaKey, + metaKeyName, +}; diff --git a/client/utils/reduxFormUtils.js b/client/utils/reduxFormUtils.js index c2c68fa0..4bb74319 100644 --- a/client/utils/reduxFormUtils.js +++ b/client/utils/reduxFormUtils.js @@ -26,7 +26,9 @@ function validateNameEmail(formProps, errors) { if (!formProps.email) { errors.email = 'Please enter an email.'; - } else if (!formProps.email.match(/^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/i)) { + } else if ( + // eslint-disable-next-line max-len + !formProps.email.match(/^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/i)) { errors.email = 'Please enter a valid email address.'; } } diff --git a/package.json b/package.json index 1653cac6..bb328c95 100644 --- a/package.json +++ b/package.json @@ -71,6 +71,8 @@ "cookie-parser": "^1.4.1", "cors": "^2.8.1", "csslint": "^0.10.0", + "csurf": "^1.9.0", + "decomment": "^0.8.7", "dotenv": "^2.0.0", "dropzone": "^4.3.0", "escape-string-regexp": "^1.0.5", @@ -78,12 +80,16 @@ "express": "^4.13.4", "express-session": "^1.13.0", "file-type": "^3.8.0", + "fs-promise": "^1.0.0", "htmlhint": "^0.9.13", + "is_js": "^0.9.0", + "is-url": "^1.2.2", "js-beautify": "^1.6.4", "jsdom": "^9.8.3", "jshint": "^2.9.4", "lodash": "^4.16.4", "loop-protect": "git+https://git@github.com/catarak/loop-protect.git", + "mjml": "^3.3.2", "moment": "^2.14.1", "mongoose": "^4.4.16", "node-uuid": "^1.4.7", @@ -92,10 +98,13 @@ "passport": "^0.3.2", "passport-github": "^1.1.0", "passport-local": "^1.0.0", + "pretty-bytes": "^3.0.1", "project-name-generator": "^2.1.3", + "pug": "^2.0.0-beta6", "q": "^1.4.1", "react": "^15.1.0", "react-dom": "^15.1.0", + "react-helmet": "^5.1.3", "react-inlinesvg": "^0.4.2", "react-redux": "^4.4.5", "react-router": "^2.6.0", diff --git a/server/config/passport.js b/server/config/passport.js index 9ad46659..769a08de 100644 --- a/server/config/passport.js +++ b/server/config/passport.js @@ -84,6 +84,7 @@ passport.use(new GitHubStrategy({ existingEmailUser.username = existingEmailUser.username || profile.username; existingEmailUser.tokens.push({ kind: 'github', accessToken }); existingEmailUser.name = existingEmailUser.name || profile.displayName; + existingEmailUser.verified = User.EmailConfirmation.Verified; existingEmailUser.save(saveErr => done(null, existingEmailUser)); } else { const user = new User(); @@ -92,6 +93,7 @@ passport.use(new GitHubStrategy({ user.username = profile.username; user.tokens.push({ kind: 'github', accessToken }); user.name = profile.displayName; + user.verified = User.EmailConfirmation.Verified; user.save(saveErr => done(null, user)); } }); diff --git a/server/controllers/aws.controller.js b/server/controllers/aws.controller.js index eda6b4c3..2b23e21f 100644 --- a/server/controllers/aws.controller.js +++ b/server/controllers/aws.controller.js @@ -1,6 +1,8 @@ import uuid from 'node-uuid'; import policy from 's3-policy'; import s3 from 's3'; +import { getProjectsForUserId } from './project.controller'; +import { findUserByUsername } from './user.controller'; const client = s3.createClient({ maxAsyncS3: 20, @@ -28,12 +30,12 @@ export function getObjectKey(url) { if (urlArray.length === 6) { const key = urlArray.pop(); const userId = urlArray.pop(); - objectKey = `${userId}/${key}` + objectKey = `${userId}/${key}`; } else { const key = urlArray.pop(); objectKey = key; } - return objectKey; + return objectKey; } export function deleteObjectsFromS3(keyList, callback) { @@ -104,3 +106,45 @@ export function copyObjectInS3(req, res) { res.json({ url: `${s3Bucket}${userId}/${newFilename}` }); }); } + +export function listObjectsInS3ForUser(req, res) { + const username = req.params.username; + findUserByUsername(username, (user) => { + const userId = user.id; + const params = { + s3Params: { + Bucket: `${process.env.S3_BUCKET}`, + Prefix: `${userId}/` + } + }; + let assets = []; + const list = client.listObjects(params) + .on('data', (data) => { + assets = assets.concat(data["Contents"].map((object) => { + return { key: object["Key"], size: object["Size"] }; + })); + }) + .on('end', () => { + const projectAssets = []; + getProjectsForUserId(userId).then((projects) => { + projects.forEach((project) => { + project.files.forEach((file) => { + if (!file.url) return; + + const foundAsset = assets.find((asset) => file.url.includes(asset.key)); + if (!foundAsset) return; + projectAssets.push({ + name: file.name, + sketchName: project.name, + sketchId: project.id, + url: file.url, + key: foundAsset.key, + size: foundAsset.size + }); + }); + }); + res.json({assets: projectAssets}); + }); + }); + }); +} diff --git a/server/controllers/file.controller.js b/server/controllers/file.controller.js index a6bc7d51..06f3ff68 100644 --- a/server/controllers/file.controller.js +++ b/server/controllers/file.controller.js @@ -48,7 +48,8 @@ function deleteMany(files, ids) { each(ids, (id, cb) => { if (files.id(id).url) { - if (!process.env.S3_DATE || (process.env.S3_DATE && moment(process.env.S3_DATE) < moment(files.id(id).createdAt))) { + if (!process.env.S3_DATE + || (process.env.S3_DATE && moment(process.env.S3_DATE) < moment(files.id(id).createdAt))) { const objectKey = getObjectKey(files.id(id).url); objectKeys.push(objectKey); } diff --git a/server/controllers/project.controller.js b/server/controllers/project.controller.js index 84b2afa1..911eab1e 100644 --- a/server/controllers/project.controller.js +++ b/server/controllers/project.controller.js @@ -1,6 +1,8 @@ import archiver from 'archiver'; import request from 'request'; import moment from 'moment'; +import isUrl from 'is-url'; +import jsdom, { serializeDocument } from 'jsdom'; import Project from '../models/project'; import User from '../models/user'; import { deleteObjectsFromS3, getObjectKey } from './aws.controller'; @@ -100,10 +102,7 @@ function deleteFilesFromS3(files) { } return false; }) - .map((file) => { - return getObjectKey(file.url); - }) - ); + .map(file => getObjectKey(file.url))); } export function deleteProject(req, res) { @@ -123,12 +122,28 @@ export function deleteProject(req, res) { }); } -export function getProjects(req, res) { - if (req.user) { - Project.find({ user: req.user._id }) // eslint-disable-line no-underscore-dangle +export function getProjectsForUserId(userId) { + return new Promise((resolve, reject) => { + Project.find({ user: userId }) .sort('-createdAt') .select('name files id createdAt updatedAt') .exec((err, projects) => { + if (err) { + console.log(err); + } + resolve(projects); + }); + }); +} + +export function getProjectsForUserName(username) { + +} + +export function getProjects(req, res) { + if (req.user) { + getProjectsForUserId(req.user._id) + .then((projects) => { res.json(projects); }); } else { @@ -144,7 +159,7 @@ export function getProjectsForUser(req, res) { res.status(404).json({ message: 'User with that username does not exist.' }); return; } - Project.find({ user: user._id }) // eslint-disable-line no-underscore-dangle + Project.find({ user: user._id }) .sort('-createdAt') .select('name files id createdAt updatedAt') .exec((innerErr, projects) => res.json(projects)); @@ -155,6 +170,48 @@ export function getProjectsForUser(req, res) { } } +function bundleExternalLibs(project, zip, callback) { + const rootFile = project.files.find(file => file.name === 'root'); + const indexHtml = project.files.find(file => file.name === 'index.html'); + let numScriptsResolved = 0; + let numScriptTags = 0; + + function resolveScriptTagSrc(scriptTag, document) { + const path = scriptTag.src.split('/'); + const filename = path[path.length - 1]; + const src = scriptTag.src; + + if (!isUrl(src)) { + numScriptsResolved += 1; + return; + } + + request({ method: 'GET', url: src, encoding: null }, (err, response, body) => { + if (err) { + console.log(err); + } else { + zip.append(body, { name: filename }); + scriptTag.src = filename; + } + + numScriptsResolved += 1; + if (numScriptsResolved === numScriptTags) { + indexHtml.content = serializeDocument(document); + callback(); + } + }); + } + + jsdom.env(indexHtml.content, (innerErr, window) => { + const indexHtmlDoc = window.document; + const scriptTags = indexHtmlDoc.getElementsByTagName('script'); + numScriptTags = scriptTags.length; + for (let i = 0; i < numScriptTags; i += 1) { + resolveScriptTagSrc(scriptTags[i], indexHtmlDoc); + } + }); +} + function buildZip(project, req, res) { const zip = archiver('zip'); const rootFile = project.files.find(file => file.name === 'root'); @@ -194,7 +251,10 @@ function buildZip(project, req, res) { } } } - addFileToZip(rootFile, '/'); + + bundleExternalLibs(project, zip, () => { + addFileToZip(rootFile, '/'); + }); } export function downloadProjectAsZip(req, res) { diff --git a/server/controllers/session.controller.js b/server/controllers/session.controller.js index dd35a07f..211b8a46 100644 --- a/server/controllers/session.controller.js +++ b/server/controllers/session.controller.js @@ -13,6 +13,7 @@ export function createSession(req, res, next) { email: req.user.email, username: req.user.username, preferences: req.user.preferences, + verified: req.user.verified, id: req.user._id }); }); @@ -25,6 +26,7 @@ export function getSession(req, res) { email: req.user.email, username: req.user.username, preferences: req.user.preferences, + verified: req.user.verified, id: req.user._id }); } diff --git a/server/controllers/user.controller.js b/server/controllers/user.controller.js index 8785d3e1..415547ee 100644 --- a/server/controllers/user.controller.js +++ b/server/controllers/user.controller.js @@ -1,17 +1,41 @@ import crypto from 'crypto'; import async from 'async'; -import nodemailer from 'nodemailer'; -import mg from 'nodemailer-mailgun-transport'; + import User from '../models/user'; +import mail from '../utils/mail'; +import { + renderEmailConfirmation, + renderResetPassword, +} from '../views/mail'; + +const random = (done) => { + crypto.randomBytes(20, (err, buf) => { + const token = buf.toString('hex'); + done(err, token); + }); +}; + +export function findUserByUsername(username, cb) { + User.findOne({ username }, + (err, user) => { + cb(user); + }); +} + +const EMAIL_VERIFY_TOKEN_EXPIRY_TIME = Date.now() + (3600000 * 24); // 24 hours export function createUser(req, res, next) { - const user = new User({ - username: req.body.username, - email: req.body.email, - password: req.body.password - }); + random((tokenError, token) => { + const user = new User({ + username: req.body.username, + email: req.body.email, + password: req.body.password, + verified: User.EmailConfirmation.Sent, + verifiedToken: token, + verifiedTokenExpires: EMAIL_VERIFY_TOKEN_EXPIRY_TIME, + }); - User.findOne({ email: req.body.email }, + User.findOne({ email: req.body.email }, (err, existingUser) => { if (err) { res.status(404).send({ error: err }); @@ -32,15 +56,29 @@ export function createUser(req, res, next) { next(loginErr); return; } - res.json({ - email: req.user.email, - username: req.user.username, - preferences: req.user.preferences, - id: req.user._id + + const protocol = process.env.NODE_ENV === 'production' ? 'https' : 'http'; + const mailOptions = renderEmailConfirmation({ + body: { + domain: `${protocol}://${req.headers.host}`, + link: `${protocol}://${req.headers.host}/verify?t=${token}` + }, + to: req.user.email, + }); + + mail.send(mailOptions, (mailErr, result) => { // eslint-disable-line no-unused-vars + res.json({ + email: req.user.email, + username: req.user.username, + preferences: req.user.preferences, + verified: req.user.verified, + id: req.user._id + }); }); }); }); }); + }); } export function duplicateUserCheck(req, res) { @@ -90,12 +128,7 @@ export function updatePreferences(req, res) { export function resetPasswordInitiate(req, res) { async.waterfall([ - (done) => { - crypto.randomBytes(20, (err, buf) => { - const token = buf.toString('hex'); - done(err, token); - }); - }, + random, (token, done) => { User.findOne({ email: req.body.email }, (err, user) => { if (!user) { @@ -111,27 +144,16 @@ export function resetPasswordInitiate(req, res) { }); }, (token, user, done) => { - const auth = { - auth: { - api_key: process.env.MAILGUN_KEY, - domain: process.env.MAILGUN_DOMAIN - } - }; - - const transporter = nodemailer.createTransport(mg(auth)); - const message = { + const protocol = process.env.NODE_ENV === 'production' ? 'https' : 'http'; + const mailOptions = renderResetPassword({ + body: { + domain: `${protocol}://${req.headers.host}`, + link: `${protocol}://${req.headers.host}/reset-password/${token}`, + }, to: user.email, - from: 'p5.js Web Editor ', - subject: 'p5.js Web Editor Password Reset', - text: `You are receiving this email because you (or someone else) have requested the reset of the password for your account. - \n\nPlease click on the following link, or paste this into your browser to complete the process: - \n\nhttp://${req.headers.host}/reset-password/${token} - \n\nIf you did not request this, please ignore this email and your password will remain unchanged. - \n\nThanks for using the p5.js Web Editor!\n` - }; - transporter.sendMail(message, (error) => { - done(error); }); + + mail.send(mailOptions, done); } ], (err) => { if (err) { @@ -153,6 +175,76 @@ export function validateResetPasswordToken(req, res) { }); } +export function emailVerificationInitiate(req, res) { + async.waterfall([ + random, + (token, done) => { + User.findById(req.user.id, (err, user) => { + if (err) { + res.status(500).json({ error: err }); + return; + } + if (!user) { + res.status(404).json({ error: 'Document not found' }); + return; + } + + if (user.verified === User.EmailConfirmation.Verified) { + res.status(409).json({ error: 'Email already verified' }); + return; + } + + const protocol = process.env.NODE_ENV === 'production' ? 'https' : 'http'; + const mailOptions = renderEmailConfirmation({ + body: { + domain: `${protocol}://${req.headers.host}`, + link: `${protocol}://${req.headers.host}/verify?t=${token}` + }, + to: user.email, + }); + + mail.send(mailOptions, (mailErr, result) => { // eslint-disable-line no-unused-vars + if (mailErr != null) { + res.status(500).send({ error: 'Error sending mail' }); + } else { + user.verified = User.EmailConfirmation.Resent; + user.verifiedToken = token; + user.verifiedTokenExpires = EMAIL_VERIFY_TOKEN_EXPIRY_TIME; // 24 hours + user.save(); + + res.json({ + email: req.user.email, + username: req.user.username, + preferences: req.user.preferences, + verified: user.verified, + id: req.user._id + }); + } + }); + }); + }, + ]); +} + +export function verifyEmail(req, res) { + const token = req.query.t; + + User.findOne({ verifiedToken: token, verifiedTokenExpires: { $gt: Date.now() } }, (err, user) => { + if (!user) { + res.status(401).json({ success: false, message: 'Token is invalid or has expired.' }); + return; + } + + user.verified = User.EmailConfirmation.Verified; + user.verifiedToken = null; + user.verifiedTokenExpires = null; + user.save() + .then((result) => { // eslint-disable-line + res.json({ success: true }); + }); + }); +} + export function updatePassword(req, res) { User.findOne({ resetPasswordToken: req.params.token, resetPasswordExpires: { $gt: Date.now() } }, (err, user) => { if (!user) { @@ -205,7 +297,6 @@ export function updateSettings(req, res) { return; } - user.email = req.body.email; user.username = req.body.username; if (req.body.currentPassword) { @@ -218,6 +309,28 @@ export function updateSettings(req, res) { user.password = req.body.newPassword; saveUser(res, user); }); + } else if (user.email !== req.body.email) { + user.verified = User.EmailConfirmation.Sent; + + user.email = req.body.email; + + random((error, token) => { + user.verifiedToken = token; + user.verifiedTokenExpires = EMAIL_VERIFY_TOKEN_EXPIRY_TIME; + + saveUser(res, user); + + const protocol = process.env.NODE_ENV === 'production' ? 'https' : 'http'; + const mailOptions = renderEmailConfirmation({ + body: { + domain: `${protocol}://${req.headers.host}`, + link: `${protocol}://${req.headers.host}/verify?t=${token}` + }, + to: user.email, + }); + + mail.send(mailOptions); + }); } else { saveUser(res, user); } diff --git a/server/examples.js b/server/examples.js index bb23c333..1e9955e1 100644 --- a/server/examples.js +++ b/server/examples.js @@ -11,9 +11,9 @@ const defaultHTML = ` - - - + + + @@ -112,7 +112,8 @@ function getSketchContent(projectsInAllCategories) { if (noNumberprojectName === 'Instance Mode : Instance Container ') { for (let i = 0; i < 4; i += 1) { const splitedRes = `${res.split('*/')[1].split('')[i]}\n`; - project.sketchContent = splitedRes.replace('p5.js', 'https://cdnjs.cloudflare.com/ajax/libs/p5.js/0.5.4/p5.min.js'); + project.sketchContent = splitedRes.replace('p5.js', + 'https://cdnjs.cloudflare.com/ajax/libs/p5.js/0.5.4/p5.min.js'); } } else { project.sketchContent = res; @@ -228,7 +229,8 @@ function createProjectsInP5user(projectsInAllCategories) { }); } - const assetsInProject = project.sketchContent.match(/assets\/[\w-]+\.[\w]*/g) || project.sketchContent.match(/assets\/[\w-]*/g) || []; + const assetsInProject = project.sketchContent.match(/assets\/[\w-]+\.[\w]*/g) + || project.sketchContent.match(/assets\/[\w-]*/g) || []; assetsInProject.forEach((assetNamePath, i) => { let assetName = assetNamePath.split('assets/')[1]; diff --git a/server/migrations/s3UnderUser.js b/server/migrations/s3UnderUser.js index a0fbb967..289d212e 100644 --- a/server/migrations/s3UnderUser.js +++ b/server/migrations/s3UnderUser.js @@ -29,7 +29,7 @@ Project.find({}, (err, projects) => { if (!project.user) return; const userId = project.user.valueOf(); project.files.forEach((file, fileIndex) => { - if (file.url && file.url.includes(process.env.S3_BUCKET)) { + if (file.url && file.url.includes(process.env.S3_BUCKET) && !file.url.includes(userId)) { const key = file.url.split('/').pop(); console.log(key); const params = { @@ -37,16 +37,20 @@ Project.find({}, (err, projects) => { CopySource: `${process.env.S3_BUCKET}/${key}`, Key: `${userId}/${key}` }; - client.moveObject(params) - .on('err', (err) => { - console.log(err); - }) - .on('end', () => { - file.url = `https://s3-${process.env.AWS_REGION}.amazonaws.com/${process.env.S3_BUCKET}/${userId}/${key}`; - project.save((err, savedProject) => { - console.log(`updated file ${key}`); + try { + client.moveObject(params) + .on('err', (err) => { + console.log(err); + }) + .on('end', () => { + file.url = `https://s3-${process.env.AWS_REGION}.amazonaws.com/${process.env.S3_BUCKET}/${userId}/${key}`; + project.save((err, savedProject) => { + console.log(`updated file ${key}`); + }); }); - }); + } catch(e) { + console.log(e); + } } }); }); diff --git a/server/models/user.js b/server/models/user.js index cd43c7c9..5ddaf2f9 100644 --- a/server/models/user.js +++ b/server/models/user.js @@ -2,6 +2,12 @@ import mongoose from 'mongoose'; const bcrypt = require('bcrypt-nodejs'); +const EmailConfirmationStates = { + Verified: 'verified', + Sent: 'sent', + Resent: 'resent', +}; + const Schema = mongoose.Schema; const userSchema = new Schema({ @@ -10,6 +16,9 @@ const userSchema = new Schema({ password: { type: String }, resetPasswordToken: String, resetPasswordExpires: Date, + verified: { type: String }, + verifiedToken: String, + verifiedTokenExpires: Date, github: { type: String }, email: { type: String, unique: true }, tokens: Array, @@ -19,7 +28,9 @@ const userSchema = new Schema({ isTabIndent: { type: Boolean, default: false }, autosave: { type: Boolean, default: true }, lintWarning: { type: Boolean, default: false }, - textOutput: { type: Number, default: 0 }, + textOutput: { type: Boolean, default: false }, + gridOutput: { type: Boolean, default: false }, + soundOutput: { type: Boolean, default: false }, theme: { type: String, default: 'light' }, autorefresh: { type: Boolean, default: false } } @@ -71,4 +82,6 @@ userSchema.statics.findByMailOrName = function findByMailOrName(email) { return this.findOne(query).exec(); }; +userSchema.statics.EmailConfirmation = EmailConfirmationStates; + export default mongoose.model('User', userSchema); diff --git a/server/routes/aws.routes.js b/server/routes/aws.routes.js index 85daddf7..e31457a6 100644 --- a/server/routes/aws.routes.js +++ b/server/routes/aws.routes.js @@ -6,5 +6,6 @@ const router = new Router(); router.route('/S3/sign').post(AWSController.signS3); router.route('/S3/copy').post(AWSController.copyObjectInS3); router.route('/S3/:object_key').delete(AWSController.deleteObjectFromS3); +router.route('/S3/:username/objects').get(AWSController.listObjectsInS3ForUser); export default router; diff --git a/server/routes/server.routes.js b/server/routes/server.routes.js index 1b69dbda..f4ec6bf2 100644 --- a/server/routes/server.routes.js +++ b/server/routes/server.routes.js @@ -40,6 +40,10 @@ router.route('/reset-password/:reset_password_token').get((req, res) => { res.send(renderIndex()); }); +router.route('/verify').get((req, res) => { + res.send(renderIndex()); +}); + router.route('/sketches').get((req, res) => { res.send(renderIndex()); }); @@ -54,6 +58,12 @@ router.route('/:username/sketches').get((req, res) => { )); }); +router.route('/:username/assets').get((req, res) => { + userExists(req.params.username, exists => ( + exists ? res.send(renderIndex()) : get404Sketch(html => res.send(html)) + )); +}); + router.route('/:username/account').get((req, res) => { userExists(req.params.username, exists => ( exists ? res.send(renderIndex()) : get404Sketch(html => res.send(html)) diff --git a/server/routes/user.routes.js b/server/routes/user.routes.js index b3468cd8..682ac698 100644 --- a/server/routes/user.routes.js +++ b/server/routes/user.routes.js @@ -17,4 +17,8 @@ router.route('/reset-password/:token').post(UserController.updatePassword); router.route('/account').put(UserController.updateSettings); +router.route('/verify/send').post(UserController.emailVerificationInitiate); + +router.route('/verify').get(UserController.verifyEmail); + export default router; diff --git a/server/server.js b/server/server.js index 79baca8d..b8d19c80 100644 --- a/server/server.js +++ b/server/server.js @@ -7,6 +7,7 @@ import session from 'express-session'; import connectMongo from 'connect-mongo'; import passport from 'passport'; import path from 'path'; +import csurf from 'csurf'; // Webpack Requirements import webpack from 'webpack'; @@ -23,6 +24,7 @@ import files from './routes/file.routes'; import aws from './routes/aws.routes'; import serverRoutes from './routes/server.routes'; import embedRoutes from './routes/embed.routes'; +import { requestsOfTypeJSON } from './utils/requestsOfType'; import { renderIndex } from './views/index'; import { get404Sketch } from './views/404Page'; @@ -73,18 +75,27 @@ app.use(session({ autoReconnect: true }) })); + +// Enables CSRF protection and stores secret in session +app.use(csurf()); +// Middleware to add CSRF token as cookie to some requests +const csrfToken = (req, res, next) => { + res.cookie('XSRF-TOKEN', req.csrfToken()); + next(); +}; + app.use(passport.initialize()); app.use(passport.session()); -app.use('/api', users); -app.use('/api', sessions); -app.use('/api', projects); -app.use('/api', files); -app.use('/api', aws); +app.use('/api', requestsOfTypeJSON(), users); +app.use('/api', requestsOfTypeJSON(), sessions); +app.use('/api', requestsOfTypeJSON(), projects); +app.use('/api', requestsOfTypeJSON(), files); +app.use('/api', requestsOfTypeJSON(), aws); // this is supposed to be TEMPORARY -- until i figure out // isomorphic rendering -app.use('/', serverRoutes); +app.use('/', csrfToken, serverRoutes); -app.use('/', embedRoutes); +app.use('/', csrfToken, embedRoutes); app.get('/auth/github', passport.authenticate('github')); app.get('/auth/github/callback', passport.authenticate('github', { failureRedirect: '/login' }), (req, res) => { res.redirect('/'); diff --git a/server/utils/mail.js b/server/utils/mail.js new file mode 100644 index 00000000..42c9b317 --- /dev/null +++ b/server/utils/mail.js @@ -0,0 +1,48 @@ +/** + * Mail service wrapping around mailgun + */ + +import nodemailer from 'nodemailer'; +import mg from 'nodemailer-mailgun-transport'; + +const auth = { + api_key: process.env.MAILGUN_KEY, + domain: process.env.MAILGUN_DOMAIN, +}; + +class Mail { + constructor() { + this.client = nodemailer.createTransport(mg({ auth })); + this.sendOptions = { + from: process.env.EMAIL_SENDER, + }; + } + + sendMail(mailOptions) { + return new Promise((resolve, reject) => { + this.client.sendMail(mailOptions, (err, info) => { + resolve(err, info); + }); + }); + } + + dispatchMail(data, callback) { + const mailOptions = { + to: data.to, + subject: data.subject, + from: this.sendOptions.from, + html: data.html, + }; + + return this.sendMail(mailOptions) + .then((err, res) => { + callback(err, res); + }); + } + + send(data, callback) { + return this.dispatchMail(data, callback); + } +} + +export default new Mail(); diff --git a/server/utils/previewGeneration.js b/server/utils/previewGeneration.js index 5b44d41a..983f1f9c 100644 --- a/server/utils/previewGeneration.js +++ b/server/utils/previewGeneration.js @@ -1,5 +1,5 @@ import { resolvePathToFile } from '../utils/filePath'; - +// eslint-disable-next-line max-len const MEDIA_FILE_REGEX_NO_QUOTES = /^(?!(http:\/\/|https:\/\/)).*\.(png|jpg|jpeg|gif|bmp|mp3|wav|aiff|ogg|json|txt|csv|svg|obj|mp4|ogg|webm|mov|otf|ttf|m4a)$/i; const STRING_REGEX = /(['"])((\\\1|.)*?)\1/gm; const EXTERNAL_LINK_REGEX = /^(http:\/\/|https:\/\/)/; diff --git a/server/utils/renderMjml.js b/server/utils/renderMjml.js new file mode 100644 index 00000000..1a9cf8f4 --- /dev/null +++ b/server/utils/renderMjml.js @@ -0,0 +1,12 @@ +import { mjml2html } from 'mjml'; + +export default (template) => { + try { + const output = mjml2html(template); + return output.html; + } catch (e) { + // fall through to null + } + + return null; +}; diff --git a/server/utils/requestsOfType.js b/server/utils/requestsOfType.js new file mode 100644 index 00000000..cac9bca4 --- /dev/null +++ b/server/utils/requestsOfType.js @@ -0,0 +1,15 @@ +/* + express middleware that sends a 406 Unacceptable + response if an incoming request's Content-Type + header does not match `type` +*/ +const requestsOfType = type => (req, res, next) => { + if (req.get('content-type') != null && !req.is(type)) { + return next({ statusCode: 406 }); // 406 UNACCEPTABLE + } + + return next(); +}; + +export default requestsOfType; +export const requestsOfTypeJSON = () => requestsOfType('application/json'); diff --git a/server/views/mail.js b/server/views/mail.js new file mode 100644 index 00000000..949aeb4b --- /dev/null +++ b/server/views/mail.js @@ -0,0 +1,56 @@ +import renderMjml from '../utils/renderMjml'; +import mailLayout from './mailLayout'; + +export const renderResetPassword = (data) => { + const subject = 'p5.js Web Editor Password Reset'; + const templateOptions = { + domain: data.body.domain, + headingText: 'Reset your password', + greetingText: 'Hello,', + messageText: 'We received a request to reset the password for your account. To reset your password, click on the button below:', // eslint-disable-line max-len + link: data.body.link, + buttonText: 'Reset password', + directLinkText: 'Or copy and paste the URL into your browser:', + noteText: 'If you did not request this, please ignore this email and your password will remain unchanged. Thanks for using the p5.js Web Editor!', // eslint-disable-line max-len + }; + + // Return MJML string + const template = mailLayout(templateOptions); + + // Render MJML to HTML string + const html = renderMjml(template); + + // Return options to send mail + return Object.assign( + {}, + data, + { html, subject }, + ); +}; + +export const renderEmailConfirmation = (data) => { + const subject = 'p5.js Email Verification'; + const templateOptions = { + domain: data.body.domain, + headingText: 'Email Verification', + greetingText: 'Hello,', + messageText: 'To verify your email, click on the button below:', + link: data.body.link, + buttonText: 'Verify Email', + directLinkText: 'Or copy and paste the URL into your browser:', + noteText: 'This link is only valid for the next 24 hours. Thanks for using the p5.js Web Editor!', + }; + + // Return MJML string + const template = mailLayout(templateOptions); + + // Render MJML to HTML string + const html = renderMjml(template); + + // Return options to send mail + return Object.assign( + {}, + data, + { html, subject }, + ); +}; diff --git a/server/views/mailLayout.js b/server/views/mailLayout.js new file mode 100644 index 00000000..cd69cab7 --- /dev/null +++ b/server/views/mailLayout.js @@ -0,0 +1,59 @@ +export default ({ + domain, + headingText, + greetingText, + messageText, + link, + buttonText, + directLinkText, + noteText, +}) => ( +` + + + + + + + + + + + + + + ${headingText} + + + + + + + + ${greetingText} + + + ${messageText} + + + ${buttonText} + + + + + + + + ${directLinkText} + + ${link} + + ${noteText} + + + + + + +` +); diff --git a/static/images/p5js-square-logo.png b/static/images/p5js-square-logo.png new file mode 100644 index 00000000..fd9506b8 Binary files /dev/null and b/static/images/p5js-square-logo.png differ diff --git a/static/p5-interceptor b/static/p5-interceptor index 344fedf8..0958be54 160000 --- a/static/p5-interceptor +++ b/static/p5-interceptor @@ -1 +1 @@ -Subproject commit 344fedf8d868c62adc571bf4212c6bea3cd20247 +Subproject commit 0958be54482722821159cd3e07777988ee349f37