Merge branch 'master' into feature-runtime-error-highlight

This commit is contained in:
Cassie Tarakajian 2017-08-01 19:55:45 +02:00 committed by GitHub
commit 069f974989
82 changed files with 2362 additions and 721 deletions

View file

@ -55,6 +55,10 @@ The automatic redirection to HTTPS is turned off by default in development. If y
S3_BUCKET=<your-s3-bucket> S3_BUCKET=<your-s3-bucket>
GITHUB_ID=<your-github-client-id> GITHUB_ID=<your-github-client-id>
GITHUB_SECRET=<your-github-client-secret> GITHUB_SECRET=<your-github-client-secret>
EMAIL_SENDER=<email-address-to-send-from>
MAILGUN_KEY=<mailgun-api-key>
MAILGUN_DOMAIN=<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. 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.

View file

@ -21,7 +21,10 @@ class Nav extends React.PureComponent {
</button> </button>
</li> </li>
{(() => { // eslint-disable-line {(() => { // 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 ( return (
<li className="nav__item"> <li className="nav__item">
<button <button
@ -80,7 +83,6 @@ class Nav extends React.PureComponent {
<p className="nav__open"> <p className="nav__open">
<Link <Link
to={`/${this.props.user.username}/sketches`} to={`/${this.props.user.username}/sketches`}
onClick={this.props.stopSketch}
> >
Open Open
</Link> </Link>
@ -91,7 +93,9 @@ class Nav extends React.PureComponent {
})()} })()}
<li className="nav__item"> <li className="nav__item">
<p className="nav__open"> <p className="nav__open">
<Link to="/p5/sketches"> <Link
to="/p5/sketches"
>
Examples Examples
</Link> </Link>
</p> </p>
@ -119,7 +123,9 @@ class Nav extends React.PureComponent {
return ( return (
<li className="nav__item"> <li className="nav__item">
<p> <p>
<Link to="/login">Log in</Link> <span className="nav__item-spacer">or</span> <Link to="/signup">Sign up</Link> <Link to="/login">Log in</Link>
<span className="nav__item-spacer">or</span>
<Link to="/signup">Sign up</Link>
</p> </p>
</li> </li>
); );
@ -136,6 +142,11 @@ class Nav extends React.PureComponent {
My sketches My sketches
</Link> </Link>
</li> </li>
<li>
<Link to={`/${this.props.user.username}/assets`}>
My assets
</Link>
</li>
<li> <li>
<Link to={`/${this.props.user.username}/account`}> <Link to={`/${this.props.user.username}/account`}>
My account My account
@ -153,7 +164,11 @@ class Nav extends React.PureComponent {
</ul> </ul>
<div className="nav__announce"> <div className="nav__announce">
This is a preview version of the editor, that has not yet been officially released. This is a preview version of the editor, that has not yet been officially released.
It is in development, you can report bugs <a href="https://github.com/processing/p5.js-web-editor/issues" target="_blank" rel="noopener noreferrer">here</a>. It is in development, you can report bugs <a
href="https://github.com/processing/p5.js-web-editor/issues"
target="_blank"
rel="noopener noreferrer"
>here</a>.
Please use with caution. Please use with caution.
</div> </div>
</nav> </nav>
@ -178,7 +193,6 @@ Nav.propTypes = {
}) })
}), }),
logoutUser: PropTypes.func.isRequired, logoutUser: PropTypes.func.isRequired,
stopSketch: PropTypes.func.isRequired,
showShareModal: PropTypes.func.isRequired, showShareModal: PropTypes.func.isRequired,
showErrorModal: PropTypes.func.isRequired, showErrorModal: PropTypes.func.isRequired,
unsavedChanges: PropTypes.bool.isRequired, unsavedChanges: PropTypes.bool.isRequired,

View file

@ -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 UPDATE_FILE_CONTENT = 'UPDATE_FILE_CONTENT';
export const TOGGLE_SKETCH = 'TOGGLE_SKETCH'; export const TOGGLE_SKETCH = 'TOGGLE_SKETCH';
export const START_SKETCH = 'START_SKETCH'; export const START_SKETCH = 'START_SKETCH';
export const STOP_SKETCH = 'STOP_SKETCH'; export const STOP_SKETCH = 'STOP_SKETCH';
export const START_TEXT_OUTPUT = 'START_TEXT_OUTPUT'; export const START_ACCESSIBLE_OUTPUT = 'START_ACCESSIBLE_OUTPUT';
export const STOP_TEXT_OUTPUT = 'STOP_TEXT_OUTPUT'; export const STOP_ACCESSIBLE_OUTPUT = 'STOP_ACCESSIBLE_OUTPUT';
export const OPEN_PREFERENCES = 'OPEN_PREFERENCES'; export const OPEN_PREFERENCES = 'OPEN_PREFERENCES';
export const CLOSE_PREFERENCES = 'CLOSE_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_LINT_WARNING = 'SET_LINT_WARNING';
export const SET_PREFERENCES = 'SET_PREFERENCES'; export const SET_PREFERENCES = 'SET_PREFERENCES';
export const SET_TEXT_OUTPUT = 'SET_TEXT_OUTPUT'; 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 OPEN_PROJECT_OPTIONS = 'OPEN_PROJECT_OPTIONS';
export const CLOSE_PROJECT_OPTIONS = 'CLOSE_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 RESET_PASSWORD_RESET = 'RESET_PASSWORD_RESET';
export const INVALID_RESET_PASSWORD_TOKEN = 'INVALID_RESET_PASSWORD_TOKEN'; 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 // eventually, handle errors more specifically and better
export const ERROR = 'ERROR'; 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 HIDE_RUNTIME_ERROR_WARNING = 'HIDE_RUNTIME_ERROR_WARNING';
export const SHOW_RUNTIME_ERROR_WARNING = 'SHOW_RUNTIME_ERROR_WARNING'; export const SHOW_RUNTIME_ERROR_WARNING = 'SHOW_RUNTIME_ERROR_WARNING';
export const SET_ASSETS = 'SET_ASSETS';

View file

@ -1,21 +1,70 @@
import React, { PropTypes } from 'react'; import React, { PropTypes } from 'react';
import InlineSVG from 'react-inlinesvg';
import { browserHistory } from 'react-router';
function Overlay(props) { const exitUrl = require('../../../images/exit.svg');
return (
<div className="overlay"> class Overlay extends React.Component {
<div className="overlay-content"> constructor(props) {
{props.children} 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 (
<div className="overlay">
<div className="overlay__content">
<section
tabIndex="0"
role="main"
aria-label={ariaLabel}
ref={(element) => { this.overlay = element; }}
className="overlay__body"
>
<header className="overlay__header">
<h2 className="overlay__title">{title}</h2>
<button className="overlay__close-button" onClick={this.close}>
<InlineSVG src={exitUrl} alt="close overlay" />
</button>
</header>
{children}
</section>
</div>
</div> </div>
</div> );
); }
} }
Overlay.propTypes = { Overlay.propTypes = {
children: PropTypes.element children: PropTypes.element,
closeOverlay: PropTypes.func,
title: PropTypes.string,
ariaLabel: PropTypes.string,
previousPath: PropTypes.string.isRequired
}; };
Overlay.defaultProps = { Overlay.defaultProps = {
children: null children: null,
title: 'Modal',
closeOverlay: null,
ariaLabel: 'modal'
}; };
export default Overlay; export default Overlay;

View file

@ -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'
};
}

View file

@ -31,15 +31,15 @@ export function endSketchRefresh() {
}; };
} }
export function startTextOutput() { export function startAccessibleOutput() {
return { return {
type: ActionTypes.START_TEXT_OUTPUT type: ActionTypes.START_ACCESSIBLE_OUTPUT
}; };
} }
export function stopTextOutput() { export function stopAccessibleOutput() {
return { return {
type: ActionTypes.STOP_TEXT_OUTPUT type: ActionTypes.STOP_ACCESSIBLE_OUTPUT
}; };
} }

View file

@ -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) { export function setTheme(value) {
// return { // return {
// type: ActionTypes.SET_THEME, // type: ActionTypes.SET_THEME,
@ -180,4 +216,3 @@ export function setAutorefresh(value) {
} }
}; };
} }

View file

@ -1,131 +1,101 @@
import React, { PropTypes } from 'react'; import React from 'react';
import InlineSVG from 'react-inlinesvg'; 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 squareLogoUrl = require('../../../images/p5js-square-logo.svg');
const playUrl = require('../../../images/play.svg'); const playUrl = require('../../../images/play.svg');
const asteriskUrl = require('../../../images/p5-asterisk.svg'); const asteriskUrl = require('../../../images/p5-asterisk.svg');
class About extends React.Component { function About(props) {
constructor(props) { return (
super(props); <div className="about__content">
this.closeAboutModal = this.closeAboutModal.bind(this); <div className="about__content-column">
} <InlineSVG className="about__logo" src={squareLogoUrl} alt="p5js Square Logo" />
<p className="about__play-video">
componentDidMount() { <a
this.aboutSection.focus(); href="http://hello.p5js.org/"
} target="_blank"
rel="noopener noreferrer"
closeAboutModal() { >
browserHistory.push(this.props.previousPath); <InlineSVG className="about__play-video-button" src={playUrl} alt="Play Hello Video" />
} Play hello! video</a>
</p>
render() { </div>
return ( <div className="about__content-column">
<section className="about" ref={(element) => { this.aboutSection = element; }} tabIndex="0"> <h3 className="about__content-column-title">New to p5.js?</h3>
<header className="about__header"> <p className="about__content-column-list">
<h2 className="about__header-title">Welcome</h2> <a
<button className="about__exit-button" onClick={this.closeAboutModal}> href="https://p5js.org/examples/"
<InlineSVG src={exitUrl} alt="Close About Overlay" /> target="_blank"
</button> rel="noopener noreferrer"
</header> >
<div className="about__content"> <InlineSVG className="about__content-column-asterisk" src={asteriskUrl} alt="p5 asterisk" />
<div className="about__content-column"> Examples</a>
<InlineSVG className="about__logo" src={squareLogoUrl} alt="p5js Square Logo" /> </p>
<p className="about__play-video"> <p className="about__content-column-list">
<a <a
href="http://hello.p5js.org/" href="https://p5js.org/tutorials/"
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
> >
<InlineSVG className="about__play-video-button" src={playUrl} alt="Play Hello Video" /> <InlineSVG className="about__content-column-asterisk" src={asteriskUrl} alt="p5 asterisk" />
Play hello! video</a> Tutorials</a>
</p> </p>
</div> </div>
<div className="about__content-column"> <div className="about__content-column">
<h3 className="about__content-column-title">New to p5.js?</h3> <h3 className="about__content-column-title">Resources</h3>
<p className="about__content-column-list"> <p className="about__content-column-list">
<a <a
href="https://p5js.org/examples/" href="https://p5js.org/libraries/"
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
> >
<InlineSVG className="about__content-column-asterisk" src={asteriskUrl} alt="p5 asterisk" /> <InlineSVG className="about__content-column-asterisk" src={asteriskUrl} alt="p5 asterisk" />
Examples</a> Libraries</a>
</p> </p>
<p className="about__content-column-list"> <p className="about__content-column-list">
<a <a
href="https://p5js.org/tutorials/" href="https://p5js.org/reference/"
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
> >
<InlineSVG className="about__content-column-asterisk" src={asteriskUrl} alt="p5 asterisk" /> <InlineSVG className="about__content-column-asterisk" src={asteriskUrl} alt="p5 asterisk" />
Tutorials</a> Reference</a>
</p> </p>
</div> <p className="about__content-column-list">
<div className="about__content-column"> <a
<h3 className="about__content-column-title">Resources</h3> href="https://forum.processing.org/two/"
<p className="about__content-column-list"> target="_blank"
<a rel="noopener noreferrer"
href="https://p5js.org/libraries/" >
target="_blank" <InlineSVG className="about__content-column-asterisk" src={asteriskUrl} alt="p5 asterisk" />
rel="noopener noreferrer" Forum</a>
> </p>
<InlineSVG className="about__content-column-asterisk" src={asteriskUrl} alt="p5 asterisk" /> </div>
Libraries</a> <div className="about__footer">
</p> <p className="about__footer-list">
<p className="about__content-column-list"> <a
<a href="https://github.com/processing/p5.js-web-editor"
href="https://p5js.org/reference/" target="_blank"
target="_blank" rel="noopener noreferrer"
rel="noopener noreferrer" >Contribute</a>
> </p>
<InlineSVG className="about__content-column-asterisk" src={asteriskUrl} alt="p5 asterisk" /> <p className="about__footer-list">
Reference</a> <a
</p> href="https://github.com/processing/p5.js-web-editor/issues/new"
<p className="about__content-column-list"> target="_blank"
<a rel="noopener noreferrer"
href="https://forum.processing.org/two/" >Report a bug</a>
target="_blank" </p>
rel="noopener noreferrer" <p className="about__footer-list">
> <a
<InlineSVG className="about__content-column-asterisk" src={asteriskUrl} alt="p5 asterisk" /> href="https://twitter.com/p5xjs?lang=en"
Forum</a> target="_blank"
</p> rel="noopener noreferrer"
</div> >Twitter</a>
</div> </p>
<div className="about__footer"> </div>
<p className="about__footer-list"> </div>
<a );
href="https://github.com/processing/p5.js-web-editor"
target="_blank"
rel="noopener noreferrer"
>Contribute</a>
</p>
<p className="about__footer-list">
<a
href="https://github.com/processing/p5.js-web-editor/issues/new"
target="_blank"
rel="noopener noreferrer"
>Report a bug</a>
</p>
<p className="about__footer-list">
<a
href="https://twitter.com/p5xjs?lang=en"
target="_blank"
rel="noopener noreferrer"
>Twitter</a>
</p>
<button className="about__ok-button" onClick={this.closeAboutModal}>OK!</button>
</div>
</section>
);
}
} }
About.propTypes = {
previousPath: PropTypes.string.isRequired
};
export default About; export default About;

