Merge branch 'master' into feature-runtime-error-highlight
This commit is contained in:
commit
069f974989
82 changed files with 2362 additions and 721 deletions
|
@ -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.
|
||||
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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;
|
||||
|
|
30
client/modules/IDE/actions/assets.js
Normal file
30
client/modules/IDE/actions/assets.js
Normal 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'
|
||||
};
|
||||
}
|
|
@ -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
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
@ -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) {
|
|||
}
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
@ -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;
|
||||
|
|
51
client/modules/IDE/components/AccessibleOutput.jsx
Normal file
51
client/modules/IDE/components/AccessibleOutput.jsx
Normal 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;
|
71
client/modules/IDE/components/AssetList.jsx
Normal file
71
client/modules/IDE/components/AssetList.jsx
Normal 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);
|
|
@ -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">
|
||||
|
|
|
@ -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') {
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
38
client/modules/IDE/components/GridOutput.jsx
Normal file
38
client/modules/IDE/components/GridOutput.jsx
Normal 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;
|
|
@ -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;
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
};
|
||||
|
||||
|
|
|
@ -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 = {
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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}
|
||||
{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({
|
||||
|
|
|
@ -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,
|
||||
|
|
12
client/modules/IDE/reducers/assets.js
Normal file
12
client/modules/IDE/reducers/assets.js
Normal 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;
|
|
@ -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>
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -8,6 +8,7 @@ const initialState = () => {
|
|||
return {
|
||||
name: generatedName,
|
||||
serveSecure: isSecurePage(),
|
||||
updatedAt: ''
|
||||
};
|
||||
};
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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({
|
||||
|
|
110
client/modules/User/pages/EmailVerificationView.jsx
Normal file
110
client/modules/User/pages/EmailVerificationView.jsx
Normal 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);
|
|
@ -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:
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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;
|
||||
}
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
)
|
||||
);
|
||||
|
||||
|
|
|
@ -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 {
|
||||
|
|
56
client/styles/components/_asset-list.scss
Normal file
56
client/styles/components/_asset-list.scss
Normal 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;
|
||||
}
|
|
@ -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;
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -12,9 +12,7 @@
|
|||
}
|
||||
|
||||
.error-modal__exit-button {
|
||||
@include themify() {
|
||||
@extend %icon;
|
||||
}
|
||||
@include icon();
|
||||
}
|
||||
|
||||
.error-modal__content {
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
20
client/styles/components/_keyboard-shortcuts.scss
Normal file
20
client/styles/components/_keyboard-shortcuts.scss
Normal 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;
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
|
@ -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;
|
||||
}
|
24
client/styles/components/_share.scss
Normal file
24
client/styles/components/_share.scss
Normal 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;
|
||||
}
|
|
@ -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;
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -1,3 +1,9 @@
|
|||
.dropzone {
|
||||
color: $primary-text-color;
|
||||
}
|
||||
|
||||
.uploader {
|
||||
min-height: #{200 / $base-font-size}rem;
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
}
|
|
@ -31,7 +31,7 @@
|
|||
@extend %hidden-element;
|
||||
}
|
||||
|
||||
.text-output {
|
||||
.accessible-output {
|
||||
@extend %hidden-element;
|
||||
}
|
||||
|
||||
|
|
|
@ -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';
|
||||
|
|
433
client/utils/codemirror-search.js
Normal file
433
client/utils/codemirror-search.js
Normal 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
16
client/utils/metaKey.js
Normal 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,
|
||||
};
|
|
@ -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.';
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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));
|
||||
}
|
||||
});
|
||||
|
|
|
@ -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});
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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
|
||||
});
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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];
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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))
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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
48
server/utils/mail.js
Normal 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();
|
|
@ -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:\/\/)/;
|
||||
|
|
12
server/utils/renderMjml.js
Normal file
12
server/utils/renderMjml.js
Normal 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;
|
||||
};
|
15
server/utils/requestsOfType.js
Normal file
15
server/utils/requestsOfType.js
Normal 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
56
server/views/mail.js
Normal 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 },
|
||||
);
|
||||
};
|
59
server/views/mailLayout.js
Normal file
59
server/views/mailLayout.js
Normal 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>
|
||||
`
|
||||
);
|
BIN
static/images/p5js-square-logo.png
Normal file
BIN
static/images/p5js-square-logo.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 5.8 KiB |
|
@ -1 +1 @@
|
|||
Subproject commit 344fedf8d868c62adc571bf4212c6bea3cd20247
|
||||
Subproject commit 0958be54482722821159cd3e07777988ee349f37
|
Loading…
Reference in a new issue