🔀 pull from develop

This commit is contained in:
ghalestrilo 2020-08-18 19:33:15 -03:00
commit f5c7a31fbd
44 changed files with 759 additions and 265 deletions

View File

@ -16,6 +16,12 @@ import More from '../images/more.svg';
import Code from '../images/code.svg';
import Terminal from '../images/terminal.svg';
import Folder from '../images/folder-padded.svg';
import CircleTerminal from '../images/circle-terminal.svg';
import CircleFolder from '../images/circle-folder.svg';
import CircleInfo from '../images/circle-info.svg';
// HOC that adds the right web accessibility props
// https://www.scottohara.me/blog/2019/05/22/contextual-images-svgs-and-a11y.html
@ -81,3 +87,9 @@ export const PlayIcon = withLabel(Play);
export const MoreIcon = withLabel(More);
export const TerminalIcon = withLabel(Terminal);
export const CodeIcon = withLabel(Code);
export const FolderIcon = withLabel(Folder);
export const CircleTerminalIcon = withLabel(CircleTerminal);
export const CircleFolderIcon = withLabel(CircleFolder);
export const CircleInfoIcon = withLabel(CircleInfo);

View File

@ -25,7 +25,7 @@ const DropdownWrapper = styled.ul`
display: flex;
flex-direction: column;
height: auto;
z-index: 9999;
z-index: 2;
border-radius: ${remSize(6)};
& li:first-child { border-radius: ${remSize(5)} ${remSize(5)} 0 0; }

View File

@ -9,7 +9,7 @@ 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 { setAllAccessibleOutput, setLanguage } from '../modules/IDE/actions/preferences';
import { logoutUser } from '../modules/User/actions';
import getConfig from '../utils/getConfig';
@ -72,7 +72,6 @@ class Nav extends React.PureComponent {
document.removeEventListener('mousedown', this.handleClick, false);
document.removeEventListener('keydown', this.closeDropDown, false);
}
setDropdown(dropdown) {
this.setState({
dropdownOpen: dropdown
@ -170,7 +169,7 @@ class Nav extends React.PureComponent {
}
handleLangSelection(event) {
i18next.changeLanguage(event.target.value);
this.props.setLanguage(event.target.value);
this.props.showToast(1500);
this.props.setToastText('Toast.LangChange');
this.setDropdown('none');
@ -808,8 +807,8 @@ Nav.propTypes = {
params: PropTypes.shape({
username: PropTypes.string
}),
t: PropTypes.func.isRequired
t: PropTypes.func.isRequired,
setLanguage: PropTypes.func.isRequired,
};
Nav.defaultProps = {
@ -839,7 +838,8 @@ const mapDispatchToProps = {
...projectActions,
...toastActions,
logoutUser,
setAllAccessibleOutput
setAllAccessibleOutput,
setLanguage
};
export default withTranslation()(withRouter(connect(mapStateToProps, mapDispatchToProps)(Nav)));

View File

@ -45,7 +45,8 @@ describe('Nav', () => {
rootFile: {
id: 'root-file'
},
t: jest.fn()
t: jest.fn(),
setLanguage: jest.fn()
};
it('renders correctly', () => {

View File

@ -1,35 +1,66 @@
import React from 'react';
import styled from 'styled-components';
import PropTypes from 'prop-types';
import { bindActionCreators } from 'redux';
import { useDispatch, useSelector } from 'react-redux';
import { remSize } from '../../theme';
import { remSize, prop } from '../../theme';
import IconButton from './IconButton';
import { TerminalIcon } from '../../common/icons';
import { TerminalIcon, FolderIcon } from '../../common/icons';
import * as IDEActions from '../../modules/IDE/actions/ide';
const BottomBarContent = styled.h2`
const BottomBarContent = styled.div`
padding: ${remSize(8)};
display: flex;
svg {
max-height: ${remSize(32)};
}
path { fill: ${prop('primaryTextColor')} !important }
.inverted {
path { fill: ${prop('backgroundColor')} !important }
rect { fill: ${prop('primaryTextColor')} !important }
}
`;
export default () => {
// Maybe this component shouldn't be connected, and instead just receive the `actions` prop
const ActionStrip = ({ toggleExplorer }) => {
const { expandConsole, collapseConsole } = bindActionCreators(IDEActions, useDispatch());
const { consoleIsExpanded } = useSelector(state => state.ide);
const actions = [{ icon: TerminalIcon, aria: 'Say Something', action: consoleIsExpanded ? collapseConsole : expandConsole }];
const actions = [
{
icon: TerminalIcon, inverted: true, aria: 'Open terminal console', action: consoleIsExpanded ? collapseConsole : expandConsole
},
{ icon: FolderIcon, aria: 'Open files explorer', action: toggleExplorer }
];
return (
<BottomBarContent>
{actions.map(({ icon, aria, action }) =>
(<IconButton
icon={icon}
aria-label={aria}
key={`bottom-bar-${aria}`}
onClick={() => action()}
/>))}
{actions.map(({
icon, aria, action, inverted
}) =>
(
<IconButton
inverted={inverted}
className={inverted && 'inverted'}
icon={icon}
aria-label={aria}
key={`bottom-bar-${aria}`}
onClick={() => action()}
/>))}
</BottomBarContent>
);
};
ActionStrip.propTypes = {
toggleExplorer: PropTypes.func
};
ActionStrip.defaultProps = {
toggleExplorer: () => {}
};
export default ActionStrip;

View File

@ -0,0 +1,24 @@
import React from 'react';
import styled from 'styled-components';
import PropTypes from 'prop-types';
import Sidebar from './Sidebar';
import ConnectedFileNode from '../../modules/IDE/components/FileNode';
const Explorer = ({ id, canEdit, onPressClose }) => (
<Sidebar title="Files" onPressClose={onPressClose}>
<ConnectedFileNode id={id} canEdit={canEdit} onClickFile={() => onPressClose()} />
</Sidebar>
);
Explorer.propTypes = {
id: PropTypes.number.isRequired,
onPressClose: PropTypes.func,
canEdit: PropTypes.bool
};
Explorer.defaultProps = {
canEdit: false,
onPressClose: () => {}
};
export default Explorer;

View File

@ -0,0 +1,43 @@
import React from 'react';
import PropTypes from 'prop-types';
import styled from 'styled-components';
import { remSize, prop } from '../../theme';
import Button from '../../common/Button';
import IconButton from './IconButton';
const FloatingContainer = styled.div`
position: fixed;
right: ${remSize(16)};
top: ${remSize(80)};
text-align: right;
z-index: 3;
svg { width: ${remSize(32)}; };
svg > path { fill: ${prop('Button.default.background')} !important };
`;
const FloatingNav = ({ items }) => (
<FloatingContainer>
{ items.map(({ icon, onPress }) =>
(
<IconButton
onClick={onPress}
icon={icon}
/>
))}
</FloatingContainer>
);
FloatingNav.propTypes = {
items: PropTypes.arrayOf(PropTypes.shape({
icon: PropTypes.element,
onPress: PropTypes.func
}))
};
FloatingNav.defaultProps = {
items: []
};
export default FloatingNav;

View File

@ -14,7 +14,7 @@ const textColor = ({ transparent, inverted }) => prop((transparent === false &&
const HeaderDiv = styled.div`
position: fixed;
${props => props.fixed && 'position: fixed;'}
width: 100%;
background: ${props => background(props)};
color: ${textColor};
@ -57,9 +57,9 @@ const TitleContainer = styled.div`
const Header = ({
title, subtitle, leftButton, children,
transparent, inverted, slim
transparent, inverted, slim, fixed
}) => (
<HeaderDiv transparent={transparent} slim={slim} inverted={inverted}>
<HeaderDiv transparent={transparent} slim={slim} inverted={inverted} fixed={fixed}>
{leftButton}
<TitleContainer padded={subtitle === null}>
{title && <h2>{title}</h2>}
@ -79,6 +79,7 @@ Header.propTypes = {
transparent: PropTypes.bool,
inverted: PropTypes.bool,
slim: PropTypes.bool,
fixed: PropTypes.bool,
};
Header.defaultProps = {
@ -88,7 +89,8 @@ Header.defaultProps = {
children: [],
transparent: false,
inverted: false,
slim: false
slim: false,
fixed: true
};
export default Header;

View File

@ -2,7 +2,10 @@ import React from 'react';
import styled from 'styled-components';
import { remSize } from '../../theme';
// Applies padding to top and bottom so editor content is always visible
export default styled.div`
z-index: 0;
margin-top: ${remSize(16)};
.CodeMirror-sizer > * { padding-bottom: ${remSize(320)}; };
`;

View File

@ -0,0 +1,46 @@
import React from 'react';
import PropTypes from 'prop-types';
import { Link } from 'react-router';
import styled from 'styled-components';
import { remSize, prop, common } from '../../theme';
import Header from './Header';
import IconButton from './IconButton';
import { ExitIcon } from '../../common/icons';
const SidebarWrapper = styled.div`
height: 100%;
width: ${remSize(180)};
position: fixed;
z-index: 2;
left: 0;
background: white;
box-shadow: 0 6px 6px 0 rgba(0,0,0,0.10);
`;
const Sidebar = ({ title, onPressClose, children }) => (
<SidebarWrapper>
{title &&
<Header slim title={title} fixed={false}>
<IconButton onClick={onPressClose} icon={ExitIcon} aria-label="Return to ide view" />
</Header>}
{children}
</SidebarWrapper>
);
Sidebar.propTypes = {
title: PropTypes.string,
onPressClose: PropTypes.func,
children: PropTypes.oneOfType([PropTypes.element, PropTypes.arrayOf(PropTypes.element)]),
};
Sidebar.defaultProps = {
title: null,
children: [],
onPressClose: () => {}
};
export default Sidebar;

View File

@ -1,10 +1,29 @@
import React from 'react';
import styled from 'styled-components';
import { useModalBehavior } from '../utils/custom-hooks';
export default (component) => {
const [visible, trigger, setRef] = useModalBehavior();
const BackgroundOverlay = styled.div`
position: fixed;
z-index: 2;
width: 100% !important;
height: 100% !important;
background: black;
opacity: 0.3;
`;
const wrapper = () => <div ref={setRef}> {visible && component} </div>; // eslint-disable-line
export default (Element, hasOverlay = false) => {
const [visible, toggle, setRef] = useModalBehavior();
return [trigger, wrapper];
const wrapper = () => (visible &&
<div>
{hasOverlay && <BackgroundOverlay />}
<div ref={setRef}>
{ (typeof (Element) === 'function')
? Element(toggle)
: Element}
</div>
</div>);
return [toggle, wrapper];
};

View File

@ -93,6 +93,7 @@ export const SHOW_TOAST = 'SHOW_TOAST';
export const HIDE_TOAST = 'HIDE_TOAST';
export const SET_TOAST_TEXT = 'SET_TOAST_TEXT';
export const SET_THEME = 'SET_THEME';
export const SET_LANGUAGE = 'SET_LANGUAGE';
export const SET_UNSAVED_CHANGES = 'SET_UNSAVED_CHANGES';
export const SET_AUTOREFRESH = 'SET_AUTOREFRESH';

View File

@ -0,0 +1,5 @@
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="16" cy="16" r="16" fill="#333333"/>
<path d="M25.144 12.0961V22.4321C25.144 22.8801 24.792 23.2321 24.344 23.2321H7.768C7.32 23.2321 7 22.8801 7 22.4321V12.0961C7 11.2321 7.704 10.5281 8.568 10.5281H23.576C24.44 10.5281 25.144 11.2321 25.144 12.0961Z" fill="#F0F0F0"/>
<path d="M9.24023 9.6C9.24023 9.6 9.24023 8 10.5842 8H15.1282C16.4402 8 16.4402 9.6 16.4402 9.6H9.24023Z" fill="#F0F0F0"/>
</svg>

After

Width:  |  Height:  |  Size: 507 B

View File

@ -0,0 +1,4 @@
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="16" cy="16" r="16" fill="#333333"/>
<path d="M16 7C11.0154 7 7 11.0154 7 16C7 20.95 11.0154 25 16 25C20.95 25 25 20.95 25 16C25 11.0154 20.95 7 16 7ZM17.3846 21.5038H14.6846V13.7154H17.3846V21.5038ZM16 12.9538C15.1 12.9538 14.4077 12.2615 14.4077 11.3615C14.4077 10.4962 15.1 9.80385 16 9.80385C16.9 9.80385 17.5923 10.4962 17.5923 11.3615C17.5923 12.2615 16.9 12.9538 16 12.9538Z" fill="#F0F0F0"/>
</svg>

After

Width:  |  Height:  |  Size: 514 B

View File

@ -0,0 +1,6 @@
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="16" cy="16" r="16" fill="#333333"/>
<rect x="5" y="8" width="22" height="16" rx="2" fill="#F0F0F0"/>
<path d="M24 21H14V20H24V21Z" fill="#333333"/>
<path d="M10.4081 16.0231L8.3676 18.0637C8.27757 18.1537 8.15754 18.1537 8.06752 18.0637C7.97749 17.9736 7.97749 17.8536 8.06752 17.7636L9.95802 15.8731L8.06752 13.9826C7.97749 13.8926 7.97749 13.7725 8.06752 13.6675C8.15754 13.5775 8.27757 13.5775 8.3676 13.6675L10.4081 15.723C10.4532 15.753 10.4832 15.8131 10.4832 15.8731C10.4832 15.9181 10.4532 15.9781 10.4081 16.0231Z" fill="#333333"/>
</svg>

After

Width:  |  Height:  |  Size: 656 B

View File

@ -0,0 +1,4 @@
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M25.144 12.0961V22.4321C25.144 22.8801 24.792 23.2321 24.344 23.2321H7.768C7.32 23.2321 7 22.8801 7 22.4321V12.0961C7 11.2321 7.704 10.5281 8.568 10.5281H23.576C24.44 10.5281 25.144 11.2321 25.144 12.0961Z" fill="#F0F0F0"/>
<path d="M9.24023 9.6C9.24023 9.6 9.24023 8 10.5842 8H15.1282C16.4402 8 16.4402 9.6 16.4402 9.6H9.24023Z" fill="#F0F0F0"/>
</svg>

After

Width:  |  Height:  |  Size: 459 B

View File

@ -4,6 +4,7 @@ import { connect } from 'react-redux';
import getConfig from '../../utils/getConfig';
import DevTools from './components/DevTools';
import { setPreviousPath } from '../IDE/actions/ide';
import { setLanguage } from '../IDE/actions/preferences';
class App extends React.Component {
constructor(props, context) {
@ -18,11 +19,17 @@ class App extends React.Component {
componentWillReceiveProps(nextProps) {
const locationWillChange = nextProps.location !== this.props.location;
const shouldSkipRemembering = nextProps.location.state && nextProps.location.state.skipSavingPath === true;
const shouldSkipRemembering =
nextProps.location.state &&
nextProps.location.state.skipSavingPath === true;
if (locationWillChange && !shouldSkipRemembering) {
this.props.setPreviousPath(this.props.location.pathname);
}
if (this.props.language !== nextProps.language) {
this.props.setLanguage(nextProps.language, { persistPreference: false });
}
}
componentDidUpdate(prevProps) {
@ -50,18 +57,22 @@ App.propTypes = {
}),
}).isRequired,
setPreviousPath: PropTypes.func.isRequired,
setLanguage: PropTypes.func.isRequired,
language: PropTypes.string,
theme: PropTypes.string,
};
App.defaultProps = {
children: null,
language: null,
theme: 'light'
};
const mapStateToProps = state => ({
theme: state.preferences.theme,
language: state.preferences.language,
});
const mapDispatchToProps = { setPreviousPath };
const mapDispatchToProps = { setPreviousPath, setLanguage };
export default connect(mapStateToProps, mapDispatchToProps)(App);

View File

@ -1,3 +1,4 @@
import i18next from 'i18next';
import apiClient from '../../../utils/apiClient';
import * as ActionTypes from '../../../constants';
@ -210,3 +211,22 @@ export function setAllAccessibleOutput(value) {
};
}
export function setLanguage(value, { persistPreference = true } = {}) {
return (dispatch, getState) => {
i18next.changeLanguage(value);
dispatch({
type: ActionTypes.SET_LANGUAGE,
language: value
});
const state = getState();
if (persistPreference && state.user.authenticated) {
const formParams = {
preferences: {
language: value
}
};
updatePreferences(formParams, dispatch);
}
};
}

View File

@ -143,9 +143,9 @@ class CollectionList extends React.Component {
<thead>
<tr>
{this._renderFieldHeader('name', 'Name')}
{(!mobile) && this._renderFieldHeader('createdAt', 'Date Created')}
{this._renderFieldHeader('updatedAt', 'Date Updated')}
{this._renderFieldHeader('numItems', '# sketches')}
{this._renderFieldHeader('createdAt', `${mobile ? '' : 'Date '}Created`)}
{this._renderFieldHeader('updatedAt', `${mobile ? '' : 'Date '}Updated`)}
{this._renderFieldHeader('numItems', mobile ? 'Sketches' : '# sketches')}
<th scope="col"></th>
</tr>
</thead>

View File

@ -213,7 +213,7 @@ class CollectionListRowBase extends React.Component {
{this.renderCollectionName()}
</span>
</th>
{(!mobile) && <td>{format(new Date(collection.createdAt), 'MMM D, YYYY')}</td>}
<td>{mobile && 'Created: '}{format(new Date(collection.createdAt), 'MMM D, YYYY')}</td>
<td>{mobile && 'Updated: '}{formatDateCell(collection.updatedAt)}</td>
<td>{mobile && '# sketches: '}{(collection.items || []).length}</td>
<td className="sketch-list__dropdown-column">

View File

@ -88,6 +88,8 @@ const Console = () => {
const cm = useRef({});
useDidUpdate(() => { cm.current.scrollTop = cm.current.scrollHeight; });
const consoleClass = classNames({
'preview-console': true,
'preview-console--collapsed': !isExpanded

View File

@ -108,10 +108,15 @@ export class FileNode extends React.Component {
handleFileClick = (event) => {
event.stopPropagation();
const { isDeleting } = this.state;
const { id, setSelectedFile, name } = this.props;
const {
id, setSelectedFile, name, onClickFile
} = this.props;
if (name !== 'root' && !isDeleting) {
setSelectedFile(id);
}
// debugger; // eslint-disable-line
if (onClickFile) { onClickFile(); }
}
handleFileNameChange = (event) => {
@ -214,7 +219,7 @@ export class FileNode extends React.Component {
renderChild = childId => (
<li key={childId}>
<ConnectedFileNode id={childId} parentId={this.props.id} canEdit={this.props.canEdit} />
<ConnectedFileNode id={childId} parentId={this.props.id} canEdit={this.props.canEdit} onClickFile={this.props.onClickFile} />
</li>
)
@ -233,7 +238,7 @@ export class FileNode extends React.Component {
const isRoot = this.props.name === 'root';
return (
<div className={itemClass}>
<div className={itemClass} >
{ !isRoot &&
<div className="file-item__content" onContextMenu={this.toggleFileOptions}>
<span className="file-item__spacer"></span>
@ -382,10 +387,12 @@ FileNode.propTypes = {
hideFolderChildren: PropTypes.func.isRequired,
canEdit: PropTypes.bool.isRequired,
openUploadFileModal: PropTypes.func.isRequired,
authenticated: PropTypes.bool.isRequired
authenticated: PropTypes.bool.isRequired,
onClickFile: PropTypes.func
};
FileNode.defaultProps = {
onClickFile: null,
parentId: '0',
isSelectedFile: false,
isFolderClosed: false,

View File

@ -437,8 +437,8 @@ class SketchList extends React.Component {
<thead>
<tr>
{this._renderFieldHeader('name', 'Sketch')}
{this._renderFieldHeader('createdAt', 'Date Created')}
{this._renderFieldHeader('updatedAt', 'Date Updated')}
{this._renderFieldHeader('createdAt', `${mobile ? '' : 'Date '}Created`)}
{this._renderFieldHeader('updatedAt', `${mobile ? '' : 'Date '}Updated`)}
<th scope="col"></th>
</tr>
</thead>

View File

@ -35,6 +35,7 @@ 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';
@ -167,13 +168,11 @@ class IDEView extends React.Component {
warnIfUnsavedChanges(this.props));
}
}
componentWillUnmount() {
document.removeEventListener('keydown', this.handleGlobalKeydown, false);
clearTimeout(this.autosaveInterval);
this.autosaveInterval = null;
}
handleGlobalKeydown(e) {
// 83 === s
if (
@ -428,6 +427,7 @@ class IDEView extends React.Component {
expandConsole={this.props.expandConsole}
clearConsole={this.props.clearConsole}
cmController={this.cmController}
language={this.props.preferences.language}
/>
</div>
</section>
@ -585,6 +585,7 @@ IDEView.propTypes = {
soundOutput: PropTypes.bool.isRequired,
theme: PropTypes.string.isRequired,
autorefresh: PropTypes.bool.isRequired,
language: PropTypes.string.isRequired
}).isRequired,
closePreferences: PropTypes.func.isRequired,
setFontSize: PropTypes.func.isRequired,

View File

@ -27,17 +27,34 @@ import Header from '../../../components/mobile/Header';
import Screen from '../../../components/mobile/MobileScreen';
import Footer from '../../../components/mobile/Footer';
import IDEWrapper from '../../../components/mobile/IDEWrapper';
import MobileExplorer from '../../../components/mobile/Explorer';
import Console from '../components/Console';
import { remSize } from '../../../theme';
// import OverlayManager from '../../../components/OverlayManager';
import ActionStrip from '../../../components/mobile/ActionStrip';
import useAsModal from '../../../components/useAsModal';
import { PreferencesIcon } from '../../../common/icons';
import Dropdown from '../../../components/Dropdown';
const getRootFile = files => files && files.filter(file => file.name === 'root')[0];
const getRootFileID = files => (root => root && root.id)(getRootFile(files));
const isUserOwner = ({ project, user }) =>
project.owner && project.owner.id === user.id;
// const userCanEditProject = (props) => {
// let canEdit;
// if (!props.owner) {
// canEdit = true;
// } else if (props.user.authenticated && props.owner.id === props.user.id) {
// canEdit = true;
// } else {
// canEdit = false;
// }
// return canEdit;
// };
const Expander = styled.div`
height: ${props => (props.expanded ? remSize(160) : remSize(27))};
`;
@ -69,17 +86,13 @@ const MobileIDEView = (props) => {
selectedFile, updateFileContent, files, user, params,
closeEditorOptions, showEditorOptions, logoutUser,
startRefreshSketch, stopSketch, expandSidebar, collapseSidebar, clearConsole, console,
showRuntimeErrorWarning, hideRuntimeErrorWarning, startSketch, getProject, clearPersistedState
showRuntimeErrorWarning, hideRuntimeErrorWarning, startSketch, getProject, clearPersistedState, setUnsavedChanges
} = props;
const [tmController, setTmController] = useState(null); // eslint-disable-line
const { username } = user;
const [triggerNavDropdown, NavDropDown] = useAsModal(<Dropdown
items={getNavOptions(username, logoutUser)}
align="right"
/>);
// Force state reset
useEffect(clearPersistedState, []);
@ -97,16 +110,29 @@ const MobileIDEView = (props) => {
setCurrentProjectID(params.project_id);
}, [params, project, username]);
// Screen Modals
const [toggleNavDropdown, NavDropDown] = useAsModal(<Dropdown
items={getNavOptions(username, logoutUser)}
align="right"
/>);
const [toggleExplorer, Explorer] = useAsModal(toggle =>
(<MobileExplorer
id={getRootFileID(files)}
canEdit={false}
onPressClose={toggle}
/>), true);
return (
<Screen fullscreen>
<Explorer />
<Header
title={project.name}
subtitle={selectedFile.name}
>
<NavItem>
<IconButton
onClick={triggerNavDropdown}
onClick={toggleNavDropdown}
icon={MoreIcon}
aria-label="Options"
/>
@ -149,6 +175,7 @@ const MobileIDEView = (props) => {
hideRuntimeErrorWarning={hideRuntimeErrorWarning}
runtimeErrorWarningVisible={ide.runtimeErrorWarningVisible}
provideController={setTmController}
setUnsavedChanges={setUnsavedChanges}
/>
</IDEWrapper>
@ -158,7 +185,7 @@ const MobileIDEView = (props) => {
<Console />
</Expander>
)}
<ActionStrip />
<ActionStrip toggleExplorer={toggleExplorer} />
</Footer>
</Screen>
);
@ -271,6 +298,7 @@ MobileIDEView.propTypes = {
logoutUser: PropTypes.func.isRequired,
setUnsavedChanges: PropTypes.func.isRequired,
getProject: PropTypes.func.isRequired,
clearPersistedState: PropTypes.func.isRequired,
params: PropTypes.shape({

View File

@ -1,5 +1,7 @@
import i18next from 'i18next';
import * as ActionTypes from '../../../constants';
const initialState = {
fontSize: 18,
autosave: true,
@ -10,7 +12,8 @@ const initialState = {
gridOutput: false,
soundOutput: false,
theme: 'light',
autorefresh: false
autorefresh: false,
language: 'en-US'
};
const preferences = (state = initialState, action) => {
@ -37,6 +40,8 @@ const preferences = (state = initialState, action) => {
return Object.assign({}, state, { autorefresh: action.value });
case ActionTypes.SET_LINE_NUMBERS:
return Object.assign({}, state, { lineNumbers: action.value });
case ActionTypes.SET_LANGUAGE:
return Object.assign({}, state, { language: action.language });
default:
return state;
}

View File

@ -24,9 +24,11 @@ import Loader from '../App/components/loader';
const EXAMPLE_USERNAME = 'p5';
// @ghalestrilo 08/13/2020: I'm sorry
const ContentWrapper = styled(Content)`
table {
table-layout: fixed;
margin-bottom: ${remSize(120)}
}
td ,thead button {
@ -55,14 +57,18 @@ const ContentWrapper = styled(Content)`
tbody td { justify-self: start; text-align: start; padding: 0 }
tbody td:nth-child(2) { justify-self: start; text-align: start; padding-left: ${remSize(12)}};
tbody td:last-child { justify-self: end; text-align: end; };
tbody td:last-child {
justify-self: end;
text-align: end;
grid-row-start: 1;
grid-column-start: 3;
};
.sketch-list__dropdown-column { width: auto; };
tbody { height: ${remSize(48)}; }
.sketches-table-container {
padding-bottom: ${remSize(160)};
background: ${prop('SketchList.background')};
}
.sketches-table__row {
@ -79,18 +85,33 @@ const ContentWrapper = styled(Content)`
};
thead tr {
grid-template-columns: 1fr 1fr 1fr 0fr;
grid-template-columns: repeat(${props => props.fieldcount}, 1fr) 0fr;
${props => props.noheader && 'display: none;'}
}
tbody tr {
padding: ${remSize(8)};
border-radius: ${remSize(4)};
grid-template-columns: 5fr 5fr 1fr;
grid-template-columns: repeat(${props => props.fieldcount - 1}) 1fr;
grid-template-areas: "name name name" "content content content";
grid-row-gap: ${remSize(12)}
}
.loader-container { position: fixed ; padding-bottom: 32% }
.sketches-table thead th {
background-color: transparent;
}
.asset-table thead th {
height: initial;
align-self: center;
}
.asset-table thead tr {
height: ${remSize(32)}
}
`;
const Subheader = styled.div`
@ -168,7 +189,7 @@ const MobileDashboard = ({ params, location }) => {
</Header>
<ContentWrapper slimheader>
<ContentWrapper slimheader fieldcount={panel === Tabs[1] ? 4 : 3} noheader={panel === Tabs[2]}>
<Subheader>
{panel === Tabs[0] && <SketchSearchbar />}
{panel === Tabs[1] && <CollectionSearchbar />}

View File

@ -2,6 +2,7 @@ import { browserHistory } from 'react-router';
import * as ActionTypes from '../../constants';
import apiClient from '../../utils/apiClient';
import { showErrorModal, justOpenedProject } from '../IDE/actions/ide';
import { setLanguage } from '../IDE/actions/preferences';
import { showToast, setToastText } from '../IDE/actions/toast';
export function authError(error) {
@ -59,6 +60,7 @@ export function validateAndLoginUser(previousPath, formProps, dispatch) {
type: ActionTypes.SET_PREFERENCES,
preferences: response.data.preferences
});
setLanguage(response.data.preferences.language, { persistPreference: false });
dispatch(justOpenedProject());
browserHistory.push(previousPath);
resolve();
@ -80,8 +82,8 @@ export function getUser() {
type: ActionTypes.SET_PREFERENCES,
preferences: response.data.preferences
});
})
.catch((error) => {
setLanguage(response.data.preferences.language, { persistPreference: false });
}).catch((error) => {
const { response } = error;
const message = response.message || response.data.error;
dispatch(authError(message));

View File

@ -1,6 +1,5 @@
import PropTypes from 'prop-types';
import React from 'react';
import Button from '../../../common/Button';
import { PlusIcon } from '../../../common/icons';
import CopyableInput from '../../IDE/components/CopyableInput';
@ -12,7 +11,7 @@ export const APIKeyPropType = PropTypes.shape({
token: PropTypes.object, // eslint-disable-line
label: PropTypes.string.isRequired,
createdAt: PropTypes.string.isRequired,
lastUsedAt: PropTypes.string,
lastUsedAt: PropTypes.string
});
class APIKeyForm extends React.Component {
@ -39,7 +38,7 @@ class APIKeyForm extends React.Component {
}
removeKey(key) {
const message = `Are you sure you want to delete "${key.label}"?`;
const message = this.props.t('APIKeyForm.ConfirmDelete', { key_label: key.label });
if (window.confirm(message)) {
this.props.removeApiKey(key.id);
@ -51,10 +50,10 @@ class APIKeyForm extends React.Component {
if (hasApiKeys) {
return (
<APIKeyList apiKeys={this.props.apiKeys} onRemove={this.removeKey} />
<APIKeyList apiKeys={this.props.apiKeys} onRemove={this.removeKey} t={this.props.t} />
);
}
return <p>You have no exsiting tokens.</p>;
return <p>{this.props.t('APIKeyForm.NoTokens')}</p>;
}
render() {
@ -63,27 +62,18 @@ class APIKeyForm extends React.Component {
return (
<div className="api-key-form">
<p className="api-key-form__summary">
Personal Access Tokens act like your password to allow automated
scripts to access the Editor API. Create a token for each script that
needs access.
{this.props.t('APIKeyForm.Summary')}
</p>
<div className="api-key-form__section">
<h3 className="api-key-form__title">Create new token</h3>
<h3 className="api-key-form__title">{this.props.t('APIKeyForm.CreateToken')}</h3>
<form className="form form--inline" onSubmit={this.addKey}>
<label
htmlFor="keyLabel"
className="form__label form__label--hidden "
>
What is this token for?
</label>
<label htmlFor="keyLabel" className="form__label form__label--hidden ">{this.props.t('APIKeyForm.TokenLabel')}</label>
<input
className="form__input"
id="keyLabel"
onChange={(event) => {
this.setState({ keyLabel: event.target.value });
}}
placeholder="What is this token for? e.g. Example import script"
onChange={(event) => { this.setState({ keyLabel: event.target.value }); }}
placeholder={this.props.t('APIKeyForm.TokenPlaceholder')}
type="text"
value={this.state.keyLabel}
/>
@ -93,29 +83,25 @@ class APIKeyForm extends React.Component {
label="Create new key"
type="submit"
>
Create
{this.props.t('APIKeyForm.CreateTokenSubmit')}
</Button>
</form>
{keyWithToken && (
<div className="api-key-form__new-token">
<h4 className="api-key-form__new-token__title">
Your new access token
</h4>
<p className="api-key-form__new-token__info">
Make sure to copy your new personal access token now. You wont
be able to see it again!
</p>
<CopyableInput
label={keyWithToken.label}
value={keyWithToken.token}
/>
</div>
)}
{
keyWithToken && (
<div className="api-key-form__new-token">
<h4 className="api-key-form__new-token__title">{this.props.t('APIKeyForm.NewTokenTitle')}</h4>
<p className="api-key-form__new-token__info">
{this.props.t('APIKeyForm.NewTokenInfo')}
</p>
<CopyableInput label={keyWithToken.label} value={keyWithToken.token} />
</div>
)
}
</div>
<div className="api-key-form__section">
<h3 className="api-key-form__title">Existing tokens</h3>
<h3 className="api-key-form__title">{this.props.t('APIKeyForm.ExistingTokensTitle')}</h3>
{this.renderApiKeys()}
</div>
</div>
@ -127,6 +113,7 @@ APIKeyForm.propTypes = {
apiKeys: PropTypes.arrayOf(PropTypes.shape(APIKeyPropType)).isRequired,
createApiKey: PropTypes.func.isRequired,
removeApiKey: PropTypes.func.isRequired,
t: PropTypes.func.isRequired
};
export default APIKeyForm;

View File

@ -8,22 +8,22 @@ import { APIKeyPropType } from './APIKeyForm';
import TrashCanIcon from '../../../images/trash-can.svg';
function APIKeyList({ apiKeys, onRemove }) {
function APIKeyList({ apiKeys, onRemove, t }) {
return (
<table className="api-key-list">
<thead>
<tr>
<th>Name</th>
<th>Created on</th>
<th>Last used</th>
<th>Actions</th>
<th>{t('APIKeyList.Name')}</th>
<th>{t('APIKeyList.Created')}</th>
<th>{t('APIKeyList.LastUsed')}</th>
<th>{t('APIKeyList.Actions')}</th>
</tr>
</thead>
<tbody>
{orderBy(apiKeys, ['createdAt'], ['desc']).map((key) => {
const lastUsed = key.lastUsedAt ?
distanceInWordsToNow(new Date(key.lastUsedAt), { addSuffix: true }) :
'Never';
t('APIKeyList.Never');
return (
<tr key={key.id}>
@ -34,7 +34,7 @@ function APIKeyList({ apiKeys, onRemove }) {
<button
className="api-key-list__delete-button"
onClick={() => onRemove(key)}
aria-label="Delete API Key"
aria-label={t('APIKeyList.DeleteARIA')}
>
<TrashCanIcon focusable="false" aria-hidden="true" />
</button>
@ -50,6 +50,7 @@ function APIKeyList({ apiKeys, onRemove }) {
APIKeyList.propTypes = {
apiKeys: PropTypes.arrayOf(PropTypes.shape(APIKeyPropType)).isRequired,
onRemove: PropTypes.func.isRequired,
t: PropTypes.func.isRequired
};
export default APIKeyList;

View File

@ -1,5 +1,6 @@
import PropTypes from 'prop-types';
import React from 'react';
import { withTranslation } from 'react-i18next';
import { domOnlyProps } from '../../../utils/reduxFormUtils';
import Button from '../../../common/Button';
@ -14,6 +15,7 @@ function AccountForm(props) {
submitting,
invalid,
pristine,
t
} = props;
const handleInitiateVerification = (evt) => {
@ -24,12 +26,10 @@ function AccountForm(props) {
return (
<form className="form" onSubmit={handleSubmit(props.updateSettings)}>
<p className="form__field">
<label htmlFor="email" className="form__label">
Email
</label>
<label htmlFor="email" className="form__label">{t('AccountForm.Email')}</label>
<input
className="form__input"
aria-label="email"
aria-label={t('AccountForm.EmailARIA')}
type="text"
id="email"
{...domOnlyProps(email)}
@ -38,28 +38,31 @@ function AccountForm(props) {
<span className="form-error">{email.error}</span>
)}
</p>
{user.verified !== 'verified' && (
<p className="form__context">
<span className="form__status">Unconfirmed.</span>
{user.emailVerificationInitiate === true ? (
<span className="form__status">
{' '}
Confirmation sent, check your email.
</span>
) : (
<Button onClick={handleInitiateVerification}>
Resend confirmation email
</Button>
)}
</p>
)}
{
user.verified !== 'verified' &&
(
<p className="form__context">
<span className="form__status">{t('AccountForm.Unconfirmed')}</span>
{
user.emailVerificationInitiate === true ?
(
<span className="form__status"> {t('AccountForm.EmailSent')}</span>
) :
(
<Button
onClick={handleInitiateVerification}
>{t('AccountForm.Resend')}
</Button>
)
}
</p>
)
}
<p className="form__field">
<label htmlFor="username" className="form__label">
User Name
</label>
<label htmlFor="username" className="form__label">{t('AccountForm.UserName')}</label>
<input
className="form__input"
aria-label="username"
aria-label={t('AccountForm.UserNameARIA')}
type="text"
id="username"
defaultValue={username}
@ -70,12 +73,10 @@ function AccountForm(props) {
)}
</p>
<p className="form__field">
<label htmlFor="current password" className="form__label">
Current Password
</label>
<label htmlFor="current password" className="form__label">{t('AccountForm.CurrentPassword')}</label>
<input
className="form__input"
aria-label="currentPassword"
aria-label={t('AccountForm.CurrentPasswordARIA')}
type="password"
id="currentPassword"
{...domOnlyProps(currentPassword)}
@ -85,12 +86,10 @@ function AccountForm(props) {
)}
</p>
<p className="form__field">
<label htmlFor="new password" className="form__label">
New Password
</label>
<label htmlFor="new password" className="form__label">{t('AccountForm.NewPassword')}</label>
<input
className="form__input"
aria-label="newPassword"
aria-label={t('AccountForm.NewPasswordARIA')}
type="password"
id="newPassword"
{...domOnlyProps(newPassword)}
@ -99,8 +98,10 @@ function AccountForm(props) {
<span className="form-error">{newPassword.error}</span>
)}
</p>
<Button type="submit" disabled={submitting || invalid || pristine}>
Save All Settings
<Button
type="submit"
disabled={submitting || invalid || pristine}
>{t('AccountForm.SubmitSaveAllSettings')}
</Button>
</form>
);
@ -123,6 +124,7 @@ AccountForm.propTypes = {
submitting: PropTypes.bool,
invalid: PropTypes.bool,
pristine: PropTypes.bool,
t: PropTypes.func.isRequired
};
AccountForm.defaultProps = {
@ -131,4 +133,4 @@ AccountForm.defaultProps = {
invalid: false,
};
export default AccountForm;
export default withTranslation()(AccountForm);

View File

@ -1,16 +1,13 @@
import PropTypes from 'prop-types';
import React from 'react';
import { withTranslation } from 'react-i18next';
import { domOnlyProps } from '../../../utils/reduxFormUtils';
import Button from '../../../common/Button';
function NewPasswordForm(props) {
const {
fields: { password, confirmPassword },
handleSubmit,
submitting,
invalid,
pristine,
fields: { password, confirmPassword }, handleSubmit, submitting, invalid, pristine,
t
} = props;
return (
<form
@ -18,12 +15,10 @@ function NewPasswordForm(props) {
onSubmit={handleSubmit(props.updatePassword.bind(this, props.params.reset_password_token))}
>
<p className="form__field">
<label htmlFor="password" className="form__label">
Password
</label>
<label htmlFor="password" className="form__label">{t('NewPasswordForm.Title')}</label>
<input
className="form__input"
aria-label="password"
aria-label={t('NewPasswordForm.TitleARIA')}
type="password"
id="Password"
{...domOnlyProps(password)}
@ -33,13 +28,11 @@ function NewPasswordForm(props) {
)}
</p>
<p className="form__field">
<label htmlFor="confirm password" className="form__label">
Confirm Password
</label>
<label htmlFor="confirm password" className="form__label">{t('NewPasswordForm.ConfirmPassword')}</label>
<input
className="form__input"
type="password"
aria-label="confirm password"
aria-label={t('NewPasswordForm.ConfirmPasswordARIA')}
id="confirm password"
{...domOnlyProps(confirmPassword)}
/>
@ -47,9 +40,7 @@ function NewPasswordForm(props) {
<span className="form-error">{confirmPassword.error}</span>
)}
</p>
<Button type="submit" disabled={submitting || invalid || pristine}>
Set New Password
</Button>
<Button type="submit" disabled={submitting || invalid || pristine}>{t('NewPasswordForm.SubmitSetNewPassword')}</Button>
</form>
);
}
@ -67,6 +58,7 @@ NewPasswordForm.propTypes = {
params: PropTypes.shape({
reset_password_token: PropTypes.string,
}).isRequired,
t: PropTypes.func.isRequired
};
NewPasswordForm.defaultProps = {
@ -75,4 +67,4 @@ NewPasswordForm.defaultProps = {
submitting: false,
};
export default NewPasswordForm;
export default withTranslation()(NewPasswordForm);

View File

@ -1,16 +1,12 @@
import PropTypes from 'prop-types';
import React from 'react';
import { withTranslation } from 'react-i18next';
import { domOnlyProps } from '../../../utils/reduxFormUtils';
import Button from '../../../common/Button';
function ResetPasswordForm(props) {
const {
fields: { email },
handleSubmit,
submitting,
invalid,
pristine,
fields: { email }, handleSubmit, submitting, invalid, pristine, t
} = props;
return (
<form
@ -18,12 +14,10 @@ function ResetPasswordForm(props) {
onSubmit={handleSubmit(props.initiateResetPassword.bind(this))}
>
<p className="form__field">
<label htmlFor="email" className="form__label">
Email used for registration
</label>
<label htmlFor="email" className="form__label">{t('ResetPasswordForm.Email')}</label>
<input
className="form__input"
aria-label="email"
aria-label={t('ResetPasswordForm.EmailARIA')}
type="text"
id="email"
{...domOnlyProps(email)}
@ -34,11 +28,8 @@ function ResetPasswordForm(props) {
</p>
<Button
type="submit"
disabled={
submitting || invalid || pristine || props.user.resetPasswordInitiate
}
>
Send Password Reset Email
disabled={submitting || invalid || pristine || props.user.resetPasswordInitiate}
>{t('ResetPasswordForm.Submit')}
</Button>
</form>
);
@ -54,8 +45,9 @@ ResetPasswordForm.propTypes = {
invalid: PropTypes.bool,
pristine: PropTypes.bool,
user: PropTypes.shape({
resetPasswordInitiate: PropTypes.bool,
resetPasswordInitiate: PropTypes.bool
}).isRequired,
t: PropTypes.func.isRequired
};
ResetPasswordForm.defaultProps = {
@ -64,4 +56,4 @@ ResetPasswordForm.defaultProps = {
invalid: false,
};
export default ResetPasswordForm;
export default withTranslation()(ResetPasswordForm);

View File

@ -1,5 +1,6 @@
import PropTypes from 'prop-types';
import React from 'react';
import { withTranslation } from 'react-i18next';
import { domOnlyProps } from '../../../utils/reduxFormUtils';
import Button from '../../../common/Button';
@ -20,12 +21,10 @@ function SignupForm(props) {
onSubmit={handleSubmit(props.signUpUser.bind(this, props.previousPath))}
>
<p className="form__field">
<label htmlFor="username" className="form__label">
User Name
</label>
<label htmlFor="username" className="form__label">{props.t('SignupForm.Title')}</label>
<input
className="form__input"
aria-label="username"
aria-label={props.t('SignupForm.TitleARIA')}
type="text"
id="username"
{...domOnlyProps(username)}
@ -35,12 +34,10 @@ function SignupForm(props) {
)}
</p>
<p className="form__field">
<label htmlFor="email" className="form__label">
Email
</label>
<label htmlFor="email" className="form__label">{props.t('SignupForm.Email')}</label>
<input
className="form__input"
aria-label="email"
aria-label={props.t('SignupForm.EmailARIA')}
type="text"
id="email"
{...domOnlyProps(email)}
@ -50,12 +47,10 @@ function SignupForm(props) {
)}
</p>
<p className="form__field">
<label htmlFor="password" className="form__label">
Password
</label>
<label htmlFor="password" className="form__label">{props.t('SignupForm.Password')}</label>
<input
className="form__input"
aria-label="password"
aria-label={props.t('SignupForm.PasswordARIA')}
type="password"
id="password"
{...domOnlyProps(password)}
@ -65,13 +60,11 @@ function SignupForm(props) {
)}
</p>
<p className="form__field">
<label htmlFor="confirm password" className="form__label">
Confirm Password
</label>
<label htmlFor="confirm password" className="form__label">{props.t('SignupForm.ConfirmPassword')}</label>
<input
className="form__input"
type="password"
aria-label="confirm password"
aria-label={props.t('SignupForm.ConfirmPasswordARIA')}
id="confirm password"
{...domOnlyProps(confirmPassword)}
/>
@ -79,8 +72,10 @@ function SignupForm(props) {
<span className="form-error">{confirmPassword.error}</span>
)}
</p>
<Button type="submit" disabled={submitting || invalid || pristine}>
Sign Up
<Button
type="submit"
disabled={submitting || invalid || pristine}
>{props.t('SignupForm.SubmitSignup')}
</Button>
</form>
);
@ -99,6 +94,7 @@ SignupForm.propTypes = {
invalid: PropTypes.bool,
pristine: PropTypes.bool,
previousPath: PropTypes.string.isRequired,
t: PropTypes.func.isRequired
};
SignupForm.defaultProps = {
@ -107,4 +103,4 @@ SignupForm.defaultProps = {
invalid: false,
};
export default SignupForm;
export default withTranslation()(SignupForm);

View File

@ -4,6 +4,7 @@ import { reduxForm } from 'redux-form';
import { bindActionCreators } from 'redux';
import { Tab, Tabs, TabList, TabPanel } from 'react-tabs';
import { Helmet } from 'react-helmet';
import { withTranslation } from 'react-i18next';
import { updateSettings, initiateVerification, createApiKey, removeApiKey } from '../actions';
import AccountForm from '../components/AccountForm';
import apiClient from '../../../utils/apiClient';
@ -16,9 +17,11 @@ function SocialLoginPanel(props) {
return (
<React.Fragment>
<AccountForm {...props} />
<h2 className="form-container__divider">Social Login</h2>
{/* eslint-disable-next-line react/prop-types */}
<h2 className="form-container__divider">{props.t('AccountView.SocialLogin')}</h2>
<p className="account__social-text">
Use your GitHub or Google account to log into the p5.js Web Editor.
{/* eslint-disable-next-line react/prop-types */}
{props.t('AccountView.SocialLoginDescription')}
</p>
<div className="account__social-stack">
<SocialAuthButton service={SocialAuthButton.services.github} />
@ -39,21 +42,21 @@ class AccountView extends React.Component {
return (
<div className="account-settings__container">
<Helmet>
<title>p5.js Web Editor | Account Settings</title>
<title>{this.props.t('AccountView.Title')}</title>
</Helmet>
<Nav layout="dashboard" />
<main className="account-settings">
<header className="account-settings__header">
<h1 className="account-settings__title">Account Settings</h1>
<h1 className="account-settings__title">{this.props.t('AccountView.Settings')}</h1>
</header>
{accessTokensUIEnabled &&
<Tabs className="account__tabs">
<TabList>
<div className="tabs__titles">
<Tab><h4 className="tabs__title">Account</h4></Tab>
{accessTokensUIEnabled && <Tab><h4 className="tabs__title">Access Tokens</h4></Tab>}
<Tab><h4 className="tabs__title">{this.props.t('AccountView.AccountTab')}</h4></Tab>
{accessTokensUIEnabled && <Tab><h4 className="tabs__title">{this.props.t('AccountView.AccessTokensTab')}</h4></Tab>}
</div>
</TabList>
<TabPanel>
@ -107,13 +110,14 @@ function asyncValidate(formProps, dispatch, props) {
AccountView.propTypes = {
previousPath: PropTypes.string.isRequired,
theme: PropTypes.string.isRequired
theme: PropTypes.string.isRequired,
t: PropTypes.func.isRequired
};
export default reduxForm({
export default withTranslation()(reduxForm({
form: 'updateAllSettings',
fields: ['username', 'email', 'currentPassword', 'newPassword'],
validate: validateSettings,
asyncValidate,
asyncBlurFields: ['username', 'email', 'currentPassword']
}, mapStateToProps, mapDispatchToProps)(AccountView);
}, mapStateToProps, mapDispatchToProps)(AccountView));

View File

@ -3,6 +3,7 @@ import React from 'react';
import { connect } from 'react-redux';
import { bindActionCreators } from 'redux';
import { browserHistory } from 'react-router';
import { withTranslation } from 'react-i18next';
import get from 'lodash/get';
import { Helmet } from 'react-helmet';
import { verifyEmailConfirmation } from '../actions';
@ -31,20 +32,20 @@ class EmailVerificationView extends React.Component {
if (this.verificationToken() == null) {
status = (
<p>That link is invalid.</p>
<p>{this.props.t('EmailVerificationView.InvalidTokenNull')}</p>
);
} else if (emailVerificationTokenState === 'checking') {
status = (
<p>Validating token, please wait...</p>
<p>{this.props.t('EmailVerificationView.Checking')}</p>
);
} else if (emailVerificationTokenState === 'verified') {
status = (
<p>All done, your email address has been verified.</p>
<p>{this.props.t('EmailVerificationView.Verified')}</p>
);
setTimeout(() => browserHistory.push('/'), 1000);
} else if (emailVerificationTokenState === 'invalid') {
status = (
<p>Something went wrong.</p>
<p>{this.props.t('EmailVerificationView.InvalidState')}</p>
);
}
@ -53,10 +54,10 @@ class EmailVerificationView extends React.Component {
<Nav layout="dashboard" />
<div className="form-container">
<Helmet>
<title>p5.js Web Editor | Email Verification</title>
<title>{this.props.t('EmailVerificationView.Title')}</title>
</Helmet>
<div className="form-container__content">
<h2 className="form-container__title">Verify your email</h2>
<h2 className="form-container__title">{this.props.t('EmailVerificationView.Verify')}</h2>
{status}
</div>
</div>
@ -83,6 +84,7 @@ EmailVerificationView.propTypes = {
'checking', 'verified', 'invalid'
]),
verifyEmailConfirmation: PropTypes.func.isRequired,
t: PropTypes.func.isRequired
};
export default connect(mapStateToProps, mapDispatchToProps)(EmailVerificationView);
export default withTranslation()(connect(mapStateToProps, mapDispatchToProps)(EmailVerificationView));

View File

@ -4,6 +4,8 @@ import { reduxForm } from 'redux-form';
import classNames from 'classnames';
import { bindActionCreators } from 'redux';
import { Helmet } from 'react-helmet';
import { withTranslation } from 'react-i18next';
import i18next from 'i18next';
import NewPasswordForm from '../components/NewPasswordForm';
import * as UserActions from '../actions';
import Nav from '../../../components/Nav';
@ -20,13 +22,13 @@ function NewPasswordView(props) {
<Nav layout="dashboard" />
<div className={newPasswordClass}>
<Helmet>
<title>p5.js Web Editor | New Password</title>
<title>{props.t('NewPasswordView.Title')}</title>
</Helmet>
<div className="form-container__content">
<h2 className="form-container__title">Set a New Password</h2>
<h2 className="form-container__title">{props.t('NewPasswordView.Description')}</h2>
<NewPasswordForm {...props} />
<p className="new-password__invalid">
The password reset token is invalid or has expired.
{props.t('NewPasswordView.TokenInvalidOrExpired')}
</p>
</div>
</div>
@ -41,21 +43,22 @@ NewPasswordView.propTypes = {
validateResetPasswordToken: PropTypes.func.isRequired,
user: PropTypes.shape({
resetPasswordInvalid: PropTypes.bool
}).isRequired
}).isRequired,
t: PropTypes.func.isRequired
};
function validate(formProps) {
const errors = {};
if (!formProps.password) {
errors.password = 'Please enter a password';
errors.password = i18next.t('NewPasswordView.EmptyPassword');
}
if (!formProps.confirmPassword) {
errors.confirmPassword = 'Please enter a password confirmation';
errors.confirmPassword = i18next.t('NewPasswordView.PasswordConfirmation');
}
if (formProps.password !== formProps.confirmPassword) {
errors.password = 'Passwords must match';
errors.password = i18next.t('NewPasswordView.PasswordMismatch');
}
return errors;
@ -71,8 +74,8 @@ function mapDispatchToProps(dispatch) {
return bindActionCreators(UserActions, dispatch);
}
export default reduxForm({
export default withTranslation()(reduxForm({
form: 'new-password',
fields: ['password', 'confirmPassword'],
validate
}, mapStateToProps, mapDispatchToProps)(NewPasswordView);
}, mapStateToProps, mapDispatchToProps)(NewPasswordView));

View File

@ -6,6 +6,7 @@ import classNames from 'classnames';
import { bindActionCreators } from 'redux';
import { reduxForm } from 'redux-form';
import { Helmet } from 'react-helmet';
import { withTranslation } from 'react-i18next';
import * as UserActions from '../actions';
import ResetPasswordForm from '../components/ResetPasswordForm';
import { validateResetPassword } from '../../../utils/reduxFormUtils';
@ -23,19 +24,18 @@ function ResetPasswordView(props) {
<Nav layout="dashboard" />
<div className={resetPasswordClass}>
<Helmet>
<title>p5.js Web Editor | Reset Password</title>
<title>{props.t('ResetPasswordView.Title')}</title>
</Helmet>
<div className="form-container__content">
<h2 className="form-container__title">Reset Your Password</h2>
<h2 className="form-container__title">{props.t('ResetPasswordView.Reset')}</h2>
<ResetPasswordForm {...props} />
<p className="reset-password__submitted">
Your password reset email should arrive shortly. If you don&apos;t see it, check
in your spam folder as sometimes it can end up there.
{props.t('ResetPasswordView.Submitted')}
</p>
<p className="form__navigation-options">
<Link className="form__login-button" to="/login">Log In</Link>
&nbsp;or&nbsp;
<Link className="form__signup-button" to="/signup">Sign Up</Link>
<Link className="form__login-button" to="/login">{props.t('ResetPasswordView.Login')}</Link>
&nbsp;{props.t('ResetPasswordView.LoginOr')}&nbsp;
<Link className="form__signup-button" to="/signup">{props.t('ResetPasswordView.SignUp')}</Link>
</p>
</div>
</div>
@ -48,6 +48,7 @@ ResetPasswordView.propTypes = {
user: PropTypes.shape({
resetPasswordInitiate: PropTypes.bool
}).isRequired,
t: PropTypes.func.isRequired
};
function mapStateToProps(state) {
@ -60,8 +61,8 @@ function mapDispatchToProps(dispatch) {
return bindActionCreators(UserActions, dispatch);
}
export default reduxForm({
export default withTranslation()(reduxForm({
form: 'reset-password',
fields: ['email'],
validate: validateResetPassword
}, mapStateToProps, mapDispatchToProps)(ResetPasswordView);
}, mapStateToProps, mapDispatchToProps)(ResetPasswordView));

View File

@ -4,6 +4,7 @@ import { bindActionCreators } from 'redux';
import { Link, browserHistory } from 'react-router';
import { Helmet } from 'react-helmet';
import { reduxForm } from 'redux-form';
import { withTranslation } from 'react-i18next';
import * as UserActions from '../actions';
import SignupForm from '../components/SignupForm';
import apiClient from '../../../utils/apiClient';
@ -26,19 +27,19 @@ class SignupView extends React.Component {
<Nav layout="dashboard" />
<main className="form-container">
<Helmet>
<title>p5.js Web Editor | Signup</title>
<title>{this.props.t('SignupView.Title')}</title>
</Helmet>
<div className="form-container__content">
<h2 className="form-container__title">Sign Up</h2>
<h2 className="form-container__title">{this.props.t('SignupView.Description')}</h2>
<SignupForm {...this.props} />
<h2 className="form-container__divider">Or</h2>
<h2 className="form-container__divider">{this.props.t('SignupView.Or')}</h2>
<div className="form-container__stack">
<SocialAuthButton service={SocialAuthButton.services.github} />
<SocialAuthButton service={SocialAuthButton.services.google} />
</div>
<p className="form__navigation-options">
Already have an account?&nbsp;
<Link className="form__login-button" to="/login">Log In</Link>
{this.props.t('SignupView.AlreadyHave')}
<Link className="form__login-button" to="/login">{this.props.t('SignupView.Login')}</Link>
</p>
</div>
</main>
@ -108,7 +109,8 @@ SignupView.propTypes = {
previousPath: PropTypes.string.isRequired,
user: PropTypes.shape({
authenticated: PropTypes.bool
})
}),
t: PropTypes.func.isRequired
};
SignupView.defaultProps = {
@ -117,11 +119,11 @@ SignupView.defaultProps = {
}
};
export default reduxForm({
export default withTranslation()(reduxForm({
form: 'signup',
fields: ['username', 'email', 'password', 'confirmPassword'],
onSubmitFail,
validate: validateSignup,
asyncValidate,
asyncBlurFields: ['username', 'email']
}, mapStateToProps, mapDispatchToProps)(SignupView);
}, mapStateToProps, mapDispatchToProps)(SignupView));

View File

@ -28,7 +28,7 @@ export const useModalBehavior = (hideOverlay) => {
const handleClickOutside = ({ target }) => {
if (ref && ref.current && !ref.current.contains(target)) {
if (ref && ref.current && !(ref.current.contains && ref.current.contains(target))) {
hide();
}
};

View File

@ -1,4 +1,5 @@
/* eslint-disable */
import i18n from 'i18next';
export const domOnlyProps = ({
initialValue,
autofill,
@ -20,19 +21,19 @@ const EMAIL_REGEX = /^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))
function validateNameEmail(formProps, errors) {
if (!formProps.username) {
errors.username = 'Please enter a username.';
errors.username = i18n.t('ReduxFormUtils.errorEmptyUsername');
} else if (!formProps.username.match(/^.{1,20}$/)) {
errors.username = 'Username must be less than 20 characters.';
errors.username = i18n.t('ReduxFormUtils.errorLongUsername');
} else if (!formProps.username.match(/^[a-zA-Z0-9._-]{1,20}$/)) {
errors.username = 'Username must only consist of numbers, letters, periods, dashes, and underscores.';
errors.username = i18n.t('ReduxFormUtils.errorValidUsername');
}
if (!formProps.email) {
errors.email = 'Please enter an email.';
errors.email = i18n.t('ReduxFormUtils.errorEmptyEmail');
} else if (
// eslint-disable-next-line max-len
!formProps.email.match(EMAIL_REGEX)) {
errors.email = 'Please enter a valid email address.';
errors.email = i18n.t('ReduxFormUtils.errorInvalidEmail');
}
}
@ -42,10 +43,10 @@ export function validateSettings(formProps) {
validateNameEmail(formProps, errors);
if (formProps.currentPassword && !formProps.newPassword) {
errors.newPassword = 'Please enter a new password or leave the current password empty.';
errors.newPassword = i18n.t('ReduxFormUtils.errorNewPassword');
}
if (formProps.newPassword && formProps.newPassword.length < 6) {
errors.newPassword = 'Password must be at least 6 characters';
errors.newPassword = i18n.t('ReduxFormUtils.errorShortPassword');
}
return errors;
}
@ -53,10 +54,10 @@ export function validateSettings(formProps) {
export function validateLogin(formProps) {
const errors = {};
if (!formProps.email) {
errors.email = 'Please enter an email';
errors.email = i18n.t('ReduxFormUtils.errorEmptyEmail');
}
if (!formProps.password) {
errors.password = 'Please enter a password';
errors.password = i18n.t('ReduxFormUtils.errorEmptyPassword');
}
return errors;
}
@ -67,17 +68,17 @@ export function validateSignup(formProps) {
validateNameEmail(formProps, errors);
if (!formProps.password) {
errors.password = 'Please enter a password';
errors.password = i18n.t('ReduxFormUtils.errorEmptyPassword');
}
if (formProps.password && formProps.password.length < 6) {
errors.password = 'Password must be at least 6 characters';
errors.password = i18n.t('ReduxFormUtils.errorShortPassword');
}
if (!formProps.confirmPassword) {
errors.confirmPassword = 'Please enter a password confirmation';
errors.confirmPassword = i18n.t('ReduxFormUtils.errorConfirmPassword');
}
if (formProps.password !== formProps.confirmPassword && formProps.confirmPassword) {
errors.confirmPassword = 'Passwords must match';
errors.confirmPassword = i18n.t('ReduxFormUtils.errorPasswordMismatch');
}
return errors;
@ -85,11 +86,11 @@ export function validateSignup(formProps) {
export function validateResetPassword(formProps) {
const errors = {};
if (!formProps.email) {
errors.email = 'Please enter an email.';
errors.email = i18n.t('ReduxFormUtils.errorEmptyEmail');
} else if (
// eslint-disable-next-line max-len
!formProps.email.match(EMAIL_REGEX)) {
errors.email = 'Please enter a valid email address.';
errors.email = i18n.t('ReduxFormUtils.errorInvalidEmail');
}
return errors;
}

View File

@ -65,7 +65,8 @@ const userSchema = new Schema({
gridOutput: { type: Boolean, default: false },
soundOutput: { type: Boolean, default: false },
theme: { type: String, default: 'light' },
autorefresh: { type: Boolean, default: false }
autorefresh: { type: Boolean, default: false },
language: { type: String, default: 'en-US' }
},
totalSize: { type: Number, default: 0 }
}, { timestamps: true, usePushEach: true });

View File

@ -183,21 +183,20 @@
"Error": "Error",
"Save": "Save",
"p5logoARIA": "p5.js Logo"
},
"IDEView": {
"SubmitFeedback": "Submit Feedback"
},
"NewFileModal": {
"Title": "Create File",
"CloseButtonARIA": "Close New File Modal",
"CloseButtonARIA": "Close New File Modal",
"EnterName": "Please enter a name",
"InvalidType": "Invalid file type. Valid extensions are .js, .css, .json, .txt, .csv, .tsv, .frag, and .vert."
},
"NewFileForm": {
"AddFileSubmit": "Add File",
"AddFileSubmit": "Add File",
"Placeholder": "Name"
},
},
"NewFolderModal": {
"Title": "Create Folder",
"CloseButtonARIA": "Close New Folder Modal",
@ -208,5 +207,114 @@
"NewFolderForm": {
"AddFolderSubmit": "Add Folder",
"Placeholder": "Name"
},
"ResetPasswordForm": {
"Email": "Email used for registration",
"EmailARIA": "email",
"Submit": "Send Password Reset Email"
},
"ResetPasswordView": {
"Title": "p5.js Web Editor | Reset Password",
"Reset": "Reset Your Password",
"Submitted": "Your password reset email should arrive shortly. If you don't see it, check\n in your spam folder as sometimes it can end up there.",
"Login": "Log In",
"LoginOr": "or",
"SignUp": "Sign Up"
},
"ReduxFormUtils": {
"errorInvalidEmail": "Please enter a valid email address",
"errorEmptyEmail": "Please enter an email",
"errorPasswordMismatch": "Passwords must match",
"errorEmptyPassword": "Please enter a password",
"errorShortPassword": "Password must be at least 6 characters",
"errorConfirmPassword": "Please enter a password confirmation",
"errorNewPassword": "Please enter a new password or leave the current password empty.",
"errorEmptyUsername": "Please enter a username.",
"errorLongUsername": "Username must be less than 20 characters.",
"errorValidUsername": "Username must only consist of numbers, letters, periods, dashes, and underscores."
},
"NewPasswordView": {
"Title": "p5.js Web Editor | New Password",
"Description": "Set a New Password",
"TokenInvalidOrExpired": "The password reset token is invalid or has expired.",
"EmptyPassword": "Please enter a password",
"PasswordConfirmation": "Please enter a password confirmation",
"PasswordMismatch": "Passwords must match"
},
"AccountForm": {
"Email": "Email",
"EmailARIA": "email",
"Unconfirmed": "Unconfirmed.",
"EmailSent": "Confirmation sent, check your email.",
"Resend": "Resend confirmation email",
"UserName": "User Name",
"UserNameARIA": "Username",
"CurrentPassword": "Current Password",
"CurrentPasswordARIA": "Current Password",
"NewPassword": "New Password",
"NewPasswordARIA": "New Password",
"SubmitSaveAllSettings": "Save All Settings"
},
"AccountView": {
"SocialLogin": "Social Login",
"SocialLoginDescription": "Use your GitHub or Google account to log into the p5.js Web Editor.",
"Title": "p5.js Web Editor | Account Settings",
"Settings": "Account Settings",
"AccountTab": "Account",
"AccessTokensTab": "Access Tokens"
},
"APIKeyForm": {
"ConfirmDelete": "Are you sure you want to delete {{key_label}}?",
"Summary": "Personal Access Tokens act like your password to allow automated\n scripts to access the Editor API. Create a token for each script\n that needs access.",
"CreateToken": "Create new token",
"TokenLabel": "What is this token for?",
"TokenPlaceholder": "What is this token for? e.g. Example import script",
"CreateTokenSubmit": "Create",
"NoTokens": "You have no existing tokens.",
"NewTokenTitle": "Your new access token",
"NewTokenInfo": "Make sure to copy your new personal access token now.\n You wont be able to see it again!",
"ExistingTokensTitle": "Existing tokens"
},
"APIKeyList": {
"Name": "Name",
"Created": "Created on",
"LastUsed": "Last used",
"Actions": "Actions",
"Never": "Never",
"DeleteARIA": "Delete API Key"
},
"NewPasswordForm": {
"Title": "Password",
"TitleARIA": "Password",
"ConfirmPassword": "Confirm Password",
"ConfirmPasswordARIA": "Confirm Password",
"SubmitSetNewPassword": "Set New Password"
},
"SignupForm": {
"Title": "User Name",
"TitleARIA": "username",
"Email": "Email",
"EmailARIA": "email",
"Password": "Password",
"PasswordARIA": "password",
"ConfirmPassword": "Confirm Password",
"ConfirmPasswordARIA": "Confirm password",
"SubmitSignup": "Sign Up"
},
"SignupView": {
"Title": "p5.js Web Editor | Signup",
"Description": "Sign Up",
"Or": "Or",
"AlreadyHave": "Already have an account?",
"Login": "Log In"
},
"EmailVerificationView": {
"Title": "p5.js Web Editor | Email Verification",
"Verify": "Verify your email",
"InvalidTokenNull": "That link is invalid.",
"Checking": "Validating token, please wait...",
"Verified": "All done, your email address has been verified.",
"InvalidState": "Something went wrong."
}
}

View File

@ -188,8 +188,8 @@
"SubmitFeedback": "Enviar retroalimentación"
},
"NewFileModal": {
"Title": "Crear Archivo",
"CloseButtonARIA": "Cerrar diálogo de crear archivo",
"Title": "Crear Archivo",
"CloseButtonARIA": "Cerrar diálogo de crear archivo",
"EnterName": "Por favor introduce un nombre",
"InvalidType": "Tipo de archivo inválido. Las extensiones válidas son .js, .css, .json, .txt, .csv, .tsv, .frag y .vert."
},
@ -198,18 +198,122 @@
"Placeholder": "Nombre"
},
"NewFolderModal": {
"Title": "Crear Directorio",
"CloseButtonARIA": "Cerrar Diálogo de Nuevo Directorio",
"EnterName": "Por favor introduce un nombre",
"EmptyName": " El nombre del directorio no debe contener solo espacios vacíos",
"InvalidExtension": "El nombre del directorio no debe contener una extensión"
},
"Title": "Crear Directorio",
"CloseButtonARIA": "Cerrar Diálogo de Nuevo Directorio",
"EnterName": "Por favor introduce un nombre",
"EmptyName": " El nombre del directorio no debe contener solo espacios vacíos",
"InvalidExtension": "El nombre del directorio no debe contener una extensión"
},
"NewFolderForm": {
"AddFolderSubmit": "Agregar Directorio",
"Placeholder": "Nombre"
},
"ResetPasswordForm": {
"Email": "Correo electrónico usado al registrarse",
"EmailARIA": "correo electrónico",
"Submit": "Enviar correo para regenerar contraseña"
},
"ResetPasswordView": {
"Title": "Editor Web p5.js | Regenerar Contraseña",
"Reset": "Regenerar Contraseña",
"Submitted": "Your password reset email should arrive shortly. If you don't see it, check\n in your spam folder as sometimes it can end up there.",
"Login": "Ingresa",
"LoginOr": "o",
"SignUp": "Registráte"
},
"ReduxFormUtils": {
"errorInvalidEmail": "Por favor introduce un correo electrónico válido",
"errorEmptyEmail": "Por favor introduce un correo electrónico",
"errorPasswordMismatch": "Las contraseñas deben coincidir",
"errorEmptyPassword": "Por favor introduce una contraseña",
"errorShortPassword": "La contraseña debe tener al menos 6 caracteres",
"errorConfirmPassword": "Por favor confirma una contraseña",
"errorNewPassword": "Por favor introduce una nueva contraseña o deja la actual contraseña vacía",
"errorEmptyUsername": "Por favor introduce tu identificación",
"errorLongUsername": "La identificación debe ser menor a 20 caracteres.",
"errorValidUsername": "La identificación debe consistir solamente de números, letras, puntos, guiones y guiones bajos."
},
"NewPasswordView": {
"Title": "Editor Web p5.js | Nueva Contraseña",
"Description": "Define una nueva contraseña",
"TokenInvalidOrExpired": "El token para regenerar la contraseña es inválido o ha expirado.",
"EmptyPassword": "Por favor introduce una contraseña",
"PasswordConfirmation": "Por favor confirma la contraseña",
"PasswordMismatch": "Las contraseña deben coincidir"
},
"AccountForm": {
"Email": "Correo Electrónico",
"EmailARIA": "correo electrónico",
"Unconfirmed": "Sin confirmar.",
"EmailSent": "Confirmación enviada, revisa tu correo electrónico.",
"Resend": "Reenviar correo de confirmación",
"UserName": "Nombre de Identificación",
"UserNameARIA": "Nombre de identificación",
"CurrentPassword": "Contraseña Actual",
"CurrentPasswordARIA": "Contraseña Actual",
"NewPassword": "Nueva Contraseña",
"NewPasswordARIA": "Nueva Contraseña",
"SubmitSaveAllSettings": "Guardar Todas Las Configuraciones"
},
"AccountView": {
"SocialLogin": "Identificacion usando redes sociales",
"SocialLoginDescription": "Usa tu cuenta de GitHub o Google para acceder al Editor Web de p5.js .",
"Title": "Editor Web p5.js | Configuración Cuenta",
"Settings": "Configuración de la Cuenta",
"AccountTab": "Cuenta",
"AccessTokensTab": "Tokens de acceso"
},
"APIKeyForm": {
"ConfirmDelete": "¿Estas seguro que quieres borrar {{key_label}}?",
"Summary": " Los Tokens de acceso personal actuan como tu contraseña para permitir\n a scripts automáticos acceder al API del Editor. Crea un token por cada script \n que necesite acceso.",
"CreateToken": "Crear nuevo token",
"TokenLabel": "¿Para que es este token?",
"TokenPlaceholder": "¿Para que es este token? p.e. Ejemplo para Importar un Archivo",
"CreateTokenSubmit": "Crear",
"NoTokens": "No tienes tokens.",
"NewTokenTitle": "Tu nuevo token de acceso",
"NewTokenInfo": "Asegurate de copiar tu token ahora mismo.\n ¡No podras verlo de nuevo!",
"ExistingTokensTitle": "Tokens existentes"
},
"APIKeyList": {
"Name": "Nombre",
"Created": "Creado en",
"LastUsed": "Usado por última vez",
"Actions": "Acciones",
"Never": "Nunca",
"DeleteARIA": "Borrar clave de API"
},
"NewPasswordForm": {
"Title": "Contraseña",
"TitleARIA": "Contraseña",
"ConfirmPassword": "Confirmar Contraseña",
"ConfirmPasswordARIA": "Confirmar contraseña",
"SubmitSetNewPassword": "Crear Nueva Contraseña"
},
"SignupForm": {
"Title": "Identificación",
"TitleARIA": "Identificación",
"Email": "Correo electrónico",
"EmailARIA": "correo electrónico",
"Password": "Contraseña",
"PasswordARIA": "contraseña",
"ConfirmPassword": "Confirma tu contraseña",
"ConfirmPasswordARIA": "Confirma tu contraseña",
"SubmitSignup": "Registráte"
},
"SignupView": {
"Title": " Editor Web p5.js | Registráte",
"Description": "Registráte",
"Or": "o",
"AlreadyHave": "¿Ya tienes cuenta? ",
"Login": "Ingresa"
},
"EmailVerificationView": {
"Title": "Editor Web p5.js | Correo de Verificación",
"Verify": "Verica tu correo",
"InvalidTokenNull": "La liga es inválida.",
"Checking": "Validando token, por favor espera...",
"Verified": "Concluido, tu correo electrónico ha sido verificado.",
"InvalidState": "Algo salió mal."
}
}