View file

@ -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 (
<section
className="accessible-output"
id="canvas-sub"
ref={(element) => { this.accessibleOutputModal = element; }}
tabIndex="0"
aria-label="accessible-output"
title="canvas text output"
>
{(() => { // eslint-disable-line
if (this.props.textOutput) {
return (
<TextOutput />
);
}
})()}
{(() => { // eslint-disable-line
if (this.props.gridOutput) {
return (
<GridOutput />
);
}
})()}
</section>
);
}
}
AccessibleOutput.propTypes = {
isPlaying: PropTypes.bool.isRequired,
previewIsRefreshing: PropTypes.bool.isRequired,
textOutput: PropTypes.bool.isRequired,
gridOutput: PropTypes.bool.isRequired
};
export default AccessibleOutput;

View file

@ -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 (
<div className="asset-table-container">
{this.props.assets.length === 0 &&
<p className="asset-table__empty">No uploaded assets.</p>
}
{this.props.assets.length > 0 &&
<table className="asset-table">
<thead>
<tr>
<th>Name</th>
<th>Size</th>
<th>View</th>
<th>Sketch</th>
</tr>
</thead>
<tbody>
{this.props.assets.map(asset =>
<tr className="asset-table__row" key={asset.key}>
<td>{asset.name}</td>
<td>{prettyBytes(asset.size)}</td>
<td><Link to={asset.url} target="_blank">View</Link></td>
<td><Link to={`/${this.props.username}/sketches/${asset.sketchId}`}>{asset.sketchName}</Link></td>
</tr>
)}
</tbody>
</table>}
</div>
);
}
}
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);

View file

