Merge branch 'release-1.0.4' into release

This commit is contained in:
Cassie Tarakajian 2020-07-13 16:09:36 -04:00
commit 9767a749cd
77 changed files with 9185 additions and 2869 deletions

3
.github/FUNDING.yml vendored
View file

@ -1 +1,2 @@
custom: https://processingfoundation.org/support
github: processing
custom: https://processingfoundation.org/

View file

@ -14,6 +14,7 @@ COPY .babelrc index.js nodemon.json ./
COPY ./webpack ./webpack
COPY client ./client
COPY server ./server
COPY translations/locales ./translations/locales
CMD ["npm", "start"]
FROM development as build

View file

@ -1,51 +0,0 @@
import React from 'react';
import PropTypes from 'prop-types';
import SortArrowUp from '../images/sort-arrow-up.svg';
import SortArrowDown from '../images/sort-arrow-down.svg';
import Github from '../images/github.svg';
import Google from '../images/google.svg';
import Plus from '../images/plus-icon.svg';
import Close from '../images/close.svg';
import DropdownArrow from '../images/down-filled-triangle.svg';
// HOC that adds the right web accessibility props
// https://www.scottohara.me/blog/2019/05/22/contextual-images-svgs-and-a11y.html
// could also give these a default size, color, etc. based on the theme
// Need to add size to these - like small icon, medium icon, large icon. etc.
function withLabel(SvgComponent) {
const Icon = (props) => {
const { 'aria-label': ariaLabel } = props;
if (ariaLabel) {
return (<SvgComponent
{...props}
aria-label={ariaLabel}
role="img"
focusable="false"
/>);
}
return (<SvgComponent
{...props}
aria-hidden
focusable="false"
/>);
};
Icon.propTypes = {
'aria-label': PropTypes.string
};
Icon.defaultProps = {
'aria-label': null
};
return Icon;
}
export const SortArrowUpIcon = withLabel(SortArrowUp);
export const SortArrowDownIcon = withLabel(SortArrowDown);
export const GithubIcon = withLabel(Github);
export const GoogleIcon = withLabel(Google);
export const PlusIcon = withLabel(Plus);
export const CloseIcon = withLabel(Close);
export const DropdownArrowIcon = withLabel(DropdownArrow);

View file

@ -1,18 +0,0 @@
import React from 'react';
import { select } from '@storybook/addon-knobs';
import * as icons from './icons';
export default {
title: 'Common/Icons',
component: icons
};
export const AllIcons = () => {
const names = Object.keys(icons);
const SelectedIcon = icons[select('name', names, names[0])];
return (
<SelectedIcon />
);
};

View file

