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>
GITHUB_ID=<your-github-client-id>
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.

View file

@ -21,7 +21,10 @@ class Nav extends React.PureComponent {
</button>
</li>
{(() => { // 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 (
<li className="nav__item">
<button
@ -80,7 +83,6 @@ class Nav extends React.PureComponent {
<p className="nav__open">
<Link
to={`/${this.props.user.username}/sketches`}
onClick={this.props.stopSketch}
>
Open
</Link>
@ -91,7 +93,9 @@ class Nav extends React.PureComponent {
})()}
<li className="nav__item">
<p className="nav__open">
<Link to="/p5/sketches">
<Link
to="/p5/sketches"
>
Examples
</Link>
</p>
@ -119,7 +123,9 @@ class Nav extends React.PureComponent {
return (
<li className="nav__item">
<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>
</li>
);
@ -136,6 +142,11 @@ class Nav extends React.PureComponent {
My sketches
</Link>
</li>
<li>
<Link to={`/${this.props.user.username}/assets`}>
My assets
</Link>
</li>
<li>
<Link to={`/${this.props.user.username}/account`}>
My account
@ -153,7 +164,11 @@ class Nav extends React.PureComponent {
</ul>
<div className="nav__announce">
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.
</div>
</nav>
@ -178,7 +193,6 @@ Nav.propTypes = {
})
}),
logoutUser: PropTypes.func.isRequired,
stopSketch: PropTypes.func.isRequired,
showShareModal: PropTypes.func.isRequired,
showErrorModal: PropTypes.func.isRequired,
unsavedChanges: PropTypes.bool.isRequired,

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 TOGGLE_SKETCH = 'TOGGLE_SKETCH';
export const START_SKETCH = 'START_SKETCH';
export const STOP_SKETCH = 'STOP_SKETCH';
export const START_TEXT_OUTPUT = 'START_TEXT_OUTPUT';
export const STOP_TEXT_OUTPUT = 'STOP_TEXT_OUTPUT';
export const START_ACCESSIBLE_OUTPUT = 'START_ACCESSIBLE_OUTPUT';
export const STOP_ACCESSIBLE_OUTPUT = 'STOP_ACCESSIBLE_OUTPUT';
export const OPEN_PREFERENCES = 'OPEN_PREFERENCES';
export const CLOSE_PREFERENCES = 'CLOSE_PREFERENCES';
@ -69,6 +71,8 @@ export const SET_AUTOSAVE = 'SET_AUTOSAVE';
export const SET_LINT_WARNING = 'SET_LINT_WARNING';
export const SET_PREFERENCES = 'SET_PREFERENCES';
export const SET_TEXT_OUTPUT = 'SET_TEXT_OUTPUT';
export const SET_GRID_OUTPUT = 'SET_GRID_OUTPUT';
export const SET_SOUND_OUTPUT = 'SET_SOUND_OUTPUT';
export const OPEN_PROJECT_OPTIONS = 'OPEN_PROJECT_OPTIONS';
export const CLOSE_PROJECT_OPTIONS = 'CLOSE_PROJECT_OPTIONS';
@ -100,6 +104,11 @@ export const RESET_PASSWORD_INITIATE = 'RESET_PASSWORD_INITIATE';
export const RESET_PASSWORD_RESET = 'RESET_PASSWORD_RESET';
export const INVALID_RESET_PASSWORD_TOKEN = 'INVALID_RESET_PASSWORD_TOKEN';
export const EMAIL_VERIFICATION_INITIATE = 'EMAIL_VERIFICATION_INITIATE';
export const EMAIL_VERIFICATION_VERIFY = 'EMAIL_VERIFICATION_VERIFY';
export const EMAIL_VERIFICATION_VERIFIED = 'EMAIL_VERIFICATION_VERIFIED';
export const EMAIL_VERIFICATION_INVALID = 'EMAIL_VERIFICATION_INVALID';
// eventually, handle errors more specifically and better
export const ERROR = 'ERROR';
@ -120,3 +129,4 @@ export const HIDE_HELP_MODAL = 'HIDE_HELP_MODAL';
export const HIDE_RUNTIME_ERROR_WARNING = 'HIDE_RUNTIME_ERROR_WARNING';
export const SHOW_RUNTIME_ERROR_WARNING = 'SHOW_RUNTIME_ERROR_WARNING';
export const SET_ASSETS = 'SET_ASSETS';

View file

@ -1,21 +1,70 @@
import React, { PropTypes } from 'react';
import InlineSVG from 'react-inlinesvg';
import { browserHistory } from 'react-router';
function Overlay(props) {
const exitUrl = require('../../../images/exit.svg');
class Overlay extends React.Component {
constructor(props) {
super(props);
this.close = this.close.bind(this);
}
componentDidMount() {
this.overlay.focus();
}
close() {
if (!this.props.closeOverlay) {
browserHistory.push(this.props.previousPath);
} else {
this.props.closeOverlay();
}
}
render() {
const {
ariaLabel,
title,
children
} = this.props;
return (
<div className="overlay">
<div className="overlay-content">
{props.children}
<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>
);
}
}
Overlay.propTypes = {
children: PropTypes.element
children: PropTypes.element,
closeOverlay: PropTypes.func,
title: PropTypes.string,
ariaLabel: PropTypes.string,
previousPath: PropTypes.string.isRequired
};
Overlay.defaultProps = {
children: null
children: null,
title: 'Modal',
closeOverlay: null,
ariaLabel: 'modal'
};
export default Overlay;

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 {
type: ActionTypes.START_TEXT_OUTPUT
type: ActionTypes.START_ACCESSIBLE_OUTPUT
};
}
export function stopTextOutput() {
export function stopAccessibleOutput() {
return {
type: ActionTypes.STOP_TEXT_OUTPUT
type: ActionTypes.STOP_ACCESSIBLE_OUTPUT
};
}

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

View file

@ -1,35 +1,12 @@
import React, { PropTypes } from 'react';
import React from 'react';
import InlineSVG from 'react-inlinesvg';
import { browserHistory } from 'react-router';
const exitUrl = require('../../../images/exit.svg');
const squareLogoUrl = require('../../../images/p5js-square-logo.svg');
const playUrl = require('../../../images/play.svg');
const asteriskUrl = require('../../../images/p5-asterisk.svg');
class About extends React.Component {
constructor(props) {
super(props);
this.closeAboutModal = this.closeAboutModal.bind(this);
}
componentDidMount() {
this.aboutSection.focus();
}
closeAboutModal() {
browserHistory.push(this.props.previousPath);
}
render() {
function About(props) {
return (
<section className="about" ref={(element) => { this.aboutSection = element; }} tabIndex="0">
<header className="about__header">
<h2 className="about__header-title">Welcome</h2>
<button className="about__exit-button" onClick={this.closeAboutModal}>
<InlineSVG src={exitUrl} alt="Close About Overlay" />
</button>
</header>
<div className="about__content">
<div className="about__content-column">
<InlineSVG className="about__logo" src={squareLogoUrl} alt="p5js Square Logo" />
@ -94,7 +71,6 @@ class About extends React.Component {
Forum</a>
</p>
</div>
</div>
<div className="about__footer">
<p className="about__footer-list">
<a
@ -117,15 +93,9 @@ class About extends React.Component {
rel="noopener noreferrer"
>Twitter</a>
</p>
<button className="about__ok-button" onClick={this.closeAboutModal}>OK!</button>
</div>
</section>
</div>
);
}
}
About.propTypes = {
previousPath: PropTypes.string.isRequired
};
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">
Clear
</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} />
</button>
<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/comment/comment';
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 { JSHINT } from 'jshint';
import { CSSLint } from 'csslint';
import { HTMLHint } from 'htmlhint';
@ -20,6 +24,13 @@ import '../../../utils/htmlmixed';
import '../../../utils/p5-javascript';
import Timer from '../components/Timer';
import EditorAccessibility from '../components/EditorAccessibility';
import {
metaKey,
} from '../../../utils/metaKey';
import search from '../../../utils/codemirror-search';
search(CodeMirror);
const beautifyCSS = beautifyJS.css;
const beautifyHTML = beautifyJS.html;
@ -63,6 +74,7 @@ class Editor extends React.Component {
fixedGutter: false,
gutters: ['CodeMirror-lint-markers'],
keyMap: 'sublime',
highlightSelectionMatches: true, // highlight current search match
lint: {
onUpdateLinting: ((annotations) => {
this.props.hideRuntimeErrorWarning();
@ -77,10 +89,11 @@ class Editor extends React.Component {
});
this._cm.setOption('extraKeys', {
'Cmd-Enter': () => null,
'Shift-Cmd-Enter': () => null,
'Ctrl-Enter': () => null,
'Shift-Ctrl-Enter': () => null
[`${metaKey}-Enter`]: () => null,
[`Shift-${metaKey}-Enter`]: () => null,
[`${metaKey}-F`]: 'findPersistent',
[`${metaKey}-G`]: 'findNext',
[`Shift-${metaKey}-G`]: 'findPrev',
});
this.initializeDocuments(this.props.files);
@ -184,7 +197,6 @@ class Editor extends React.Component {
}
initializeDocuments(files) {
console.log('calling initialize documents');
this._docs = {};
files.forEach((file) => {
if (file.name !== 'root') {

View file

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

View file

@ -45,7 +45,11 @@ export class FileNode extends React.Component {
if (oldFileExtension && !newFileExtension) {
this.props.updateFileName(this.props.id, this.originalFileName);
}
if (oldFileExtension && newFileExtension && oldFileExtension[0].toLowerCase() !== newFileExtension[0].toLowerCase()) {
if (
oldFileExtension &&
newFileExtension &&
oldFileExtension[0].toLowerCase() !== newFileExtension[0].toLowerCase()
) {
this.props.updateFileName(this.props.id, this.originalFileName);
}
}

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

View file

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

View file

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

View file

@ -1,29 +1,20 @@
import React, { PropTypes } from 'react';
import InlineSVG from 'react-inlinesvg';
const exitUrl = require('../../../images/exit.svg');
class ShareModal extends React.Component {
componentDidMount() {
this.shareModal.focus();
}
render() {
function ShareModal(props) {
const {
projectId,
ownerUsername
} = props;
const hostname = window.location.origin;
return (
<section className="share-modal" ref={(element) => { this.shareModal = element; }} tabIndex="0">
<header className="share-modal__header">
<h2>Share Sketch</h2>
<button className="about__exit-button" onClick={this.props.closeShareModal}>
<InlineSVG src={exitUrl} alt="Close Share Overlay" />
</button>
</header>
<div className="share-modal">
<div className="share-modal__section">
<label className="share-modal__label" htmlFor="share-modal__embed">Embed</label>
<input
type="text"
className="share-modal__input"
id="share-modal__embed"
value={`<iframe src="${hostname}/embed/${this.props.projectId}"></iframe>`}
value={`<iframe src="${hostname}/embed/${projectId}"></iframe>`}
/>
</div>
<div className="share-modal__section">
@ -32,7 +23,7 @@ class ShareModal extends React.Component {
type="text"
className="share-modal__input"
id="share-modal__fullscreen"
value={`${hostname}/full/${this.props.projectId}`}
value={`${hostname}/full/${projectId}`}
/>
</div>
<div className="share-modal__section">
@ -41,17 +32,15 @@ class ShareModal extends React.Component {
type="text"
className="share-modal__input"
id="share-modal__edit"
value={`${hostname}/${this.props.ownerUsername}/sketches/${this.props.projectId}`}
value={`${hostname}/${ownerUsername}/sketches/${projectId}`}
/>
</div>
</section>
</div>
);
}
}
ShareModal.propTypes = {
projectId: PropTypes.string.isRequired,
closeShareModal: PropTypes.func.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 ToastActions from '../actions/toast';
const exitUrl = require('../../../images/exit.svg');
const trashCan = require('../../../images/trash-can.svg');
class SketchList extends React.Component {
constructor(props) {
super(props);
this.closeSketchList = this.closeSketchList.bind(this);
this.props.getProjects(this.props.username);
}
componentDidMount() {
document.getElementById('sketchlist').focus();
}
closeSketchList() {
browserHistory.push(this.props.previousPath);
}
render() {
const username = this.props.username !== undefined ? this.props.username : this.props.user.username;
return (
<section className="sketch-list" aria-label="project list" tabIndex="0" role="main" id="sketchlist">
<header className="sketch-list__header">
<h2 className="sketch-list__header-title">Open a Sketch</h2>
<button className="sketch-list__exit-button" onClick={this.closeSketchList}>
<InlineSVG src={exitUrl} alt="Close Sketch List Overlay" />
</button>
</header>
<div className="sketches-table-container">
{ this.props.sketches.length === 0 &&
<p className="sketches-table__empty">No sketches.</p>
}
{ this.props.sketches.length > 0 &&
<table className="sketches-table" summary="table containing all saved projects">
<thead>
<tr>
@ -79,9 +66,8 @@ class SketchList extends React.Component {
</tr>
)}
</tbody>
</table>
</table>}
</div>
</section>
);
}
}
@ -98,8 +84,7 @@ SketchList.propTypes = {
updatedAt: PropTypes.string.isRequired
})).isRequired,
username: PropTypes.string,
deleteProject: PropTypes.func.isRequired,
previousPath: PropTypes.string.isRequired,
deleteProject: PropTypes.func.isRequired
};
SketchList.defaultProps = {

View file

@ -1,28 +1,16 @@
import React, { PropTypes } from 'react';
import React from 'react';
class TextOutput extends React.Component {
componentDidMount() {
this.canvasTextOutput.focus();
}
componentDidUpdate(prevProps) {
// if the user explicitly clicks on the play button, want to refocus on the text output
if (this.props.isPlaying && this.props.previewIsRefreshing) {
this.canvasTextOutput.focus();
}
this.TextOutputModal.focus();
}
render() {
return (
<section
className="text-output"
id="canvas-sub"
ref={(element) => { this.canvasTextOutput = element; }}
tabIndex="0"
aria-label="text-output"
title="canvas text output"
id="textOutput-content"
ref={(element) => { this.TextOutputModal = element; }}
>
<h2> Output </h2>
<section id="textOutput-content">
</section>
<h2> Text Output </h2>
<p
tabIndex="0"
role="main"
@ -31,10 +19,8 @@ class TextOutput extends React.Component {
>
</p>
<table
tabIndex="0"
role="main"
id="textOutput-content-table"
aria-label="text output details"
summary="text output details"
>
</table>
<div
@ -49,9 +35,4 @@ class TextOutput extends React.Component {
}
}
TextOutput.propTypes = {
isPlaying: PropTypes.bool.isRequired,
previewIsRefreshing: PropTypes.bool.isRequired
};
export default TextOutput;

View file

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

View file

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

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>
<html>
<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.10/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/p5.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.11/addons/p5.sound.min.js"></script>
<link rel="stylesheet" type="text/css" href="style.css">
</head>
<body>

View file

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

View file

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

View file

@ -8,6 +8,7 @@ const initialState = () => {
return {
name: generatedName,
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() {
return {
type: ActionTypes.RESET_PASSWORD_RESET

View file

@ -4,11 +4,19 @@ import { domOnlyProps } from '../../../utils/reduxFormUtils';
function AccountForm(props) {
const {
fields: { username, email, currentPassword, newPassword },
user,
handleSubmit,
initiateVerification,
submitting,
invalid,
pristine
} = props;
const handleInitiateVerification = (evt) => {
evt.preventDefault();
initiateVerification();
};
return (
<form className="form" onSubmit={handleSubmit(props.updateSettings)}>
<p className="form__field">
@ -22,6 +30,26 @@ function AccountForm(props) {
/>
{email.touched && email.error && <span className="form-error">{email.error}</span>}
</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">
<label htmlFor="username" className="form__label">User Name</label>
<input
@ -43,7 +71,11 @@ function AccountForm(props) {
id="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 className="form__field">
<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>}
</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>
);
}
@ -66,9 +103,14 @@ AccountForm.propTypes = {
username: PropTypes.object.isRequired,
email: PropTypes.object.isRequired,
currentPassword: PropTypes.object.isRequired,
newPassword: PropTypes.object.isRequired
newPassword: PropTypes.object.isRequired,
}).isRequired,
user: PropTypes.shape({
verified: PropTypes.number.isRequired,
emailVerificationInitiate: PropTypes.bool.isRequired,
}).isRequired,
handleSubmit: PropTypes.func.isRequired,
initiateVerification: PropTypes.func.isRequired,
updateSettings: PropTypes.func.isRequired,
submitting: PropTypes.bool,
invalid: PropTypes.bool,

View file

@ -25,7 +25,11 @@ function NewPasswordForm(props) {
id="confirm password"
{...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>
<input type="submit" disabled={submitting || invalid || pristine} value="Set New Password" aria-label="sign up" />
</form>

View file

@ -15,7 +15,12 @@ function ResetPasswordForm(props) {
{...domOnlyProps(email)}
/>
</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>
);
}

View file

@ -47,7 +47,11 @@ function SignupForm(props) {
id="confirm password"
{...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>
<input type="submit" disabled={submitting || invalid || pristine} value="Sign Up" aria-label="sign up" />
</form>

View file

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

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 });
case ActionTypes.INVALID_RESET_PASSWORD_TOKEN:
return Object.assign({}, state, { resetPasswordInvalid: true });
case ActionTypes.EMAIL_VERIFICATION_INITIATE:
return Object.assign({}, state, { emailVerificationInitiate: true });
case ActionTypes.EMAIL_VERIFICATION_VERIFY:
return Object.assign({}, state, { emailVerificationTokenState: 'checking' });
case ActionTypes.EMAIL_VERIFICATION_VERIFIED:
return Object.assign({}, state, { emailVerificationTokenState: 'verified' });
case ActionTypes.EMAIL_VERIFICATION_INVALID:
return Object.assign({}, state, { emailVerificationTokenState: 'invalid' });
case ActionTypes.SETTINGS_UPDATED:
return { ...state, ...action.user };
default:

View file

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

View file

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

View file

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

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

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

View file

@ -105,6 +105,169 @@
width: #{48 / $base-font-size}rem;
}
/*
Search dialog
*/
.CodeMirror-dialog {
position: fixed;
top: 0;
left: 50%;
margin-left: - #{365/2/$base-font-size}rem;
z-index: 10;
width: 100%;
max-width: #{365 / $base-font-size}rem;
font-family: Montserrat, sans-serif;
padding: #{14 / $base-font-size}rem #{20 / $base-font-size}rem #{14 / $base-font-size}rem #{18 / $base-font-size}rem;
border-radius: 2px;
@include themify() {
background-color: getThemifyVariable('modal-background-color');
box-shadow: 0 12px 12px 0 getThemifyVariable('shadow-color');
border: solid 0.5px getThemifyVariable('modal-border-color');
}
}
.CodeMirror-search-title {
display: block;
margin-bottom: #{12 / $base-font-size}rem;
font-size: #{21 / $base-font-size}rem;
font-weight: bold;
}
.CodeMirror-search-field {
display: block;
width: 100%;
margin-bottom: #{12 / $base-font-size}rem;
@include themify() {
background-color: getThemifyVariable('search-background-color');
border: solid 0.5px getThemifyVariable('button-border-color');
}
&:focus {
background-color: $white;
}
}
.CodeMirror-search-count {
display: block;
height: #{20 / $base-font-size}rem;
text-align: right;
}
.CodeMirror-search-actions {
display: flex;
justify-content: space-between;
}
/*
*/
.CodeMirror-search-modifiers {
display: flex;
justify-content: flex-end;
}
.CodeMirror-regexp-button,
.CodeMirror-case-button,
.CodeMirror-word-button {
width: 20px;
height: 20px;
margin-left: #{10 / $base-font-size}rem;
word-break: keep-all;
white-space: nowrap;
@include themify() {
color: getThemifyVariable('inactive-text-color');
}
}
.CodeMirror-regexp-button .label,
.CodeMirror-case-button .label,
.CodeMirror-word-button .label {
@extend %hidden-element;
}
[aria-checked="true"] {
@include themify() {
color: getThemifyVariable('primary-text-color');
}
}
/*
Previous / Next buttons
*/
// Visually hide button text
.CodeMirror-search-button .label {
@extend %hidden-element;
}
.CodeMirror-search-button {
margin-right: #{10 / $base-font-size}rem;
}
.CodeMirror-search-button::after {
display: block;
content: ' ';
width: 14px;
height: 14px;
@include icon();
background-repeat: no-repeat;
background-position: center;
}
// Previous button
.CodeMirror-search-button.prev::after {
background-image: url(../images/up-arrow.svg)
}
// Next button
.CodeMirror-search-button.next::after {
background-image: url(../images/down-arrow.svg)
}
/*
Close button
*/
.CodeMirror-close-button {
position: absolute;
top: #{14 / $base-font-size}rem;
right: #{18 / $base-font-size}rem;
display: flex;
flex-direction: row;
}
// Visually hide button text
.CodeMirror-close-button .label {
@extend %hidden-element;
}
.CodeMirror-close-button:after {
display: block;
content: ' ';
width: 16px;
height: 16px;
margin-left: #{8 / $base-font-size}rem;
@include icon();
background: transparent url(../images/exit.svg) no-repeat;
}
.editor-holder {
height: calc(100% - #{29 / $base-font-size}rem);
width: 100%;
@ -116,9 +279,7 @@
}
.editor__options-button {
@include themify() {
@extend %icon;
}
@include icon();
position: absolute;
top: #{10 / $base-font-size}rem;
right: #{2 / $base-font-size}rem;

View file

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

View file

@ -33,6 +33,15 @@
border-color: $secondary-form-title-color;
}
.form__context {
text-align: left;
margin-top: #{15 / $base-font-size}rem;
}
.form__status {
color: $form-navigation-options-color;
}
.form input[type="submit"] {
@extend %forms-button;
}

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 {
@include themify() {
@extend %icon;
}
@include icon();
}
.modal__header {
@ -50,68 +48,3 @@
text-align: center;
margin: #{20 / $base-font-size}rem 0;
}
.uploader {
min-height: #{200 / $base-font-size}rem;
width: 100%;
text-align: center;
}
.share-modal {
@extend %modal;
padding: #{20 / $base-font-size}rem;
width: #{500 / $base-font-size}rem;
}
.share-modal__header {
display: flex;
justify-content: space-between;
}
.share-modal__section {
width: 100%;
display: flex;
align-items: center;
padding: #{10 / $base-font-size}rem 0;
}
.share-modal__label {
width: #{86 / $base-font-size}rem;
}
.share-modal__input {
flex: 1;
}
.keyboard-shortcuts {
@extend %modal;
padding: #{20 / $base-font-size}rem;
width: #{450 / $base-font-size}rem;
}
.keyboard-shortcuts__header {
display: flex;
justify-content: space-between;
margin-bottom: #{20 / $base-font-size}rem;
}
.keyboard-shortcuts__close {
@include themify() {
@extend %icon;
}
}
.keyboard-shortcut-item {
display: flex;
& + & {
margin-top: #{10 / $base-font-size}rem;
}
align-items: baseline;
}
.keyboard-shortcut__command {
width: 50%;
font-weight: bold;
text-align: right;
padding-right: #{10 / $base-font-size}rem;
}

View file

@ -9,10 +9,31 @@
overflow-y: hidden;
}
.overlay-content {
.overlay__content {
height: 100%;
width: 100%;
display: flex;
justify-content: center;
align-items: center;
}
.overlay__body {
@extend %modal;
display: flex;
flex-wrap: wrap;
flex-flow: column;
max-height: 80%;
max-width: 65%;
}
.overlay__header {
display: flex;
justify-content: space-between;
padding: #{40 / $base-font-size}rem #{20 / $base-font-size}rem #{30 / $base-font-size}rem #{20 / $base-font-size}rem;
flex: 1 0 auto;
}
.overlay__close-button {
@include icon();
padding: #{12 / $base-font-size}rem #{16 / $base-font-size}rem;
}

View file

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

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

View file

@ -1,3 +1,9 @@
.dropzone {
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;
}
.text-output {
.accessible-output {
@extend %hidden-element;
}

View file

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

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) {
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.';
}
}

View file

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

View file

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

View file

@ -1,6 +1,8 @@
import uuid from 'node-uuid';
import policy from 's3-policy';
import s3 from 's3';
import { getProjectsForUserId } from './project.controller';
import { findUserByUsername } from './user.controller';
const client = s3.createClient({
maxAsyncS3: 20,
@ -28,7 +30,7 @@ export function getObjectKey(url) {
if (urlArray.length === 6) {
const key = urlArray.pop();
const userId = urlArray.pop();
objectKey = `${userId}/${key}`
objectKey = `${userId}/${key}`;
} else {
const key = urlArray.pop();
objectKey = key;
@ -104,3 +106,45 @@ export function copyObjectInS3(req, res) {
res.json({ url: `${s3Bucket}${userId}/${newFilename}` });
});
}
export function listObjectsInS3ForUser(req, res) {
const username = req.params.username;
findUserByUsername(username, (user) => {
const userId = user.id;
const params = {
s3Params: {
Bucket: `${process.env.S3_BUCKET}`,
Prefix: `${userId}/`
}
};
let assets = [];
const list = client.listObjects(params)
.on('data', (data) => {
assets = assets.concat(data["Contents"].map((object) => {
return { key: object["Key"], size: object["Size"] };
}));
})
.on('end', () => {
const projectAssets = [];
getProjectsForUserId(userId).then((projects) => {
projects.forEach((project) => {
project.files.forEach((file) => {
if (!file.url) return;
const foundAsset = assets.find((asset) => file.url.includes(asset.key));
if (!foundAsset) return;
projectAssets.push({
name: file.name,
sketchName: project.name,
sketchId: project.id,
url: file.url,
key: foundAsset.key,
size: foundAsset.size
});
});
});
res.json({assets: projectAssets});
});
});
});
}

View file

@ -48,7 +48,8 @@ function deleteMany(files, ids) {
each(ids, (id, cb) => {
if (files.id(id).url) {
if (!process.env.S3_DATE || (process.env.S3_DATE && moment(process.env.S3_DATE) < moment(files.id(id).createdAt))) {
if (!process.env.S3_DATE
|| (process.env.S3_DATE && moment(process.env.S3_DATE) < moment(files.id(id).createdAt))) {
const objectKey = getObjectKey(files.id(id).url);
objectKeys.push(objectKey);
}

View file

@ -1,6 +1,8 @@
import archiver from 'archiver';
import request from 'request';
import moment from 'moment';
import isUrl from 'is-url';
import jsdom, { serializeDocument } from 'jsdom';
import Project from '../models/project';
import User from '../models/user';
import { deleteObjectsFromS3, getObjectKey } from './aws.controller';
@ -100,10 +102,7 @@ function deleteFilesFromS3(files) {
}
return false;
})
.map((file) => {
return getObjectKey(file.url);
})
);
.map(file => getObjectKey(file.url)));
}
export function deleteProject(req, res) {
@ -123,12 +122,28 @@ export function deleteProject(req, res) {
});
}
export function getProjects(req, res) {
if (req.user) {
Project.find({ user: req.user._id }) // eslint-disable-line no-underscore-dangle
export function getProjectsForUserId(userId) {
return new Promise((resolve, reject) => {
Project.find({ user: userId })
.sort('-createdAt')
.select('name files id createdAt updatedAt')
.exec((err, projects) => {
if (err) {
console.log(err);
}
resolve(projects);
});
});
}
export function getProjectsForUserName(username) {
}
export function getProjects(req, res) {
if (req.user) {
getProjectsForUserId(req.user._id)
.then((projects) => {
res.json(projects);
});
} else {
@ -144,7 +159,7 @@ export function getProjectsForUser(req, res) {
res.status(404).json({ message: 'User with that username does not exist.' });
return;
}
Project.find({ user: user._id }) // eslint-disable-line no-underscore-dangle
Project.find({ user: user._id })
.sort('-createdAt')
.select('name files id createdAt updatedAt')
.exec((innerErr, projects) => res.json(projects));
@ -155,6 +170,48 @@ export function getProjectsForUser(req, res) {
}
}
function bundleExternalLibs(project, zip, callback) {
const rootFile = project.files.find(file => file.name === 'root');
const indexHtml = project.files.find(file => file.name === 'index.html');
let numScriptsResolved = 0;
let numScriptTags = 0;
function resolveScriptTagSrc(scriptTag, document) {
const path = scriptTag.src.split('/');
const filename = path[path.length - 1];
const src = scriptTag.src;
if (!isUrl(src)) {
numScriptsResolved += 1;
return;
}
request({ method: 'GET', url: src, encoding: null }, (err, response, body) => {
if (err) {
console.log(err);
} else {
zip.append(body, { name: filename });
scriptTag.src = filename;
}
numScriptsResolved += 1;
if (numScriptsResolved === numScriptTags) {
indexHtml.content = serializeDocument(document);
callback();
}
});
}
jsdom.env(indexHtml.content, (innerErr, window) => {
const indexHtmlDoc = window.document;
const scriptTags = indexHtmlDoc.getElementsByTagName('script');
numScriptTags = scriptTags.length;
for (let i = 0; i < numScriptTags; i += 1) {
resolveScriptTagSrc(scriptTags[i], indexHtmlDoc);
}
});
}
function buildZip(project, req, res) {
const zip = archiver('zip');
const rootFile = project.files.find(file => file.name === 'root');
@ -194,7 +251,10 @@ function buildZip(project, req, res) {
}
}
}
bundleExternalLibs(project, zip, () => {
addFileToZip(rootFile, '/');
});
}
export function downloadProjectAsZip(req, res) {

View file

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

View file

@ -1,14 +1,38 @@
import crypto from 'crypto';
import async from 'async';
import nodemailer from 'nodemailer';
import mg from 'nodemailer-mailgun-transport';
import User from '../models/user';
import mail from '../utils/mail';
import {
renderEmailConfirmation,
renderResetPassword,
} from '../views/mail';
const random = (done) => {
crypto.randomBytes(20, (err, buf) => {
const token = buf.toString('hex');
done(err, token);
});
};
export function findUserByUsername(username, cb) {
User.findOne({ username },
(err, user) => {
cb(user);
});
}
const EMAIL_VERIFY_TOKEN_EXPIRY_TIME = Date.now() + (3600000 * 24); // 24 hours
export function createUser(req, res, next) {
random((tokenError, token) => {
const user = new User({
username: req.body.username,
email: req.body.email,
password: req.body.password
password: req.body.password,
verified: User.EmailConfirmation.Sent,
verifiedToken: token,
verifiedTokenExpires: EMAIL_VERIFY_TOKEN_EXPIRY_TIME,
});
User.findOne({ email: req.body.email },
@ -32,15 +56,29 @@ export function createUser(req, res, next) {
next(loginErr);
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: req.user.email,
});
mail.send(mailOptions, (mailErr, result) => { // eslint-disable-line no-unused-vars
res.json({
email: req.user.email,
username: req.user.username,
preferences: req.user.preferences,
verified: req.user.verified,
id: req.user._id
});
});
});
});
});
});
}
export function duplicateUserCheck(req, res) {
@ -90,12 +128,7 @@ export function updatePreferences(req, res) {
export function resetPasswordInitiate(req, res) {
async.waterfall([
(done) => {
crypto.randomBytes(20, (err, buf) => {
const token = buf.toString('hex');
done(err, token);
});
},
random,
(token, done) => {
User.findOne({ email: req.body.email }, (err, user) => {
if (!user) {
@ -111,27 +144,16 @@ export function resetPasswordInitiate(req, res) {
});
},
(token, user, done) => {
const auth = {
auth: {
api_key: process.env.MAILGUN_KEY,
domain: process.env.MAILGUN_DOMAIN
}
};
const transporter = nodemailer.createTransport(mg(auth));
const message = {
const protocol = process.env.NODE_ENV === 'production' ? 'https' : 'http';
const mailOptions = renderResetPassword({
body: {
domain: `${protocol}://${req.headers.host}`,
link: `${protocol}://${req.headers.host}/reset-password/${token}`,
},
to: user.email,
from: 'p5.js Web Editor <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) => {
if (err) {
@ -153,6 +175,76 @@ export function validateResetPasswordToken(req, res) {
});
}
export function emailVerificationInitiate(req, res) {
async.waterfall([
random,
(token, done) => {
User.findById(req.user.id, (err, user) => {
if (err) {
res.status(500).json({ error: err });
return;
}
if (!user) {
res.status(404).json({ error: 'Document not found' });
return;
}
if (user.verified === User.EmailConfirmation.Verified) {
res.status(409).json({ error: 'Email already verified' });
return;
}
const protocol = process.env.NODE_ENV === 'production' ? 'https' : 'http';
const mailOptions = renderEmailConfirmation({
body: {
domain: `${protocol}://${req.headers.host}`,
link: `${protocol}://${req.headers.host}/verify?t=${token}`
},
to: user.email,
});
mail.send(mailOptions, (mailErr, result) => { // eslint-disable-line no-unused-vars
if (mailErr != null) {
res.status(500).send({ error: 'Error sending mail' });
} else {
user.verified = User.EmailConfirmation.Resent;
user.verifiedToken = token;
user.verifiedTokenExpires = EMAIL_VERIFY_TOKEN_EXPIRY_TIME; // 24 hours
user.save();
res.json({
email: req.user.email,
username: req.user.username,
preferences: req.user.preferences,
verified: user.verified,
id: req.user._id
});
}
});
});
},
]);
}
export function verifyEmail(req, res) {
const token = req.query.t;
User.findOne({ verifiedToken: token, verifiedTokenExpires: { $gt: Date.now() } }, (err, user) => {
if (!user) {
res.status(401).json({ success: false, message: 'Token is invalid or has expired.' });
return;
}
user.verified = User.EmailConfirmation.Verified;
user.verifiedToken = null;
user.verifiedTokenExpires = null;
user.save()
.then((result) => { // eslint-disable-line
res.json({ success: true });
});
});
}
export function updatePassword(req, res) {
User.findOne({ resetPasswordToken: req.params.token, resetPasswordExpires: { $gt: Date.now() } }, (err, user) => {
if (!user) {
@ -205,7 +297,6 @@ export function updateSettings(req, res) {
return;
}
user.email = req.body.email;
user.username = req.body.username;
if (req.body.currentPassword) {
@ -218,6 +309,28 @@ export function updateSettings(req, res) {
user.password = req.body.newPassword;
saveUser(res, user);
});
} else if (user.email !== req.body.email) {
user.verified = User.EmailConfirmation.Sent;
user.email = req.body.email;
random((error, token) => {
user.verifiedToken = token;
user.verifiedTokenExpires = EMAIL_VERIFY_TOKEN_EXPIRY_TIME;
saveUser(res, user);
const protocol = process.env.NODE_ENV === 'production' ? 'https' : 'http';
const mailOptions = renderEmailConfirmation({
body: {
domain: `${protocol}://${req.headers.host}`,
link: `${protocol}://${req.headers.host}/verify?t=${token}`
},
to: user.email,
});
mail.send(mailOptions);
});
} else {
saveUser(res, user);
}

View file

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

View file

@ -29,7 +29,7 @@ Project.find({}, (err, projects) => {
if (!project.user) return;
const userId = project.user.valueOf();
project.files.forEach((file, fileIndex) => {
if (file.url && file.url.includes(process.env.S3_BUCKET)) {
if (file.url && file.url.includes(process.env.S3_BUCKET) && !file.url.includes(userId)) {
const key = file.url.split('/').pop();
console.log(key);
const params = {
@ -37,6 +37,7 @@ Project.find({}, (err, projects) => {
CopySource: `${process.env.S3_BUCKET}/${key}`,
Key: `${userId}/${key}`
};
try {
client.moveObject(params)
.on('err', (err) => {
console.log(err);
@ -47,6 +48,9 @@ Project.find({}, (err, projects) => {
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 EmailConfirmationStates = {
Verified: 'verified',
Sent: 'sent',
Resent: 'resent',
};
const Schema = mongoose.Schema;
const userSchema = new Schema({
@ -10,6 +16,9 @@ const userSchema = new Schema({
password: { type: String },
resetPasswordToken: String,
resetPasswordExpires: Date,
verified: { type: String },
verifiedToken: String,
verifiedTokenExpires: Date,
github: { type: String },
email: { type: String, unique: true },
tokens: Array,
@ -19,7 +28,9 @@ const userSchema = new Schema({
isTabIndent: { type: Boolean, default: false },
autosave: { type: Boolean, default: true },
lintWarning: { type: Boolean, default: false },
textOutput: { type: Number, default: 0 },
textOutput: { type: Boolean, default: false },
gridOutput: { type: Boolean, default: false },
soundOutput: { type: Boolean, default: false },
theme: { type: String, default: 'light' },
autorefresh: { type: Boolean, default: false }
}
@ -71,4 +82,6 @@ userSchema.statics.findByMailOrName = function findByMailOrName(email) {
return this.findOne(query).exec();
};
userSchema.statics.EmailConfirmation = EmailConfirmationStates;
export default mongoose.model('User', userSchema);

View file

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

View file

@ -40,6 +40,10 @@ router.route('/reset-password/:reset_password_token').get((req, res) => {
res.send(renderIndex());
});
router.route('/verify').get((req, res) => {
res.send(renderIndex());
});
router.route('/sketches').get((req, res) => {
res.send(renderIndex());
});
@ -54,6 +58,12 @@ router.route('/:username/sketches').get((req, res) => {
));
});
router.route('/:username/assets').get((req, res) => {
userExists(req.params.username, exists => (
exists ? res.send(renderIndex()) : get404Sketch(html => res.send(html))
));
});
router.route('/:username/account').get((req, res) => {
userExists(req.params.username, exists => (
exists ? res.send(renderIndex()) : get404Sketch(html => res.send(html))

View file

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

View file

@ -7,6 +7,7 @@ import session from 'express-session';
import connectMongo from 'connect-mongo';
import passport from 'passport';
import path from 'path';
import csurf from 'csurf';
// Webpack Requirements
import webpack from 'webpack';
@ -23,6 +24,7 @@ import files from './routes/file.routes';
import aws from './routes/aws.routes';
import serverRoutes from './routes/server.routes';
import embedRoutes from './routes/embed.routes';
import { requestsOfTypeJSON } from './utils/requestsOfType';
import { renderIndex } from './views/index';
import { get404Sketch } from './views/404Page';
@ -73,18 +75,27 @@ app.use(session({
autoReconnect: true
})
}));
// Enables CSRF protection and stores secret in session
app.use(csurf());
// Middleware to add CSRF token as cookie to some requests
const csrfToken = (req, res, next) => {
res.cookie('XSRF-TOKEN', req.csrfToken());
next();
};
app.use(passport.initialize());
app.use(passport.session());
app.use('/api', users);
app.use('/api', sessions);
app.use('/api', projects);
app.use('/api', files);
app.use('/api', aws);
app.use('/api', requestsOfTypeJSON(), users);
app.use('/api', requestsOfTypeJSON(), sessions);
app.use('/api', requestsOfTypeJSON(), projects);
app.use('/api', requestsOfTypeJSON(), files);
app.use('/api', requestsOfTypeJSON(), aws);
// this is supposed to be TEMPORARY -- until i figure out
// isomorphic rendering
app.use('/', serverRoutes);
app.use('/', csrfToken, serverRoutes);
app.use('/', embedRoutes);
app.use('/', csrfToken, embedRoutes);
app.get('/auth/github', passport.authenticate('github'));
app.get('/auth/github/callback', passport.authenticate('github', { failureRedirect: '/login' }), (req, res) => {
res.redirect('/');

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';
// eslint-disable-next-line max-len
const MEDIA_FILE_REGEX_NO_QUOTES = /^(?!(http:\/\/|https:\/\/)).*\.(png|jpg|jpeg|gif|bmp|mp3|wav|aiff|ogg|json|txt|csv|svg|obj|mp4|ogg|webm|mov|otf|ttf|m4a)$/i;
const STRING_REGEX = /(['"])((\\\1|.)*?)\1/gm;
const EXTERNAL_LINK_REGEX = /^(http:\/\/|https:\/\/)/;

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