@ -24,7 +24,11 @@ class Console extends React.Component {
<button className="preview-console__clear" onClick={this.props.clearConsole} aria-label="clear console"> <button className="preview-console__clear" onClick={this.props.clearConsole} aria-label="clear console">
Clear Clear
</button> </button>
<button className="preview-console__collapse" onClick={this.props.collapseConsole} aria-label="collapse console"> <button
className="preview-console__collapse"
onClick={this.props.collapseConsole}
aria-label="collapse console"
>
<InlineSVG src={downArrowUrl} /> <InlineSVG src={downArrowUrl} />
</button> </button>
<button className="preview-console__expand" onClick={this.props.expandConsole} aria-label="expand console"> <button className="preview-console__expand" onClick={this.props.expandConsole} aria-label="expand console">

View file

@ -9,7 +9,11 @@ import 'codemirror/addon/lint/css-lint';
import 'codemirror/addon/lint/html-lint'; import 'codemirror/addon/lint/html-lint';
import 'codemirror/addon/comment/comment'; import 'codemirror/addon/comment/comment';
import 'codemirror/keymap/sublime'; import 'codemirror/keymap/sublime';
import 'codemirror/addon/search/searchcursor';
import 'codemirror/addon/search/matchesonscrollbar';
import 'codemirror/addon/search/match-highlighter';
import 'codemirror/addon/search/jump-to-line'; import 'codemirror/addon/search/jump-to-line';
import { JSHINT } from 'jshint'; import { JSHINT } from 'jshint';
import { CSSLint } from 'csslint'; import { CSSLint } from 'csslint';
import { HTMLHint } from 'htmlhint'; import { HTMLHint } from 'htmlhint';
@ -20,6 +24,13 @@ import '../../../utils/htmlmixed';
import '../../../utils/p5-javascript'; import '../../../utils/p5-javascript';
import Timer from '../components/Timer'; import Timer from '../components/Timer';
import EditorAccessibility from '../components/EditorAccessibility'; import EditorAccessibility from '../components/EditorAccessibility';
import {
metaKey,
} from '../../../utils/metaKey';
import search from '../../../utils/codemirror-search';
search(CodeMirror);
const beautifyCSS = beautifyJS.css; const beautifyCSS = beautifyJS.css;
const beautifyHTML = beautifyJS.html; const beautifyHTML = beautifyJS.html;
@ -63,6 +74,7 @@ class Editor extends React.Component {
fixedGutter: false, fixedGutter: false,
gutters: ['CodeMirror-lint-markers'], gutters: ['CodeMirror-lint-markers'],
keyMap: 'sublime', keyMap: 'sublime',
highlightSelectionMatches: true, // highlight current search match
lint: { lint: {
onUpdateLinting: ((annotations) => { onUpdateLinting: ((annotations) => {
this.props.hideRuntimeErrorWarning(); this.props.hideRuntimeErrorWarning();
@ -77,10 +89,11 @@ class Editor extends React.Component {
}); });
this._cm.setOption('extraKeys', { this._cm.setOption('extraKeys', {
'Cmd-Enter': () => null, [`${metaKey}-Enter`]: () => null,
'Shift-Cmd-Enter': () => null, [`Shift-${metaKey}-Enter`]: () => null,
'Ctrl-Enter': () => null, [`${metaKey}-F`]: 'findPersistent',
'Shift-Ctrl-Enter': () => null [`${metaKey}-G`]: 'findNext',
[`Shift-${metaKey}-G`]: 'findPrev',
}); });
this.initializeDocuments(this.props.files); this.initializeDocuments(this.props.files);
@ -184,7 +197,6 @@ class Editor extends React.Component {
} }
initializeDocuments(files) { initializeDocuments(files) {
console.log('calling initialize documents');
this._docs = {}; this._docs = {};
files.forEach((file) => { files.forEach((file) => {
if (file.name !== 'root') { if (file.name !== 'root') {

View file

@ -1,15 +1,7 @@
import React, { PropTypes } from 'react'; import React, { PropTypes } from 'react';
import InlineSVG from 'react-inlinesvg';
import { Link } from 'react-router'; import { Link } from 'react-router';
const exitUrl = require('../../../images/exit.svg');
class ErrorModal extends React.Component { class ErrorModal extends React.Component {
componentDidMount() {
this.errorModal.focus();
}
forceAuthentication() { forceAuthentication() {
return ( return (
<p> <p>
@ -40,25 +32,17 @@ class ErrorModal extends React.Component {
render() { render() {
return ( return (
<section className="error-modal" ref={(element) => { this.errorModal = element; }} tabIndex="0"> <div className="error-modal__content">
<header className="error-modal__header"> {(() => { // eslint-disable-line
<h2 className="error-modal__title">Error</h2> if (this.props.type === 'forceAuthentication') {
<button className="error-modal__exit-button" onClick={this.props.closeModal}> return this.forceAuthentication();
<InlineSVG src={exitUrl} alt="Close Error Modal" /> } else if (this.props.type === 'staleSession') {
</button> return this.staleSession();
</header> } else if (this.props.type === 'staleProject') {
<div className="error-modal__content"> return this.staleProject();
{(() => { // eslint-disable-line }
if (this.props.type === 'forceAuthentication') { })()}
return this.forceAuthentication(); </div>
} else if (this.props.type === 'staleSession') {
return this.staleSession();
} else if (this.props.type === 'staleProject') {
return this.staleProject();
}
})()}
</div>
</section>
); );
} }
} }

View file

@ -45,7 +45,11 @@ export class FileNode extends React.Component {
if (oldFileExtension && !newFileExtension) { if (oldFileExtension && !newFileExtension) {
this.props.updateFileName(this.props.id, this.originalFileName); 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); this.props.updateFileName(this.props.id, this.originalFileName);
} }
} }

View file

@ -0,0 +1,38 @@
import React from 'react';
class GridOutput extends React.Component {
componentDidMount() {
this.GridOutputModal.focus();
}
render() {
return (
<section
id="gridOutput-content"
ref={(element) => { this.GridOutputModal = element; }}
>
<h2> Grid Output </h2>
<p
tabIndex="0"
role="main"
id="gridOutput-content-summary"
aria-label="grid output summary"
>
</p>
<table
id="gridOutput-content-table"
summary="grid output details"
>
</table>
<div
tabIndex="0"
role="main"
id="gridOutput-content-details"
aria-label="grid output details"
>
</div>
</section>
);
}
}
export default GridOutput;

View file

@ -1,82 +1,84 @@
import React, { PropTypes } from 'react'; import React from 'react';
import InlineSVG from 'react-inlinesvg';
const exitUrl = require('../../../images/exit.svg'); import {
metaKeyName,
} from '../../../utils/metaKey';
class KeyboardShortcutModal extends React.Component { function KeyboardShortcutModal() {
componentDidMount() { return (
this.isMac = navigator.userAgent.toLowerCase().indexOf('mac') !== -1; <ul className="keyboard-shortcuts" title="keyboard shortcuts">
} <li className="keyboard-shortcut-item">
<span className="keyboard-shortcut__command">Shift + Tab</span>
render() { <span>Tidy</span>
return ( </li>
<section className="keyboard-shortcuts"> <li className="keyboard-shortcut-item">
<header className="keyboard-shortcuts__header"> <span className="keyboard-shortcut__command">
<h2>Keyboard Shortcuts</h2> {metaKeyName} + S
<button className="keyboard-shortcuts__close" onClick={this.props.closeModal}> </span>
<InlineSVG src={exitUrl} alt="Close Keyboard Shortcuts Overlay" /> <span>Save</span>
</button> </li>
</header> <li className="keyboard-shortcut-item">
<ul title="keyboard shortcuts"> <span className="keyboard-shortcut__command">
<li className="keyboard-shortcut-item"> {metaKeyName} + F
<span className="keyboard-shortcut__command">Shift + Tab</span> </span>
<span>Tidy</span> <span>Find Text</span>
</li> </li>
<li className="keyboard-shortcut-item"> <li className="keyboard-shortcut-item">
<span className="keyboard-shortcut__command"> <span className="keyboard-shortcut__command">
{this.isMac ? 'Command + S' : 'Control + S'} {metaKeyName} + G
</span> </span>
<span>Save</span> <span>Find Next Text Match</span>
</li> </li>
<li className="keyboard-shortcut-item"> <li className="keyboard-shortcut-item">
<span className="keyboard-shortcut__command"> <span className="keyboard-shortcut__command">
{this.isMac ? 'Command + [' : 'Control + ['} {metaKeyName} + Shift + G
</span> </span>
<span>Indent Code Left</span> <span>Find Previous Text Match</span>
</li> </li>
<li className="keyboard-shortcut-item"> <li className="keyboard-shortcut-item">
<span className="keyboard-shortcut__command"> <span className="keyboard-shortcut__command">
{this.isMac ? 'Command + ]' : 'Control + ]'} {metaKeyName} + [
</span> </span>
<span>Indent Code Right</span> <span>Indent Code Left</span>
</li> </li>
<li className="keyboard-shortcut-item"> <li className="keyboard-shortcut-item">
<span className="keyboard-shortcut__command"> <span className="keyboard-shortcut__command">
{this.isMac ? 'Command + /' : 'Control + /'} {metaKeyName} + ]
</span> </span>
<span>Comment Line</span> <span>Indent Code Right</span>
</li> </li>
<li className="keyboard-shortcut-item"> <li className="keyboard-shortcut-item">
<span className="keyboard-shortcut__command"> <span className="keyboard-shortcut__command">
{this.isMac ? 'Command + Enter' : 'Control + Enter'}</span> {metaKeyName} + /
<span>Start Sketch</span> </span>
</li> <span>Comment Line</span>
<li className="keyboard-shortcut-item"> </li>
<span className="keyboard-shortcut__command"> <li className="keyboard-shortcut-item">
{this.isMac ? 'Command + Shift + Enter' : 'Control + Shift + Enter'} <span className="keyboard-shortcut__command">
</span> {metaKeyName} + Enter
<span>Stop Sketch</span> </span>
</li> <span>Start Sketch</span>
<li className="keyboard-shortcut-item"> </li>
<span className="keyboard-shortcut__command"> <li className="keyboard-shortcut-item">
{this.isMac ? 'Command + Shift + 1' : 'Control + Shift + 1'} <span className="keyboard-shortcut__command">
</span> {metaKeyName} + Shift + Enter
<span>Toggle Text-based Canvas</span> </span>
</li> <span>Stop Sketch</span>
<li className="keyboard-shortcut-item"> </li>
<span className="keyboard-shortcut__command"> <li className="keyboard-shortcut-item">
{this.isMac ? 'Command + Shift + 2' : 'Control + Shift + 2'} <span className="keyboard-shortcut__command">
</span> {metaKeyName} + Shift + 1
<span>Turn Off Text-based Canvas</span> </span>
</li> <span>Toggle Text-based Canvas</span>
</ul> </li>
</section> <li className="keyboard-shortcut-item">
); <span className="keyboard-shortcut__command">
} {metaKeyName} + Shift + 2
</span>
<span>Turn Off Text-based Canvas</span>
</li>
</ul>
);
} }
KeyboardShortcutModal.propTypes = {
closeModal: PropTypes.func.isRequired
};
export default KeyboardShortcutModal; export default KeyboardShortcutModal;

View file

@ -261,50 +261,41 @@ class Preferences extends React.Component {
<div className="preference__options"> <div className="preference__options">
<input <input
type="radio" type="checkbox"
onChange={() => this.props.setTextOutput(1)} onChange={(event) => {
this.props.setTextOutput(event.target.checked);
}}
aria-label="text output on" aria-label="text output on"
name="text output" name="text output"
id="text-output-on" id="text-output-on"
className="preference__radio-button"
value="On" value="On"
checked={Boolean(this.props.textOutput === 1)} checked={(this.props.textOutput)}
/> />
<label htmlFor="text-output-on" className="preference__option preference__canvas">Plain-text</label> <label htmlFor="text-output-on" className="preference__option preference__canvas">Plain-text</label>
<input <input
type="radio" type="checkbox"
onChange={() => this.props.setTextOutput(2)} onChange={(event) => {
aria-label="table text output on" this.props.setGridOutput(event.target.checked);
name="table text output" }}
id="grid-output-on" aria-label="table output on"
className="preference__radio-button" name="table output"
value="Grid On" id="table-output-on"
checked={Boolean(this.props.textOutput === 2)} value="On"
checked={(this.props.gridOutput)}
/> />
<label htmlFor="grid-output-on" className="preference__option preference__canvas">Table-text</label> <label htmlFor="table-output-on" className="preference__option preference__canvas">Table-text</label>
<input <input
type="radio" type="checkbox"
onChange={() => this.props.setTextOutput(3)} onChange={(event) => {
this.props.setSoundOutput(event.target.checked);
}}
aria-label="sound output on" aria-label="sound output on"
name="sound output" name="sound output"
id="sound-output-on" id="sound-output-on"
className="preference__radio-button"
value="On" value="On"
checked={Boolean(this.props.textOutput === 3)} checked={(this.props.soundOutput)}
/> />
<label htmlFor="sound-output-on" className="preference__option preference__canvas">Sound</label> <label htmlFor="sound-output-on" className="preference__option preference__canvas">Sound</label>
<input
type="radio"
onChange={() => 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)}
/>
<label htmlFor="text-output-off" className="preference__option preference__canvas">Off</label>
</div> </div>
</div> </div>
</section> </section>
@ -324,8 +315,12 @@ Preferences.propTypes = {
setFontSize: PropTypes.func.isRequired, setFontSize: PropTypes.func.isRequired,
autosave: PropTypes.bool.isRequired, autosave: PropTypes.bool.isRequired,
setAutosave: PropTypes.func.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, setTextOutput: PropTypes.func.isRequired,
setGridOutput: PropTypes.func.isRequired,
setSoundOutput: PropTypes.func.isRequired,
lintWarning: PropTypes.bool.isRequired, lintWarning: PropTypes.bool.isRequired,
setLintWarning: PropTypes.func.isRequired, setLintWarning: PropTypes.func.isRequired,
theme: PropTypes.string.isRequired, theme: PropTypes.string.isRequired,

View file

@ -7,8 +7,12 @@ import loopProtect from 'loop-protect';
import { getBlobUrl } from '../actions/files'; import { getBlobUrl } from '../actions/files';
import { resolvePathToFile } from '../../../../server/utils/filePath'; import { resolvePathToFile } from '../../../../server/utils/filePath';
const decomment = require('decomment');
const startTag = '@fs-'; 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; 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 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 STRING_REGEX = /(['"])((\\\1|.)*?)\1/gm;
const TEXT_FILE_REGEX = /(.+\.json$|.+\.txt$|.+\.csv$)/i; const TEXT_FILE_REGEX = /(.+\.json$|.+\.txt$|.+\.csv$)/i;
@ -54,20 +58,17 @@ function hijackConsoleErrorsScript(offs) {
} }
return [line - l, file]; return [line - l, file];
} }
// catch reference errors, via http://stackoverflow.com/a/12747364/2994108 // catch reference errors, via http://stackoverflow.com/a/12747364/2994108
window.onerror = function (msg, url, lineNumber, columnNo, error) { window.onerror = function (msg, url, lineNumber, columnNo, error) {
var string = msg.toLowerCase(); var string = msg.toLowerCase();
var substring = "script error"; var substring = "script error";
var data = {}; var data = {};
if (string.indexOf(substring) !== -1){ if (string.indexOf(substring) !== -1){
data = 'Script Error: See Browser Console for Detail'; data = 'Script Error: See Browser Console for Detail';
} else { } else {
var fileInfo = getScriptOff(lineNumber); var fileInfo = getScriptOff(lineNumber);
data = msg + ' (' + fileInfo[1] + ': line ' + fileInfo[0] + ')'; data = msg + ' (' + fileInfo[1] + ': line ' + fileInfo[0] + ')';
} }
window.parent.postMessage([{ window.parent.postMessage([{
method: 'error', method: 'error',
arguments: data, arguments: data,
@ -114,7 +115,7 @@ class PreviewFrame extends React.Component {
} }
// if user switches textoutput preferences // if user switches textoutput preferences
if (this.props.isTextOutputPlaying !== prevProps.isTextOutputPlaying) { if (this.props.isAccessibleOutputPlaying !== prevProps.isAccessibleOutputPlaying) {
this.renderSketch(); this.renderSketch();
return; return;
} }
@ -124,6 +125,16 @@ class PreviewFrame extends React.Component {
return; 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) { if (this.props.fullView && this.props.files[0].id !== prevProps.files[0].id) {
this.renderSketch(); this.renderSketch();
} }
@ -165,38 +176,43 @@ class PreviewFrame extends React.Component {
'/loop-protect.min.js', '/loop-protect.min.js',
'/hijackConsole.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 = []; let interceptorScripts = [];
if (this.props.textOutput === 0) { interceptorScripts = [
this.props.setTextOutput(1); '/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) { if (this.props.textOutput) {
interceptorScripts = [ let textInterceptorScripts = [];
'/p5-interceptor/registry.js', textInterceptorScripts = [
'/p5-interceptor/loadData.js',
'/p5-interceptor/interceptorHelperFunctions.js',
'/p5-interceptor/baseInterceptor.js',
'/p5-interceptor/entities/entity.min.js',
'/p5-interceptor/textInterceptor/interceptorFunctions.js', '/p5-interceptor/textInterceptor/interceptorFunctions.js',
'/p5-interceptor/textInterceptor/interceptorP5.js', '/p5-interceptor/textInterceptor/interceptorP5.js'
'/p5-interceptor/ntc.min.js'
]; ];
} else if (this.props.textOutput === 2) { interceptorScripts = interceptorScripts.concat(textInterceptorScripts);
interceptorScripts = [ }
'/p5-interceptor/registry.js', if (this.props.gridOutput) {
'/p5-interceptor/loadData.js', let gridInterceptorScripts = [];
'/p5-interceptor/interceptorHelperFunctions.js', gridInterceptorScripts = [
'/p5-interceptor/baseInterceptor.js',
'/p5-interceptor/entities/entity.min.js',
'/p5-interceptor/gridInterceptor/interceptorFunctions.js', '/p5-interceptor/gridInterceptor/interceptorFunctions.js',
'/p5-interceptor/gridInterceptor/interceptorP5.js', '/p5-interceptor/gridInterceptor/interceptorP5.js'
'/p5-interceptor/ntc.min.js'
]; ];
} else if (this.props.textOutput === 3) { interceptorScripts = interceptorScripts.concat(gridInterceptorScripts);
interceptorScripts = [ }
'/p5-interceptor/loadData.js', if (this.props.soundOutput) {
let soundInterceptorScripts = [];
soundInterceptorScripts = [
'/p5-interceptor/soundInterceptor/interceptorP5.js' '/p5-interceptor/soundInterceptor/interceptorP5.js'
]; ];
interceptorScripts = interceptorScripts.concat(soundInterceptorScripts);
} }
scriptsToInject = scriptsToInject.concat(interceptorScripts); scriptsToInject = scriptsToInject.concat(interceptorScripts);
} }
@ -264,6 +280,7 @@ class PreviewFrame extends React.Component {
} }
} }
}); });
newContent = decomment(newContent, { ignore: /noprotect/g });
newContent = loopProtect(newContent); newContent = loopProtect(newContent);
return newContent; return newContent;
} }
@ -373,8 +390,10 @@ class PreviewFrame extends React.Component {
PreviewFrame.propTypes = { PreviewFrame.propTypes = {
isPlaying: PropTypes.bool.isRequired, isPlaying: PropTypes.bool.isRequired,
isTextOutputPlaying: PropTypes.bool.isRequired, isAccessibleOutputPlaying: PropTypes.bool.isRequired,
textOutput: PropTypes.number.isRequired, textOutput: PropTypes.bool.isRequired,
gridOutput: PropTypes.bool.isRequired,
soundOutput: PropTypes.bool.isRequired,
setTextOutput: PropTypes.func.isRequired, setTextOutput: PropTypes.func.isRequired,
htmlFile: PropTypes.shape({ htmlFile: PropTypes.shape({
content: PropTypes.string.isRequired content: PropTypes.string.isRequired

View file

@ -1,57 +1,46 @@
import React, { PropTypes } from 'react'; import React, { PropTypes } from 'react';
import InlineSVG from 'react-inlinesvg';
const exitUrl = require('../../../images/exit.svg'); function ShareModal(props) {
const {
class ShareModal extends React.Component { projectId,
componentDidMount() { ownerUsername
this.shareModal.focus(); } = props;
} const hostname = window.location.origin;
render() { return (
const hostname = window.location.origin; <div className="share-modal">
return ( <div className="share-modal__section">
<section className="share-modal" ref={(element) => { this.shareModal = element; }} tabIndex="0"> <label className="share-modal__label" htmlFor="share-modal__embed">Embed</label>
<header className="share-modal__header"> <input
<h2>Share Sketch</h2> type="text"
<button className="about__exit-button" onClick={this.props.closeShareModal}> className="share-modal__input"
<InlineSVG src={exitUrl} alt="Close Share Overlay" /> id="share-modal__embed"
</button> value={`<iframe src="${hostname}/embed/${projectId}"></iframe>`}
</header> />
<div className="share-modal__section"> </div>
<label className="share-modal__label" htmlFor="share-modal__embed">Embed</label> <div className="share-modal__section">
<input <label className="share-modal__label" htmlFor="share-modal__fullscreen">Fullscreen</label>
type="text" <input
className="share-modal__input" type="text"
id="share-modal__embed" className="share-modal__input"
value={`<iframe src="${hostname}/embed/${this.props.projectId}"></iframe>`} id="share-modal__fullscreen"
/> value={`${hostname}/full/${projectId}`}
</div> />
<div className="share-modal__section"> </div>
<label className="share-modal__label" htmlFor="share-modal__fullscreen">Fullscreen</label> <div className="share-modal__section">
<input <label className="share-modal__label" htmlFor="share-modal__edit">Edit</label>
type="text" <input
className="share-modal__input" type="text"
id="share-modal__fullscreen" className="share-modal__input"
value={`${hostname}/full/${this.props.projectId}`} id="share-modal__edit"
/> value={`${hostname}/${ownerUsername}/sketches/${projectId}`}
</div> />
<div className="share-modal__section"> </div>
<label className="share-modal__label" htmlFor="share-modal__edit">Edit</label> </div>
<input );
type="text"
className="share-modal__input"
id="share-modal__edit"
value={`${hostname}/${this.props.ownerUsername}/sketches/${this.props.projectId}`}
/>
</div>
</section>
);
}
} }
ShareModal.propTypes = { ShareModal.propTypes = {
projectId: PropTypes.string.isRequired, projectId: PropTypes.string.isRequired,
closeShareModal: PropTypes.func.isRequired,
ownerUsername: PropTypes.string.isRequired ownerUsername: PropTypes.string.isRequired
}; };

View file

@ -8,35 +8,22 @@ import * as SketchActions from '../actions/projects';
import * as ProjectActions from '../actions/project'; import * as ProjectActions from '../actions/project';
import * as ToastActions from '../actions/toast'; import * as ToastActions from '../actions/toast';
const exitUrl = require('../../../images/exit.svg');
const trashCan = require('../../../images/trash-can.svg'); const trashCan = require('../../../images/trash-can.svg');
class SketchList extends React.Component { class SketchList extends React.Component {
constructor(props) { constructor(props) {
super(props); super(props);
this.closeSketchList = this.closeSketchList.bind(this);
this.props.getProjects(this.props.username); this.props.getProjects(this.props.username);
} }
componentDidMount() {
document.getElementById('sketchlist').focus();
}
closeSketchList() {
browserHistory.push(this.props.previousPath);
}
render() { render() {
const username = this.props.username !== undefined ? this.props.username : this.props.user.username; const username = this.props.username !== undefined ? this.props.username : this.props.user.username;
return ( return (
<section className="sketch-list" aria-label="project list" tabIndex="0" role="main" id="sketchlist"> <div className="sketches-table-container">
<header className="sketch-list__header"> { this.props.sketches.length === 0 &&
<h2 className="sketch-list__header-title">Open a Sketch</h2> <p className="sketches-table__empty">No sketches.</p>
<button className="sketch-list__exit-button" onClick={this.closeSketchList}> }
<InlineSVG src={exitUrl} alt="Close Sketch List Overlay" /> { this.props.sketches.length > 0 &&
</button>
</header>
<div className="sketches-table-container">
<table className="sketches-table" summary="table containing all saved projects"> <table className="sketches-table" summary="table containing all saved projects">
<thead> <thead>
<tr> <tr>
@ -79,9 +66,8 @@ class SketchList extends React.Component {
</tr> </tr>
)} )}
</tbody> </tbody>
</table> </table>}
</div> </div>
</section>
); );
} }
} }
@ -98,8 +84,7 @@ SketchList.propTypes = {
updatedAt: PropTypes.string.isRequired updatedAt: PropTypes.string.isRequired
})).isRequired, })).isRequired,
username: PropTypes.string, username: PropTypes.string,
deleteProject: PropTypes.func.isRequired, deleteProject: PropTypes.func.isRequired
previousPath: PropTypes.string.isRequired,
}; };
SketchList.defaultProps = { SketchList.defaultProps = {

View file

@ -1,28 +1,16 @@
import React, { PropTypes } from 'react'; import React from 'react';
class TextOutput extends React.Component { class TextOutput extends React.Component {
componentDidMount() { componentDidMount() {
this.canvasTextOutput.focus(); this.TextOutputModal.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();
}
} }
render() { render() {
return ( return (
<section <section
className="text-output" id="textOutput-content"
id="canvas-sub" ref={(element) => { this.TextOutputModal = element; }}
ref={(element) => { this.canvasTextOutput = element; }}
tabIndex="0"
aria-label="text-output"
title="canvas text output"
> >
<h2> Output </h2> <h2> Text Output </h2>
<section id="textOutput-content">
</section>
<p <p
tabIndex="0" tabIndex="0"
role="main" role="main"
@ -31,10 +19,8 @@ class TextOutput extends React.Component {
> >
</p> </p>
<table <table
tabIndex="0"
role="main"
id="textOutput-content-table" id="textOutput-content-table"
aria-label="text output details" summary="text output details"
> >
</table> </table>
<div <div
@ -49,9 +35,4 @@ class TextOutput extends React.Component {
} }
} }
TextOutput.propTypes = {
isPlaying: PropTypes.bool.isRequired,
previewIsRefreshing: PropTypes.bool.isRequired
};
export default TextOutput; export default TextOutput;

View file

@ -64,7 +64,7 @@ class Toolbar extends React.Component {
className="toolbar__play-sketch-button" className="toolbar__play-sketch-button"
onClick={() => { onClick={() => {
this.props.clearConsole(); this.props.clearConsole();
this.props.startTextOutput(); this.props.startAccessibleOutput();
this.props.startSketchAndRefresh(); this.props.startSketchAndRefresh();
}} }}
aria-label="play sketch" aria-label="play sketch"
@ -85,7 +85,7 @@ class Toolbar extends React.Component {
</button> </button>
<button <button
className={stopButtonClass} className={stopButtonClass}
onClick={() => { this.props.stopTextOutput(); this.props.stopSketch(); }} onClick={() => { this.props.stopAccessibleOutput(); this.props.stopSketch(); }}
aria-label="stop sketch" aria-label="stop sketch"
> >
<InlineSVG src={stopUrl} alt="Stop Sketch" /> <InlineSVG src={stopUrl} alt="Stop Sketch" />
@ -141,7 +141,10 @@ class Toolbar extends React.Component {
}} }}
> >
{this.props.project.name}&nbsp; {this.props.project.name}&nbsp;
{this.canEditProjectName() && <InlineSVG className="toolbar__edit-name-button" src={editProjectNameUrl} alt="Edit Project Name" />} {
this.canEditProjectName() &&
<InlineSVG className="toolbar__edit-name-button" src={editProjectNameUrl} alt="Edit Project Name" />
}
</a> </a>
<input <input
type="text" type="text"
@ -184,8 +187,8 @@ Toolbar.propTypes = {
isPlaying: PropTypes.bool.isRequired, isPlaying: PropTypes.bool.isRequired,
preferencesIsVisible: PropTypes.bool.isRequired, preferencesIsVisible: PropTypes.bool.isRequired,
stopSketch: PropTypes.func.isRequired, stopSketch: PropTypes.func.isRequired,
startTextOutput: PropTypes.func.isRequired, startAccessibleOutput: PropTypes.func.isRequired,
stopTextOutput: PropTypes.func.isRequired, stopAccessibleOutput: PropTypes.func.isRequired,
setProjectName: PropTypes.func.isRequired, setProjectName: PropTypes.func.isRequired,
openPreferences: PropTypes.func.isRequired, openPreferences: PropTypes.func.isRequired,
owner: PropTypes.shape({ owner: PropTypes.shape({

View file

@ -2,12 +2,13 @@ import React, { PropTypes } from 'react';
import { bindActionCreators } from 'redux'; import { bindActionCreators } from 'redux';
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import { withRouter } from 'react-router'; import { withRouter } from 'react-router';
import { Helmet } from 'react-helmet';
import SplitPane from 'react-split-pane'; import SplitPane from 'react-split-pane';
import Editor from '../components/Editor'; import Editor from '../components/Editor';
import Sidebar from '../components/Sidebar'; import Sidebar from '../components/Sidebar';
import PreviewFrame from '../components/PreviewFrame'; import PreviewFrame from '../components/PreviewFrame';
import Toolbar from '../components/Toolbar'; import Toolbar from '../components/Toolbar';
import TextOutput from '../components/TextOutput'; import AccessibleOutput from '../components/AccessibleOutput';
import Preferences from '../components/Preferences'; import Preferences from '../components/Preferences';
import NewFileModal from '../components/NewFileModal'; import NewFileModal from '../components/NewFileModal';
import NewFolderModal from '../components/NewFolderModal'; import NewFolderModal from '../components/NewFolderModal';
@ -29,6 +30,7 @@ import * as ConsoleActions from '../actions/console';
import { getHTMLFile } from '../reducers/files'; import { getHTMLFile } from '../reducers/files';
import Overlay from '../../App/components/Overlay'; import Overlay from '../../App/components/Overlay';
import SketchList from '../components/SketchList'; import SketchList from '../components/SketchList';
import AssetList from '../components/AssetList';
import About from '../components/About'; import About from '../components/About';
class IDEView extends React.Component { class IDEView extends React.Component {
@ -97,7 +99,9 @@ class IDEView extends React.Component {
componentDidUpdate(prevProps) { componentDidUpdate(prevProps) {
if (this.isUserOwner() && this.props.project.id) { if (this.isUserOwner() && this.props.project.id) {
if (this.props.preferences.autosave && this.props.ide.unsavedChanges && !this.props.ide.justOpenedProject) { if (this.props.preferences.autosave && this.props.ide.unsavedChanges && !this.props.ide.justOpenedProject) {
if (this.props.selectedFile.name === prevProps.selectedFile.name && this.props.selectedFile.content !== prevProps.selectedFile.content) { if (
this.props.selectedFile.name === prevProps.selectedFile.name &&
this.props.selectedFile.content !== prevProps.selectedFile.content) {
if (this.autosaveInterval) { if (this.autosaveInterval) {
clearTimeout(this.autosaveInterval); clearTimeout(this.autosaveInterval);
} }
@ -165,15 +169,14 @@ class IDEView extends React.Component {
this.props.startSketchAndRefresh(); this.props.startSketchAndRefresh();
} else if (e.keyCode === 50 && ((e.metaKey && this.isMac) || (e.ctrlKey && !this.isMac)) && e.shiftKey) { } else if (e.keyCode === 50 && ((e.metaKey && this.isMac) || (e.ctrlKey && !this.isMac)) && e.shiftKey) {
e.preventDefault(); e.preventDefault();
this.props.setTextOutput(0); this.props.setTextOutput(false);
this.props.setGridOutput(false);
this.props.setSoundOutput(false);
} else if (e.keyCode === 49 && ((e.metaKey && this.isMac) || (e.ctrlKey && !this.isMac)) && e.shiftKey) { } else if (e.keyCode === 49 && ((e.metaKey && this.isMac) || (e.ctrlKey && !this.isMac)) && e.shiftKey) {
e.preventDefault(); e.preventDefault();
if (this.props.preferences.textOutput === 3) { this.props.setTextOutput(true);
this.props.preferences.textOutput = 1; this.props.setGridOutput(true);
} else { this.props.setSoundOutput(true);
this.props.preferences.textOutput += 1;
}
this.props.setTextOutput(this.props.preferences.textOutput);
} }
} }
@ -198,6 +201,9 @@ class IDEView extends React.Component {
render() { render() {
return ( return (
<div className="ide"> <div className="ide">
<Helmet>
<title>{this.props.project.name}</title>
</Helmet>
{this.props.toast.isVisible && <Toast />} {this.props.toast.isVisible && <Toast />}
<Nav <Nav
user={this.props.user} user={this.props.user}
@ -217,8 +223,8 @@ class IDEView extends React.Component {
className="Toolbar" className="Toolbar"
isPlaying={this.props.ide.isPlaying} isPlaying={this.props.ide.isPlaying}
stopSketch={this.props.stopSketch} stopSketch={this.props.stopSketch}
startTextOutput={this.props.startTextOutput} startAccessibleOutput={this.props.startAccessibleOutput}
stopTextOutput={this.props.stopTextOutput} stopAccessibleOutput={this.props.stopAccessibleOutput}
projectName={this.props.project.name} projectName={this.props.project.name}
setProjectName={this.props.setProjectName} setProjectName={this.props.setProjectName}
showEditProjectName={this.props.showEditProjectName} showEditProjectName={this.props.showEditProjectName}
@ -228,6 +234,8 @@ class IDEView extends React.Component {
serveSecure={this.props.project.serveSecure} serveSecure={this.props.project.serveSecure}
setServeSecure={this.props.setServeSecure} setServeSecure={this.props.setServeSecure}
setTextOutput={this.props.setTextOutput} setTextOutput={this.props.setTextOutput}
setGridOutput={this.props.setGridOutput}
setSoundOutput={this.props.setSoundOutput}
owner={this.props.project.owner} owner={this.props.project.owner}
project={this.props.project} project={this.props.project}
infiniteLoop={this.props.ide.infiniteLoop} infiniteLoop={this.props.ide.infiniteLoop}
@ -254,7 +262,11 @@ class IDEView extends React.Component {
lintWarning={this.props.preferences.lintWarning} lintWarning={this.props.preferences.lintWarning}
setLintWarning={this.props.setLintWarning} setLintWarning={this.props.setLintWarning}
textOutput={this.props.preferences.textOutput} textOutput={this.props.preferences.textOutput}
gridOutput={this.props.preferences.gridOutput}
soundOutput={this.props.preferences.soundOutput}
setTextOutput={this.props.setTextOutput} setTextOutput={this.props.setTextOutput}
setGridOutput={this.props.setGridOutput}
setSoundOutput={this.props.setSoundOutput}
theme={this.props.preferences.theme} theme={this.props.preferences.theme}
setTheme={this.props.setTheme} setTheme={this.props.setTheme}
/> />
@ -350,11 +362,19 @@ class IDEView extends React.Component {
</div> </div>
<div> <div>
{(() => { {(() => {
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 ( return (
<TextOutput <AccessibleOutput
isPlaying={this.props.ide.isPlaying} isPlaying={this.props.ide.isPlaying}
previewIsRefreshing={this.props.ide.previewIsRefreshing} previewIsRefreshing={this.props.ide.previewIsRefreshing}
textOutput={this.props.preferences.textOutput}
gridOutput={this.props.preferences.gridOutput}
/> />
); );
} }
@ -366,9 +386,13 @@ class IDEView extends React.Component {
files={this.props.files} files={this.props.files}
content={this.props.selectedFile.content} content={this.props.selectedFile.content}
isPlaying={this.props.ide.isPlaying} isPlaying={this.props.ide.isPlaying}
isTextOutputPlaying={this.props.ide.isTextOutputPlaying} isAccessibleOutputPlaying={this.props.ide.isAccessibleOutputPlaying}
textOutput={this.props.preferences.textOutput} textOutput={this.props.preferences.textOutput}
gridOutput={this.props.preferences.gridOutput}
soundOutput={this.props.preferences.soundOutput}
setTextOutput={this.props.setTextOutput} setTextOutput={this.props.setTextOutput}
setGridOutput={this.props.setGridOutput}
setSoundOutput={this.props.setSoundOutput}
dispatchConsoleEvent={this.props.dispatchConsoleEvent} dispatchConsoleEvent={this.props.dispatchConsoleEvent}
autorefresh={this.props.preferences.autorefresh} autorefresh={this.props.preferences.autorefresh}
previewIsRefreshing={this.props.ide.previewIsRefreshing} previewIsRefreshing={this.props.ide.previewIsRefreshing}
@ -407,10 +431,30 @@ class IDEView extends React.Component {
{(() => { // eslint-disable-line {(() => { // eslint-disable-line
if (this.props.location.pathname.match(/sketches$/)) { if (this.props.location.pathname.match(/sketches$/)) {
return ( return (
<Overlay> <Overlay
ariaLabel="project list"
title="Open a Sketch"
previousPath={this.props.ide.previousPath}
>
<SketchList <SketchList
username={this.props.params.username} username={this.props.params.username}
previousPath={this.props.ide.previousPath} user={this.props.user}
/>
</Overlay>
);
}
})()}
{(() => { // eslint-disable-line
if (this.props.location.pathname.match(/assets$/)) {
return (
<Overlay
title="Assets"
ariaLabel="asset list"
previousPath={this.props.ide.previousPath}
>
<AssetList
username={this.props.params.username}
user={this.props.user}
/> />
</Overlay> </Overlay>
); );
@ -419,7 +463,11 @@ class IDEView extends React.Component {
{(() => { // eslint-disable-line {(() => { // eslint-disable-line
if (this.props.location.pathname === '/about') { if (this.props.location.pathname === '/about') {
return ( return (
<Overlay> <Overlay
previousPath={this.props.ide.previousPath}
title="Welcome"
ariaLabel="about"
>
<About previousPath={this.props.ide.previousPath} /> <About previousPath={this.props.ide.previousPath} />
</Overlay> </Overlay>
); );
@ -428,10 +476,13 @@ class IDEView extends React.Component {
{(() => { // eslint-disable-line {(() => { // eslint-disable-line
if (this.props.ide.shareModalVisible) { if (this.props.ide.shareModalVisible) {
return ( return (
<Overlay> <Overlay
title="Share Sketch"
ariaLabel="share"
closeOverlay={this.props.closeShareModal}
>
<ShareModal <ShareModal
projectId={this.props.project.id} projectId={this.props.project.id}
closeShareModal={this.props.closeShareModal}
ownerUsername={this.props.project.owner.username} ownerUsername={this.props.project.owner.username}
/> />
</Overlay> </Overlay>
@ -441,10 +492,12 @@ class IDEView extends React.Component {
{(() => { // eslint-disable-line {(() => { // eslint-disable-line
if (this.props.ide.keyboardShortcutVisible) { if (this.props.ide.keyboardShortcutVisible) {
return ( return (
<Overlay> <Overlay
<KeyboardShortcutModal title="Keyboard Shortcuts"
closeModal={this.props.closeKeyboardShortcutModal} ariaLabel="keyboard shortcuts"
/> closeOverlay={this.props.closeKeyboardShortcutModal}
>
<KeyboardShortcutModal />
</Overlay> </Overlay>
); );
} }
@ -452,10 +505,13 @@ class IDEView extends React.Component {
{(() => { // eslint-disable-line {(() => { // eslint-disable-line
if (this.props.ide.errorType) { if (this.props.ide.errorType) {
return ( return (
<Overlay> <Overlay
title="Error"
ariaLabel="error"
closeOverlay={this.props.hideErrorModal}
>
<ErrorModal <ErrorModal
type={this.props.ide.errorType} type={this.props.ide.errorType}
closeModal={this.props.hideErrorModal}
/> />
</Overlay> </Overlay>
); );
@ -498,7 +554,7 @@ IDEView.propTypes = {
saveProject: PropTypes.func.isRequired, saveProject: PropTypes.func.isRequired,
ide: PropTypes.shape({ ide: PropTypes.shape({
isPlaying: PropTypes.bool.isRequired, isPlaying: PropTypes.bool.isRequired,
isTextOutputPlaying: PropTypes.bool.isRequired, isAccessibleOutputPlaying: PropTypes.bool.isRequired,
consoleEvent: PropTypes.array, consoleEvent: PropTypes.array,
modalIsVisible: PropTypes.bool.isRequired, modalIsVisible: PropTypes.bool.isRequired,
sidebarIsExpanded: PropTypes.bool.isRequired, sidebarIsExpanded: PropTypes.bool.isRequired,
@ -513,7 +569,7 @@ IDEView.propTypes = {
infiniteLoop: PropTypes.bool.isRequired, infiniteLoop: PropTypes.bool.isRequired,
previewIsRefreshing: PropTypes.bool.isRequired, previewIsRefreshing: PropTypes.bool.isRequired,
infiniteLoopMessage: PropTypes.string.isRequired, infiniteLoopMessage: PropTypes.string.isRequired,
projectSavedTime: PropTypes.string.isRequired, projectSavedTime: PropTypes.string,
previousPath: PropTypes.string.isRequired, previousPath: PropTypes.string.isRequired,
justOpenedProject: PropTypes.bool.isRequired, justOpenedProject: PropTypes.bool.isRequired,
errorType: PropTypes.string, errorType: PropTypes.string,
@ -521,8 +577,8 @@ IDEView.propTypes = {
runtimeErrorWarningVisible: PropTypes.bool.isRequired, runtimeErrorWarningVisible: PropTypes.bool.isRequired,
}).isRequired, }).isRequired,
stopSketch: PropTypes.func.isRequired, stopSketch: PropTypes.func.isRequired,
startTextOutput: PropTypes.func.isRequired, startAccessibleOutput: PropTypes.func.isRequired,
stopTextOutput: PropTypes.func.isRequired, stopAccessibleOutput: PropTypes.func.isRequired,
project: PropTypes.shape({ project: PropTypes.shape({
id: PropTypes.string, id: PropTypes.string,
name: PropTypes.string.isRequired, name: PropTypes.string.isRequired,
@ -547,7 +603,9 @@ IDEView.propTypes = {
isTabIndent: PropTypes.bool.isRequired, isTabIndent: PropTypes.bool.isRequired,
autosave: PropTypes.bool.isRequired, autosave: PropTypes.bool.isRequired,
lintWarning: 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, theme: PropTypes.string.isRequired,
autorefresh: PropTypes.bool.isRequired autorefresh: PropTypes.bool.isRequired
}).isRequired, }).isRequired,
@ -559,6 +617,8 @@ IDEView.propTypes = {
setAutosave: PropTypes.func.isRequired, setAutosave: PropTypes.func.isRequired,
setLintWarning: PropTypes.func.isRequired, setLintWarning: PropTypes.func.isRequired,
setTextOutput: PropTypes.func.isRequired, setTextOutput: PropTypes.func.isRequired,
setGridOutput: PropTypes.func.isRequired,
setSoundOutput: PropTypes.func.isRequired,
files: PropTypes.arrayOf(PropTypes.shape({ files: PropTypes.arrayOf(PropTypes.shape({
id: PropTypes.string.isRequired, id: PropTypes.string.isRequired,
name: PropTypes.string.isRequired, name: PropTypes.string.isRequired,

View file

@ -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;

View file

@ -13,9 +13,9 @@ const defaultHTML =
`<!DOCTYPE html> `<!DOCTYPE html>
<html> <html>
<head> <head>
<script src="https://cdnjs.cloudflare.com/ajax/libs/p5.js/0.5.10/p5.min.js"></script> <script src="https://cdnjs.cloudflare.com/ajax/libs/p5.js/0.5.11/p5.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/p5.js/0.5.10/addons/p5.dom.min.js"></script> <script src="https://cdnjs.cloudflare.com/ajax/libs/p5.js/0.5.11/addons/p5.dom.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/p5.js/0.5.10/addons/p5.sound.min.js"></script> <script src="https://cdnjs.cloudflare.com/ajax/libs/p5.js/0.5.11/addons/p5.sound.min.js"></script>
<link rel="stylesheet" type="text/css" href="style.css"> <link rel="stylesheet" type="text/css" href="style.css">
</head> </head>
<body> <body>

View file

@ -2,7 +2,7 @@ import * as ActionTypes from '../../../constants';
const initialState = { const initialState = {
isPlaying: false, isPlaying: false,
isTextOutputPlaying: false, isAccessibleOutputPlaying: false,
modalIsVisible: false, modalIsVisible: false,
sidebarIsExpanded: false, sidebarIsExpanded: false,
consoleIsExpanded: true, consoleIsExpanded: true,
@ -28,10 +28,10 @@ const ide = (state = initialState, action) => {
return Object.assign({}, state, { isPlaying: true }); return Object.assign({}, state, { isPlaying: true });
case ActionTypes.STOP_SKETCH: case ActionTypes.STOP_SKETCH:
return Object.assign({}, state, { isPlaying: false }); return Object.assign({}, state, { isPlaying: false });
case ActionTypes.START_TEXT_OUTPUT: case ActionTypes.START_ACCESSIBLE_OUTPUT:
return Object.assign({}, state, { isTextOutputPlaying: true }); return Object.assign({}, state, { isAccessibleOutputPlaying: true });
case ActionTypes.STOP_TEXT_OUTPUT: case ActionTypes.STOP_ACCESSIBLE_OUTPUT:
return Object.assign({}, state, { isTextOutputPlaying: false }); return Object.assign({}, state, { isAccessibleOutputPlaying: false });
case ActionTypes.CONSOLE_EVENT: case ActionTypes.CONSOLE_EVENT:
return Object.assign({}, state, { consoleEvent: action.event }); return Object.assign({}, state, { consoleEvent: action.event });
case ActionTypes.SHOW_MODAL: case ActionTypes.SHOW_MODAL:

View file

@ -6,7 +6,9 @@ const initialState = {
isTabIndent: true, isTabIndent: true,
autosave: true, autosave: true,
lintWarning: false, lintWarning: false,
textOutput: 0, textOutput: false,
gridOutput: false,
soundOutput: false,
theme: 'light', theme: 'light',
autorefresh: false autorefresh: false
}; };
@ -31,6 +33,10 @@ const preferences = (state = initialState, action) => {
return Object.assign({}, state, { lintWarning: action.value }); return Object.assign({}, state, { lintWarning: action.value });
case ActionTypes.SET_TEXT_OUTPUT: case ActionTypes.SET_TEXT_OUTPUT:
return Object.assign({}, state, { textOutput: action.value }); 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: case ActionTypes.SET_PREFERENCES:
return action.preferences; return action.preferences;
case ActionTypes.SET_THEME: case ActionTypes.SET_THEME:

View file

@ -8,6 +8,7 @@ const initialState = () => {
return { return {
name: generatedName, name: generatedName,
serveSecure: isSecurePage(), serveSecure: isSecurePage(),
updatedAt: ''
}; };
}; };

View file

@ -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() { export function resetPasswordReset() {
return { return {
type: ActionTypes.RESET_PASSWORD_RESET type: ActionTypes.RESET_PASSWORD_RESET

View file

@ -4,11 +4,19 @@ import { domOnlyProps } from '../../../utils/reduxFormUtils';
function AccountForm(props) { function AccountForm(props) {
const { const {
fields: { username, email, currentPassword, newPassword }, fields: { username, email, currentPassword, newPassword },
user,
handleSubmit, handleSubmit,
initiateVerification,
submitting, submitting,
invalid, invalid,
pristine pristine
} = props; } = props;
const handleInitiateVerification = (evt) => {
evt.preventDefault();
initiateVerification();
};
return ( return (
<form className="form" onSubmit={handleSubmit(props.updateSettings)}> <form className="form" onSubmit={handleSubmit(props.updateSettings)}>
<p className="form__field"> <p className="form__field">
@ -22,6 +30,26 @@ function AccountForm(props) {
/> />
{email.touched && email.error && <span className="form-error">{email.error}</span>} {email.touched && email.error && <span className="form-error">{email.error}</span>}
</p> </p>
{
user.verified !== 'verified' &&
(
<p className="form__context">
<span className="form__status">Unconfirmed.</span>
{
user.emailVerificationInitiate === true ?
(
<span className="form__status"> Confirmation sent, check your email.</span>
) :
(
<button
className="form__action"
onClick={handleInitiateVerification}
>Resend confirmation email</button>
)
}
</p>
)
}
<p className="form__field"> <p className="form__field">
<label htmlFor="username" className="form__label">User Name</label> <label htmlFor="username" className="form__label">User Name</label>
<input <input
@ -43,7 +71,11 @@ function AccountForm(props) {
id="currentPassword" id="currentPassword"
{...domOnlyProps(currentPassword)} {...domOnlyProps(currentPassword)}
/> />
{currentPassword.touched && currentPassword.error && <span className="form-error">{currentPassword.error}</span>} {
currentPassword.touched &&
currentPassword.error &&
<span className="form-error">{currentPassword.error}</span>
}
</p> </p>
<p className="form__field"> <p className="form__field">
<label htmlFor="new password" className="form__label">New Password</label> <label htmlFor="new password" className="form__label">New Password</label>
@ -56,7 +88,12 @@ function AccountForm(props) {
/> />
{newPassword.touched && newPassword.error && <span className="form-error">{newPassword.error}</span>} {newPassword.touched && newPassword.error && <span className="form-error">{newPassword.error}</span>}
</p> </p>
<input type="submit" disabled={submitting || invalid || pristine} value="Save All Settings" aria-label="updateSettings" /> <input
type="submit"
disabled={submitting || invalid || pristine}
value="Save All Settings"
aria-label="updateSettings"
/>
</form> </form>
); );
} }
@ -66,9 +103,14 @@ AccountForm.propTypes = {
username: PropTypes.object.isRequired, username: PropTypes.object.isRequired,
email: PropTypes.object.isRequired, email: PropTypes.object.isRequired,
currentPassword: 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, }).isRequired,
handleSubmit: PropTypes.func.isRequired, handleSubmit: PropTypes.func.isRequired,
initiateVerification: PropTypes.func.isRequired,
updateSettings: PropTypes.func.isRequired, updateSettings: PropTypes.func.isRequired,
submitting: PropTypes.bool, submitting: PropTypes.bool,
invalid: PropTypes.bool, invalid: PropTypes.bool,

View file

@ -25,7 +25,11 @@ function NewPasswordForm(props) {
id="confirm password" id="confirm password"
{...domOnlyProps(confirmPassword)} {...domOnlyProps(confirmPassword)}
/> />
{confirmPassword.touched && confirmPassword.error && <span className="form-error">{confirmPassword.error}</span>} {
confirmPassword.touched &&
confirmPassword.error &&
<span className="form-error">{confirmPassword.error}</span>
}
</p> </p>
<input type="submit" disabled={submitting || invalid || pristine} value="Set New Password" aria-label="sign up" /> <input type="submit" disabled={submitting || invalid || pristine} value="Set New Password" aria-label="sign up" />
</form> </form>

View file

@ -15,7 +15,12 @@ function ResetPasswordForm(props) {
{...domOnlyProps(email)} {...domOnlyProps(email)}
/> />
</p> </p>
<input type="submit" disabled={submitting || invalid || pristine || props.user.resetPasswordInitiate} value="Send Password Reset Email" aria-label="Send email to reset password" /> <input
type="submit"
disabled={submitting || invalid || pristine || props.user.resetPasswordInitiate}
value="Send Password Reset Email"
aria-label="Send email to reset password"
/>
</form> </form>
); );
} }

View file

@ -47,7 +47,11 @@ function SignupForm(props) {
id="confirm password" id="confirm password"
{...domOnlyProps(confirmPassword)} {...domOnlyProps(confirmPassword)}
/> />
{confirmPassword.touched && confirmPassword.error && <span className="form-error">{confirmPassword.error}</span>} {
confirmPassword.touched &&
confirmPassword.error &&
<span className="form-error">{confirmPassword.error}</span>
}
</p> </p>
<input type="submit" disabled={submitting || invalid || pristine} value="Sign Up" aria-label="sign up" /> <input type="submit" disabled={submitting || invalid || pristine} value="Sign Up" aria-label="sign up" />
</form> </form>

View file

@ -4,7 +4,7 @@ import { bindActionCreators } from 'redux';
import { browserHistory } from 'react-router'; import { browserHistory } from 'react-router';
import InlineSVG from 'react-inlinesvg'; import InlineSVG from 'react-inlinesvg';
import axios from 'axios'; import axios from 'axios';
import { updateSettings } from '../actions'; import { updateSettings, initiateVerification } from '../actions';
import AccountForm from '../components/AccountForm'; import AccountForm from '../components/AccountForm';
import { validateSettings } from '../../../utils/reduxFormUtils'; import { validateSettings } from '../../../utils/reduxFormUtils';
import GithubButton from '../components/GithubButton'; import GithubButton from '../components/GithubButton';
@ -59,7 +59,7 @@ function mapStateToProps(state) {
} }
function mapDispatchToProps(dispatch) { function mapDispatchToProps(dispatch) {
return bindActionCreators({ updateSettings }, dispatch); return bindActionCreators({ updateSettings, initiateVerification }, dispatch);
} }
function asyncValidate(formProps, dispatch, props) { function asyncValidate(formProps, dispatch, props) {
@ -81,7 +81,7 @@ function asyncValidate(formProps, dispatch, props) {
} }
AccountView.propTypes = { AccountView.propTypes = {
previousPath: PropTypes.string.isRequired previousPath: PropTypes.string.isRequired,
}; };
export default reduxForm({ export default reduxForm({

View file

@ -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 = (
<p>That link is invalid</p>
);
} else if (emailVerificationTokenState === 'checking') {
status = (
<p>Validating token, please wait...</p>
);
} else if (emailVerificationTokenState === 'verified') {
status = (
<p>All done, your email address has been verified.</p>
);
} else if (emailVerificationTokenState === 'invalid') {
status = (
<p>Something went wrong.</p>
);
}
return (
<div className="form-container">
<div className="form-container__header">
<button className="form-container__logo-button" onClick={this.gotoHomePage}>
<InlineSVG src={logoUrl} alt="p5js Logo" />
</button>
<button className="form-container__exit-button" onClick={this.closeLoginPage}>
<InlineSVG src={exitUrl} alt="Close Login Page" />
</button>
</div>
<div className="form-container__content">
<h2 className="form-container__title">Verify your email</h2>
{status}
</div>
</div>
);
}
}
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);

View file

@ -19,6 +19,14 @@ const user = (state = { authenticated: false }, action) => {
return Object.assign({}, state, { resetPasswordInitiate: false }); return Object.assign({}, state, { resetPasswordInitiate: false });
case ActionTypes.INVALID_RESET_PASSWORD_TOKEN: case ActionTypes.INVALID_RESET_PASSWORD_TOKEN:
return Object.assign({}, state, { resetPasswordInvalid: true }); 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: case ActionTypes.SETTINGS_UPDATED:
return { ...state, ...action.user }; return { ...state, ...action.user };
default: default:

View file

@ -9,6 +9,7 @@ import user from './modules/User/reducers';
import sketches from './modules/IDE/reducers/projects'; import sketches from './modules/IDE/reducers/projects';
import toast from './modules/IDE/reducers/toast'; import toast from './modules/IDE/reducers/toast';
import console from './modules/IDE/reducers/console'; import console from './modules/IDE/reducers/console';
import assets from './modules/IDE/reducers/assets';
const rootReducer = combineReducers({ const rootReducer = combineReducers({
form, form,
@ -20,7 +21,8 @@ const rootReducer = combineReducers({
sketches, sketches,
editorAccessibility, editorAccessibility,
toast, toast,
console console,
assets
}); });
export default rootReducer; export default rootReducer;

View file

@ -7,15 +7,21 @@ import FullView from './modules/IDE/pages/FullView';
import LoginView from './modules/User/pages/LoginView'; import LoginView from './modules/User/pages/LoginView';
import SignupView from './modules/User/pages/SignupView'; import SignupView from './modules/User/pages/SignupView';
import ResetPasswordView from './modules/User/pages/ResetPasswordView'; import ResetPasswordView from './modules/User/pages/ResetPasswordView';
import EmailVerificationView from './modules/User/pages/EmailVerificationView';
import NewPasswordView from './modules/User/pages/NewPasswordView'; import NewPasswordView from './modules/User/pages/NewPasswordView';
import AccountView from './modules/User/pages/AccountView'; import AccountView from './modules/User/pages/AccountView';
// import SketchListView from './modules/Sketch/pages/SketchListView'; // import SketchListView from './modules/Sketch/pages/SketchListView';
import { getUser } from './modules/User/actions'; import { getUser } from './modules/User/actions';
import { stopSketch } from './modules/IDE/actions/ide';
const checkAuth = (store) => { const checkAuth = (store) => {
store.dispatch(getUser()); store.dispatch(getUser());
}; };
const onRouteChange = (store) => {
store.dispatch(stopSketch());
};
const routes = (store) => { const routes = (store) => {
const sourceProtocol = findSourceProtocol(store.getState()); const sourceProtocol = findSourceProtocol(store.getState());
@ -28,11 +34,12 @@ const routes = (store) => {
}); });
return ( return (
<Route path="/" component={App}> <Route path="/" component={App} onChange={() => { onRouteChange(store); }}>
<IndexRoute component={IDEView} onEnter={checkAuth(store)} /> <IndexRoute component={IDEView} onEnter={checkAuth(store)} />
<Route path="/login" component={forceToHttps(LoginView)} /> <Route path="/login" component={forceToHttps(LoginView)} />
<Route path="/signup" component={forceToHttps(SignupView)} /> <Route path="/signup" component={forceToHttps(SignupView)} />
<Route path="/reset-password" component={forceToHttps(ResetPasswordView)} /> <Route path="/reset-password" component={forceToHttps(ResetPasswordView)} />
<Route path="/verify" component={forceToHttps(EmailVerificationView)} />
<Route <Route
path="/reset-password/:reset_password_token" path="/reset-password/:reset_password_token"
component={forceToHttps(NewPasswordView)} component={forceToHttps(NewPasswordView)}
@ -42,6 +49,7 @@ const routes = (store) => {
<Route path="/sketches" component={IDEView} /> <Route path="/sketches" component={IDEView} />
<Route path="/:username/sketches/:project_id" component={IDEView} /> <Route path="/:username/sketches/:project_id" component={IDEView} />
<Route path="/:username/sketches" component={IDEView} /> <Route path="/:username/sketches" component={IDEView} />
<Route path="/:username/assets" component={IDEView} />
<Route path="/:username/account" component={forceToHttps(AccountView)} /> <Route path="/:username/account" component={forceToHttps(AccountView)} />
<Route path="/about" component={IDEView} /> <Route path="/about" component={IDEView} />
</Route> </Route>

View file

@ -17,3 +17,23 @@
} }
} }
} }
@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;
}

View file

@ -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{ %icon-toast{
@include themify() { @include themify() {
color: $toast-text-color color: $toast-text-color

View file

@ -44,6 +44,7 @@ $themes: (
input-text-color: #333, input-text-color: #333,
input-border-color: #b5b5b5, input-border-color: #b5b5b5,
about-list-text-color: #4a4a4a, about-list-text-color: #4a4a4a,
search-background-color: #ebebeb
), ),
dark: ( dark: (
logo-color: $p5js-pink, logo-color: $p5js-pink,
@ -79,6 +80,7 @@ $themes: (
input-text-color: #333, input-text-color: #333,
input-border-color: #b5b5b5, input-border-color: #b5b5b5,
about-list-text-color: #f4f4f4, about-list-text-color: #f4f4f4,
search-background-color: #ebebeb
), ),
contrast: ( contrast: (
logo-color: $yellow, logo-color: $yellow,
@ -113,6 +115,7 @@ $themes: (
input-text-color: #333, input-text-color: #333,
input-border-color: #b5b5b5, input-border-color: #b5b5b5,
about-list-text-color: #f4f4f4, about-list-text-color: #f4f4f4,
search-background-color: $white
) )
); );

View file

@ -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 { .about__logo {
@include themify() { @include themify() {
& path { & path {
@ -69,10 +37,15 @@
display: flex; display: flex;
flex-direction: row; flex-direction: row;
justify-content: space-between; justify-content: space-between;
flex-wrap: wrap;
padding-top: #{17 / $base-font-size}rem; padding-top: #{17 / $base-font-size}rem;
padding-right: #{78 / $base-font-size}rem; padding-right: #{78 / $base-font-size}rem;
padding-bottom: #{20 / $base-font-size}rem; padding-bottom: #{20 / $base-font-size}rem;
padding-left: #{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 { .about__content-column {
@ -112,6 +85,7 @@
padding-right: #{20 / $base-font-size}rem; padding-right: #{20 / $base-font-size}rem;
padding-bottom: #{21 / $base-font-size}rem; padding-bottom: #{21 / $base-font-size}rem;
padding-left: #{291 / $base-font-size}rem; padding-left: #{291 / $base-font-size}rem;
width: 100%;
} }
.about__footer-list { .about__footer-list {

View file

@ -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;
}

View file

@ -72,9 +72,7 @@
.preview-console__collapse { .preview-console__collapse {
padding-top: #{3 / $base-font-size}rem; padding-top: #{3 / $base-font-size}rem;
@include themify() { @include icon();
@extend %icon;
}
.preview-console--collapsed & { .preview-console--collapsed & {
display: none; display: none;
} }
@ -82,9 +80,7 @@
.preview-console__expand { .preview-console__expand {
padding-top: #{3 / $base-font-size}rem; padding-top: #{3 / $base-font-size}rem;
@include themify() { @include icon();
@extend %icon;
}
display: none; display: none;
.preview-console--collapsed & { .preview-console--collapsed & {
display: inline-block; display: inline-block;

View file

@ -105,6 +105,169 @@
width: #{48 / $base-font-size}rem; 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 { .editor-holder {
height: calc(100% - #{29 / $base-font-size}rem); height: calc(100% - #{29 / $base-font-size}rem);
width: 100%; width: 100%;
@ -116,9 +279,7 @@
} }
.editor__options-button { .editor__options-button {
@include themify() { @include icon();
@extend %icon;
}
position: absolute; position: absolute;
top: #{10 / $base-font-size}rem; top: #{10 / $base-font-size}rem;
right: #{2 / $base-font-size}rem; right: #{2 / $base-font-size}rem;

View file

@ -12,9 +12,7 @@
} }
.error-modal__exit-button { .error-modal__exit-button {
@include themify() { @include icon();
@extend %icon;
}
} }
.error-modal__content { .error-modal__content {

View file

@ -33,6 +33,15 @@
border-color: $secondary-form-title-color; 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"] { .form input[type="submit"] {
@extend %forms-button; @extend %forms-button;
} }

View file

@ -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;
}

View file

@ -22,9 +22,7 @@
} }
.modal__exit-button { .modal__exit-button {
@include themify() { @include icon();
@extend %icon;
}
} }
.modal__header { .modal__header {
@ -50,68 +48,3 @@
text-align: center; text-align: center;
margin: #{20 / $base-font-size}rem 0; 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;
}

View file

@ -9,10 +9,31 @@
overflow-y: hidden; overflow-y: hidden;
} }
.overlay-content { .overlay__content {
height: 100%; height: 100%;
width: 100%; width: 100%;
display: flex; display: flex;
justify-content: center; justify-content: center;
align-items: 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;
}

View file

@ -17,9 +17,7 @@
} }
.preferences__exit-button { .preferences__exit-button {
@include themify() { @include icon();
@extend %icon;
}
padding-top: #{5 / $base-font-size}rem; padding-top: #{5 / $base-font-size}rem;
margin-right: #{-6 / $base-font-size}rem; margin-right: #{-6 / $base-font-size}rem;
} }
@ -151,5 +149,5 @@
} }
.preference__option.preference__canvas:not(:last-child) { .preference__option.preference__canvas:not(:last-child) {
padding-right: #{20 / $base-font-size}rem; padding-right: #{14 / $base-font-size}rem;
} }

View file

@ -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;
}

View file

@ -25,9 +25,7 @@
} }
.sidebar__add { .sidebar__add {
@include themify() { @include icon();
@extend %icon;
}
.sidebar--contracted & { .sidebar--contracted & {
display: none; display: none;
} }
@ -124,8 +122,8 @@
} }
.sidebar__file-item-show-options { .sidebar__file-item-show-options {
@include icon();
@include themify() { @include themify() {
@extend %icon;
padding: #{4 / $base-font-size}rem 0; padding: #{4 / $base-font-size}rem 0;
background-color: map-get($theme-map, 'file-selected-color'); background-color: map-get($theme-map, 'file-selected-color');
padding-right: #{6 / $base-font-size}rem; padding-right: #{6 / $base-font-size}rem;
@ -183,9 +181,7 @@
} }
.sidebar__expand { .sidebar__expand {
@include themify() { @include icon();
@extend %icon;
}
position: absolute; position: absolute;
top: #{7 / $base-font-size}rem; top: #{7 / $base-font-size}rem;
left: #{1 / $base-font-size}rem; left: #{1 / $base-font-size}rem;
@ -200,9 +196,7 @@
} }
.sidebar__contract { .sidebar__contract {
@include themify() { @include icon();
@extend %icon;
}
position: absolute; position: absolute;
top: #{7 / $base-font-size}rem; top: #{7 / $base-font-size}rem;
left: #{34 / $base-font-size}rem; left: #{34 / $base-font-size}rem;

View file

@ -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 { .sketches-table-container {
flex: 1 0 0%;
overflow-y: scroll; overflow-y: scroll;
max-width: 100%;
width: #{1000 / $base-font-size}rem;
min-height: #{400 / $base-font-size}rem;
} }
.sketches-table { .sketches-table {
@ -67,13 +50,6 @@
font-weight: normal; 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 { .visibility-toggle .sketch-list__trash-button {
@extend %hidden-element; @extend %hidden-element;
width:#{20 / $base-font-size}rem; width:#{20 / $base-font-size}rem;
@ -95,3 +71,8 @@
} }
} }
} }
.sketches-table__empty {
text-align: center;
font-size: #{16 / $base-font-size}rem;
}

View file

@ -1,3 +1,9 @@
.dropzone { .dropzone {
color: $primary-text-color; color: $primary-text-color;
} }
.uploader {
min-height: #{200 / $base-font-size}rem;
width: 100%;
text-align: center;
}

View file

@ -31,7 +31,7 @@
@extend %hidden-element; @extend %hidden-element;
} }
.text-output { .accessible-output {
@extend %hidden-element; @extend %hidden-element;
} }

View file

@ -34,6 +34,9 @@
@import 'components/error-modal'; @import 'components/error-modal';
@import 'components/preview-frame'; @import 'components/preview-frame';
@import 'components/help-modal'; @import 'components/help-modal';
@import 'components/share';
@import 'components/asset-list';
@import 'components/keyboard-shortcuts';
@import 'layout/ide'; @import 'layout/ide';
@import 'layout/fullscreen'; @import 'layout/fullscreen';

View file

@ -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 = `
<h3 class="CodeMirror-search-title">Find</h3>
<input type="text" class="search-input CodeMirror-search-field" placeholder="Find in files" />
<div class="CodeMirror-search-actions">
<div class="CodeMirror-search-modifiers button-wrap">
<button
title="Regular expression"
aria-label="Regular expression"
role="checkbox"
class="CodeMirror-search-modifier-button CodeMirror-regexp-button"
>
<span aria-hidden="true" class="button">.*</span>
</button>
<button
title="Case sensitive"
aria-label="Case sensitive"
role="checkbox"
class="CodeMirror-search-modifier-button CodeMirror-case-button"
>
<span aria-hidden="true" class="button">Aa</span>
</button>
<button
title="Whole words"
aria-label="Whole words"
role="checkbox"
class="CodeMirror-search-modifier-button CodeMirror-word-button"
>
<span aria-hidden="true" class="button">" "</span>
</button>
</div>
<div class="CodeMirror-search-nav">
<button
title="Previous"
aria-label="Previous"
class="CodeMirror-search-button icon up-arrow prev"
>
</button>
<button
title="Next"
aria-label="Next"
class="CodeMirror-search-button icon down-arrow next"
>
</button>
</div>
</div>
<button
title="Close"
aria-label="Close"
class="CodeMirror-close-button close icon">
</button>
`;
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 =
'<input type="text" class="search-input CodeMirror-search-field"/><div class="close icon"></div>';
var replacementQueryDialog = 'With: <input type="text" class="replace-input CodeMirror-search-field"/>';
var doReplaceConfirm = "Replace? <button>Yes</button> <button>No</button> <button>All</button> <button>Stop</button>";
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);};
};

16
client/utils/metaKey.js Normal file
View file

@ -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,
};

View file

@ -26,7 +26,9 @@ function validateNameEmail(formProps, errors) {
if (!formProps.email) { if (!formProps.email) {
errors.email = 'Please enter an 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.'; errors.email = 'Please enter a valid email address.';
} }
} }

View file

@ -71,6 +71,8 @@
"cookie-parser": "^1.4.1", "cookie-parser": "^1.4.1",
"cors": "^2.8.1", "cors": "^2.8.1",
"csslint": "^0.10.0", "csslint": "^0.10.0",
"csurf": "^1.9.0",
"decomment": "^0.8.7",
"dotenv": "^2.0.0", "dotenv": "^2.0.0",
"dropzone": "^4.3.0", "dropzone": "^4.3.0",
"escape-string-regexp": "^1.0.5", "escape-string-regexp": "^1.0.5",
@ -78,12 +80,16 @@
"express": "^4.13.4", "express": "^4.13.4",
"express-session": "^1.13.0", "express-session": "^1.13.0",
"file-type": "^3.8.0", "file-type": "^3.8.0",
"fs-promise": "^1.0.0",
"htmlhint": "^0.9.13", "htmlhint": "^0.9.13",
"is_js": "^0.9.0",
"is-url": "^1.2.2",
"js-beautify": "^1.6.4", "js-beautify": "^1.6.4",
"jsdom": "^9.8.3", "jsdom": "^9.8.3",
"jshint": "^2.9.4", "jshint": "^2.9.4",
"lodash": "^4.16.4", "lodash": "^4.16.4",
"loop-protect": "git+https://git@github.com/catarak/loop-protect.git", "loop-protect": "git+https://git@github.com/catarak/loop-protect.git",
"mjml": "^3.3.2",
"moment": "^2.14.1", "moment": "^2.14.1",
"mongoose": "^4.4.16", "mongoose": "^4.4.16",
"node-uuid": "^1.4.7", "node-uuid": "^1.4.7",
@ -92,10 +98,13 @@
"passport": "^0.3.2", "passport": "^0.3.2",
"passport-github": "^1.1.0", "passport-github": "^1.1.0",
"passport-local": "^1.0.0", "passport-local": "^1.0.0",
"pretty-bytes": "^3.0.1",
"project-name-generator": "^2.1.3", "project-name-generator": "^2.1.3",
"pug": "^2.0.0-beta6",
"q": "^1.4.1", "q": "^1.4.1",
"react": "^15.1.0", "react": "^15.1.0",
"react-dom": "^15.1.0", "react-dom": "^15.1.0",
"react-helmet": "^5.1.3",
"react-inlinesvg": "^0.4.2", "react-inlinesvg": "^0.4.2",
"react-redux": "^4.4.5", "react-redux": "^4.4.5",
"react-router": "^2.6.0", "react-router": "^2.6.0",

View file

@ -84,6 +84,7 @@ passport.use(new GitHubStrategy({
existingEmailUser.username = existingEmailUser.username || profile.username; existingEmailUser.username = existingEmailUser.username || profile.username;
existingEmailUser.tokens.push({ kind: 'github', accessToken }); existingEmailUser.tokens.push({ kind: 'github', accessToken });
existingEmailUser.name = existingEmailUser.name || profile.displayName; existingEmailUser.name = existingEmailUser.name || profile.displayName;
existingEmailUser.verified = User.EmailConfirmation.Verified;
existingEmailUser.save(saveErr => done(null, existingEmailUser)); existingEmailUser.save(saveErr => done(null, existingEmailUser));
} else { } else {
const user = new User(); const user = new User();
@ -92,6 +93,7 @@ passport.use(new GitHubStrategy({
user.username = profile.username; user.username = profile.username;
user.tokens.push({ kind: 'github', accessToken }); user.tokens.push({ kind: 'github', accessToken });
user.name = profile.displayName; user.name = profile.displayName;
user.verified = User.EmailConfirmation.Verified;
user.save(saveErr => done(null, user)); user.save(saveErr => done(null, user));
} }
}); });

View file

@ -1,6 +1,8 @@
import uuid from 'node-uuid'; import uuid from 'node-uuid';
import policy from 's3-policy'; import policy from 's3-policy';
import s3 from 's3'; import s3 from 's3';
import { getProjectsForUserId } from './project.controller';
import { findUserByUsername } from './user.controller';
const client = s3.createClient({ const client = s3.createClient({
maxAsyncS3: 20, maxAsyncS3: 20,
@ -28,7 +30,7 @@ export function getObjectKey(url) {
if (urlArray.length === 6) { if (urlArray.length === 6) {
const key = urlArray.pop(); const key = urlArray.pop();
const userId = urlArray.pop(); const userId = urlArray.pop();
objectKey = `${userId}/${key}` objectKey = `${userId}/${key}`;
} else { } else {
const key = urlArray.pop(); const key = urlArray.pop();
objectKey = key; objectKey = key;
@ -104,3 +106,45 @@ export function copyObjectInS3(req, res) {
res.json({ url: `${s3Bucket}${userId}/${newFilename}` }); 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});
});
});
});
}

View file

@ -48,7 +48,8 @@ function deleteMany(files, ids) {
each(ids, (id, cb) => { each(ids, (id, cb) => {
if (files.id(id).url) { 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); const objectKey = getObjectKey(files.id(id).url);
objectKeys.push(objectKey); objectKeys.push(objectKey);
} }

View file

@ -1,6 +1,8 @@
import archiver from 'archiver'; import archiver from 'archiver';
import request from 'request'; import request from 'request';
import moment from 'moment'; import moment from 'moment';
import isUrl from 'is-url';
import jsdom, { serializeDocument } from 'jsdom';
import Project from '../models/project'; import Project from '../models/project';
import User from '../models/user'; import User from '../models/user';
import { deleteObjectsFromS3, getObjectKey } from './aws.controller'; import { deleteObjectsFromS3, getObjectKey } from './aws.controller';
@ -100,10 +102,7 @@ function deleteFilesFromS3(files) {
} }
return false; return false;
}) })
.map((file) => { .map(file => getObjectKey(file.url)));
return getObjectKey(file.url);
})
);
} }
export function deleteProject(req, res) { export function deleteProject(req, res) {
@ -123,12 +122,28 @@ export function deleteProject(req, res) {
}); });
} }
export function getProjects(req, res) { export function getProjectsForUserId(userId) {
if (req.user) { return new Promise((resolve, reject) => {
Project.find({ user: req.user._id }) // eslint-disable-line no-underscore-dangle Project.find({ user: userId })
.sort('-createdAt') .sort('-createdAt')
.select('name files id createdAt updatedAt') .select('name files id createdAt updatedAt')
.exec((err, projects) => { .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); res.json(projects);
}); });
} else { } else {
@ -144,7 +159,7 @@ export function getProjectsForUser(req, res) {
res.status(404).json({ message: 'User with that username does not exist.' }); res.status(404).json({ message: 'User with that username does not exist.' });
return; return;
} }
Project.find({ user: user._id }) // eslint-disable-line no-underscore-dangle Project.find({ user: user._id })
.sort('-createdAt') .sort('-createdAt')
.select('name files id createdAt updatedAt') .select('name files id createdAt updatedAt')
.exec((innerErr, projects) => res.json(projects)); .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) { function buildZip(project, req, res) {
const zip = archiver('zip'); const zip = archiver('zip');
const rootFile = project.files.find(file => file.name === 'root'); 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) { export function downloadProjectAsZip(req, res) {

View file

@ -13,6 +13,7 @@ export function createSession(req, res, next) {
email: req.user.email, email: req.user.email,
username: req.user.username, username: req.user.username,
preferences: req.user.preferences, preferences: req.user.preferences,
verified: req.user.verified,
id: req.user._id id: req.user._id
}); });
}); });
@ -25,6 +26,7 @@ export function getSession(req, res) {
email: req.user.email, email: req.user.email,
username: req.user.username, username: req.user.username,
preferences: req.user.preferences, preferences: req.user.preferences,
verified: req.user.verified,
id: req.user._id id: req.user._id
}); });
} }

View file

@ -1,17 +1,41 @@
import crypto from 'crypto'; import crypto from 'crypto';
import async from 'async'; import async from 'async';
import nodemailer from 'nodemailer';
import mg from 'nodemailer-mailgun-transport';
import User from '../models/user'; 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) { export function createUser(req, res, next) {
const user = new User({ random((tokenError, token) => {
username: req.body.username, const user = new User({
email: req.body.email, username: req.body.username,
password: req.body.password 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) => { (err, existingUser) => {
if (err) { if (err) {
res.status(404).send({ error: err }); res.status(404).send({ error: err });
@ -32,15 +56,29 @@ export function createUser(req, res, next) {
next(loginErr); next(loginErr);
return; return;
} }
res.json({
email: req.user.email, const protocol = process.env.NODE_ENV === 'production' ? 'https' : 'http';
username: req.user.username, const mailOptions = renderEmailConfirmation({
preferences: req.user.preferences, body: {
id: req.user._id 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) { export function duplicateUserCheck(req, res) {
@ -90,12 +128,7 @@ export function updatePreferences(req, res) {
export function resetPasswordInitiate(req, res) { export function resetPasswordInitiate(req, res) {
async.waterfall([ async.waterfall([
(done) => { random,
crypto.randomBytes(20, (err, buf) => {
const token = buf.toString('hex');
done(err, token);
});
},
(token, done) => { (token, done) => {
User.findOne({ email: req.body.email }, (err, user) => { User.findOne({ email: req.body.email }, (err, user) => {
if (!user) { if (!user) {
@ -111,27 +144,16 @@ export function resetPasswordInitiate(req, res) {
}); });
}, },
(token, user, done) => { (token, user, done) => {
const auth = { const protocol = process.env.NODE_ENV === 'production' ? 'https' : 'http';
auth: { const mailOptions = renderResetPassword({
api_key: process.env.MAILGUN_KEY, body: {
domain: process.env.MAILGUN_DOMAIN domain: `${protocol}://${req.headers.host}`,
} link: `${protocol}://${req.headers.host}/reset-password/${token}`,
}; },
const transporter = nodemailer.createTransport(mg(auth));
const message = {
to: user.email, to: user.email,
from: 'p5.js Web Editor <noreply@p5js.org>',
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) => { ], (err) => {
if (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) { export function updatePassword(req, res) {
User.findOne({ resetPasswordToken: req.params.token, resetPasswordExpires: { $gt: Date.now() } }, (err, user) => { User.findOne({ resetPasswordToken: req.params.token, resetPasswordExpires: { $gt: Date.now() } }, (err, user) => {
if (!user) { if (!user) {
@ -205,7 +297,6 @@ export function updateSettings(req, res) {
return; return;
} }
user.email = req.body.email;
user.username = req.body.username; user.username = req.body.username;
if (req.body.currentPassword) { if (req.body.currentPassword) {
@ -218,6 +309,28 @@ export function updateSettings(req, res) {
user.password = req.body.newPassword; user.password = req.body.newPassword;
saveUser(res, user); 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 { } else {
saveUser(res, user); saveUser(res, user);
} }

View file

@ -11,9 +11,9 @@ const defaultHTML =
`<!DOCTYPE html> `<!DOCTYPE html>
<html> <html>
<head> <head>
<script src="https://cdnjs.cloudflare.com/ajax/libs/p5.js/0.5.10/p5.min.js"></script> <script src="https://cdnjs.cloudflare.com/ajax/libs/p5.js/0.5.11/p5.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/p5.js/0.5.10/addons/p5.dom.min.js"></script> <script src="https://cdnjs.cloudflare.com/ajax/libs/p5.js/0.5.11/addons/p5.dom.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/p5.js/0.5.10/addons/p5.sound.min.js"></script> <script src="https://cdnjs.cloudflare.com/ajax/libs/p5.js/0.5.11/addons/p5.sound.min.js"></script>
<link rel="stylesheet" type="text/css" href="style.css"> <link rel="stylesheet" type="text/css" href="style.css">
</head> </head>
<body> <body>
@ -112,7 +112,8 @@ function getSketchContent(projectsInAllCategories) {
if (noNumberprojectName === 'Instance Mode : Instance Container ') { if (noNumberprojectName === 'Instance Mode : Instance Container ') {
for (let i = 0; i < 4; i += 1) { for (let i = 0; i < 4; i += 1) {
const splitedRes = `${res.split('*/')[1].split('</html>')[i]}</html>\n`; const splitedRes = `${res.split('*/')[1].split('</html>')[i]}</html>\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 { } else {
project.sketchContent = res; 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) => { assetsInProject.forEach((assetNamePath, i) => {
let assetName = assetNamePath.split('assets/')[1]; let assetName = assetNamePath.split('assets/')[1];

View file

@ -29,7 +29,7 @@ Project.find({}, (err, projects) => {
if (!project.user) return; if (!project.user) return;
const userId = project.user.valueOf(); const userId = project.user.valueOf();
project.files.forEach((file, fileIndex) => { 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(); const key = file.url.split('/').pop();
console.log(key); console.log(key);
const params = { const params = {
@ -37,16 +37,20 @@ Project.find({}, (err, projects) => {
CopySource: `${process.env.S3_BUCKET}/${key}`, CopySource: `${process.env.S3_BUCKET}/${key}`,
Key: `${userId}/${key}` Key: `${userId}/${key}`
}; };
client.moveObject(params) try {
.on('err', (err) => { client.moveObject(params)
console.log(err); .on('err', (err) => {
}) console.log(err);
.on('end', () => { })
file.url = `https://s3-${process.env.AWS_REGION}.amazonaws.com/${process.env.S3_BUCKET}/${userId}/${key}`; .on('end', () => {
project.save((err, savedProject) => { file.url = `https://s3-${process.env.AWS_REGION}.amazonaws.com/${process.env.S3_BUCKET}/${userId}/${key}`;
console.log(`updated file ${key}`); project.save((err, savedProject) => {
console.log(`updated file ${key}`);
});
}); });
}); } catch(e) {
console.log(e);
}
} }
}); });
}); });

View file

@ -2,6 +2,12 @@ import mongoose from 'mongoose';
const bcrypt = require('bcrypt-nodejs'); const bcrypt = require('bcrypt-nodejs');
const EmailConfirmationStates = {
Verified: 'verified',
Sent: 'sent',
Resent: 'resent',
};
const Schema = mongoose.Schema; const Schema = mongoose.Schema;
const userSchema = new Schema({ const userSchema = new Schema({
@ -10,6 +16,9 @@ const userSchema = new Schema({
password: { type: String }, password: { type: String },
resetPasswordToken: String, resetPasswordToken: String,
resetPasswordExpires: Date, resetPasswordExpires: Date,
verified: { type: String },
verifiedToken: String,
verifiedTokenExpires: Date,
github: { type: String }, github: { type: String },
email: { type: String, unique: true }, email: { type: String, unique: true },
tokens: Array, tokens: Array,
@ -19,7 +28,9 @@ const userSchema = new Schema({
isTabIndent: { type: Boolean, default: false }, isTabIndent: { type: Boolean, default: false },
autosave: { type: Boolean, default: true }, autosave: { type: Boolean, default: true },
lintWarning: { type: Boolean, default: false }, 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' }, theme: { type: String, default: 'light' },
autorefresh: { type: Boolean, default: false } autorefresh: { type: Boolean, default: false }
} }
@ -71,4 +82,6 @@ userSchema.statics.findByMailOrName = function findByMailOrName(email) {
return this.findOne(query).exec(); return this.findOne(query).exec();
}; };
userSchema.statics.EmailConfirmation = EmailConfirmationStates;
export default mongoose.model('User', userSchema); export default mongoose.model('User', userSchema);

View file

@ -6,5 +6,6 @@ const router = new Router();
router.route('/S3/sign').post(AWSController.signS3); router.route('/S3/sign').post(AWSController.signS3);
router.route('/S3/copy').post(AWSController.copyObjectInS3); router.route('/S3/copy').post(AWSController.copyObjectInS3);
router.route('/S3/:object_key').delete(AWSController.deleteObjectFromS3); router.route('/S3/:object_key').delete(AWSController.deleteObjectFromS3);
router.route('/S3/:username/objects').get(AWSController.listObjectsInS3ForUser);
export default router; export default router;

View file

@ -40,6 +40,10 @@ router.route('/reset-password/:reset_password_token').get((req, res) => {
res.send(renderIndex()); res.send(renderIndex());
}); });
router.route('/verify').get((req, res) => {
res.send(renderIndex());
});
router.route('/sketches').get((req, res) => { router.route('/sketches').get((req, res) => {
res.send(renderIndex()); 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) => { router.route('/:username/account').get((req, res) => {
userExists(req.params.username, exists => ( userExists(req.params.username, exists => (
exists ? res.send(renderIndex()) : get404Sketch(html => res.send(html)) exists ? res.send(renderIndex()) : get404Sketch(html => res.send(html))

View file

@ -17,4 +17,8 @@ router.route('/reset-password/:token').post(UserController.updatePassword);
router.route('/account').put(UserController.updateSettings); router.route('/account').put(UserController.updateSettings);
router.route('/verify/send').post(UserController.emailVerificationInitiate);
router.route('/verify').get(UserController.verifyEmail);
export default router; export default router;

View file

@ -7,6 +7,7 @@ import session from 'express-session';
import connectMongo from 'connect-mongo'; import connectMongo from 'connect-mongo';
import passport from 'passport'; import passport from 'passport';
import path from 'path'; import path from 'path';
import csurf from 'csurf';
// Webpack Requirements // Webpack Requirements
import webpack from 'webpack'; import webpack from 'webpack';
@ -23,6 +24,7 @@ import files from './routes/file.routes';
import aws from './routes/aws.routes'; import aws from './routes/aws.routes';
import serverRoutes from './routes/server.routes'; import serverRoutes from './routes/server.routes';
import embedRoutes from './routes/embed.routes'; import embedRoutes from './routes/embed.routes';
import { requestsOfTypeJSON } from './utils/requestsOfType';
import { renderIndex } from './views/index'; import { renderIndex } from './views/index';
import { get404Sketch } from './views/404Page'; import { get404Sketch } from './views/404Page';
@ -73,18 +75,27 @@ app.use(session({
autoReconnect: true 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.initialize());
app.use(passport.session()); app.use(passport.session());
app.use('/api', users); app.use('/api', requestsOfTypeJSON(), users);
app.use('/api', sessions); app.use('/api', requestsOfTypeJSON(), sessions);
app.use('/api', projects); app.use('/api', requestsOfTypeJSON(), projects);
app.use('/api', files); app.use('/api', requestsOfTypeJSON(), files);
app.use('/api', aws); app.use('/api', requestsOfTypeJSON(), aws);
// this is supposed to be TEMPORARY -- until i figure out // this is supposed to be TEMPORARY -- until i figure out
// isomorphic rendering // 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', passport.authenticate('github'));
app.get('/auth/github/callback', passport.authenticate('github', { failureRedirect: '/login' }), (req, res) => { app.get('/auth/github/callback', passport.authenticate('github', { failureRedirect: '/login' }), (req, res) => {
res.redirect('/'); res.redirect('/');

48
server/utils/mail.js Normal file
View file

@ -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();

View file

@ -1,5 +1,5 @@
import { resolvePathToFile } from '../utils/filePath'; 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 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 STRING_REGEX = /(['"])((\\\1|.)*?)\1/gm;
const EXTERNAL_LINK_REGEX = /^(http:\/\/|https:\/\/)/; const EXTERNAL_LINK_REGEX = /^(http:\/\/|https:\/\/)/;

View file

@ -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;
};

View file

@ -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');

56
server/views/mail.js Normal file
View file

@ -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 },
);
};

View file

@ -0,0 +1,59 @@
export default ({
domain,
headingText,
greetingText,
messageText,
link,
buttonText,
directLinkText,
noteText,
}) => (
`
<mjml>
<mj-body>
<mj-container>
<mj-section>
<mj-column>
<mj-image width="192" src="${domain}/images/p5js-square-logo.png" alt="p5.js" />
<mj-divider border-color="#ed225d"></mj-divider>
</mj-column>
</mj-section>
<mj-section>
<mj-column>
<mj-text font-size="20px" color="#333333" font-family="sans-serif">
${headingText}
</mj-text>
</mj-column>
</mj-section>
<mj-section>
<mj-column>
<mj-text color="#333333">
${greetingText}
</mj-text>
<mj-text color="#333333">
${messageText}
</mj-text>
<mj-button background-color="#ed225d" href="${link}">
${buttonText}
</mj-button>
</mj-column>
</mj-section>
<mj-section>
<mj-column>
<mj-text color="#333333">
${directLinkText}
</mj-text>
<mj-text align="center" color="#333333"><a href="${link}">${link}</a></mj-text>
<mj-text color="#333333">
${noteText}
</mj-text>
</mj-column>
</mj-section>
</mj-container>
</mj-body>
</mjml>
`
);

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

@ -1 +1 @@
Subproject commit 344fedf8d868c62adc571bf4212c6bea3cd20247 Subproject commit 0958be54482722821159cd3e07777988ee349f37