@ -1,12 +1,17 @@
import React from 'react';
import PropTypes from 'prop-types';
import styled from 'styled-components';
import { prop } from '../theme';
import SortArrowUp from '../images/sort-arrow-up.svg';
import SortArrowDown from '../images/sort-arrow-down.svg';
import Github from '../images/github.svg';
import Google from '../images/google.svg';
import Plus from '../images/plus-icon.svg';
import Close from '../images/close.svg';
import Exit from '../images/exit.svg';
import DropdownArrow from '../images/down-filled-triangle.svg';
import Preferences from '../images/preferences.svg';
import Play from '../images/triangle-arrow-right.svg';
// HOC that adds the right web accessibility props
// https://www.scottohara.me/blog/2019/05/22/contextual-images-svgs-and-a11y.html
@ -15,16 +20,33 @@ import DropdownArrow from '../images/down-filled-triangle.svg';
// Need to add size to these - like small icon, medium icon, large icon. etc.
function withLabel(SvgComponent) {
const Icon = (props) => {
const StyledIcon = styled(SvgComponent)`
&&& {
color: ${prop('Icon.default')};
& g, & path, & polygon {
opacity: 1;
fill: ${prop('Icon.default')};
}
&:hover {
color: ${prop('Icon.hover')};
& g, & path, & polygon {
opacity: 1;
fill: ${prop('Icon.hover')};
}
}
}
`;
const { 'aria-label': ariaLabel } = props;
if (ariaLabel) {
return (<SvgComponent
return (<StyledIcon
{...props}
aria-label={ariaLabel}
role="img"
focusable="false"
/>);
}
return (<SvgComponent
return (<StyledIcon
{...props}
aria-hidden
focusable="false"
@ -48,4 +70,7 @@ export const GithubIcon = withLabel(Github);
export const GoogleIcon = withLabel(Google);
export const PlusIcon = withLabel(Plus);
export const CloseIcon = withLabel(Close);
export const ExitIcon = withLabel(Exit);
export const DropdownArrowIcon = withLabel(DropdownArrow);
export const PreferencesIcon = withLabel(Preferences);
export const PlayIcon = withLabel(Play);

View file

@ -4,20 +4,21 @@ import { connect } from 'react-redux';
import { withRouter } from 'react-router';
import { Link } from 'react-router';
import classNames from 'classnames';
import { withTranslation } from 'react-i18next';
import i18next from 'i18next';
import * as IDEActions from '../modules/IDE/actions/ide';
import * as toastActions from '../modules/IDE/actions/toast';
import * as projectActions from '../modules/IDE/actions/project';
import { setAllAccessibleOutput } from '../modules/IDE/actions/preferences';
import { logoutUser } from '../modules/User/actions';
import getConfig from '../utils/getConfig';
import { metaKeyName, } from '../utils/metaKey';
import CaretLeftIcon from '../images/left-arrow.svg';
import TriangleIcon from '../images/down-filled-triangle.svg';
import LogoIcon from '../images/p5js-logo-small.svg';
const __process = (typeof global !== 'undefined' ? global : window).process;
class Nav extends React.PureComponent {
constructor(props) {
super(props);
@ -56,6 +57,10 @@ class Nav extends React.PureComponent {
this.handleFocusForHelp = this.handleFocus.bind(this, 'help');
this.toggleDropdownForAccount = this.toggleDropdown.bind(this, 'account');
this.handleFocusForAccount = this.handleFocus.bind(this, 'account');
this.toggleDropdownForLang = this.toggleDropdown.bind(this, 'lang');
this.handleFocusForLang = this.handleFocus.bind(this, 'lang');
this.handleLangSelection = this.handleLangSelection.bind(this);
this.closeDropDown = this.closeDropDown.bind(this);
}
@ -164,6 +169,13 @@ class Nav extends React.PureComponent {
this.setDropdown('none');
}
handleLangSelection(event) {
i18next.changeLanguage(event.target.value);
this.props.showToast(1500);
this.props.setToastText('LangChange');
this.setDropdown('none');
}
handleLogout() {
this.props.logoutUser();
this.setDropdown('none');
@ -234,7 +246,7 @@ class Nav extends React.PureComponent {
<Link to="/" className="nav__back-link">
<CaretLeftIcon className="nav__back-icon" focusable="false" aria-hidden="true" />
<span className="nav__item-header">
Back to Editor
{this.props.t('BackEditor')}
</span>
</Link>
</li>
@ -259,7 +271,7 @@ class Nav extends React.PureComponent {
}
}}
>
<span className="nav__item-header">File</span>
<span className="nav__item-header">{this.props.t('File')}</span>
<TriangleIcon className="nav__item-header-triangle" focusable="false" aria-hidden="true" />
</button>
<ul className="nav__dropdown">
@ -269,17 +281,17 @@ class Nav extends React.PureComponent {
onFocus={this.handleFocusForFile}
onBlur={this.handleBlur}
>
New
{this.props.t('New')}
</button>
</li>
{ __process.env.LOGIN_ENABLED && (!this.props.project.owner || this.isUserOwner()) &&
{ getConfig('LOGIN_ENABLED') && (!this.props.project.owner || this.isUserOwner()) &&
<li className="nav__dropdown-item">
<button
onClick={this.handleSave}
onFocus={this.handleFocusForFile}
onBlur={this.handleBlur}
>
Save
{this.props.t('Save')}
<span className="nav__keyboard-shortcut">{metaKeyName}+S</span>
</button>
</li> }
@ -290,7 +302,7 @@ class Nav extends React.PureComponent {
onFocus={this.handleFocusForFile}
onBlur={this.handleBlur}
>
Duplicate
{this.props.t('Duplicate')}
</button>
</li> }
{ this.props.project.id &&
@ -300,7 +312,7 @@ class Nav extends React.PureComponent {
onFocus={this.handleFocusForFile}
onBlur={this.handleBlur}
>
Share
{this.props.t('Share')}
</button>
</li> }
{ this.props.project.id &&
@ -310,7 +322,7 @@ class Nav extends React.PureComponent {
onFocus={this.handleFocusForFile}
onBlur={this.handleBlur}
>
Download
{this.props.t('Download')}
</button>
</li> }
{ this.props.user.authenticated &&
@ -321,10 +333,10 @@ class Nav extends React.PureComponent {
onBlur={this.handleBlur}
onClick={this.setDropdownForNone}
>
Open
{this.props.t('Open')}
</Link>
</li> }
{__process.env.UI_COLLECTIONS_ENABLED &&
{getConfig('UI_COLLECTIONS_ENABLED') &&
this.props.user.authenticated &&
this.props.project.id &&
<li className="nav__dropdown-item">
@ -334,10 +346,10 @@ class Nav extends React.PureComponent {
onBlur={this.handleBlur}
onClick={this.setDropdownForNone}
>
Add to Collection
{this.props.t('AddToCollection')}
</Link>
</li>}
{ __process.env.EXAMPLES_ENABLED &&
{ getConfig('EXAMPLES_ENABLED') &&
<li className="nav__dropdown-item">
<Link
to="/p5/sketches"
@ -345,7 +357,7 @@ class Nav extends React.PureComponent {
onBlur={this.handleBlur}
onClick={this.setDropdownForNone}
>
Examples
{this.props.t('Examples')}
</Link>
</li> }
</ul>
@ -361,7 +373,7 @@ class Nav extends React.PureComponent {
}
}}
>
<span className="nav__item-header">Edit</span>
<span className="nav__item-header">{this.props.t('Edit')}</span>
<TriangleIcon className="nav__item-header-triangle" focusable="false" aria-hidden="true" />
</button>
<ul className="nav__dropdown" >
@ -374,7 +386,7 @@ class Nav extends React.PureComponent {
onFocus={this.handleFocusForEdit}
onBlur={this.handleBlur}
>
Tidy Code
{this.props.t('TidyCode')}
<span className="nav__keyboard-shortcut">{'\u21E7'}+Tab</span>
</button>
</li>
@ -384,7 +396,7 @@ class Nav extends React.PureComponent {
onFocus={this.handleFocusForEdit}
onBlur={this.handleBlur}
>
Find
{this.props.t('Find')}
<span className="nav__keyboard-shortcut">{metaKeyName}+F</span>
</button>
</li>
@ -394,7 +406,7 @@ class Nav extends React.PureComponent {
onFocus={this.handleFocusForEdit}
onBlur={this.handleBlur}
>
Find Next
{this.props.t('FindNext')}
<span className="nav__keyboard-shortcut">{metaKeyName}+G</span>
</button>
</li>
@ -404,7 +416,7 @@ class Nav extends React.PureComponent {
onFocus={this.handleFocusForEdit}
onBlur={this.handleBlur}
>
Find Previous
{this.props.t('FindPrevious')}
<span className="nav__keyboard-shortcut">{'\u21E7'}+{metaKeyName}+G</span>
</button>
</li>
@ -421,7 +433,7 @@ class Nav extends React.PureComponent {
}
}}
>
<span className="nav__item-header">Sketch</span>
<span className="nav__item-header">{this.props.t('Sketch')}</span>
<TriangleIcon className="nav__item-header-triangle" focusable="false" aria-hidden="true" />
</button>
<ul className="nav__dropdown">
@ -431,7 +443,7 @@ class Nav extends React.PureComponent {
onFocus={this.handleFocusForSketch}
onBlur={this.handleBlur}
>
Add File
{this.props.t('AddFile')}
</button>
</li>
<li className="nav__dropdown-item">
@ -440,7 +452,7 @@ class Nav extends React.PureComponent {
onFocus={this.handleFocusForSketch}
onBlur={this.handleBlur}
>
Add Folder
{this.props.t('AddFolder')}
</button>
</li>
<li className="nav__dropdown-item">
@ -449,7 +461,7 @@ class Nav extends React.PureComponent {
onFocus={this.handleFocusForSketch}
onBlur={this.handleBlur}
>
Run
{this.props.t('Run')}
<span className="nav__keyboard-shortcut">{metaKeyName}+Enter</span>
</button>
</li>
@ -459,7 +471,7 @@ class Nav extends React.PureComponent {
onFocus={this.handleFocusForSketch}
onBlur={this.handleBlur}
>
Stop
{this.props.t('Stop')}
<span className="nav__keyboard-shortcut">{'\u21E7'}+{metaKeyName}+Enter</span>
</button>
</li>
@ -496,7 +508,7 @@ class Nav extends React.PureComponent {
}
}}
>
<span className="nav__item-header">Help</span>
<span className="nav__item-header">{this.props.t('Help')}</span>
<TriangleIcon className="nav__item-header-triangle" focusable="false" aria-hidden="true" />
</button>
<ul className="nav__dropdown">
@ -506,7 +518,7 @@ class Nav extends React.PureComponent {
onBlur={this.handleBlur}
onClick={this.handleKeyboardShortcuts}
>
Keyboard Shortcuts
{this.props.t('KeyboardShortcuts')}
</button>
</li>
<li className="nav__dropdown-item">
@ -517,7 +529,7 @@ class Nav extends React.PureComponent {
onFocus={this.handleFocusForHelp}
onBlur={this.handleBlur}
onClick={this.setDropdownForNone}
>Reference
>{this.props.t('Reference')}
</a>
</li>
<li className="nav__dropdown-item">
@ -527,7 +539,7 @@ class Nav extends React.PureComponent {
onBlur={this.handleBlur}
onClick={this.setDropdownForNone}
>
About
{this.props.t('About')}
</Link>
</li>
</ul>
@ -536,18 +548,73 @@ class Nav extends React.PureComponent {
);
}
renderLanguageMenu(navDropdownState) {
return (
<ul className="nav__items-right" title="user-menu">
<li className={navDropdownState.lang}>
<button
onClick={this.toggleDropdownForLang}
onBlur={this.handleBlur}
onFocus={this.clearHideTimeout}
onMouseOver={() => {
if (this.state.dropdownOpen !== 'none') {
this.setDropdown('lang');
}
}}
>
<span className="nav__item-header"> {this.props.t('Lang')}</span>
<TriangleIcon className="nav__item-header-triangle" focusable="false" aria-hidden="true" />
</button>
<ul className="nav__dropdown">
<li className="nav__dropdown-item">
<button
onFocus={this.handleFocusForLang}
onBlur={this.handleBlur}
value="it"
onClick={e => this.handleLangSelection(e)}
>
Italian (Test Fallback)
</button>
</li>
<li className="nav__dropdown-item">
<button
onFocus={this.handleFocusForLang}
onBlur={this.handleBlur}
value="en-US"
onClick={e => this.handleLangSelection(e)}
>English
</button>
</li>
<li className="nav__dropdown-item">
<button
onFocus={this.handleFocusForLang}
onBlur={this.handleBlur}
value="es-419"
onClick={e => this.handleLangSelection(e)}
>
Español
</button>
</li>
</ul>
</li>
</ul>
);
}
renderUnauthenticatedUserMenu(navDropdownState) {
return (
<ul className="nav__items-right" title="user-menu">
<li className="nav__item">
<Link to="/login" className="nav__auth-button">
<span className="nav__item-header">Log in</span>
<span className="nav__item-header">{this.props.t('Login')}</span>
</Link>
</li>
<span className="nav__item-or">or</span>
<span className="nav__item-or">{this.props.t('LoginOr')}</span>
<li className="nav__item">
<Link to="/signup" className="nav__auth-button">
<span className="nav__item-header">Sign up</span>
<span className="nav__item-header">{this.props.t('SignUp')}</span>
</Link>
</li>
</ul>
@ -558,7 +625,7 @@ class Nav extends React.PureComponent {
return (
<ul className="nav__items-right" title="user-menu">
<li className="nav__item">
<span>Hello, {this.props.user.username}!</span>
<span>{this.props.t('Hello')}, {this.props.user.username}!</span>
</li>
<span className="nav__item-spacer">|</span>
<li className={navDropdownState.account}>
@ -573,7 +640,7 @@ class Nav extends React.PureComponent {
}
}}
>
My Account
{this.props.t('MyAccount')}
<TriangleIcon className="nav__item-header-triangle" focusable="false" aria-hidden="true" />
</button>
<ul className="nav__dropdown">
@ -584,10 +651,10 @@ class Nav extends React.PureComponent {
onBlur={this.handleBlur}
onClick={this.setDropdownForNone}
>
My sketches
{this.props.t('MySketches')}
</Link>
</li>
{__process.env.UI_COLLECTIONS_ENABLED &&
{getConfig('UI_COLLECTIONS_ENABLED') &&
<li className="nav__dropdown-item">
<Link
to={`/${this.props.user.username}/collections`}
@ -595,7 +662,7 @@ class Nav extends React.PureComponent {
onBlur={this.handleBlur}
onClick={this.setDropdownForNone}
>
My collections
{this.props.t('MyCollections')}
</Link>
</li>
}
@ -606,7 +673,7 @@ class Nav extends React.PureComponent {
onBlur={this.handleBlur}
onClick={this.setDropdownForNone}
>
My assets
{this.props.t('MyAssets')}
</Link>
</li>
<li className="nav__dropdown-item">
@ -616,7 +683,7 @@ class Nav extends React.PureComponent {
onBlur={this.handleBlur}
onClick={this.setDropdownForNone}
>
Settings
{this.props.t('Settings')}
</Link>
</li>
<li className="nav__dropdown-item">
@ -625,7 +692,7 @@ class Nav extends React.PureComponent {
onFocus={this.handleFocusForAccount}
onBlur={this.handleBlur}
>
Log out
{this.props.t('LogOut')}
</button>
</li>
</ul>
@ -635,7 +702,7 @@ class Nav extends React.PureComponent {
}
renderUserMenu(navDropdownState) {
const isLoginEnabled = __process.env.LOGIN_ENABLED;
const isLoginEnabled = getConfig('LOGIN_ENABLED');
const isAuthenticated = this.props.user.authenticated;
if (isLoginEnabled && isAuthenticated) {
@ -678,6 +745,10 @@ class Nav extends React.PureComponent {
account: classNames({
'nav__item': true,
'nav__item--open': this.state.dropdownOpen === 'account'
}),
lang: classNames({
'nav__item': true,
'nav__item--open': this.state.dropdownOpen === 'lang'
})
};
@ -685,6 +756,7 @@ class Nav extends React.PureComponent {
<header>
<nav className="nav" title="main-navigation" ref={(node) => { this.node = node; }}>
{this.renderLeftLayout(navDropdownState)}
{getConfig('TRANSLATIONS_ENABLED') && this.renderLanguageMenu(navDropdownState)}
{this.renderUserMenu(navDropdownState)}
</nav>
</header>
@ -735,7 +807,9 @@ Nav.propTypes = {
}).isRequired,
params: PropTypes.shape({
username: PropTypes.string
})
}),
t: PropTypes.func.isRequired
};
Nav.defaultProps = {
@ -768,5 +842,5 @@ const mapDispatchToProps = {
setAllAccessibleOutput
};
export default withRouter(connect(mapStateToProps, mapDispatchToProps)(Nav));
export default withTranslation('WebEditor')(withRouter(connect(mapStateToProps, mapDispatchToProps)(Nav)));
export { Nav as NavComponent };

View file

@ -1,183 +0,0 @@
import React from 'react';
import { shallow } from 'enzyme';
import { FileNode } from '../../modules/IDE/components/FileNode';
describe('<FileNode />', () => {
let component;
let props = {};
let input;
let renameTriggerButton;
const changeName = (newFileName) => {
renameTriggerButton.simulate('click');
input.simulate('change', { target: { value: newFileName } });
input.simulate('blur');
};
const getState = () => component.state();
const getUpdatedName = () => getState().updatedName;
describe('with valid props, regardless of filetype', () => {
['folder', 'file'].forEach((fileType) => {
beforeEach(() => {
props = {
...props,
id: '0',
name: 'test.jsx',
fileType,
canEdit: true,
children: [],
authenticated: false,
setSelectedFile: jest.fn(),
deleteFile: jest.fn(),
updateFileName: jest.fn(),
resetSelectedFile: jest.fn(),
newFile: jest.fn(),
newFolder: jest.fn(),
showFolderChildren: jest.fn(),
hideFolderChildren: jest.fn(),
openUploadFileModal: jest.fn(),
setProjectName: jest.fn(),
};
component = shallow(<FileNode {...props} />);
});
describe('when changing name', () => {
beforeEach(() => {
input = component.find('.sidebar__file-item-input');
renameTriggerButton = component
.find('.sidebar__file-item-option')
.first();
component.setState({ isEditing: true });
});
describe('to an empty name', () => {
const newName = '';
beforeEach(() => changeName(newName));
it('should not save', () => expect(props.updateFileName).not.toHaveBeenCalled());
it('should reset name', () => expect(getUpdatedName()).toEqual(props.name));
});
});
});
});
describe('as file with valid props', () => {
beforeEach(() => {
props = {
...props,
id: '0',
name: 'test.jsx',
fileType: 'file',
canEdit: true,
children: [],
authenticated: false,
setSelectedFile: jest.fn(),
deleteFile: jest.fn(),
updateFileName: jest.fn(),
resetSelectedFile: jest.fn(),
newFile: jest.fn(),
newFolder: jest.fn(),
showFolderChildren: jest.fn(),
hideFolderChildren: jest.fn(),
openUploadFileModal: jest.fn()
};
component = shallow(<FileNode {...props} />);
});
describe('when changing name', () => {
beforeEach(() => {
input = component.find('.sidebar__file-item-input');
renameTriggerButton = component
.find('.sidebar__file-item-option')
.first();
component.setState({ isEditing: true });
});
it('should render', () => expect(component).toBeDefined());
// it('should debug', () => console.log(component.debug()));
describe('to a valid filename', () => {
const newName = 'newname.jsx';
beforeEach(() => changeName(newName));
it('should save the name', () => {
expect(props.updateFileName).toBeCalledWith(props.id, newName);
});
});
// Failure Scenarios
describe('to an extensionless filename', () => {
const newName = 'extensionless';
beforeEach(() => changeName(newName));
});
it('should not save', () => expect(props.setProjectName).not.toHaveBeenCalled());
it('should reset name', () => expect(getUpdatedName()).toEqual(props.name));
describe('to different extension', () => {
const newName = 'name.gif';
beforeEach(() => changeName(newName));
it('should not save', () => expect(props.setProjectName).not.toHaveBeenCalled());
it('should reset name', () => expect(getUpdatedName()).toEqual(props.name));
});
describe('to just an extension', () => {
const newName = '.jsx';
beforeEach(() => changeName(newName));
it('should not save', () => expect(props.updateFileName).not.toHaveBeenCalled());
it('should reset name', () => expect(getUpdatedName()).toEqual(props.name));
});
});
});
describe('as folder with valid props', () => {
beforeEach(() => {
props = {
...props,
id: '0',
children: [],
name: 'filename',
fileType: 'folder',
canEdit: true,
authenticated: false,
setSelectedFile: jest.fn(),
deleteFile: jest.fn(),
updateFileName: jest.fn(),
resetSelectedFile: jest.fn(),
newFile: jest.fn(),
newFolder: jest.fn(),
showFolderChildren: jest.fn(),
hideFolderChildren: jest.fn(),
openUploadFileModal: jest.fn()
};
component = shallow(<FileNode {...props} />);
});
describe('when changing name', () => {
beforeEach(() => {
input = component.find('.sidebar__file-item-input');
renameTriggerButton = component
.find('.sidebar__file-item-option')
.first();
component.setState({ isEditing: true });
});
describe('to a foldername', () => {
const newName = 'newfoldername';
beforeEach(() => changeName(newName));
it('should save', () => expect(props.updateFileName).toBeCalledWith(props.id, newName));
it('should update name', () => expect(getUpdatedName()).toEqual(newName));
});
describe('to a filename', () => {
const newName = 'filename.jsx';
beforeEach(() => changeName(newName));
it('should not save', () => expect(props.updateFileName).not.toHaveBeenCalled());
it('should reset name', () => expect(getUpdatedName()).toEqual(props.name));
});
});
});
});

View file

@ -1,9 +1,9 @@
import React from 'react';
import { shallow } from 'enzyme';
import renderer from 'react-test-renderer';
import { render } from '@testing-library/react';
import { NavComponent } from './../Nav';
import { NavComponent } from '../Nav';
describe('Nav', () => {
const props = {
@ -44,19 +44,12 @@ describe('Nav', () => {
setToastText: jest.fn(),
rootFile: {
id: 'root-file'
}
},
t: jest.fn()
};
const getWrapper = () => shallow(<NavComponent {...props} />);
test('it renders main navigation', () => {
const nav = getWrapper();
expect(nav.exists('.nav')).toEqual(true);
});
it('renders correctly', () => {
const tree = renderer
.create(<NavComponent {...props} />)
.toJSON();
expect(tree).toMatchSnapshot();
const { asFragment } = render(<NavComponent {...props} />);
expect(asFragment()).toMatchSnapshot();
});
});

View file

@ -1,105 +0,0 @@
import React from 'react';
import { shallow } from 'enzyme';
import { ToolbarComponent } from '../../modules/IDE/components/Toolbar';
const initialProps = {
isPlaying: false,
preferencesIsVisible: false,
stopSketch: jest.fn(),
setProjectName: jest.fn(),
openPreferences: jest.fn(),
showEditProjectName: jest.fn(),
hideEditProjectName: jest.fn(),
infiniteLoop: false,
autorefresh: false,
setAutorefresh: jest.fn(),
setTextOutput: jest.fn(),
setGridOutput: jest.fn(),
startSketch: jest.fn(),
startAccessibleSketch: jest.fn(),
saveProject: jest.fn(),
currentUser: 'me',
originalProjectName: 'testname',
owner: {
username: 'me'
},
project: {
name: 'testname',
isEditingName: false,
id: 'id',
},
};
describe('<ToolbarComponent />', () => {
let component;
let props = initialProps;
let input;
let renameTriggerButton;
const changeName = (newFileName) => {
component.find('.toolbar__project-name').simulate('click', { preventDefault: jest.fn() });
input = component.find('.toolbar__project-name-input');
renameTriggerButton = component.find('.toolbar__edit-name-button');
renameTriggerButton.simulate('click');
input.simulate('change', { target: { value: newFileName } });
input.simulate('blur');
};
const setProps = (additionalProps) => {
props = {
...props,
...additionalProps,
project: {
...props.project,
...(additionalProps || {}).project
},
};
};
// Test Cases
describe('with valid props', () => {
beforeEach(() => {
setProps();
component = shallow(<ToolbarComponent {...props} />);
});
it('renders', () => expect(component).toBeDefined());
describe('when use owns sketch', () => {
beforeEach(() => setProps({ currentUser: props.owner.username }));
describe('when changing sketch name', () => {
beforeEach(() => {
setProps({
project: { isEditingName: true, name: 'testname' },
setProjectName: jest.fn(name => component.setProps({ project: { name } })),
});
component = shallow(<ToolbarComponent {...props} />);
});
describe('to a valid name', () => {
beforeEach(() => changeName('hello'));
it('should save', () => expect(props.setProjectName).toBeCalledWith('hello'));
});
describe('to an empty name', () => {
beforeEach(() => changeName(''));
it('should set name to empty', () => expect(props.setProjectName).toBeCalledWith(''));
it(
'should detect empty name and revert to original',
() => expect(props.setProjectName).toHaveBeenLastCalledWith(initialProps.project.name)
);
});
});
});
describe('when user does not own sketch', () => {
beforeEach(() => setProps({ currentUser: 'not-the-owner' }));
it('should disable edition', () => expect(component.find('.toolbar__edit-name-button')).toEqual({}));
});
});
});

View file

@ -1,346 +1,219 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Nav renders correctly 1`] = `
<DocumentFragment>
<header>
<nav
className="nav"
class="nav"
title="main-navigation"
>
<ul
className="nav__items-left"
class="nav__items-left"
>
<li
className="nav__item-logo"
class="nav__item-logo"
>
<test-file-stub
aria-label="p5.js Logo"
className="svg__logo"
classname="svg__logo"
focusable="false"
role="img"
/>
</li>
<li
className="nav__item"
>
<button
onBlur={[Function]}
onClick={[Function]}
onFocus={[Function]}
onMouseOver={[Function]}
class="nav__item"
>
<button>
<span
className="nav__item-header"
>
File
</span>
class="nav__item-header"
/>
<test-file-stub
aria-hidden="true"
className="nav__item-header-triangle"
classname="nav__item-header-triangle"
focusable="false"
/>
</button>
<ul
className="nav__dropdown"
class="nav__dropdown"
>
<li
className="nav__dropdown-item"
class="nav__dropdown-item"
>
<button
onBlur={[Function]}
onClick={[Function]}
onFocus={[Function]}
>
New
</button>
<button />
</li>
<li
className="nav__dropdown-item"
class="nav__dropdown-item"
>
<button
onBlur={[Function]}
onClick={[Function]}
onFocus={[Function]}
>
Duplicate
</button>
<button />
</li>
<li
className="nav__dropdown-item"
class="nav__dropdown-item"
>
<button
onBlur={[Function]}
onClick={[Function]}
onFocus={[Function]}
>
Share
</button>
<button />
</li>
<li
className="nav__dropdown-item"
class="nav__dropdown-item"
>
<button
onBlur={[Function]}
onClick={[Function]}
onFocus={[Function]}
>
Download
</button>
<button />
</li>
<li
className="nav__dropdown-item"
class="nav__dropdown-item"
>
<a
onBlur={[Function]}
onClick={[Function]}
onFocus={[Function]}
style={Object {}}
>
Open
</a>
<a />
</li>
</ul>
</li>
<li
className="nav__item"
>
<button
onBlur={[Function]}
onClick={[Function]}
onFocus={[Function]}
onMouseOver={[Function]}
class="nav__item"
>
<button>
<span
className="nav__item-header"
>
Edit
</span>
class="nav__item-header"
/>
<test-file-stub
aria-hidden="true"
className="nav__item-header-triangle"
classname="nav__item-header-triangle"
focusable="false"
/>
</button>
<ul
className="nav__dropdown"
class="nav__dropdown"
>
<li
className="nav__dropdown-item"
class="nav__dropdown-item"
>
<button
onBlur={[Function]}
onClick={[Function]}
onFocus={[Function]}
>
Tidy Code
<button>
<span
className="nav__keyboard-shortcut"
class="nav__keyboard-shortcut"
>
+Tab
⇧+Tab
</span>
</button>
</li>
<li
className="nav__dropdown-item"
class="nav__dropdown-item"
>
<button
onBlur={[Function]}
onClick={[Function]}
onFocus={[Function]}
>
Find
<button>
<span
className="nav__keyboard-shortcut"
class="nav__keyboard-shortcut"
>
+F
⌃+F
</span>
</button>
</li>
<li
className="nav__dropdown-item"
class="nav__dropdown-item"
>
<button
onBlur={[Function]}
onClick={[Function]}
onFocus={[Function]}
>
Find Next
<button>
<span
className="nav__keyboard-shortcut"
class="nav__keyboard-shortcut"
>
+G
⌃+G
</span>
</button>
</li>
<li
className="nav__dropdown-item"
class="nav__dropdown-item"
>
<button
onBlur={[Function]}
onClick={[Function]}
onFocus={[Function]}
>
Find Previous
<button>
<span
className="nav__keyboard-shortcut"
class="nav__keyboard-shortcut"
>
+
+G
⇧+⌃+G
</span>
</button>
</li>
</ul>
</li>
<li
className="nav__item"
>
<button
onBlur={[Function]}
onClick={[Function]}
onFocus={[Function]}
onMouseOver={[Function]}
class="nav__item"
>
<button>
<span
className="nav__item-header"
>
Sketch
</span>
class="nav__item-header"
/>
<test-file-stub
aria-hidden="true"
className="nav__item-header-triangle"
classname="nav__item-header-triangle"
focusable="false"
/>
</button>
<ul
className="nav__dropdown"
class="nav__dropdown"
>
<li
className="nav__dropdown-item"
class="nav__dropdown-item"
>
<button
onBlur={[Function]}
onClick={[Function]}
onFocus={[Function]}
>
Add File
</button>
<button />
</li>
<li
className="nav__dropdown-item"
class="nav__dropdown-item"
>
<button
onBlur={[Function]}
onClick={[Function]}
onFocus={[Function]}
>
Add Folder
</button>
<button />
</li>
<li
className="nav__dropdown-item"
class="nav__dropdown-item"
>
<button
onBlur={[Function]}
onClick={[Function]}
onFocus={[Function]}
>
Run
<button>
<span
className="nav__keyboard-shortcut"
class="nav__keyboard-shortcut"
>
+Enter
⌃+Enter
</span>
</button>
</li>
<li
className="nav__dropdown-item"
class="nav__dropdown-item"
>
<button
onBlur={[Function]}
onClick={[Function]}
onFocus={[Function]}
>
Stop
<button>
<span
className="nav__keyboard-shortcut"
class="nav__keyboard-shortcut"
>
+
+Enter
⇧+⌃+Enter
</span>
</button>
</li>
</ul>
</li>
<li
className="nav__item"
>
<button
onBlur={[Function]}
onClick={[Function]}
onFocus={[Function]}
onMouseOver={[Function]}
class="nav__item"
>
<button>
<span
className="nav__item-header"
>
Help
</span>
class="nav__item-header"
/>
<test-file-stub
aria-hidden="true"
className="nav__item-header-triangle"
classname="nav__item-header-triangle"
focusable="false"
/>
</button>
<ul
className="nav__dropdown"
class="nav__dropdown"
>
<li
className="nav__dropdown-item"
class="nav__dropdown-item"
>
<button
onBlur={[Function]}
onClick={[Function]}
onFocus={[Function]}
>
Keyboard Shortcuts
</button>
<button />
</li>
<li
className="nav__dropdown-item"
class="nav__dropdown-item"
>
<a
href="https://p5js.org/reference/"
onBlur={[Function]}
onClick={[Function]}
onFocus={[Function]}
rel="noopener noreferrer"
target="_blank"
>
Reference
</a>
/>
</li>
<li
className="nav__dropdown-item"
class="nav__dropdown-item"
>
<a
onBlur={[Function]}
onClick={[Function]}
onFocus={[Function]}
style={Object {}}
>
About
</a>
<a />
</li>
</ul>
</li>
</ul>
</nav>
</header>
</DocumentFragment>
`;

View file

@ -0,0 +1,20 @@
import React from 'react';
import styled from 'styled-components';
import { prop, remSize } from '../../theme';
const background = prop('MobilePanel.default.background');
const textColor = prop('primaryTextColor');
const Footer = styled.div`
position: fixed;
width: 100%;
background: ${background};
color: ${textColor};
padding: ${remSize(12)};
padding-left: ${remSize(32)};
z-index: 1;
bottom: 0;
`;
export default Footer;

View file

@ -0,0 +1,80 @@
import React from 'react';
import styled from 'styled-components';
import PropTypes from 'prop-types';
import { prop, remSize } from '../../theme';
const background = prop('MobilePanel.default.background');
const textColor = prop('primaryTextColor');
const HeaderDiv = styled.div`
position: fixed;
width: 100%;
background: ${props => (props.transparent ? 'transparent' : background)};
color: ${textColor};
padding: ${remSize(12)};
padding-left: ${remSize(16)};
padding-right: ${remSize(16)};
z-index: 1;
display: flex;
flex: 1;
flex-direction: row;
justify-content: flex-start;
align-items: center;
// TODO:
svg {
max-height: ${remSize(32)};
padding: ${remSize(4)}
}
`;
const IconContainer = styled.div`
margin-left: ${props => (props.leftButton ? remSize(32) : remSize(4))};
display: flex;
`;
const TitleContainer = styled.div`
margin-left: ${remSize(4)};
margin-right: auto;
${props => props.padded && `h2{
padding-top: ${remSize(10)};
padding-bottom: ${remSize(10)};
}`}
`;
const Header = ({
title, subtitle, leftButton, children, transparent
}) => (
<HeaderDiv transparent={transparent}>
{leftButton}
<TitleContainer padded={subtitle === null}>
{title && <h2>{title}</h2>}
{subtitle && <h3>{subtitle}</h3>}
</TitleContainer>
<IconContainer>
{children}
</IconContainer>
</HeaderDiv>
);
Header.propTypes = {
title: PropTypes.string,
subtitle: PropTypes.string,
leftButton: PropTypes.element,
children: PropTypes.oneOfType([PropTypes.element, PropTypes.arrayOf(PropTypes.element)]),
transparent: PropTypes.bool
};
Header.defaultProps = {
title: null,
subtitle: null,
leftButton: null,
children: [],
transparent: false
};
export default Header;

View file

@ -0,0 +1,8 @@
import React from 'react';
import styled from 'styled-components';
import { remSize } from '../../theme';
export default styled.div`
z-index: 0;
margin-top: ${remSize(16)};
`;

View file

@ -0,0 +1,31 @@
import React from 'react';
import PropTypes from 'prop-types';
import styled from 'styled-components';
import Button from '../../common/Button';
import { remSize } from '../../theme';
const ButtonWrapper = styled(Button)`
width: ${remSize(48)};
> svg {
width: 100%;
height: 100%;
}
`;
const IconButton = (props) => {
const { icon, ...otherProps } = props;
const Icon = icon;
return (<ButtonWrapper
iconBefore={<Icon />}
kind={Button.kinds.inline}
focusable="false"
{...otherProps}
/>);
};
IconButton.propTypes = {
icon: PropTypes.func.isRequired
};
export default IconButton;

View file

@ -0,0 +1,19 @@
import React from 'react';
import PropTypes from 'prop-types';
const Screen = ({ children, fullscreen }) => (
<div className={fullscreen && 'fullscreen-preview'}>
{children}
</div>
);
Screen.defaultProps = {
fullscreen: false
};
Screen.propTypes = {
children: PropTypes.node.isRequired,
fullscreen: PropTypes.bool
};
export default Screen;

View file

@ -0,0 +1,67 @@
import React from 'react';
import PropTypes from 'prop-types';
import styled from 'styled-components';
import { prop, remSize } from '../../theme';
const PreferenceTitle = styled.h4.attrs(props => ({ ...props, className: 'preference__title' }))`
color: ${prop('primaryTextColor')};
`;
const Preference = styled.div.attrs(props => ({ ...props, className: 'preference' }))`
flex-wrap: nowrap !important;
align-items: baseline !important;
justify-items: space-between;
`;
const OptionLabel = styled.label.attrs({ className: 'preference__option' })`
font-size: ${remSize(14)} !important;
`;
const PreferencePicker = ({
title, value, onSelect, options,
}) => (
<Preference>
<PreferenceTitle>{title}</PreferenceTitle>
<div className="preference__options">
{options.map(option => (
<React.Fragment key={`${option.name}-${option.id}`} >
<input
type="radio"
onChange={() => onSelect(option.value)}
aria-label={option.ariaLabel}
name={option.name}
key={`${option.name}-${option.id}-input`}
id={option.id}
className="preference__radio-button"
value={option.value}
checked={value === option.value}
/>
<OptionLabel
key={`${option.name}-${option.id}-label`}
htmlFor={option.id}
>
{option.label}
</OptionLabel>
</React.Fragment>))}
</div>
</Preference>
);
PreferencePicker.defaultProps = {
options: []
};
PreferencePicker.propTypes = {
title: PropTypes.string.isRequired,
value: PropTypes.oneOfType([PropTypes.bool, PropTypes.string]).isRequired,
options: PropTypes.arrayOf(PropTypes.shape({
id: PropTypes.string,
name: PropTypes.string,
label: PropTypes.string,
ariaLabel: PropTypes.string,
})),
onSelect: PropTypes.func.isRequired,
};
export default PreferencePicker;

38
client/i18n.js Normal file
View file

@ -0,0 +1,38 @@
import i18n from 'i18next';
import { initReactI18next } from 'react-i18next';
import LanguageDetector from 'i18next-browser-languagedetector';
import Backend from 'i18next-http-backend';
const fallbackLng = ['en-US'];
const availableLanguages = ['en-US', 'es-419'];
const options = {
loadPath: 'locales/{{lng}}/translations.json',
requestOptions: { // used for fetch, can also be a function (payload) => ({ method: 'GET' })
mode: 'no-cors'
},
allowMultiLoading: false, // set loadPath: '/locales/resources.json?lng={{lng}}&ns={{ns}}' to adapt to multiLoading
};
i18n
.use(initReactI18next) // pass the i18n instance to react-i18next.
.use(LanguageDetector)// to detect the language from currentBrowser
.use(Backend) // to fetch the data from server
.init({
lng: 'en-US',
defaultNS: 'WebEditor',
fallbackLng, // if user computer language is not on the list of available languages, than we will be using the fallback language specified earlier
debug: false,
backend: options,
getAsync: false,
initImmediate: false,
useSuspense: true,
whitelist: availableLanguages,
interpolation: {
escapeValue: false, // react already safes from xss
},
saveMissing: false, // if a key is not found AND this flag is set to true, i18next will call the handler missingKeyHandler
missingKeyHandler: false // function(lng, ns, key, fallbackValue) { } custom logic about how to handle the missing keys
});
export default i18n;

View file

@ -1,4 +1,4 @@
import React from 'react';
import React, { Suspense } from 'react';
import { render } from 'react-dom';
import { hot } from 'react-hot-loader/root';
import { Provider } from 'react-redux';
@ -7,6 +7,8 @@ import { Router, browserHistory } from 'react-router';
import configureStore from './store';
import routes from './routes';
import ThemeProvider from './modules/App/components/ThemeProvider';
import Loader from './modules/App/components/loader';
import i18n from './i18n';
require('./styles/main.scss');
@ -29,6 +31,8 @@ const App = () => (
const HotApp = hot(App);
render(
<HotApp />,
<Suspense fallback={(<Loader />)}>
<HotApp />
</Suspense>,
document.getElementById('root')
);

5
client/jest.setup.js Normal file
View file

@ -0,0 +1,5 @@
import '@babel/polyfill';
// See: https://github.com/testing-library/jest-dom
// eslint-disable-next-line import/no-extraneous-dependencies
import '@testing-library/jest-dom';

View file

@ -1,11 +1,10 @@
import PropTypes from 'prop-types';
import React from 'react';
import { connect } from 'react-redux';
import getConfig from '../../utils/getConfig';
import DevTools from './components/DevTools';
import { setPreviousPath } from '../IDE/actions/ide';
const __process = (typeof global !== 'undefined' ? global : window).process;
class App extends React.Component {
constructor(props, context) {
super(props, context);
@ -35,7 +34,7 @@ class App extends React.Component {
render() {
return (
<div className="app">
{this.state.isMounted && !window.devToolsExtension && __process.env.NODE_ENV === 'development' && <DevTools />}
{this.state.isMounted && !window.devToolsExtension && getConfig('NODE_ENV') === 'development' && <DevTools />}
{this.props.children}
</div>
);

View file

@ -1,10 +1,7 @@
import axios from 'axios';
import apiClient from '../../../utils/apiClient';
import * as ActionTypes from '../../../constants';
import { startLoader, stopLoader } from './loader';
const __process = (typeof global !== 'undefined' ? global : window).process;
const ROOT_URL = __process.env.API_URL;
function setAssets(assets, totalSize) {
return {
type: ActionTypes.SET_ASSETS,
@ -16,7 +13,7 @@ function setAssets(assets, totalSize) {
export function getAssets() {
return (dispatch) => {
dispatch(startLoader());
axios.get(`${ROOT_URL}/S3/objects`, { withCredentials: true })
apiClient.get('/S3/objects')
.then((response) => {
dispatch(setAssets(response.data.assets, response.data.totalSize));
dispatch(stopLoader());
@ -39,7 +36,7 @@ export function deleteAsset(assetKey) {
export function deleteAssetRequest(assetKey) {
return (dispatch) => {
axios.delete(`${ROOT_URL}/S3/${assetKey}`, { withCredentials: true })
apiClient.delete(`/S3/${assetKey}`)
.then((response) => {
dispatch(deleteAsset(assetKey));
})

View file

@ -1,11 +1,9 @@
import axios from 'axios';
import { browserHistory } from 'react-router';
import apiClient from '../../../utils/apiClient';
import * as ActionTypes from '../../../constants';
import { startLoader, stopLoader } from './loader';
import { setToastText, showToast } from './toast';
const __process = (typeof global !== 'undefined' ? global : window).process;
const ROOT_URL = __process.env.API_URL;
const TOAST_DISPLAY_TIME_MS = 1500;
@ -15,11 +13,11 @@ export function getCollections(username) {
dispatch(startLoader());
let url;
if (username) {
url = `${ROOT_URL}/${username}/collections`;
url = `/${username}/collections`;
} else {
url = `${ROOT_URL}/collections`;
url = '/collections';
}
axios.get(url, { withCredentials: true })
apiClient.get(url)
.then((response) => {
dispatch({
type: ActionTypes.SET_COLLECTIONS,
@ -41,8 +39,8 @@ export function getCollections(username) {
export function createCollection(collection) {
return (dispatch) => {
dispatch(startLoader());
const url = `${ROOT_URL}/collections`;
return axios.post(url, collection, { withCredentials: true })
const url = '/collections';
return apiClient.post(url, collection)
.then((response) => {
dispatch({
type: ActionTypes.CREATE_COLLECTION
@ -73,8 +71,8 @@ export function createCollection(collection) {
export function addToCollection(collectionId, projectId) {
return (dispatch) => {
dispatch(startLoader());
const url = `${ROOT_URL}/collections/${collectionId}/${projectId}`;
return axios.post(url, { withCredentials: true })
const url = `/collections/${collectionId}/${projectId}`;
return apiClient.post(url)
.then((response) => {
dispatch({
type: ActionTypes.ADD_TO_COLLECTION,
@ -105,8 +103,8 @@ export function addToCollection(collectionId, projectId) {
export function removeFromCollection(collectionId, projectId) {
return (dispatch) => {
dispatch(startLoader());
const url = `${ROOT_URL}/collections/${collectionId}/${projectId}`;
return axios.delete(url, { withCredentials: true })
const url = `/collections/${collectionId}/${projectId}`;
return apiClient.delete(url)
.then((response) => {
dispatch({
type: ActionTypes.REMOVE_FROM_COLLECTION,
@ -136,8 +134,8 @@ export function removeFromCollection(collectionId, projectId) {
export function editCollection(collectionId, { name, description }) {
return (dispatch) => {
const url = `${ROOT_URL}/collections/${collectionId}`;
return axios.patch(url, { name, description }, { withCredentials: true })
const url = `/collections/${collectionId}`;
return apiClient.patch(url, { name, description })
.then((response) => {
dispatch({
type: ActionTypes.EDIT_COLLECTION,
@ -159,8 +157,8 @@ export function editCollection(collectionId, { name, description }) {
export function deleteCollection(collectionId) {
return (dispatch) => {
const url = `${ROOT_URL}/collections/${collectionId}`;
return axios.delete(url, { withCredentials: true })
const url = `/collections/${collectionId}`;
return apiClient.delete(url)
.then((response) => {
dispatch({
type: ActionTypes.DELETE_COLLECTION,

View file

@ -1,13 +1,11 @@
import axios from 'axios';
import objectID from 'bson-objectid';
import blobUtil from 'blob-util';
import { reset } from 'redux-form';
import apiClient from '../../../utils/apiClient';
import * as ActionTypes from '../../../constants';
import { setUnsavedChanges, closeNewFolderModal, closeNewFileModal } from './ide';
import { setProjectSavedTime } from './project';
const __process = (typeof global !== 'undefined' ? global : window).process;
const ROOT_URL = __process.env.API_URL;
function appendToFilename(filename, string) {
const dotIndex = filename.lastIndexOf('.');
@ -50,7 +48,7 @@ export function createFile(formProps) {
parentId,
children: []
};
axios.post(`${ROOT_URL}/projects/${state.project.id}/files`, postParams, { withCredentials: true })
apiClient.post(`/projects/${state.project.id}/files`, postParams)
.then((response) => {
dispatch({
type: ActionTypes.CREATE_FILE,
@ -106,7 +104,7 @@ export function createFolder(formProps) {
parentId,
fileType: 'folder'
};
axios.post(`${ROOT_URL}/projects/${state.project.id}/files`, postParams, { withCredentials: true })
apiClient.post(`/projects/${state.project.id}/files`, postParams)
.then((response) => {
dispatch({
type: ActionTypes.CREATE_FILE,
@ -161,7 +159,7 @@ export function deleteFile(id, parentId) {
parentId
}
};
axios.delete(`${ROOT_URL}/projects/${state.project.id}/files/${id}`, deleteConfig, { withCredentials: true })
apiClient.delete(`/projects/${state.project.id}/files/${id}`, deleteConfig)
.then(() => {
dispatch({
type: ActionTypes.DELETE_FILE,

View file

@ -1,11 +1,8 @@
import axios from 'axios';
import apiClient from '../../../utils/apiClient';
import * as ActionTypes from '../../../constants';
const __process = (typeof global !== 'undefined' ? global : window).process;
const ROOT_URL = __process.env.API_URL;
function updatePreferences(formParams, dispatch) {
axios.put(`${ROOT_URL}/preferences`, formParams, { withCredentials: true })
apiClient.put('/preferences', formParams)
.then(() => {
})
.catch((error) => {

View file

@ -1,8 +1,9 @@
import { browserHistory } from 'react-router';
import axios from 'axios';
import objectID from 'bson-objectid';
import each from 'async/each';
import isEqual from 'lodash/isEqual';
import apiClient from '../../../utils/apiClient';
import getConfig from '../../../utils/getConfig';
import * as ActionTypes from '../../../constants';
import { showToast, setToastText } from './toast';
import {
@ -14,8 +15,7 @@ import {
} from './ide';
import { clearState, saveState } from '../../../persistState';
const __process = (typeof global !== 'undefined' ? global : window).process;
const ROOT_URL = __process.env.API_URL;
const ROOT_URL = getConfig('API_URL');
export function setProject(project) {
return {
@ -52,7 +52,7 @@ export function setNewProject(project) {
export function getProject(id, username) {
return (dispatch, getState) => {
dispatch(justOpenedProject());
axios.get(`${ROOT_URL}/${username}/projects/${id}`, { withCredentials: true })
apiClient.get(`/${username}/projects/${id}`)
.then((response) => {
dispatch(setProject(response.data));
dispatch(setUnsavedChanges(false));
@ -142,7 +142,7 @@ export function saveProject(selectedFile = null, autosave = false) {
fileToUpdate.content = selectedFile.content;
}
if (state.project.id) {
return axios.put(`${ROOT_URL}/projects/${state.project.id}`, formParams, { withCredentials: true })
return apiClient.put(`/projects/${state.project.id}`, formParams)
.then((response) => {
dispatch(endSavingProject());
dispatch(setUnsavedChanges(false));
@ -155,18 +155,20 @@ export function saveProject(selectedFile = null, autosave = false) {
if (!autosave) {
if (state.ide.justOpenedProject && state.preferences.autosave) {
dispatch(showToast(5500));
dispatch(setToastText('Project saved.'));
dispatch(setToastText('Sketch saved.'));
setTimeout(() => dispatch(setToastText('Autosave enabled.')), 1500);
dispatch(resetJustOpenedProject());
} else {
dispatch(showToast(1500));
dispatch(setToastText('Project saved.'));
dispatch(setToastText('Sketch saved.'));
}
}
})
.catch((error) => {
const { response } = error;
dispatch(endSavingProject());
dispatch(setToastText('Failed to save sketch.'));
dispatch(showToast(1500));
if (response.status === 403) {
dispatch(showErrorModal('staleSession'));
} else if (response.status === 409) {
@ -177,7 +179,7 @@ export function saveProject(selectedFile = null, autosave = false) {
});
}
return axios.post(`${ROOT_URL}/projects`, formParams, { withCredentials: true })
return apiClient.post('/projects', formParams)
.then((response) => {
dispatch(endSavingProject());
const { hasChanges, synchedProject } = getSynchedProject(getState(), response.data);
@ -195,18 +197,20 @@ export function saveProject(selectedFile = null, autosave = false) {
if (!autosave) {
if (state.preferences.autosave) {
dispatch(showToast(5500));
dispatch(setToastText('Project saved.'));
dispatch(setToastText('Sketch saved.'));
setTimeout(() => dispatch(setToastText('Autosave enabled.')), 1500);
dispatch(resetJustOpenedProject());
} else {
dispatch(showToast(1500));
dispatch(setToastText('Project saved.'));
dispatch(setToastText('Sketch saved.'));
}
}
})
.catch((error) => {
const { response } = error;
dispatch(endSavingProject());
dispatch(setToastText('Failed to save sketch.'));
dispatch(showToast(1500));
if (response.status === 403) {
dispatch(showErrorModal('staleSession'));
} else {
@ -260,7 +264,7 @@ export function cloneProject(id) {
if (!id) {
resolve(getState());
} else {
fetch(`${ROOT_URL}/projects/${id}`)
apiClient.get(`/projects/${id}`)
.then(res => res.json())
.then(data => resolve({
files: data.files,
@ -287,7 +291,7 @@ export function cloneProject(id) {
const formParams = {
url: file.url
};
axios.post(`${ROOT_URL}/S3/copy`, formParams, { withCredentials: true })
apiClient.post('/S3/copy', formParams)
.then((response) => {
file.url = response.data.url;
callback(null);
@ -298,7 +302,7 @@ export function cloneProject(id) {
}, (err) => {
// if not errors in duplicating the files on S3, then duplicate it
const formParams = Object.assign({}, { name: `${state.project.name} copy` }, { files: newFiles });
axios.post(`${ROOT_URL}/projects`, formParams, { withCredentials: true })
apiClient.post('/projects', formParams)
.then((response) => {
browserHistory.push(`/${response.data.user.username}/sketches/${response.data.id}`);
dispatch(setNewProject(response.data));
@ -337,7 +341,7 @@ export function setProjectSavedTime(updatedAt) {
export function changeProjectName(id, newName) {
return (dispatch, getState) => {
const state = getState();
axios.put(`${ROOT_URL}/projects/${id}`, { name: newName }, { withCredentials: true })
apiClient.put(`/projects/${id}`, { name: newName })
.then((response) => {
if (response.status === 200) {
dispatch({
@ -364,7 +368,7 @@ export function changeProjectName(id, newName) {
export function deleteProject(id) {
return (dispatch, getState) => {
axios.delete(`${ROOT_URL}/projects/${id}`, { withCredentials: true })
apiClient.delete(`/projects/${id}`)
.then(() => {
const state = getState();
if (id === state.project.id) {

View file

@ -1,21 +1,18 @@
import axios from 'axios';
import apiClient from '../../../utils/apiClient';
import * as ActionTypes from '../../../constants';
import { startLoader, stopLoader } from './loader';
const __process = (typeof global !== 'undefined' ? global : window).process;
const ROOT_URL = __process.env.API_URL;
// eslint-disable-next-line
export function getProjects(username) {
return (dispatch) => {
dispatch(startLoader());
let url;
if (username) {
url = `${ROOT_URL}/${username}/projects`;
url = `/${username}/projects`;
} else {
url = `${ROOT_URL}/projects`;
url = '/projects';
}
axios.get(url, { withCredentials: true })
apiClient.get(url)
.then((response) => {
dispatch({
type: ActionTypes.SET_PROJECTS,

View file

@ -1,11 +1,10 @@
import axios from 'axios';
import apiClient from '../../../utils/apiClient';
import getConfig from '../../../utils/getConfig';
import { createFile } from './files';
import { TEXT_FILE_REGEX } from '../../../../server/utils/fileUtils';
const __process = (typeof global !== 'undefined' ? global : window).process;
const s3BucketHttps = __process.env.S3_BUCKET_URL_BASE ||
`https://s3-${__process.env.AWS_REGION}.amazonaws.com/${__process.env.S3_BUCKET}/`;
const ROOT_URL = __process.env.API_URL;
const s3BucketHttps = getConfig('S3_BUCKET_URL_BASE') ||
`https://s3-${getConfig('AWS_REGION')}.amazonaws.com/${getConfig('S3_BUCKET')}/`;
const MAX_LOCAL_FILE_SIZE = 80000; // bytes, aka 80 KB
function localIntercept(file, options = {}) {
@ -46,18 +45,13 @@ export function dropzoneAcceptCallback(userId, file, done) {
});
} else {
file.postData = []; // eslint-disable-line
axios.post(
`${ROOT_URL}/S3/sign`, {
apiClient.post('/S3/sign', {
name: file.name,
type: file.type,
size: file.size,
userId
// _csrf: document.getElementById('__createPostToken').value
},
{
withCredentials: true
}
)
})
.then((response) => {
file.custom_status = 'ready'; // eslint-disable-line
file.postData = response.data; // eslint-disable-line

View file

@ -1,11 +1,12 @@
import React from 'react';
import { Helmet } from 'react-helmet';
import { useTranslation } from 'react-i18next';
import SquareLogoIcon from '../../../images/p5js-square-logo.svg';
// import PlayIcon from '../../../images/play.svg';
import AsteriskIcon from '../../../images/p5-asterisk.svg';
function About(props) {
const { t } = useTranslation();
return (
<div className="about__content">
<Helmet>
@ -25,7 +26,7 @@ function About(props) {
</p> */}
</div>
<div className="about__content-column">
<h3 className="about__content-column-title">New to p5.js?</h3>
<h3 className="about__content-column-title">{t('NewP5')}</h3>
<p className="about__content-column-list">
<a
href="https://p5js.org/examples/"
@ -33,7 +34,7 @@ function About(props) {
rel="noopener noreferrer"
>
<AsteriskIcon className="about__content-column-asterisk" aria-hidden="true" focusable="false" />
Examples
{t('Examples')}
</a>
</p>
<p className="about__content-column-list">
@ -43,12 +44,12 @@ function About(props) {
rel="noopener noreferrer"
>
<AsteriskIcon className="about__content-column-asterisk" aria-hidden="true" focusable="false" />
Learn
{t('Learn')}
</a>
</p>
</div>
<div className="about__content-column">
<h3 className="about__content-column-title">Resources</h3>
<h3 className="about__content-column-title">{t('Resources')}</h3>
<p className="about__content-column-list">
<a
href="https://p5js.org/libraries/"
@ -56,7 +57,7 @@ function About(props) {
rel="noopener noreferrer"
>
<AsteriskIcon className="about__content-column-asterisk" aria-hidden="true" focusable="false" />
Libraries
{t('Libraries')}
</a>
</p>
<p className="about__content-column-list">
@ -66,7 +67,7 @@ function About(props) {
rel="noopener noreferrer"
>
<AsteriskIcon className="about__content-column-asterisk" aria-hidden="true" focusable="false" />
Reference
{t('Reference')}
</a>
</p>
<p className="about__content-column-list">
@ -76,7 +77,7 @@ function About(props) {
rel="noopener noreferrer"
>
<AsteriskIcon className="about__content-column-asterisk" aria-hidden="true" focusable="false" />
Forum
{t('Forum')}
</a>
</p>
</div>
@ -86,7 +87,7 @@ function About(props) {
href="https://github.com/processing/p5.js-web-editor"
target="_blank"
rel="noopener noreferrer"
>Contribute
>{t('Contribute')}
</a>
</p>
<p className="about__footer-list">
@ -94,7 +95,7 @@ function About(props) {
href="https://github.com/processing/p5.js-web-editor/issues/new"
target="_blank"
rel="noopener noreferrer"
>Report a bug
>{t('Report')}
</a>
</p>
<p className="about__footer-list">

View file

@ -3,8 +3,9 @@ import React from 'react';
import { connect } from 'react-redux';
import prettyBytes from 'pretty-bytes';
const __process = (typeof global !== 'undefined' ? global : window).process;
const limit = __process.env.UPLOAD_LIMIT || 250000000;
import getConfig from '../../../utils/getConfig';
const limit = getConfig('UPLOAD_LIMIT') || 250000000;
const MAX_SIZE_B = limit;
const formatPercent = (percent) => {

View file

@ -206,12 +206,14 @@ export class FileNode extends React.Component {
</div>
}
<button
aria-label="Name"
className="sidebar__file-item-name"
onClick={this.handleFileClick}
>
{this.state.updatedName}
</button>
<input
data-testid="input"
type="text"
className="sidebar__file-item-input"
value={this.state.updatedName}

View file

@ -0,0 +1,31 @@
import React from 'react';
import { action } from '@storybook/addon-actions';
import { FileNode } from './FileNode';
export default {
title: 'IDE/FileNode',
component: FileNode
};
export const Show = () => (
<FileNode
id="nodeId"
parantId="parentId"
name="File name"
fileType="jpeg"
isSelectedFile
isFolderClosed={false}
setSelectedFile={action('setSelectedFile')}
deleteFile={action('deleteFile')}
updateFileName={action('updateFileName')}
resetSelectedFile={action('resetSelectedFile')}
newFile={action('newFile')}
newFolder={action('newFolder')}
showFolderChildren={action('showFolderChildren')}
hideFolderChildren={action('hideFolderChildren')}
openUploadFileModal={action('openUploadFileModal')}
canEdit
authenticated
/>
);

View file

@ -0,0 +1,127 @@
import React from 'react';
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react';
import { FileNode } from './FileNode';
describe('<FileNode />', () => {
const changeName = (newFileName) => {
const renameButton = screen.getByText(/Rename/i);
fireEvent.click(renameButton);
const input = screen.getByTestId('input');
fireEvent.change(input, { target: { value: newFileName } });
fireEvent.blur(input);
};
const expectFileNameToBe = async (expectedName) => {
const name = screen.getByLabelText(/Name/i);
await waitFor(() => within(name).queryByText(expectedName));
};
const renderFileNode = (fileType, extraProps = {}) => {
const props = {
...extraProps,
id: '0',
name: fileType === 'folder' ? 'afolder' : 'test.jsx',
fileType,
canEdit: true,
children: [],
authenticated: false,
setSelectedFile: jest.fn(),
deleteFile: jest.fn(),
updateFileName: jest.fn(),
resetSelectedFile: jest.fn(),
newFile: jest.fn(),
newFolder: jest.fn(),
showFolderChildren: jest.fn(),
hideFolderChildren: jest.fn(),
openUploadFileModal: jest.fn(),
setProjectName: jest.fn(),
};
render(<FileNode {...props} />);
return props;
};
describe('fileType: file', () => {
it('cannot change to an empty name', async () => {
const props = renderFileNode('file');
changeName('');
await waitFor(() => expect(props.updateFileName).not.toHaveBeenCalled());
await expectFileNameToBe(props.name);
});
it('can change to a valid filename', async () => {
const newName = 'newname.jsx';
const props = renderFileNode('file');
changeName(newName);
await waitFor(() => expect(props.updateFileName).toHaveBeenCalledWith(props.id, newName));
await expectFileNameToBe(newName);
});
it('must have an extension', async () => {
const newName = 'newname';
const props = renderFileNode('file');
changeName(newName);
await waitFor(() => expect(props.updateFileName).not.toHaveBeenCalled());
await expectFileNameToBe(props.name);
});
it('can change to a different extension', async () => {
const newName = 'newname.gif';
const props = renderFileNode('file');
changeName(newName);
await waitFor(() => expect(props.updateFileName).not.toHaveBeenCalled());
await expectFileNameToBe(props.name);
});
it('cannot be just an extension', async () => {
const newName = '.jsx';
const props = renderFileNode('file');
changeName(newName);
await waitFor(() => expect(props.updateFileName).not.toHaveBeenCalled());
await expectFileNameToBe(props.name);
});
});
describe('fileType: folder', () => {
it('cannot change to an empty name', async () => {
const props = renderFileNode('folder');
changeName('');
await waitFor(() => expect(props.updateFileName).not.toHaveBeenCalled());
await expectFileNameToBe(props.name);
});
it('can change to another name', async () => {
const newName = 'foldername';
const props = renderFileNode('folder');
changeName(newName);
await waitFor(() => expect(props.updateFileName).toHaveBeenCalledWith(props.id, newName));
await expectFileNameToBe(newName);
});
it('cannot have a file extension', async () => {
const newName = 'foldername.jsx';
const props = renderFileNode('folder');
changeName(newName);
await waitFor(() => expect(props.updateFileName).not.toHaveBeenCalled());
await expectFileNameToBe(props.name);
});
});
});

View file

@ -4,11 +4,11 @@ import Dropzone from 'dropzone';
import { bindActionCreators } from 'redux';
import { connect } from 'react-redux';
import * as UploaderActions from '../actions/uploader';
import getConfig from '../../../utils/getConfig';
import { fileExtensionsAndMimeTypes } from '../../../../server/utils/fileUtils';
const __process = (typeof global !== 'undefined' ? global : window).process;
const s3Bucket = __process.env.S3_BUCKET_URL_BASE ||
`https://s3-${__process.env.AWS_REGION}.amazonaws.com/${__process.env.S3_BUCKET}/`;
const s3Bucket = getConfig('S3_BUCKET_URL_BASE') ||
`https://s3-${getConfig('AWS_REGION')}.amazonaws.com/${getConfig('S3_BUCKET')}/`;
class FileUploader extends React.Component {
componentDidMount() {

View file

@ -1,53 +1,55 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { metaKeyName, } from '../../../utils/metaKey';
function KeyboardShortcutModal() {
const { t } = useTranslation();
return (
<div className="keyboard-shortcuts">
<h3 className="keyboard-shortcuts__title">Code Editing</h3>
<h3 className="keyboard-shortcuts__title">{t('CodeEditing')}</h3>
<p className="keyboard-shortcuts__description">
Code editing keyboard shortcuts follow <a href="https://shortcuts.design/toolspage-sublimetext.html" target="_blank" rel="noopener noreferrer">Sublime Text shortcuts</a>.
{t('Code editing keyboard shortcuts follow')} <a href="https://shortcuts.design/toolspage-sublimetext.html" target="_blank" rel="noopener noreferrer">{t('Sublime Text shortcuts')}</a>.
</p>
<ul className="keyboard-shortcuts__list">
<li className="keyboard-shortcut-item">
<span className="keyboard-shortcut__command">{'\u21E7'} + Tab</span>
<span>Tidy</span>
<span>{t('Tidy')}</span>
</li>
<li className="keyboard-shortcut-item">
<span className="keyboard-shortcut__command">
{metaKeyName} + F
</span>
<span>Find Text</span>
<span>{t('FindText')}</span>
</li>
<li className="keyboard-shortcut-item">
<span className="keyboard-shortcut__command">
{metaKeyName} + G
</span>
<span>Find Next Text Match</span>
<span>{t('FindNextTextMatch')}</span>
</li>
<li className="keyboard-shortcut-item">
<span className="keyboard-shortcut__command">
{metaKeyName} + {'\u21E7'} + G
</span>
<span>Find Previous Text Match</span>
<span>{t('FindPreviousTextMatch')}</span>
</li>
<li className="keyboard-shortcut-item">
<span className="keyboard-shortcut__command">
{metaKeyName} + [
</span>
<span>Indent Code Left</span>
<span>{t('IndentCodeLeft')}</span>
</li>
<li className="keyboard-shortcut-item">
<span className="keyboard-shortcut__command">
{metaKeyName} + ]
</span>
<span>Indent Code Right</span>
<span>{t('IndentCodeRight')}</span>
</li>
<li className="keyboard-shortcut-item">
<span className="keyboard-shortcut__command">
{metaKeyName} + /
</span>
<span>Comment Line</span>
<span>{t('CommentLine')}</span>
</li>
</ul>
<h3 className="keyboard-shortcuts__title">General</h3>
@ -56,31 +58,31 @@ function KeyboardShortcutModal() {
<span className="keyboard-shortcut__command">
{metaKeyName} + S
</span>
<span>Save</span>
<span>{t('Save')}</span>
</li>
<li className="keyboard-shortcut-item">
<span className="keyboard-shortcut__command">
{metaKeyName} + Enter
</span>
<span>Start Sketch</span>
<span>{t('StartSketch')}</span>
</li>
<li className="keyboard-shortcut-item">
<span className="keyboard-shortcut__command">
{metaKeyName} + {'\u21E7'} + Enter
</span>
<span>Stop Sketch</span>
<span>{t('StopSketch')}</span>
</li>
<li className="keyboard-shortcut-item">
<span className="keyboard-shortcut__command">
{metaKeyName} + {'\u21E7'} + 1
</span>
<span>Turn on Accessible Output</span>
<span>{t('TurnOnAccessibleOutput')}</span>
</li>
<li className="keyboard-shortcut-item">
<span className="keyboard-shortcut__command">
{metaKeyName} + {'\u21E7'} + 2
</span>
<span>Turn off Accessible Output</span>
<span>{t('TurnOffAccessibleOutput')}</span>
</li>
</ul>
</div>

View file

@ -2,6 +2,7 @@ import PropTypes from 'prop-types';
import React from 'react';
import { Helmet } from 'react-helmet';
import { Tab, Tabs, TabList, TabPanel } from 'react-tabs';
import { withTranslation } from 'react-i18next';
// import { bindActionCreators } from 'redux';
// import { connect } from 'react-redux';
// import * as PreferencesActions from '../actions/preferences';
@ -98,13 +99,13 @@ class Preferences extends React.Component {
<Tabs>
<TabList>
<div className="tabs__titles">
<Tab><h4 className="tabs__title">General Settings</h4></Tab>
<Tab><h4 className="tabs__title">Accessibility</h4></Tab>
<Tab><h4 className="tabs__title">{this.props.t('GeneralSettings')}</h4></Tab>
<Tab><h4 className="tabs__title">{this.props.t('Accessibility')}</h4></Tab>
</div>
</TabList>
<TabPanel>
<div className="preference">
<h4 className="preference__title">Theme</h4>
<h4 className="preference__title">{this.props.t('Theme')}</h4>
<div className="preference__options">
<input
type="radio"
@ -116,7 +117,7 @@ class Preferences extends React.Component {
value="light"
checked={this.props.theme === 'light'}
/>
<label htmlFor="light-theme-on" className="preference__option">Light</label>
<label htmlFor="light-theme-on" className="preference__option">{this.props.t('Light')}</label>
<input
type="radio"
onChange={() => this.props.setTheme('dark')}
@ -127,7 +128,7 @@ class Preferences extends React.Component {
value="dark"
checked={this.props.theme === 'dark'}
/>
<label htmlFor="dark-theme-on" className="preference__option">Dark</label>
<label htmlFor="dark-theme-on" className="preference__option">{this.props.t('Dark')}</label>
<input
type="radio"
onChange={() => this.props.setTheme('contrast')}
@ -138,11 +139,11 @@ class Preferences extends React.Component {
value="contrast"
checked={this.props.theme === 'contrast'}
/>
<label htmlFor="high-contrast-theme-on" className="preference__option">High Contrast</label>
<label htmlFor="high-contrast-theme-on" className="preference__option">{this.props.t('HighContrast')}</label>
</div>
</div>
<div className="preference">
<h4 className="preference__title">Text size</h4>
<h4 className="preference__title">{this.props.t('TextSize')}</h4>
<button
className="preference__minus-button"
onClick={this.decreaseFontSize}
@ -150,7 +151,7 @@ class Preferences extends React.Component {
disabled={this.state.fontSize <= 8}
>
<MinusIcon focusable="false" aria-hidden="true" />
<h6 className="preference__label">Decrease</h6>
<h6 className="preference__label">{this.props.t('Decrease')}</h6>
</button>
<form onSubmit={this.onFontInputSubmit}>
<input
@ -171,11 +172,11 @@ class Preferences extends React.Component {
disabled={this.state.fontSize >= 36}
>
<PlusIcon focusable="false" aria-hidden="true" />
<h6 className="preference__label">Increase</h6>
<h6 className="preference__label">{this.props.t('Increase')}</h6>
</button>
</div>
<div className="preference">
<h4 className="preference__title">Autosave</h4>
<h4 className="preference__title">{this.props.t('Autosave')}</h4>
<div className="preference__options">
<input
type="radio"
@ -187,7 +188,7 @@ class Preferences extends React.Component {
value="On"
checked={this.props.autosave}
/>
<label htmlFor="autosave-on" className="preference__option">On</label>
<label htmlFor="autosave-on" className="preference__option">{this.props.t('On')}</label>
<input
type="radio"
onChange={() => this.props.setAutosave(false)}
@ -198,11 +199,11 @@ class Preferences extends React.Component {
value="Off"
checked={!this.props.autosave}
/>
<label htmlFor="autosave-off" className="preference__option">Off</label>
<label htmlFor="autosave-off" className="preference__option">{this.props.t('Off')}</label>
</div>
</div>
<div className="preference">
<h4 className="preference__title">Word Wrap</h4>
<h4 className="preference__title">{this.props.t('WordWrap')}</h4>
<div className="preference__options">
<input
type="radio"
@ -214,7 +215,7 @@ class Preferences extends React.Component {
value="On"
checked={this.props.linewrap}
/>
<label htmlFor="linewrap-on" className="preference__option">On</label>
<label htmlFor="linewrap-on" className="preference__option">{this.props.t('On')}</label>
<input
type="radio"
onChange={() => this.props.setLinewrap(false)}
@ -225,13 +226,13 @@ class Preferences extends React.Component {
value="Off"
checked={!this.props.linewrap}
/>
<label htmlFor="linewrap-off" className="preference__option">Off</label>
<label htmlFor="linewrap-off" className="preference__option">{this.props.t('Off')}</label>
</div>
</div>
</TabPanel>
<TabPanel>
<div className="preference">
<h4 className="preference__title">Line numbers</h4>
<h4 className="preference__title">{this.props.t('LineNumbers')}</h4>
<div className="preference__options">
<input
type="radio"
@ -243,7 +244,7 @@ class Preferences extends React.Component {
value="On"
checked={this.props.lineNumbers}
/>
<label htmlFor="line-numbers-on" className="preference__option">On</label>
<label htmlFor="line-numbers-on" className="preference__option">{this.props.t('On')}</label>
<input
type="radio"
onChange={() => this.props.setLineNumbers(false)}
@ -254,11 +255,11 @@ class Preferences extends React.Component {
value="Off"
checked={!this.props.lineNumbers}
/>
<label htmlFor="line-numbers-off" className="preference__option">Off</label>
<label htmlFor="line-numbers-off" className="preference__option">{this.props.t('Off')}</label>
</div>
</div>
<div className="preference">
<h4 className="preference__title">Lint warning sound</h4>
<h4 className="preference__title">{this.props.t('LintWarningSound')}</h4>
<div className="preference__options">
<input
type="radio"
@ -270,7 +271,7 @@ class Preferences extends React.Component {
value="On"
checked={this.props.lintWarning}
/>
<label htmlFor="lint-warning-on" className="preference__option">On</label>
<label htmlFor="lint-warning-on" className="preference__option">{this.props.t('On')}</label>
<input
type="radio"
onChange={() => this.props.setLintWarning(false)}
@ -281,19 +282,19 @@ class Preferences extends React.Component {
value="Off"
checked={!this.props.lintWarning}
/>
<label htmlFor="lint-warning-off" className="preference__option">Off</label>
<label htmlFor="lint-warning-off" className="preference__option">{this.props.t('Off')}</label>
<button
className="preference__preview-button"
onClick={() => beep.play()}
aria-label="preview sound"
>
Preview sound
{this.props.t('PreviewSound')}
</button>
</div>
</div>
<div className="preference">
<h4 className="preference__title">Accessible text-based canvas</h4>
<h6 className="preference__subtitle">Used with screen reader</h6>
<h4 className="preference__title">{this.props.t('AccessibleTextBasedCanvas')}</h4>
<h6 className="preference__subtitle">{this.props.t('UsedScreenReader')}</h6>
<div className="preference__options">
<input
@ -307,7 +308,7 @@ class Preferences extends React.Component {
value="On"
checked={(this.props.textOutput)}
/>
<label htmlFor="text-output-on" className="preference__option preference__canvas">Plain-text</label>
<label htmlFor="text-output-on" className="preference__option preference__canvas">{this.props.t('PlainText')}</label>
<input
type="checkbox"
onChange={(event) => {
@ -319,7 +320,7 @@ class Preferences extends React.Component {
value="On"
checked={(this.props.gridOutput)}
/>
<label htmlFor="table-output-on" className="preference__option preference__canvas">Table-text</label>
<label htmlFor="table-output-on" className="preference__option preference__canvas">{this.props.t('TableText')}</label>
<input
type="checkbox"
onChange={(event) => {
@ -331,7 +332,7 @@ class Preferences extends React.Component {
value="On"
checked={(this.props.soundOutput)}
/>
<label htmlFor="sound-output-on" className="preference__option preference__canvas">Sound</label>
<label htmlFor="sound-output-on" className="preference__option preference__canvas">{this.props.t('Sound')}</label>
</div>
</div>
</TabPanel>
@ -360,6 +361,7 @@ Preferences.propTypes = {
setLintWarning: PropTypes.func.isRequired,
theme: PropTypes.string.isRequired,
setTheme: PropTypes.func.isRequired,
t: PropTypes.func.isRequired,
};
export default Preferences;
export default withTranslation('WebEditor')(Preferences);

View file

@ -22,6 +22,23 @@ import {
import { hijackConsoleErrorsScript, startTag, getAllScriptOffsets }
from '../../../utils/consoleUtils';
const shouldRenderSketch = (props, prevProps = undefined) => {
const { isPlaying, previewIsRefreshing, fullView } = props;
// if the user explicitly clicks on the play button
if (isPlaying && previewIsRefreshing) return true;
if (!prevProps) return false;
return (props.isPlaying !== prevProps.isPlaying // if sketch starts or stops playing, want to rerender
|| props.isAccessibleOutputPlaying !== prevProps.isAccessibleOutputPlaying // if user switches textoutput preferences
|| props.textOutput !== prevProps.textOutput
|| props.gridOutput !== prevProps.gridOutput
|| props.soundOutput !== prevProps.soundOutput
|| (fullView && props.files[0].id !== prevProps.files[0].id));
};
class PreviewFrame extends React.Component {
constructor(props) {
super(props);
@ -30,53 +47,25 @@ class PreviewFrame extends React.Component {
componentDidMount() {
window.addEventListener('message', this.handleConsoleEvent);
const props = {
...this.props,
previewIsRefreshing: this.props.previewIsRefreshing,
isAccessibleOutputPlaying: this.props.isAccessibleOutputPlaying
};
if (shouldRenderSketch(props)) this.renderSketch();
}
componentDidUpdate(prevProps) {
// if sketch starts or stops playing, want to rerender
if (this.props.isPlaying !== prevProps.isPlaying) {
this.renderSketch();
return;
}
// if the user explicitly clicks on the play button
if (this.props.isPlaying && this.props.previewIsRefreshing) {
this.renderSketch();
return;
}
// if user switches textoutput preferences
if (this.props.isAccessibleOutputPlaying !== prevProps.isAccessibleOutputPlaying) {
this.renderSketch();
return;
}
if (this.props.textOutput !== prevProps.textOutput) {
this.renderSketch();
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();
}
if (shouldRenderSketch(this.props, prevProps)) this.renderSketch();
// small bug - if autorefresh is on, and the usr changes files
// in the sketch, preview will reload
}
componentWillUnmount() {
window.removeEventListener('message', this.handleConsoleEvent);
ReactDOM.unmountComponentAtNode(this.iframeElement.contentDocument.body);
const iframeBody = this.iframeElement.contentDocument.body;
if (iframeBody) { ReactDOM.unmountComponentAtNode(iframeBody); }
}
handleConsoleEvent(messageEvent) {
@ -249,16 +238,18 @@ class PreviewFrame extends React.Component {
jsFileStrings.forEach((jsFileString) => {
if (jsFileString.match(MEDIA_FILE_QUOTED_REGEX)) {
const filePath = jsFileString.substr(1, jsFileString.length - 2);
const quoteCharacter = jsFileString.substr(0, 1);
const resolvedFile = resolvePathToFile(filePath, files);
if (resolvedFile) {
if (resolvedFile.url) {
newContent = newContent.replace(filePath, resolvedFile.url);
newContent = newContent.replace(jsFileString, quoteCharacter + resolvedFile.url + quoteCharacter);
} else if (resolvedFile.name.match(PLAINTEXT_FILE_REGEX)) {
// could also pull file from API instead of using bloburl
const blobURL = getBlobUrl(resolvedFile);
this.props.setBlobUrl(resolvedFile, blobURL);
const filePathRegex = new RegExp(filePath, 'gi');
newContent = newContent.replace(filePathRegex, blobURL);
newContent = newContent.replace(jsFileString, quoteCharacter + blobURL + quoteCharacter);
}
}
}
@ -274,10 +265,11 @@ class PreviewFrame extends React.Component {
cssFileStrings.forEach((cssFileString) => {
if (cssFileString.match(MEDIA_FILE_QUOTED_REGEX)) {
const filePath = cssFileString.substr(1, cssFileString.length - 2);
const quoteCharacter = cssFileString.substr(0, 1);
const resolvedFile = resolvePathToFile(filePath, files);
if (resolvedFile) {
if (resolvedFile.url) {
newContent = newContent.replace(filePath, resolvedFile.url);
newContent = newContent.replace(cssFileString, quoteCharacter + resolvedFile.url + quoteCharacter);
}
}
}
@ -353,6 +345,8 @@ class PreviewFrame extends React.Component {
'preview-frame': true,
'preview-frame--full-view': this.props.fullView
});
const sandboxAttributes =
'allow-scripts allow-pointer-lock allow-same-origin allow-popups allow-forms allow-modals allow-downloads';
return (
<iframe
id="canvas_frame"
@ -362,7 +356,7 @@ class PreviewFrame extends React.Component {
frameBorder="0"
title="sketch preview"
ref={(element) => { this.iframeElement = element; }}
sandbox="allow-scripts allow-pointer-lock allow-same-origin allow-popups allow-forms allow-modals"
sandbox={sandboxAttributes}
/>
);
}
@ -393,7 +387,7 @@ PreviewFrame.propTypes = {
clearConsole: PropTypes.func.isRequired,
cmController: PropTypes.shape({
getContent: PropTypes.func
})
}),
};
PreviewFrame.defaultProps = {

View file

@ -2,15 +2,17 @@ import PropTypes from 'prop-types';
import React from 'react';
import { bindActionCreators } from 'redux';
import { connect } from 'react-redux';
import { useTranslation } from 'react-i18next';
import * as ToastActions from '../actions/toast';
import ExitIcon from '../../../images/exit.svg';
function Toast(props) {
const { t } = useTranslation('WebEditor');
return (
<section className="toast">
<p>
{props.text}
{t(props.text)}
</p>
<button className="toast__close" onClick={props.hideToast} aria-label="Close Alert" >
<ExitIcon focusable="false" aria-hidden="true" />

View file

@ -17,21 +17,36 @@ class Toolbar extends React.Component {
super(props);
this.handleKeyPress = this.handleKeyPress.bind(this);
this.handleProjectNameChange = this.handleProjectNameChange.bind(this);
this.handleProjectNameSave = this.handleProjectNameSave.bind(this);
this.state = {
projectNameInputValue: props.project.name,
};
}
handleKeyPress(event) {
if (event.key === 'Enter') {
this.props.hideEditProjectName();
this.projectNameInput.blur();
}
}
handleProjectNameChange(event) {
this.props.setProjectName(event.target.value);
this.setState({ projectNameInputValue: event.target.value });
}
validateProjectName() {
if ((this.props.project.name.trim()).length === 0) {
this.props.setProjectName(this.originalProjectName);
handleProjectNameSave() {
const newProjectName = this.state.projectNameInputValue.trim();
if (newProjectName.length === 0) {
this.setState({
projectNameInputValue: this.props.project.name,
});
} else {
this.props.setProjectName(newProjectName);
this.props.hideEditProjectName();
if (this.props.project.id) {
this.props.saveProject();
}
}
}
@ -108,7 +123,6 @@ class Toolbar extends React.Component {
className="toolbar__project-name"
onClick={() => {
if (canEditProjectName) {
this.originalProjectName = this.props.project.name;
this.props.showEditProjectName();
setTimeout(() => this.projectNameInput.focus(), 0);
}
@ -130,16 +144,11 @@ class Toolbar extends React.Component {
type="text"
maxLength="128"
className="toolbar__project-name-input"
value={this.props.project.name}
aria-label="New sketch name"
value={this.state.projectNameInputValue}
onChange={this.handleProjectNameChange}
ref={(element) => { this.projectNameInput = element; }}
onBlur={() => {
this.validateProjectName();
this.props.hideEditProjectName();
if (this.props.project.id) {
this.props.saveProject();
}
}}
onBlur={this.handleProjectNameSave}
onKeyPress={this.handleKeyPress}
/>
{(() => { // eslint-disable-line

View file

@ -0,0 +1,84 @@
import React from 'react';
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import lodash from 'lodash';
import { ToolbarComponent } from './Toolbar';
const renderComponent = (extraProps = {}) => {
const props = lodash.merge({
isPlaying: false,
preferencesIsVisible: false,
stopSketch: jest.fn(),
setProjectName: jest.fn(),
openPreferences: jest.fn(),
showEditProjectName: jest.fn(),
hideEditProjectName: jest.fn(),
infiniteLoop: false,
autorefresh: false,
setAutorefresh: jest.fn(),
setTextOutput: jest.fn(),
setGridOutput: jest.fn(),
startSketch: jest.fn(),
startAccessibleSketch: jest.fn(),
saveProject: jest.fn(),
currentUser: 'me',
originalProjectName: 'testname',
owner: {
username: 'me'
},
project: {
name: 'testname',
isEditingName: false,
id: 'id',
},
}, extraProps);
render(<ToolbarComponent {...props} />);
return props;
};
describe('<ToolbarComponent />', () => {
it('sketch owner can switch to sketch name editing mode', async () => {
const props = renderComponent();
const sketchName = screen.getByLabelText('Edit sketch name');
fireEvent.click(sketchName);
await waitFor(() => expect(props.showEditProjectName).toHaveBeenCalled());
});
it('non-owner can\t switch to sketch editing mode', async () => {
const props = renderComponent({ currentUser: 'not-me' });
const sketchName = screen.getByLabelText('Edit sketch name');
fireEvent.click(sketchName);
expect(sketchName).toBeDisabled();
await waitFor(() => expect(props.showEditProjectName).not.toHaveBeenCalled());
});
it('sketch owner can change name', async () => {
const props = renderComponent({ project: { isEditingName: true } });
const sketchNameInput = screen.getByLabelText('New sketch name');
fireEvent.change(sketchNameInput, { target: { value: 'my new sketch name' } });
fireEvent.blur(sketchNameInput);
await waitFor(() => expect(props.setProjectName).toHaveBeenCalledWith('my new sketch name'));
await waitFor(() => expect(props.saveProject).toHaveBeenCalled());
});
it('sketch owner can\'t change to empty name', async () => {
const props = renderComponent({ project: { isEditingName: true } });
const sketchNameInput = screen.getByLabelText('New sketch name');
fireEvent.change(sketchNameInput, { target: { value: '' } });
fireEvent.blur(sketchNameInput);
await waitFor(() => expect(props.setProjectName).not.toHaveBeenCalled());
await waitFor(() => expect(props.saveProject).not.toHaveBeenCalled());
});
});

View file

@ -3,12 +3,12 @@ import PropTypes from 'prop-types';
import { connect } from 'react-redux';
import { Link } from 'react-router';
import prettyBytes from 'pretty-bytes';
import getConfig from '../../../utils/getConfig';
import FileUploader from './FileUploader';
import { getreachedTotalSizeLimit } from '../selectors/users';
import ExitIcon from '../../../images/exit.svg';
const __process = (typeof global !== 'undefined' ? global : window).process;
const limit = __process.env.UPLOAD_LIMIT || 250000000;
const limit = getConfig('UPLOAD_LIMIT') || 250000000;
const limitText = prettyBytes(limit);
class UploadFileModal extends React.Component {

View file

@ -3,6 +3,7 @@ import React from 'react';
import { bindActionCreators } from 'redux';
import { connect } from 'react-redux';
import { withRouter } from 'react-router';
import { withTranslation } from 'react-i18next';
import { Helmet } from 'react-helmet';
import SplitPane from 'react-split-pane';
import Editor from '../components/Editor';
@ -34,11 +35,38 @@ import AddToCollectionList from '../components/AddToCollectionList';
import Feedback from '../components/Feedback';
import { CollectionSearchbar } from '../components/Searchbar';
function getTitle(props) {
const { id } = props.project;
return id ? `p5.js Web Editor | ${props.project.name}` : 'p5.js Web Editor';
}
function isUserOwner(props) {
return props.project.owner && props.project.owner.id === props.user.id;
}
function warnIfUnsavedChanges(props) { // eslint-disable-line
const { route } = props.route;
if (route && (route.action === 'PUSH' && (route.pathname === '/login' || route.pathname === '/signup'))) {
// don't warn
props.persistState();
window.onbeforeunload = null;
} else if (route && (props.location.pathname === '/login' || props.location.pathname === '/signup')) {
// don't warn
props.persistState();
window.onbeforeunload = null;
} else if (props.ide.unsavedChanges) {
if (!window.confirm(props.t('WarningUnsavedChanges'))) {
return false;
}
props.setUnsavedChanges(false);
return true;
}
}
class IDEView extends React.Component {
constructor(props) {
super(props);
this.handleGlobalKeydown = this.handleGlobalKeydown.bind(this);
this.warnIfUnsavedChanges = this.warnIfUnsavedChanges.bind(this);
this.state = {
consoleSize: props.ide.consoleIsExpanded ? 150 : 29,
@ -62,9 +90,9 @@ class IDEView extends React.Component {
this.isMac = navigator.userAgent.toLowerCase().indexOf('mac') !== -1;
document.addEventListener('keydown', this.handleGlobalKeydown, false);
this.props.router.setRouteLeaveHook(this.props.route, route => this.warnIfUnsavedChanges(route));
this.props.router.setRouteLeaveHook(this.props.route, this.handleUnsavedChanges);
window.onbeforeunload = () => this.warnIfUnsavedChanges();
window.onbeforeunload = this.handleUnsavedChanges;
this.autosaveInterval = null;
}
@ -92,7 +120,7 @@ class IDEView extends React.Component {
}
componentDidUpdate(prevProps) {
if (this.isUserOwner() && this.props.project.id) {
if (isUserOwner(this.props) && 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 &&
@ -113,7 +141,7 @@ class IDEView extends React.Component {
}
if (this.props.route.path !== prevProps.route.path) {
this.props.router.setRouteLeaveHook(this.props.route, route => this.warnIfUnsavedChanges(route));
this.props.router.setRouteLeaveHook(this.props.route, () => warnIfUnsavedChanges(this.props));
}
}
@ -123,21 +151,12 @@ class IDEView extends React.Component {
this.autosaveInterval = null;
}
getTitle = () => {
const { id } = this.props.project;
return id ? `p5.js Web Editor | ${this.props.project.name}` : 'p5.js Web Editor';
}
isUserOwner() {
return this.props.project.owner && this.props.project.owner.id === this.props.user.id;
}
handleGlobalKeydown(e) {
// 83 === s
if (e.keyCode === 83 && ((e.metaKey && this.isMac) || (e.ctrlKey && !this.isMac))) {
e.preventDefault();
e.stopPropagation();
if (this.isUserOwner() || (this.props.user.authenticated && !this.props.project.owner)) {
if (isUserOwner(this.props) || (this.props.user.authenticated && !this.props.project.owner)) {
this.props.saveProject(this.cmController.getContent());
} else if (this.props.user.authenticated) {
this.props.cloneProject();
@ -186,39 +205,23 @@ class IDEView extends React.Component {
}
}
warnIfUnsavedChanges(route) { // eslint-disable-line
if (route && (route.action === 'PUSH' && (route.pathname === '/login' || route.pathname === '/signup'))) {
// don't warn
this.props.persistState();
window.onbeforeunload = null;
} else if (route && (this.props.location.pathname === '/login' || this.props.location.pathname === '/signup')) {
// don't warn
this.props.persistState();
window.onbeforeunload = null;
} else if (this.props.ide.unsavedChanges) {
if (!window.confirm('Are you sure you want to leave this page? You have unsaved changes.')) {
return false;
}
this.props.setUnsavedChanges(false);
return true;
}
}
handleUnsavedChanges = () => warnIfUnsavedChanges(this.props);
render() {
return (
<div className="ide">
<Helmet>
<title>{this.getTitle()}</title>
<title>{getTitle(this.props)}</title>
</Helmet>
{this.props.toast.isVisible && <Toast />}
<Nav
warnIfUnsavedChanges={this.warnIfUnsavedChanges}
warnIfUnsavedChanges={this.handleUnsavedChanges}
cmController={this.cmController}
/>
<Toolbar />
<Toolbar key={this.props.project.id} />
{this.props.ide.preferencesIsVisible &&
<Overlay
title="Settings"
title={this.props.t('Settings')}
ariaLabel="settings"
closeOverlay={this.props.closePreferences}
>
@ -313,7 +316,7 @@ class IDEView extends React.Component {
isExpanded={this.props.ide.sidebarIsExpanded}
expandSidebar={this.props.expandSidebar}
collapseSidebar={this.props.collapseSidebar}
isUserOwner={this.isUserOwner()}
isUserOwner={isUserOwner(this.props)}
clearConsole={this.props.clearConsole}
consoleEvents={this.props.console}
showRuntimeErrorWarning={this.props.showRuntimeErrorWarning}
@ -334,7 +337,7 @@ class IDEView extends React.Component {
</SplitPane>
<section className="preview-frame-holder">
<header className="preview-frame__header">
<h2 className="preview-frame__title">Preview</h2>
<h2 className="preview-frame__title">{this.props.t('Preview')}</h2>
</header>
<div className="preview-frame__content">
<div className="preview-frame-overlay" ref={(element) => { this.overlay = element; }}>
@ -395,7 +398,7 @@ class IDEView extends React.Component {
}
{ this.props.location.pathname === '/about' &&
<Overlay
title="About"
title={this.props.t('About')}
previousPath={this.props.ide.previousPath}
ariaLabel="about"
>
@ -441,7 +444,7 @@ class IDEView extends React.Component {
}
{this.props.ide.keyboardShortcutVisible &&
<Overlay
title="Keyboard Shortcuts"
title={this.props.t('KeyboardShortcuts')}
ariaLabel="keyboard shortcuts"
closeOverlay={this.props.closeKeyboardShortcutModal}
>
@ -604,12 +607,12 @@ IDEView.propTypes = {
showErrorModal: PropTypes.func.isRequired,
hideErrorModal: PropTypes.func.isRequired,
clearPersistedState: PropTypes.func.isRequired,
persistState: PropTypes.func.isRequired,
showRuntimeErrorWarning: PropTypes.func.isRequired,
hideRuntimeErrorWarning: PropTypes.func.isRequired,
startSketch: PropTypes.func.isRequired,
openUploadFileModal: PropTypes.func.isRequired,
closeUploadFileModal: PropTypes.func.isRequired
closeUploadFileModal: PropTypes.func.isRequired,
t: PropTypes.func.isRequired
};
function mapStateToProps(state) {
@ -646,4 +649,6 @@ function mapDispatchToProps(dispatch) {
);
}
export default withRouter(connect(mapStateToProps, mapDispatchToProps)(IDEView));
export default withTranslation('WebEditor')(withRouter(connect(mapStateToProps, mapDispatchToProps)(IDEView)));

View file

@ -0,0 +1,250 @@
import React from 'react';
import PropTypes from 'prop-types';
import { connect } from 'react-redux';
import { withRouter } from 'react-router';
import { useState } from 'react';
// Imports to be Refactored
import { bindActionCreators } from 'redux';
import * as FileActions from '../actions/files';
import * as IDEActions from '../actions/ide';
import * as ProjectActions from '../actions/project';
import * as EditorAccessibilityActions from '../actions/editorAccessibility';
import * as PreferencesActions from '../actions/preferences';
import * as UserActions from '../../User/actions';
import * as ToastActions from '../actions/toast';
import * as ConsoleActions from '../actions/console';
import { getHTMLFile } from '../reducers/files';
// Local Imports
import Editor from '../components/Editor';
import { PreferencesIcon, PlayIcon, ExitIcon } from '../../../common/icons';
import IconButton from '../../../components/mobile/IconButton';
import Header from '../../../components/mobile/Header';
import Screen from '../../../components/mobile/MobileScreen';
import Footer from '../../../components/mobile/Footer';
import IDEWrapper from '../../../components/mobile/IDEWrapper';
const isUserOwner = ({ project, user }) => (project.owner && project.owner.id === user.id);
const MobileIDEView = (props) => {
const {
preferences, ide, editorAccessibility, project, updateLintMessage, clearLintMessage,
selectedFile, updateFileContent, files,
closeEditorOptions, showEditorOptions, showKeyboardShortcutModal, setUnsavedChanges,
startRefreshSketch, stopSketch, expandSidebar, collapseSidebar, clearConsole, console,
showRuntimeErrorWarning, hideRuntimeErrorWarning, startSketch
} = props;
const [tmController, setTmController] = useState(null); // eslint-disable-line
const [overlay, setOverlay] = useState(null); // eslint-disable-line
return (
<Screen fullscreen>
<Header
title={project.name}
subtitle={selectedFile.name}
leftButton={
<IconButton to="/mobile" icon={ExitIcon} aria-label="Return to original editor" />
}
>
<IconButton
to="/mobile/preferences"
onClick={() => setOverlay('preferences')}
icon={PreferencesIcon}
aria-label="Open preferences menu"
/>
<IconButton to="/mobile/preview" onClick={() => { startSketch(); }} icon={PlayIcon} aria-label="Run sketch" />
</Header>
<IDEWrapper>
<Editor
lintWarning={preferences.lintWarning}
linewrap={preferences.linewrap}
lintMessages={editorAccessibility.lintMessages}
updateLintMessage={updateLintMessage}
clearLintMessage={clearLintMessage}
file={selectedFile}
updateFileContent={updateFileContent}
fontSize={preferences.fontSize}
lineNumbers={preferences.lineNumbers}
files={files}
editorOptionsVisible={ide.editorOptionsVisible}
showEditorOptions={showEditorOptions}
closeEditorOptions={closeEditorOptions}
showKeyboardShortcutModal={showKeyboardShortcutModal}
setUnsavedChanges={setUnsavedChanges}
isPlaying={ide.isPlaying}
theme={preferences.theme}
startRefreshSketch={startRefreshSketch}
stopSketch={stopSketch}
autorefresh={preferences.autorefresh}
unsavedChanges={ide.unsavedChanges}
projectSavedTime={project.updatedAt}
isExpanded={ide.sidebarIsExpanded}
expandSidebar={expandSidebar}
collapseSidebar={collapseSidebar}
isUserOwner={isUserOwner(props)}
clearConsole={clearConsole}
consoleEvents={console}
showRuntimeErrorWarning={showRuntimeErrorWarning}
hideRuntimeErrorWarning={hideRuntimeErrorWarning}
runtimeErrorWarningVisible={ide.runtimeErrorWarningVisible}
provideController={setTmController}
/>
</IDEWrapper>
<Footer><h2>Bottom Bar</h2></Footer>
</Screen>
);
};
MobileIDEView.propTypes = {
preferences: PropTypes.shape({
fontSize: PropTypes.number.isRequired,
autosave: PropTypes.bool.isRequired,
linewrap: PropTypes.bool.isRequired,
lineNumbers: PropTypes.bool.isRequired,
lintWarning: PropTypes.bool.isRequired,
textOutput: PropTypes.bool.isRequired,
gridOutput: PropTypes.bool.isRequired,
soundOutput: PropTypes.bool.isRequired,
theme: PropTypes.string.isRequired,
autorefresh: PropTypes.bool.isRequired
}).isRequired,
ide: PropTypes.shape({
isPlaying: PropTypes.bool.isRequired,
isAccessibleOutputPlaying: PropTypes.bool.isRequired,
consoleEvent: PropTypes.array,
modalIsVisible: PropTypes.bool.isRequired,
sidebarIsExpanded: PropTypes.bool.isRequired,
consoleIsExpanded: PropTypes.bool.isRequired,
preferencesIsVisible: PropTypes.bool.isRequired,
projectOptionsVisible: PropTypes.bool.isRequired,
newFolderModalVisible: PropTypes.bool.isRequired,
shareModalVisible: PropTypes.bool.isRequired,
shareModalProjectId: PropTypes.string.isRequired,
shareModalProjectName: PropTypes.string.isRequired,
shareModalProjectUsername: PropTypes.string.isRequired,
editorOptionsVisible: PropTypes.bool.isRequired,
keyboardShortcutVisible: PropTypes.bool.isRequired,
unsavedChanges: PropTypes.bool.isRequired,
infiniteLoop: PropTypes.bool.isRequired,
previewIsRefreshing: PropTypes.bool.isRequired,
infiniteLoopMessage: PropTypes.string.isRequired,
projectSavedTime: PropTypes.string,
previousPath: PropTypes.string.isRequired,
justOpenedProject: PropTypes.bool.isRequired,
errorType: PropTypes.string,
runtimeErrorWarningVisible: PropTypes.bool.isRequired,
uploadFileModalVisible: PropTypes.bool.isRequired
}).isRequired,
editorAccessibility: PropTypes.shape({
lintMessages: PropTypes.array.isRequired,
}).isRequired,
project: PropTypes.shape({
id: PropTypes.string,
name: PropTypes.string.isRequired,
owner: PropTypes.shape({
username: PropTypes.string,
id: PropTypes.string
}),
updatedAt: PropTypes.string
}).isRequired,
startSketch: PropTypes.func.isRequired,
updateLintMessage: PropTypes.func.isRequired,
clearLintMessage: PropTypes.func.isRequired,
selectedFile: PropTypes.shape({
id: PropTypes.string.isRequired,
content: PropTypes.string.isRequired,
name: PropTypes.string.isRequired
}).isRequired,
updateFileContent: PropTypes.func.isRequired,
files: PropTypes.arrayOf(PropTypes.shape({
id: PropTypes.string.isRequired,
name: PropTypes.string.isRequired,
content: PropTypes.string.isRequired
})).isRequired,
closeEditorOptions: PropTypes.func.isRequired,
showEditorOptions: PropTypes.func.isRequired,
showKeyboardShortcutModal: PropTypes.func.isRequired,
setUnsavedChanges: PropTypes.func.isRequired,
startRefreshSketch: PropTypes.func.isRequired,
stopSketch: PropTypes.func.isRequired,
expandSidebar: PropTypes.func.isRequired,
collapseSidebar: PropTypes.func.isRequired,
clearConsole: PropTypes.func.isRequired,
console: PropTypes.arrayOf(PropTypes.shape({
method: PropTypes.string.isRequired,
args: PropTypes.arrayOf(PropTypes.string)
})).isRequired,
showRuntimeErrorWarning: PropTypes.func.isRequired,
hideRuntimeErrorWarning: PropTypes.func.isRequired,
user: PropTypes.shape({
authenticated: PropTypes.bool.isRequired,
id: PropTypes.string,
username: PropTypes.string
}).isRequired,
};
function mapStateToProps(state) {
return {
files: state.files,
selectedFile: state.files.find(file => file.isSelectedFile) ||
state.files.find(file => file.name === 'sketch.js') ||
state.files.find(file => file.name !== 'root'),
htmlFile: getHTMLFile(state.files),
ide: state.ide,
preferences: state.preferences,
editorAccessibility: state.editorAccessibility,
user: state.user,
project: state.project,
toast: state.toast,
console: state.console
};
}
function mapDispatchToProps(dispatch) {
return bindActionCreators(
Object.assign(
{},
EditorAccessibilityActions,
FileActions,
ProjectActions,
IDEActions,
PreferencesActions,
UserActions,
ToastActions,
ConsoleActions
),
dispatch
);
}
export default withRouter(connect(mapStateToProps, mapDispatchToProps)(MobileIDEView));

View file

@ -37,6 +37,11 @@ const getSortedCollections = createSelector(
return orderBy(collections, 'name', 'desc');
}
return orderBy(collections, 'name', 'asc');
} else if (field === 'numItems') {
if (direction === DIRECTION.DESC) {
return orderBy(collections, 'items.length', 'desc');
}
return orderBy(collections, 'items.length', 'asc');
}
const sortedCollections = [...collections].sort((a, b) => {
const result =

View file

@ -1,10 +1,10 @@
import { createSelector } from 'reselect';
import getConfig from '../../../utils/getConfig';
const __process = (typeof global !== 'undefined' ? global : window).process;
const getAuthenticated = state => state.user.authenticated;
const getTotalSize = state => state.user.totalSize;
const getAssetsTotalSize = state => state.assets.totalSize;
const limit = __process.env.UPLOAD_LIMIT || 250000000;
const limit = getConfig('UPLOAD_LIMIT') || 250000000;
export const getCanUploadMedia = createSelector(
getAuthenticated,

View file

@ -0,0 +1,226 @@
import React from 'react';
import { bindActionCreators } from 'redux';
import { connect } from 'react-redux';
import { withRouter } from 'react-router';
import PropTypes from 'prop-types';
import styled from 'styled-components';
import * as PreferencesActions from '../IDE/actions/preferences';
import * as IdeActions from '../IDE/actions/ide';
import IconButton from '../../components/mobile/IconButton';
import Screen from '../../components/mobile/MobileScreen';
import Header from '../../components/mobile/Header';
import PreferencePicker from '../../components/mobile/PreferencePicker';
import { ExitIcon } from '../../common/icons';
import { remSize, prop } from '../../theme';
const Content = styled.div`
z-index: 0;
margin-top: ${remSize(68)};
`;
const SettingsHeader = styled(Header)`
background: transparent;
`;
const SectionHeader = styled.h2`
color: ${prop('primaryTextColor')};
padding-top: ${remSize(32)};
`;
const SectionSubeader = styled.h3`
color: ${prop('primaryTextColor')};
`;
const MobilePreferences = (props) => {
const {
setTheme, setAutosave, setLinewrap, setTextOutput, setGridOutput, setSoundOutput, lineNumbers, lintWarning
} = props;
const {
theme, autosave, linewrap, textOutput, gridOutput, soundOutput, setLineNumbers, setLintWarning
} = props;
const generalSettings = [
{
title: 'Theme',
value: theme,
options: [
{
value: 'light', label: 'light', ariaLabel: 'light theme on', name: 'light theme', id: 'light-theme-on'
},
{
value: 'dark', label: 'dark', ariaLabel: 'dark theme on', name: 'dark theme', id: 'dark-theme-on'
},
{
value: 'contrast',
label: 'contrast',
ariaLabel: 'contrast theme on',
name: 'contrast theme',
id: 'contrast-theme-on'
}
],
onSelect: x => setTheme(x) // setTheme
},
{
title: 'Autosave',
value: autosave,
options: [
{
value: true, label: 'On', ariaLabel: 'autosave on', name: 'autosave', id: 'autosave-on'
},
{
value: false, label: 'Off', ariaLabel: 'autosave off', name: 'autosave', id: 'autosave-off'
},
],
onSelect: x => setAutosave(x) // setAutosave
},
{
title: 'Word Wrap',
value: linewrap,
options: [
{
value: true, label: 'On', ariaLabel: 'linewrap on', name: 'linewrap', id: 'linewrap-on'
},
{
value: false, label: 'Off', ariaLabel: 'linewrap off', name: 'linewrap', id: 'linewrap-off'
},
],
onSelect: x => setLinewrap(x)
}
];
const outputSettings = [
{
title: 'Plain-text',
value: textOutput,
options: [
{
value: true, label: 'On', ariaLabel: 'text output on', name: 'text output', id: 'text-output-on'
},
{
value: false, label: 'Off', ariaLabel: 'text output off', name: 'text output', id: 'text-output-off'
},
],
onSelect: x => setTextOutput(x)
},
{
title: 'Table-text',
value: gridOutput,
options: [
{
value: true, label: 'On', ariaLabel: 'table output on', name: 'table output', id: 'table-output-on'
},
{
value: false, label: 'Off', ariaLabel: 'table output off', name: 'table output', id: 'table-output-off'
},
],
onSelect: x => setGridOutput(x)
},
{
title: 'Sound',
value: soundOutput,
options: [
{
value: true, label: 'On', ariaLabel: 'sound output on', name: 'sound output', id: 'sound-output-on'
},
{
value: false, label: 'Off', ariaLabel: 'sound output off', name: 'sound output', id: 'sound-output-off'
},
],
onSelect: x => setSoundOutput(x)
},
];
const accessibilitySettings = [
{
title: 'Line Numbers',
value: lineNumbers,
options: [
{
value: true, label: 'On', ariaLabel: 'line numbers on', name: 'line numbers', id: 'line-numbers-on'
},
{
value: false, label: 'Off', ariaLabel: 'line numbers off', name: 'line numbers', id: 'line-numbers-off'
},
],
onSelect: x => setLineNumbers(x)
},
{
title: 'Lint Warning Sound',
value: lintWarning,
options: [
{
value: true, label: 'On', ariaLabel: 'lint warning on', name: 'lint warning', id: 'lint-warning-on'
},
{
value: false, label: 'Off', ariaLabel: 'lint warning off', name: 'lint warning', id: 'lint-warning-off'
},
],
onSelect: x => setLintWarning(x)
},
];
return (
<Screen fullscreen>
<section>
<SettingsHeader transparent title="Preferences">
<IconButton to="/mobile" icon={ExitIcon} aria-label="Return to ide view" />
</SettingsHeader>
<section className="preferences">
<Content>
<SectionHeader>General Settings</SectionHeader>
{ generalSettings.map(option => <PreferencePicker key={`${option.title}wrapper`} {...option} />) }
<SectionHeader>Accessibility</SectionHeader>
{ accessibilitySettings.map(option => <PreferencePicker key={`${option.title}wrapper`} {...option} />) }
<SectionHeader>Accessible Output</SectionHeader>
<SectionSubeader>Used with screen reader</SectionSubeader>
{ outputSettings.map(option => <PreferencePicker key={`${option.title}wrapper`} {...option} />) }
</Content>
</section>
</section>
</Screen>);
};
MobilePreferences.propTypes = {
fontSize: PropTypes.number.isRequired,
lineNumbers: PropTypes.bool.isRequired,
autosave: PropTypes.bool.isRequired,
linewrap: PropTypes.bool.isRequired,
textOutput: PropTypes.bool.isRequired,
gridOutput: PropTypes.bool.isRequired,
soundOutput: PropTypes.bool.isRequired,
lintWarning: PropTypes.bool.isRequired,
theme: PropTypes.string.isRequired,
setLinewrap: PropTypes.func.isRequired,
setLintWarning: PropTypes.func.isRequired,
setTheme: PropTypes.func.isRequired,
setFontSize: PropTypes.func.isRequired,
setLineNumbers: PropTypes.func.isRequired,
setAutosave: PropTypes.func.isRequired,
setTextOutput: PropTypes.func.isRequired,
setGridOutput: PropTypes.func.isRequired,
setSoundOutput: PropTypes.func.isRequired,
};
const mapStateToProps = state => ({
...state.preferences,
});
const mapDispatchToProps = dispatch => bindActionCreators({
...PreferencesActions,
...IdeActions
}, dispatch);
export default withRouter(connect(mapStateToProps, mapDispatchToProps)(MobilePreferences));

View file

@ -0,0 +1,184 @@
import React from 'react';
import PropTypes from 'prop-types';
import { bindActionCreators } from 'redux';
import { connect } from 'react-redux';
import styled from 'styled-components';
import Header from '../../components/mobile/Header';
import IconButton from '../../components/mobile/IconButton';
import PreviewFrame from '../IDE/components/PreviewFrame';
import Screen from '../../components/mobile/MobileScreen';
import * as ProjectActions from '../IDE/actions/project';
import * as IDEActions from '../IDE/actions/ide';
import * as PreferencesActions from '../IDE/actions/preferences';
import * as ConsoleActions from '../IDE/actions/console';
import * as FilesActions from '../IDE/actions/files';
import { getHTMLFile } from '../IDE/reducers/files';
import { ExitIcon } from '../../common/icons';
import { remSize } from '../../theme';
const Content = styled.div`
z-index: 0;
margin-top: ${remSize(68)};
`;
const MobileSketchView = (props) => {
// TODO: useSelector requires react-redux ^7.1.0
// const htmlFile = useSelector(state => getHTMLFile(state.files));
// const jsFiles = useSelector(state => getJSFiles(state.files));
// const cssFiles = useSelector(state => getCSSFiles(state.files));
// const files = useSelector(state => state.files);
const {
htmlFile, files, selectedFile, projectName
} = props;
// Actions
const {
setTextOutput, setGridOutput, setSoundOutput,
endSketchRefresh, stopSketch,
dispatchConsoleEvent, expandConsole, clearConsole,
setBlobUrl,
} = props;
const { preferences, ide } = props;
return (
<Screen fullscreen>
<Header
leftButton={
<IconButton to="/mobile" icon={ExitIcon} aria-label="Return to original editor" />
}
title={projectName}
/>
<Content>
<PreviewFrame
htmlFile={htmlFile}
files={files}
head={<link type="text/css" rel="stylesheet" href="/preview-styles.css" />}
content={selectedFile.content}
isPlaying
isAccessibleOutputPlaying={ide.isAccessibleOutputPlaying}
previewIsRefreshing={ide.previewIsRefreshing}
textOutput={preferences.textOutput}
gridOutput={preferences.gridOutput}
soundOutput={preferences.soundOutput}
autorefresh={preferences.autorefresh}
setTextOutput={setTextOutput}
setGridOutput={setGridOutput}
setSoundOutput={setSoundOutput}
dispatchConsoleEvent={dispatchConsoleEvent}
endSketchRefresh={endSketchRefresh}
stopSketch={stopSketch}
setBlobUrl={setBlobUrl}
expandConsole={expandConsole}
clearConsole={clearConsole}
/>
</Content>
</Screen>);
};
MobileSketchView.propTypes = {
params: PropTypes.shape({
project_id: PropTypes.string,
username: PropTypes.string
}).isRequired,
htmlFile: PropTypes.shape({
id: PropTypes.string.isRequired,
content: PropTypes.string.isRequired,
name: PropTypes.string.isRequired
}).isRequired,
files: PropTypes.arrayOf(PropTypes.shape({
id: PropTypes.string.isRequired,
content: PropTypes.string.isRequired,
name: PropTypes.string.isRequired
})).isRequired,
selectedFile: PropTypes.shape({
id: PropTypes.string.isRequired,
content: PropTypes.string.isRequired,
name: PropTypes.string.isRequired
}).isRequired,
preferences: PropTypes.shape({
fontSize: PropTypes.number.isRequired,
autosave: PropTypes.bool.isRequired,
linewrap: PropTypes.bool.isRequired,
lineNumbers: PropTypes.bool.isRequired,
lintWarning: PropTypes.bool.isRequired,
textOutput: PropTypes.bool.isRequired,
gridOutput: PropTypes.bool.isRequired,
soundOutput: PropTypes.bool.isRequired,
theme: PropTypes.string.isRequired,
autorefresh: PropTypes.bool.isRequired
}).isRequired,
ide: PropTypes.shape({
isPlaying: PropTypes.bool.isRequired,
isAccessibleOutputPlaying: PropTypes.bool.isRequired,
consoleEvent: PropTypes.array,
modalIsVisible: PropTypes.bool.isRequired,
sidebarIsExpanded: PropTypes.bool.isRequired,
consoleIsExpanded: PropTypes.bool.isRequired,
preferencesIsVisible: PropTypes.bool.isRequired,
projectOptionsVisible: PropTypes.bool.isRequired,
newFolderModalVisible: PropTypes.bool.isRequired,
shareModalVisible: PropTypes.bool.isRequired,
shareModalProjectId: PropTypes.string.isRequired,
shareModalProjectName: PropTypes.string.isRequired,
shareModalProjectUsername: PropTypes.string.isRequired,
editorOptionsVisible: PropTypes.bool.isRequired,
keyboardShortcutVisible: PropTypes.bool.isRequired,
unsavedChanges: PropTypes.bool.isRequired,
infiniteLoop: PropTypes.bool.isRequired,
previewIsRefreshing: PropTypes.bool.isRequired,
infiniteLoopMessage: PropTypes.string.isRequired,
projectSavedTime: PropTypes.string,
previousPath: PropTypes.string.isRequired,
justOpenedProject: PropTypes.bool.isRequired,
errorType: PropTypes.string,
runtimeErrorWarningVisible: PropTypes.bool.isRequired,
uploadFileModalVisible: PropTypes.bool.isRequired
}).isRequired,
projectName: PropTypes.string.isRequired,
setTextOutput: PropTypes.func.isRequired,
setGridOutput: PropTypes.func.isRequired,
setSoundOutput: PropTypes.func.isRequired,
dispatchConsoleEvent: PropTypes.func.isRequired,
endSketchRefresh: PropTypes.func.isRequired,
stopSketch: PropTypes.func.isRequired,
setBlobUrl: PropTypes.func.isRequired,
expandConsole: PropTypes.func.isRequired,
clearConsole: PropTypes.func.isRequired,
};
function mapStateToProps(state) {
return {
htmlFile: getHTMLFile(state.files),
projectName: state.project.name,
files: state.files,
ide: state.ide,
preferences: state.preferences,
selectedFile: state.files.find(file => file.isSelectedFile) ||
state.files.find(file => file.name === 'sketch.js') ||
state.files.find(file => file.name !== 'root'),
};
}
function mapDispatchToProps(dispatch) {
return bindActionCreators({
...ProjectActions, ...IDEActions, ...PreferencesActions, ...ConsoleActions, ...FilesActions
}, dispatch);
}
export default connect(mapStateToProps, mapDispatchToProps)(MobileSketchView);

View file

@ -1,13 +1,9 @@
import { browserHistory } from 'react-router';
import axios from 'axios';
import * as ActionTypes from '../../constants';
import apiClient from '../../utils/apiClient';
import { showErrorModal, justOpenedProject } from '../IDE/actions/ide';
import { showToast, setToastText } from '../IDE/actions/toast';
const __process = (typeof global !== 'undefined' ? global : window).process;
const ROOT_URL = __process.env.API_URL;
export function authError(error) {
return {
type: ActionTypes.AUTH_ERROR,
@ -17,7 +13,7 @@ export function authError(error) {
export function signUpUser(previousPath, formValues) {
return (dispatch) => {
axios.post(`${ROOT_URL}/signup`, formValues, { withCredentials: true })
apiClient.post('/signup', formValues)
.then((response) => {
dispatch({
type: ActionTypes.AUTH_USER,
@ -34,7 +30,7 @@ export function signUpUser(previousPath, formValues) {
}
export function loginUser(formValues) {
return axios.post(`${ROOT_URL}/login`, formValues, { withCredentials: true });
return apiClient.post('/login', formValues);
}
export function loginUserSuccess(user) {
@ -74,7 +70,7 @@ export function validateAndLoginUser(previousPath, formProps, dispatch) {
export function getUser() {
return (dispatch) => {
axios.get(`${ROOT_URL}/session`, { withCredentials: true })
apiClient.get('/session')
.then((response) => {
dispatch({
type: ActionTypes.AUTH_USER,
@ -95,7 +91,7 @@ export function getUser() {
export function validateSession() {
return (dispatch, getState) => {
axios.get(`${ROOT_URL}/session`, { withCredentials: true })
apiClient.get('/session')
.then((response) => {
const state = getState();
if (state.user.username !== response.data.username) {
@ -113,7 +109,7 @@ export function validateSession() {
export function logoutUser() {
return (dispatch) => {
axios.get(`${ROOT_URL}/logout`, { withCredentials: true })
apiClient.get('/logout')
.then(() => {
dispatch({
type: ActionTypes.UNAUTH_USER
@ -131,7 +127,7 @@ export function initiateResetPassword(formValues) {
dispatch({
type: ActionTypes.RESET_PASSWORD_INITIATE
});
axios.post(`${ROOT_URL}/reset-password`, formValues, { withCredentials: true })
apiClient.post('/reset-password', formValues)
.then(() => {
// do nothing
})
@ -150,7 +146,7 @@ export function initiateVerification() {
dispatch({
type: ActionTypes.EMAIL_VERIFICATION_INITIATE
});
axios.post(`${ROOT_URL}/verify/send`, {}, { withCredentials: true })
apiClient.post('/verify/send', {})
.then(() => {
// do nothing
})
@ -170,7 +166,7 @@ export function verifyEmailConfirmation(token) {
type: ActionTypes.EMAIL_VERIFICATION_VERIFY,
state: 'checking',
});
return axios.get(`${ROOT_URL}/verify?t=${token}`, {}, { withCredentials: true })
return apiClient.get(`/verify?t=${token}`, {})
.then(response => dispatch({
type: ActionTypes.EMAIL_VERIFICATION_VERIFIED,
message: response.data,
@ -194,7 +190,7 @@ export function resetPasswordReset() {
export function validateResetPasswordToken(token) {
return (dispatch) => {
axios.get(`${ROOT_URL}/reset-password/${token}`)
apiClient.get(`/reset-password/${token}`)
.then(() => {
// do nothing if the token is valid
})
@ -206,7 +202,7 @@ export function validateResetPasswordToken(token) {
export function updatePassword(token, formValues) {
return (dispatch) => {
axios.post(`${ROOT_URL}/reset-password/${token}`, formValues)
apiClient.post(`/reset-password/${token}`, formValues)
.then((response) => {
dispatch(loginUserSuccess(response.data));
browserHistory.push('/');
@ -226,7 +222,7 @@ export function updateSettingsSuccess(user) {
export function updateSettings(formValues) {
return dispatch =>
axios.put(`${ROOT_URL}/account`, formValues, { withCredentials: true })
apiClient.put('/account', formValues)
.then((response) => {
dispatch(updateSettingsSuccess(response.data));
browserHistory.push('/');
@ -248,7 +244,7 @@ export function createApiKeySuccess(user) {
export function createApiKey(label) {
return dispatch =>
axios.post(`${ROOT_URL}/account/api-keys`, { label }, { withCredentials: true })
apiClient.post('/account/api-keys', { label })
.then((response) => {
dispatch(createApiKeySuccess(response.data));
})
@ -260,7 +256,7 @@ export function createApiKey(label) {
export function removeApiKey(keyId) {
return dispatch =>
axios.delete(`${ROOT_URL}/account/api-keys/${keyId}`, { withCredentials: true })
apiClient.delete(`/account/api-keys/${keyId}`)
.then((response) => {
dispatch({
type: ActionTypes.API_KEY_REMOVED,

View file

@ -71,41 +71,50 @@ ShareURL.propTypes = {
value: PropTypes.string.isRequired,
};
class CollectionItemRowBase extends React.Component {
handleSketchRemove = () => {
if (window.confirm(`Are you sure you want to remove "${this.props.item.project.name}" from this collection?`)) {
this.props.removeFromCollection(this.props.collection.id, this.props.item.project.id);
}
}
const CollectionItemRowBase = ({
collection, item, isOwner, removeFromCollection
}) => {
const projectIsDeleted = item.isDeleted;
render() {
const { item } = this.props;
const sketchOwnerUsername = item.project.user.username;
const sketchUrl = `/${item.project.user.username}/sketches/${item.project.id}`;
const handleSketchRemove = () => {
const name = projectIsDeleted ? 'deleted sketch' : item.project.name;
if (window.confirm(`Are you sure you want to remove "${name}" from this collection?`)) {
removeFromCollection(collection.id, item.projectId);
}
};
const name = projectIsDeleted ? <span>Sketch was deleted</span> : (
<Link to={`/${item.project.user.username}/sketches/${item.projectId}`}>
{item.project.name}
</Link>
);
const sketchOwnerUsername = projectIsDeleted ? null : item.project.user.username;
return (
<tr
className="sketches-table__row"
className={`sketches-table__row ${projectIsDeleted ? 'is-deleted' : ''}`}
>
<th scope="row">
<Link to={sketchUrl}>
{item.project.name}
</Link>
{name}
</th>
<td>{format(new Date(item.createdAt), 'MMM D, YYYY h:mm A')}</td>
<td>{sketchOwnerUsername}</td>
<td className="collection-row__action-column ">
{isOwner &&
<button
className="collection-row__remove-button"
onClick={this.handleSketchRemove}
onClick={handleSketchRemove}
aria-label="Remove sketch from collection"
>
<RemoveIcon focusable="false" aria-hidden="true" />
</button>
}
</td>
</tr>);
}
}
};
CollectionItemRowBase.propTypes = {
collection: PropTypes.shape({
@ -114,14 +123,17 @@ CollectionItemRowBase.propTypes = {
}).isRequired,
item: PropTypes.shape({
createdAt: PropTypes.string.isRequired,
projectId: PropTypes.string.isRequired,
isDeleted: PropTypes.bool.isRequired,
project: PropTypes.shape({
id: PropTypes.string.isRequired,
name: PropTypes.string.isRequired,
user: PropTypes.shape({
username: PropTypes.string.isRequired
})
}),
}).isRequired,
}).isRequired,
isOwner: PropTypes.bool.isRequired,
user: PropTypes.shape({
username: PropTypes.string,
authenticated: PropTypes.bool.isRequired
@ -342,6 +354,7 @@ class Collection extends React.Component {
render() {
const title = this.hasCollection() ? this.getCollectionName() : null;
const isOwner = this.isOwner();
return (
<main className="collection-container" data-has-items={this.hasCollectionItems() ? 'true' : 'false'}>
@ -372,6 +385,7 @@ class Collection extends React.Component {
user={this.props.user}
username={this.getUsername()}
collection={this.props.collection}
isOwner={isOwner}
/>))}
</tbody>
</table>

View file

@ -3,18 +3,15 @@ import React from 'react';
import { reduxForm } from 'redux-form';
import { bindActionCreators } from 'redux';
import { Tab, Tabs, TabList, TabPanel } from 'react-tabs';
import axios from 'axios';
import { Helmet } from 'react-helmet';
import { updateSettings, initiateVerification, createApiKey, removeApiKey } from '../actions';
import AccountForm from '../components/AccountForm';
import apiClient from '../../../utils/apiClient';
import { validateSettings } from '../../../utils/reduxFormUtils';
import SocialAuthButton from '../components/SocialAuthButton';
import APIKeyForm from '../components/APIKeyForm';
import Nav from '../../../components/Nav';
const __process = (typeof global !== 'undefined' ? global : window).process;
const ROOT_URL = __process.env.API_URL;
function SocialLoginPanel(props) {
return (
<React.Fragment>
@ -96,7 +93,7 @@ function asyncValidate(formProps, dispatch, props) {
const queryParams = {};
queryParams[fieldToValidate] = formProps[fieldToValidate];
queryParams.check_type = fieldToValidate;
return axios.get(`${ROOT_URL}/signup/duplicate_check`, { params: queryParams })
return apiClient.get('/signup/duplicate_check', { params: queryParams })
.then((response) => {
if (response.data.exists) {
const error = {};

View file

@ -1,19 +1,16 @@
import PropTypes from 'prop-types';
import React from 'react';
import { bindActionCreators } from 'redux';
import axios from 'axios';
import { Link, browserHistory } from 'react-router';
import { Helmet } from 'react-helmet';
import { reduxForm } from 'redux-form';
import * as UserActions from '../actions';
import SignupForm from '../components/SignupForm';
import apiClient from '../../../utils/apiClient';
import { validateSignup } from '../../../utils/reduxFormUtils';
import SocialAuthButton from '../components/SocialAuthButton';
import Nav from '../../../components/Nav';
const __process = (typeof global !== 'undefined' ? global : window).process;
const ROOT_URL = __process.env.API_URL;
class SignupView extends React.Component {
gotoHomePage = () => {
browserHistory.push('/');
@ -86,7 +83,7 @@ function asyncValidate(formProps, dispatch, props) {
const queryParams = {};
queryParams[fieldToValidate] = formProps[fieldToValidate];
queryParams.check_type = fieldToValidate;
return axios.get(`${ROOT_URL}/signup/duplicate_check`, { params: queryParams })
return apiClient.get('/signup/duplicate_check', { params: queryParams })
.then((response) => {
if (response.data.exists) {
errors[fieldToValidate] = response.data.message;

View file

@ -2,6 +2,9 @@ import { Route, IndexRoute } from 'react-router';
import React from 'react';
import App from './modules/App/App';
import IDEView from './modules/IDE/pages/IDEView';
import MobileIDEView from './modules/IDE/pages/MobileIDEView';
import MobileSketchView from './modules/Mobile/MobileSketchView';
import MobilePreferences from './modules/Mobile/MobilePreferences';
import FullView from './modules/IDE/pages/FullView';
import LoginView from './modules/User/pages/LoginView';
import SignupView from './modules/User/pages/SignupView';
@ -20,7 +23,11 @@ const checkAuth = (store) => {
store.dispatch(getUser());
};
// TODO: This short-circuit seems unnecessary - using the mobile <Switch /> navigator (future) should prevent this from being called
const onRouteChange = (store) => {
const path = window.location.pathname;
if (path.includes('/mobile')) return;
store.dispatch(stopSketch());
};
@ -49,6 +56,10 @@ const routes = store => (
<Route path="/:username/collections/create" component={DashboardView} />
<Route path="/:username/collections/:collection_id" component={CollectionView} />
<Route path="/about" component={IDEView} />
<Route path="/mobile" component={MobileIDEView} />
<Route path="/mobile/preview" component={MobileSketchView} />
<Route path="/mobile/preferences" component={MobilePreferences} />
</Route>
);

View file

@ -3,15 +3,14 @@ import thunk from 'redux-thunk';
import DevTools from './modules/App/components/DevTools';
import rootReducer from './reducers';
import { clearState, loadState } from './persistState';
const __process = (typeof global !== 'undefined' ? global : window).process;
import getConfig from './utils/getConfig';
export default function configureStore(initialState) {
const enhancers = [
applyMiddleware(thunk),
];
if (__process.env.CLIENT && __process.env.NODE_ENV === 'development') {
if (getConfig('CLIENT') && getConfig('NODE_ENV') === 'development') {
// Enable DevTools only when rendering on client and during development.
enhancers.push(window.devToolsExtension ? window.devToolsExtension() : DevTools.instrument());
}

View file

@ -28,7 +28,7 @@
top: 0;
left: 0;
bottom: 0;
width: calc(var(--percent) * 100%);
width: calc(var(--percent, 1) * 100%);
@include themify() {
background-color: getThemifyVariable('progress-bar-active-color');

View file

@ -85,6 +85,10 @@
}
}
.sketches-table__row.is-deleted > * {
font-style: italic;
}
.sketches-table thead {
font-size: #{12 / $base-font-size}rem;
@include themify() {

View file

@ -85,6 +85,17 @@ export default {
border: grays.middleLight,
},
},
Icon: {
default: grays.middleGray,
hover: grays.darker
},
MobilePanel: {
default: {
foreground: colors.black,
background: grays.light,
border: grays.middleLight,
},
}
},
[Theme.dark]: {
colors,
@ -113,6 +124,17 @@ export default {
border: grays.middleDark,
},
},
Icon: {
default: grays.middleLight,
hover: grays.lightest
},
MobilePanel: {
default: {
foreground: grays.light,
background: grays.dark,
border: grays.middleDark,
},
}
},
[Theme.contrast]: {
colors,
@ -141,5 +163,16 @@ export default {
border: grays.middleDark,
},
},
Icon: {
default: grays.mediumLight,
hover: colors.yellow
},
MobilePanel: {
default: {
foreground: grays.light,
background: grays.dark,
border: grays.middleDark,
},
}
},
};

17
client/utils/apiClient.js Normal file
View file

@ -0,0 +1,17 @@
import axios from 'axios';
import getConfig from './getConfig';
const ROOT_URL = getConfig('API_URL');
/**
* Configures an Axios instance with the correct API URL
*/
function createClientInstance() {
return axios.create({
baseURL: ROOT_URL,
withCredentials: true
});
}
export default createClientInstance();

View file

@ -60,7 +60,7 @@ export const getAllScriptOffsets = (htmlFile) => {
if (ind === -1) {
foundJSScript = false;
} else {
endFilenameInd = htmlFile.indexOf('.js', ind + startTag.length + 3);
endFilenameInd = htmlFile.indexOf('.js', ind + startTag.length + 1);
filename = htmlFile.substring(ind + startTag.length, endFilenameInd);
lineOffset = htmlFile.substring(0, ind).split('\n').length + hijackConsoleErrorsScriptLength;
offs.push([lineOffset, filename]);

24
client/utils/getConfig.js Normal file
View file

@ -0,0 +1,24 @@
function isTestEnvironment() {
// eslint-disable-next-line no-use-before-define
return getConfig('NODE_ENV', { warn: false }) === 'test';
}
/**
* Returns config item from environment
*/
function getConfig(key, options = { warn: !isTestEnvironment() }) {
if (key == null) {
throw new Error('"key" must be provided to getConfig()');
}
const env = (typeof global !== 'undefined' ? global : window)?.process?.env || {};
const value = env[key];
if (value == null && options?.warn !== false) {
console.warn(`getConfig("${key}") returned null`);
}
return value;
}
export default getConfig;

View file

@ -0,0 +1,28 @@
import getConfig from './getConfig';
describe('utils/getConfig()', () => {
beforeEach(() => {
delete global.process.env.CONFIG_TEST_KEY_NAME;
delete window.process.env.CONFIG_TEST_KEY_NAME;
});
it('throws if key is not defined', () => {
expect(() => getConfig(/* key is missing */)).toThrow(/must be provided/);
});
it('fetches from global.process', () => {
global.process.env.CONFIG_TEST_KEY_NAME = 'editor.p5js.org';
expect(getConfig('CONFIG_TEST_KEY_NAME')).toBe('editor.p5js.org');
});
it('fetches from window.process', () => {
window.process.env.CONFIG_TEST_KEY_NAME = 'editor.p5js.org';
expect(getConfig('CONFIG_TEST_KEY_NAME')).toBe('editor.p5js.org');
});
it('warns but does not throw if no value found', () => {
expect(() => getConfig('CONFIG_TEST_KEY_NAME')).not.toThrow();
});
});

View file

@ -69,9 +69,8 @@ Note that this is optional, unless you are working on the part of the applicatio
If your S3 bucket is in the US East (N Virginia) region (us-east-1), you'll
need to set a custom URL base for it, because it does not follow the standard
naming pattern as the rest of the regions. Instead, add the following to your
environment/.env file:
`S3_BUCKET_URL_BASE=https://s3.amazonaws.com`
environment/.env file, changing `BUCKET_NAME` to your bucket name. This is necessary because this override is currently treated as the full path to the bucket rather than as a proper base url:
`S3_BUCKET_URL_BASE=https://s3.amazonaws.com/{BUCKET_NAME}/`
If you've configured your S3 bucket and DNS records to use a custom domain
name, you can also set it using this variable. I.e.:

View file

@ -13,19 +13,19 @@ This project release guide is based on
1. `$ git checkout develop`
2. `$ git checkout -b release-<newversion>`
3. Do all of the release branch testing necessary. This could be as simple as running `npm test:ci`, or it could take user testing over a few days.
4. `$ git checkout release`
5. `$ git merge --no-ff release-<newversion>`
6. `$ npm version <newversion>` (see [npm-version](https://docs.npmjs.com/cli/version) for valid values of <newversion>).
4. `$ npm version <newversion>` (see [npm-version](https://docs.npmjs.com/cli/version) for valid values of <newversion>).
5. `$ git checkout release`
6. `$ git merge --no-ff release-<newversion>`
7. `$ git push && git push --tags`
8. `$ git checkout develop`
9. `$ git merge --no-ff release-<newversion>`
10. Create a release on GitHub. You can do this in one of two ways:
9. Create a release on GitHub. Make sure that you release from the `release` branch! You can do this in one of two ways:
1. (Preferred) Use the [`hub` command line tool](https://hub.github.com/). You can automate adding all commit messages since the last release with the following command:
```sh
$ hub release create -d -m "<newversion>" -m "$(git log `git describe --tags --abbrev=0 HEAD^`..HEAD --oneline)" <newversion>`
```
Note that this creates a draft release, which you can then edit on GitHub. This allows you to create release notes from the list of commit messages, but then edit these notes as you wish.
2. [Draft a new release on Github](https://github.com/processing/p5.js-web-editor/releases/new).
10. `$ git merge --no-ff release-<newversion>`
Travis CI will automatically deploy the release to production, as well as push a production tagged Docker image to DockerHub.

View file

@ -1,8 +0,0 @@
import { configure } from 'enzyme'
import Adapter from 'enzyme-adapter-react-16'
import '@babel/polyfill'
import mongoose from 'mongoose'
mongoose.Promise = global.Promise;
configure({ adapter: new Adapter() })

8725
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -1,6 +1,6 @@
{
"name": "p5.js-web-editor",
"version": "1.0.3",
"version": "1.0.4",
"description": "The web editor for p5.js.",
"scripts": {
"clean": "rimraf dist",
@ -36,12 +36,30 @@
]
},
"jest": {
"setupFiles": [
"<rootDir>/jest.setup.js"
"projects": [
{
"displayName": "server",
"testEnvironment": "node",
"setupFilesAfterEnv": [
"<rootDir>/server/jest.setup.js"
],
"testMatch": [
"<rootDir>/server/**/*.test.(js|jsx)"
]
},
{
"displayName": "client",
"setupFilesAfterEnv": [
"<rootDir>/client/jest.setup.js"
],
"moduleNameMapper": {
"^.+\\.(jpg|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$": "<rootDir>/client/__test__/mocks/fileMock.js"
},
"testMatch": [
"<rootDir>/client/**/*.test.(js|jsx)"
]
}
]
},
"main": "index.js",
"author": "Cassie Tarakajian",
@ -78,15 +96,14 @@
"@storybook/addons": "^5.3.6",
"@storybook/react": "^5.3.6",
"@svgr/webpack": "^5.4.0",
"@testing-library/jest-dom": "^5.10.1",
"@testing-library/react": "^10.2.1",
"babel-core": "^7.0.0-bridge.0",
"babel-eslint": "^9.0.0",
"babel-jest": "^24.9.0",
"babel-loader": "^8.0.0",
"babel-plugin-transform-react-remove-prop-types": "^0.2.12",
"css-loader": "^3.4.2",
"cssnano": "^4.1.10",
"enzyme": "^3.11.0",
"enzyme-adapter-react-16": "^1.15.2",
"eslint": "^4.19.1",
"eslint-config-airbnb": "^16.1.0",
"eslint-plugin-import": "^2.20.2",
@ -94,10 +111,10 @@
"eslint-plugin-react": "^7.18.3",
"file-loader": "^2.0.0",
"husky": "^4.2.5",
"jest": "^24.9.0",
"jest": "^26.0.1",
"lint-staged": "^10.1.3",
"mini-css-extract-plugin": "^0.8.2",
"node-sass": "^4.13.1",
"node-sass": "^4.14.1",
"nodemon": "^1.19.4",
"optimize-css-assets-webpack-plugin": "^5.0.3",
"postcss-cssnext": "^3.1.0",
@ -137,6 +154,7 @@
"connect-mongo": "^1.3.2",
"console-feed": "^2.8.11",
"cookie-parser": "^1.4.3",
"copy-webpack-plugin": "^6.0.3",
"cors": "^2.8.5",
"cross-env": "^5.2.1",
"csslint": "^1.0.5",
@ -151,12 +169,15 @@
"express-session": "^1.17.0",
"friendly-words": "^1.1.10",
"htmlhint": "^0.10.1",
"i18next": "^19.4.5",
"i18next-browser-languagedetector": "^4.2.0",
"i18next-http-backend": "^1.0.15",
"is-url": "^1.2.4",
"jest-express": "^1.11.0",
"js-beautify": "^1.10.3",
"jsdom": "^9.8.3",
"jshint": "^2.11.0",
"lodash": "^4.17.15",
"lodash": "^4.17.19",
"loop-protect": "github:catarak/loop-protect",
"mime-types": "^2.1.26",
"mjml": "^3.3.2",
@ -178,6 +199,7 @@
"react-dom": "^16.12.0",
"react-helmet": "^5.1.3",
"react-hot-loader": "^4.12.19",
"react-i18next": "^11.5.0",
"react-redux": "^5.1.2",
"react-router": "^3.2.5",
"react-split-pane": "^0.1.89",

View file

@ -32,7 +32,7 @@ export default function addProjectToCollection(req, res) {
return null;
}
const projectInCollection = collection.items.find(p => p.project._id === project._id);
const projectInCollection = collection.items.find(p => p.projectId === project._id);
if (projectInCollection) {
sendFailure(404, 'Project already in collection');

View file

@ -23,7 +23,7 @@ export default function addProjectToCollection(req, res) {
return null;
}
const project = collection.items.find(p => p.project._id === projectId);
const project = collection.items.find(p => p.projectId === projectId);
if (project != null) {
project.remove();

View file

@ -34,6 +34,7 @@ export function serveProject(req, res) {
resolveScripts(sketchDoc, files);
resolveStyles(sketchDoc, files);
res.setHeader('Cache-Control', 'public, max-age=0');
res.send(serializeDocument(sketchDoc));
});
});

4
server/jest.setup.js Normal file
View file

@ -0,0 +1,4 @@
import '@babel/polyfill';
import mongoose from 'mongoose';
mongoose.Promise = global.Promise;

View file

@ -15,6 +15,14 @@ collectedProjectSchema.virtual('id').get(function getId() {
return this._id.toHexString();
});
collectedProjectSchema.virtual('projectId').get(function projectId() {
return this.populated('project');
});
collectedProjectSchema.virtual('isDeleted').get(function isDeleted() {
return this.project == null;
});
collectedProjectSchema.set('toJSON', {
virtuals: true
});

View file

@ -114,10 +114,20 @@ router.get('/about', (req, res) => {
res.send(renderIndex());
});
router.get('/feedback', (req, res) => {
if (process.env.MOBILE_ENABLED) {
router.get('/mobile', (req, res) => {
res.send(renderIndex());
});
router.get('/mobile/preview', (req, res) => {
res.send(renderIndex());
});
router.get('/mobile/preferences', (req, res) => {
res.send(renderIndex());
});
}
router.get('/:username/collections/create', (req, res) => {
userExists(req.params.username, (exists) => {
const isLoggedInUser = req.user && req.user.username === req.params.username;

View file

@ -73,7 +73,7 @@ function getCategories() {
function getSketchesInCategories(categories) {
return Q.all(categories.map((category) => {
const options = {
url: `${category.url.replace('?ref=master', '')}?client_id=${clientId}&client_secret=${clientSecret}`,
url: `${category.url.replace('?ref=main', '')}?client_id=${clientId}&client_secret=${clientSecret}`,
method: 'GET',
headers,
json: true
@ -107,7 +107,7 @@ function getSketchesInCategories(categories) {
function getSketchContent(projectsInAllCategories) {
return Q.all(projectsInAllCategories.map(projectsInOneCategory => Q.all(projectsInOneCategory.map((project) => {
const options = {
url: `${project.sketchUrl.replace('?ref=master', '')}?client_id=${clientId}&client_secret=${clientSecret}`,
url: `${project.sketchUrl.replace('?ref=main', '')}?client_id=${clientId}&client_secret=${clientSecret}`,
method: 'GET',
headers
};
@ -264,7 +264,7 @@ function createProjectsInP5user(projectsInAllCategories) {
const fileID = objectID().toHexString();
newProject.files.push({
name: assetName,
url: `https://cdn.jsdelivr.net/gh/processing/p5.js-website@master/src/data/examples/assets/${assetName}`,
url: `https://cdn.jsdelivr.net/gh/processing/p5.js-website@main/src/data/examples/assets/${assetName}`,
id: fileID,
_id: fileID,
children: [],

View file

@ -75,10 +75,11 @@ app.use(corsMiddleware);
app.options('*', corsMiddleware);
// Body parser, cookie parser, sessions, serve public assets
app.use('/locales', Express.static(path.resolve(__dirname, '../dist/static/locales'), { cacheControl: false }));
app.use(Express.static(path.resolve(__dirname, '../dist/static'), {
maxAge: process.env.STATIC_MAX_AGE || (process.env.NODE_ENV === 'production' ? '1d' : '0')
}));
app.use(bodyParser.urlencoded({ limit: '50mb', extended: true }));
app.use(bodyParser.json({ limit: '50mb' }));
app.use(cookieParser());

View file

@ -32,6 +32,9 @@ export function renderIndex() {
window.process.env.UI_ACCESS_TOKEN_ENABLED = ${process.env.UI_ACCESS_TOKEN_ENABLED === 'false' ? false : true};
window.process.env.UI_COLLECTIONS_ENABLED = ${process.env.UI_COLLECTIONS_ENABLED === 'false' ? false : true};
window.process.env.UPLOAD_LIMIT = ${process.env.UPLOAD_LIMIT ? `${process.env.UPLOAD_LIMIT}` : undefined};
window.process.env.MOBILE_ENABLED = ${process.env.MOBILE_ENABLED ? `${process.env.MOBILE_ENABLED}` : undefined};
window.process.env.TRANSLATIONS_ENABLED = ${process.env.TRANSLATIONS_ENABLED === 'true' ? true :false};
</script>
</head>
<body>

View file

@ -0,0 +1,115 @@
{
"Contribute": "Contribute",
"NewP5": "New to p5.js?",
"Report": "Report a bug",
"Learn": "Learn",
"About": "About",
"Resources": "Resources",
"Libraries": "Libraries",
"Forum": "Forum",
"File": "File",
"New": "New",
"Save": "Save",
"Share": "Share",
"Duplicate": "Duplicate",
"Examples": "Examples",
"Edit": "Edit",
"TidyCode": "Tidy Code",
"Find": "Find",
"AddToCollection": "Add to Collection",
"FindNext": "Find Next",
"FindPrevious": "Find Previous",
"Sketch": "Sketch",
"AddFile": "Add File",
"AddFolder": "Add Folder",
"Run": "Run",
"Stop": "Stop",
"Help": "Help",
"KeyboardShortcuts": "Keyboard Shortcuts",
"Reference": "Reference",
"Tidy": "Tidy",
"Lang": "Language",
"FindNextMatch": "Find Next Match",
"FindPrevMatch": "Find Previous Match",
"IndentCodeLeft": "Indent Code Left",
"IndentCodeRight": "Indent Code Right",
"CommentLine": "Comment Line",
"StartSketch": "Start Sketch",
"StopSketch": "StopSketch",
"TurnOnAccessibleOutput": "Turn On Accessible Output",
"TurnOffAccessibleOutput": "Turn Off Accessible Output",
"ToogleSidebar": "Toogle Sidebar",
"ToogleConsole": "Toogle Console",
"Preview": "Preview",
"Auto-refresh": "Auto-refresh",
"Console": "Console",
"Settings": "Settings",
"GeneralSettings": "General settings",
"Theme": "Theme",
"Light": "Light",
"Dark": "Dark",
"HighContrast": "High Contrast",
"TextSize": "Text Size",
"Decrease": "Decrease",
"Increase": "Increase",
"IndentationAmount": "Indentation amount",
"Autosave": "Autosave",
"On": "On",
"Off": "Off",
"SketchSettings": "Sketch Settings",
"SecurityProtocol": "Security Protocol",
"ServeOverHTTPS": "Serve over HTTPS",
"Accessibility": "Accessibility",
"LintWarningSound": "Lint warning sound",
"PreviewSound": "Preview sound",
"AccessibleTextBasedCanvas": "Accessible text-based canvas",
"UsedScreenReader": "Used with screen reader",
"PlainText": "Plain-text",
"TableText": "Table-text",
"Sound": "Sound",
"WordWrap": "Word Wrap",
"LineNumbers": "Line numbers",
"LangChange": "Language changed",
"Welcome": "Welcome",
"Login": "Log in",
"LoginOr": "or",
"SignUp": "Sign up",
"Email": "email",
"Username": "username",
"LoginGithub": "Login with Github",
"LoginGoogle": "Login with Google",
"DontHaveAccount": "Don't have an account?",
"ForgotPassword": "Forgot your password?",
"ResetPassword": "Reset your password",
"BackEditor": "Back to Editor",
"UsernameSplit": "User Name",
"Password": "Password",
"ConfirmPassword": "Confirm Password",
"OpenedNewSketch": "Opened new sketch.",
"Hello": "Hello",
"MyAccount": "My Account",
"My":"My",
"Sketches": "My sketches",
"Collections": "My collections",
"Asset": "Asset",
"MyAssets": "My assets",
"TitleAbout": "p5.js Web Editor | About",
"CodeEditing": "Code Editing",
"Error": "Error",
"In order to save": "In order to save",
"you must be logged in": "you must be logged in",
"Please": "please",
"Find in files": "Find in files",
"Create": "Create",
"enter a name": "enter a name",
"Add": "Add",
"Folder": "Folder",
"FindText": "Find Text",
"FindNextTextMatch": "Find Next Text Match",
"FindPreviousTextMatch": "Find Previous Text Match",
"Code editing keyboard shortcuts follow": "Code editing keyboard shortcuts follow",
"Sublime Text shortcuts": "Sublime Text shortcuts",
"WarningUnsavedChanges": "Are you sure you want to leave this page? You have unsaved changes."
}

View file

@ -0,0 +1,113 @@
{
"Contribute": "Contribuir",
"NewP5": "¿Empezando con p5.js?",
"Report": "Reporta un error",
"Learn": "Aprende",
"About": "Acerca de",
"Resources": "Recursos",
"Libraries": "Bibliotecas",
"Forum": "Foro",
"File": "Archivo",
"New": "Nuevo",
"Save": "Guardar",
"Share": "Compartir",
"Duplicate": "Duplicar",
"Examples": "Ejemplos",
"Edit": "Editar",
"TidyCode": "Ordenar código",
"Find": "Buscar",
"AddToCollection": "Agregar a colección",
"FindNext": "Buscar siguiente",
"FindPrevious": "Buscar anterior",
"Sketch": "Bosquejo",
"AddFile": "Agregar archivo",
"AddFolder": "Agregar directorio",
"Run": "Ejecutar",
"Stop": "Detener",
"Help": "Ayuda",
"KeyboardShortcuts": "Atajos",
"Reference": "Referencia",
"Tidy": "Ordenar",
"Lang": "Lenguaje",
"FindNextMatch": "Encontrar siguiente ocurrencia",
"FindPrevMatch": "Encontrar ocurrencia previa",
"IndentCodeLeft": "Indentar código a la izquierda",
"IndentCodeRight": "Indentar código a la derecha",
"CommentLine": "Comentar línea de código",
"StartSketch": "Iniciar bosquejo",
"StopSketch": "Detener bosquejo",
"TurnOnAccessibleOutput": "Activar salida accesible",
"TurnOffAccessibleOutput": "Desactivar salida accesible",
"ToogleSidebar": "Alternar barra de deslizamiento",
"ToogleConsole": "Alternar consola",
"Preview": "Vista previa",
"Auto-refresh": "Auto-refrescar",
"Console": "Consola",
"Settings": "Configuración",
"GeneralSettings": "Configuración general",
"Theme": "Modo de visualización",
"Light": "Claro",
"Dark": "Oscuro",
"HighContrast": "Alto contraste",
"TextSize": "Tamaño del texto",
"Decrease": "Disminuir",
"Increase": "Aumentar",
"IndentationAmount": "Cantidad de indentación",
"Autosave": "Grabar automáticamente",
"On": "Activar",
"Off": "Desactivar",
"SketchSettings": "Configuración del bosquejo",
"SecurityProtocol": "Protocolo de seguridad",
"ServeOverHTTPS": "Usar HTTPS",
"Accessibility": "Accesibilidad",
"LintWarningSound": "Sonido de alarma Lint",
"PreviewSound": "Probar sonido",
"AccessibleTextBasedCanvas": "Lienzo accesible por texto",
"UsedScreenReader": "Uso con screen reader",
"PlainText": "Texto sin formato",
"TableText": "Tablero de texto",
"Sound": "Sonido",
"WordWrap": "Ajuste automático de línea",
"LineNumbers": "Número de línea",
"LangChange": "Lenguaje cambiado",
"Welcome": "Bienvenida",
"Login": "Ingresa",
"LoginOr": "o",
"SignUp": "registráte",
"email": "correo electrónico",
"username": "nombre de usuario",
"LoginGithub": "Ingresa con Github",
"LoginGoogle": "Ingresa con Google",
"DontHaveAccount": "No tienes cuenta?",
"ForgotPassword": "¿Olvidaste tu contraseña?",
"ResetPassword": "Regenera tu contraseña",
"BackEditor": "Regresa al editor",
"UsernameSplit": "Nombre de usuario",
"Password": "Contraseña",
"ConfirmPassword": "Confirma la contraseña",
"OpenedNewSketch": "Creaste nuevo bosquejo.",
"Hello": "Hola",
"MyAccount": "Mi Cuenta",
"My": "Mi",
"MySketches": "Mis bosquejos",
"MyCollections":"Mis colecciones",
"Asset": "Asset",
"MyAssets": "Mis assets",
"TitleAbout": "Editor Web p5.js | Acerca de",
"CodeEditing": "Editando Código",
"Error": "Error",
"In order to save": "Para guardar",
"you must be logged in": "debes ingresar a tu cuenta",
"Please": "Por favor",
"Find in files": "Encontrar en archivos",
"Create": "Create",
"enter a name": "enter a name",
"Add": "Add",
"Folder": "Directorio",
"FindText": "Encontrar texto",
"FindNextTextMatch": "Encontrar la siguiente ocurrencia de texto",
"FindPreviousTextMatch": "Encontrar la ocurrencia previa de texto",
"Code editing keyboard shortcuts follow": "Los atajos para edición son como",
"Sublime Text shortcuts": "los atajos de Sublime Text ",
"WarningUnsavedChanges": "¿Estás seguro de que quieres salir de la página? Tienes cambios sin guardar."
}

View file

@ -1,5 +1,6 @@
const webpack = require('webpack');
const path = require('path');
const CopyWebpackPlugin = require('copy-webpack-plugin')
if (process.env.NODE_ENV === 'development') {
require('dotenv').config();
@ -40,7 +41,13 @@ module.exports = {
'process.env': {
NODE_ENV: JSON.stringify('development')
}
})
}),
new CopyWebpackPlugin({
patterns: [
{from: path.resolve(__dirname, '../translations/locales') , to: path.resolve(__dirname, 'locales')}
]
}
)
],
module: {
rules: [

View file

@ -8,6 +8,7 @@ const cssnext = require('postcss-cssnext');
const postcssFocus = require('postcss-focus');
const postcssReporter = require('postcss-reporter');
const cssnano = require('cssnano');
const CopyWebpackPlugin = require('copy-webpack-plugin')
if (process.env.NODE_ENV === "development") {
require('dotenv').config();
}
@ -144,7 +145,13 @@ module.exports = [{
}),
new MiniCssExtractPlugin({
filename: 'app.[hash].css',
})
}),
new CopyWebpackPlugin({
patterns: [
{from: path.resolve(__dirname, '../translations/locales') , to: path.resolve(__dirname, '../dist/static/locales')}
]
}
)
]
},
{