Merge master

This commit is contained in:
Cassie Tarakajian 2020-04-02 15:37:13 -04:00
commit f6cc77cd70
170 changed files with 9351 additions and 1966 deletions

View file

@ -1,4 +1,4 @@
API_URL=/api API_URL=/editor
AWS_ACCESS_KEY=<your-aws-access-key> AWS_ACCESS_KEY=<your-aws-access-key>
AWS_REGION=<your-aws-region> AWS_REGION=<your-aws-region>
AWS_SECRET_KEY=<your-aws-secret-key> AWS_SECRET_KEY=<your-aws-secret-key>
@ -23,3 +23,5 @@ PORT=8000
S3_BUCKET=<your-s3-bucket> S3_BUCKET=<your-s3-bucket>
S3_BUCKET_URL_BASE=<alt-for-s3-url> S3_BUCKET_URL_BASE=<alt-for-s3-url>
SESSION_SECRET=whatever_you_want_this_to_be_it_only_matters_for_production SESSION_SECRET=whatever_you_want_this_to_be_it_only_matters_for_production
UI_ACCESS_TOKEN_ENABLED=false
UPLOAD_LIMIT=250000000

2
.github/config.yml vendored
View file

@ -10,7 +10,7 @@ newIssueWelcomeComment: >
# Comment to be posted to on PRs from first time contributors in your repository # Comment to be posted to on PRs from first time contributors in your repository
newPRWelcomeComment: > newPRWelcomeComment: >
🎉 Thanks for opening this pull request! Please check out our [contributing guidelines](https://github.com/processing/p5.js-web-editor/blob/master/CONTRIBUTING.md) if you haven't already. 🎉 Thanks for opening this pull request! Please check out our [contributing guidelines](https://github.com/processing/p5.js-web-editor/blob/master/.github/CONTRIBUTING.md) if you haven't already.
# Configuration for first-pr-merge - https://github.com/behaviorbot/first-pr-merge # Configuration for first-pr-merge - https://github.com/behaviorbot/first-pr-merge

1
.nvmrc Normal file
View file

@ -0,0 +1 @@
12.16.1

View file

@ -1,7 +1,7 @@
sudo: required sudo: required
language: node_js language: node_js
node_js: node_js:
- "11.15.0" - "12.16.1"
cache: cache:
directories: directories:

View file

@ -1,4 +1,4 @@
FROM node:10.15.0 as base FROM node:12.16.1 as base
ENV APP_HOME=/usr/src/app \ ENV APP_HOME=/usr/src/app \
TERM=xterm TERM=xterm
RUN mkdir -p $APP_HOME RUN mkdir -p $APP_HOME

View file

@ -16,7 +16,7 @@
], ],
"env": { "env": {
"API_URL": { "API_URL": {
"value": "/api" "value": "/editor"
}, },
"AWS_ACCESS_KEY": { "AWS_ACCESS_KEY": {
"description": "AWS Access Key", "description": "AWS Access Key",

View file

@ -0,0 +1,24 @@
import React from 'react';
import PropTypes from 'prop-types';
import InlineSVG from 'react-inlinesvg';
const addIcon = require('../images/plus.svg');
const removeIcon = require('../images/minus.svg');
const AddRemoveButton = ({ type, onClick }) => {
const alt = type === 'add' ? 'add to collection' : 'remove from collection';
const icon = type === 'add' ? addIcon : removeIcon;
return (
<button className="overlay__close-button" onClick={onClick}>
<InlineSVG src={icon} alt={alt} />
</button>
);
};
AddRemoveButton.propTypes = {
type: PropTypes.oneOf(['add', 'remove']).isRequired,
onClick: PropTypes.func.isRequired,
};
export default AddRemoveButton;

View file

@ -2,7 +2,7 @@ import PropTypes from 'prop-types';
import React from 'react'; import React from 'react';
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import { withRouter } from 'react-router'; import { withRouter } from 'react-router';
import { Link } from 'react-router'; import { Link, browserHistory } from 'react-router';
import InlineSVG from 'react-inlinesvg'; import InlineSVG from 'react-inlinesvg';
import classNames from 'classnames'; import classNames from 'classnames';
import * as IDEActions from '../modules/IDE/actions/ide'; import * as IDEActions from '../modules/IDE/actions/ide';
@ -12,6 +12,7 @@ import { setAllAccessibleOutput } from '../modules/IDE/actions/preferences';
import { logoutUser } from '../modules/User/actions'; import { logoutUser } from '../modules/User/actions';
import { metaKeyName, } from '../utils/metaKey'; import { metaKeyName, } from '../utils/metaKey';
import caretLeft from '../images/left-arrow.svg';
const triangleUrl = require('../images/down-filled-triangle.svg'); const triangleUrl = require('../images/down-filled-triangle.svg');
const logoUrl = require('../images/p5js-logo-small.svg'); const logoUrl = require('../images/p5js-logo-small.svg');
@ -92,11 +93,12 @@ class Nav extends React.PureComponent {
} }
handleNew() { handleNew() {
if (!this.props.unsavedChanges) { const { unsavedChanges, warnIfUnsavedChanges } = this.props;
if (!unsavedChanges) {
this.props.showToast(1500); this.props.showToast(1500);
this.props.setToastText('Opened new sketch.'); this.props.setToastText('Opened new sketch.');
this.props.newProject(); this.props.newProject();
} else if (this.props.warnIfUnsavedChanges()) { } else if (warnIfUnsavedChanges && warnIfUnsavedChanges()) {
this.props.showToast(1500); this.props.showToast(1500);
this.props.setToastText('Opened new sketch.'); this.props.setToastText('Opened new sketch.');
this.props.newProject(); this.props.newProject();
@ -165,6 +167,8 @@ class Nav extends React.PureComponent {
handleLogout() { handleLogout() {
this.props.logoutUser(); this.props.logoutUser();
// if you're on the settings page, probably.
browserHistory.push('/');
this.setDropdown('none'); this.setDropdown('none');
} }
@ -222,6 +226,439 @@ class Nav extends React.PureComponent {
this.timer = setTimeout(this.setDropdown.bind(this, 'none'), 10); this.timer = setTimeout(this.setDropdown.bind(this, 'none'), 10);
} }
renderDashboardMenu(navDropdownState) {
return (
<ul className="nav__items-left" title="project-menu">
<li className="nav__item-logo">
<InlineSVG src={logoUrl} alt="p5.js logo" className="svg__logo" />
</li>
<li className="nav__item nav__item--no-icon">
<Link to="/" className="nav__back-link">
<InlineSVG src={caretLeft} className="nav__back-icon" />
<span className="nav__item-header">
Back to Editor
</span>
</Link>
</li>
</ul>
);
}
renderProjectMenu(navDropdownState) {
return (
<ul className="nav__items-left" title="project-menu">
<li className="nav__item-logo">
<InlineSVG src={logoUrl} alt="p5.js logo" className="svg__logo" />
</li>
<li className={navDropdownState.file}>
<button
onClick={this.toggleDropdownForFile}
onBlur={this.handleBlur}
onFocus={this.clearHideTimeout}
onMouseOver={() => {
if (this.state.dropdownOpen !== 'none') {
this.setDropdown('file');
}
}}
>
<span className="nav__item-header">File</span>
<InlineSVG className="nav__item-header-triangle" src={triangleUrl} />
</button>
<ul className="nav__dropdown">
<li className="nav__dropdown-item">
<button
onClick={this.handleNew}
onFocus={this.handleFocusForFile}
onBlur={this.handleBlur}
>
New
</button>
</li>
{ __process.env.LOGIN_ENABLED && (!this.props.project.owner || this.isUserOwner()) &&
<li className="nav__dropdown-item">
<button
onClick={this.handleSave}
onFocus={this.handleFocusForFile}
onBlur={this.handleBlur}
>
Save
<span className="nav__keyboard-shortcut">{metaKeyName}+s</span>
</button>
</li> }
{ this.props.project.id && this.props.user.authenticated &&
<li className="nav__dropdown-item">
<button
onClick={this.handleDuplicate}
onFocus={this.handleFocusForFile}
onBlur={this.handleBlur}
>
Duplicate
</button>
</li> }
{ this.props.project.id &&
<li className="nav__dropdown-item">
<button
onClick={this.handleShare}
onFocus={this.handleFocusForFile}
onBlur={this.handleBlur}
>
Share
</button>
</li> }
{ this.props.project.id &&
<li className="nav__dropdown-item">
<button
onClick={this.handleDownload}
onFocus={this.handleFocusForFile}
onBlur={this.handleBlur}
>
Download
</button>
</li> }
{ this.props.user.authenticated &&
<li className="nav__dropdown-item">
<Link
to={`/${this.props.user.username}/sketches`}
onFocus={this.handleFocusForFile}
onBlur={this.handleBlur}
onClick={this.setDropdownForNone}
>
Open
</Link>
</li> }
{__process.env.UI_COLLECTIONS_ENABLED &&
this.props.user.authenticated &&
this.props.project.id &&
<li className="nav__dropdown-item">
<Link
to={`/${this.props.user.username}/sketches/${this.props.project.id}/add-to-collection`}
onFocus={this.handleFocusForFile}
onBlur={this.handleBlur}
onClick={this.setDropdownForNone}
>
Add to Collection
</Link>
</li>}
{ __process.env.EXAMPLES_ENABLED &&
<li className="nav__dropdown-item">
<Link
to="/p5/sketches"
onFocus={this.handleFocusForFile}
onBlur={this.handleBlur}
onClick={this.setDropdownForNone}
>
Examples
</Link>
</li> }
</ul>
</li>
<li className={navDropdownState.edit}>
<button
onClick={this.toggleDropdownForEdit}
onBlur={this.handleBlur}
onFocus={this.clearHideTimeout}
onMouseOver={() => {
if (this.state.dropdownOpen !== 'none') {
this.setDropdown('edit');
}
}}
>
<span className="nav__item-header">Edit</span>
<InlineSVG className="nav__item-header-triangle" src={triangleUrl} />
</button>
<ul className="nav__dropdown" >
<li className="nav__dropdown-item">
<button
onClick={() => {
this.props.cmController.tidyCode();
this.setDropdown('none');
}}
onFocus={this.handleFocusForEdit}
onBlur={this.handleBlur}
>
Tidy Code
<span className="nav__keyboard-shortcut">{'\u21E7'}+Tab</span>
</button>
</li>
<li className="nav__dropdown-item">
<button
onClick={this.handleFind}
onFocus={this.handleFocusForEdit}
onBlur={this.handleBlur}
>
Find
<span className="nav__keyboard-shortcut">{metaKeyName}+F</span>
</button>
</li>
<li className="nav__dropdown-item">
<button
onClick={this.handleFindNext}
onFocus={this.handleFocusForEdit}
onBlur={this.handleBlur}
>
Find Next
<span className="nav__keyboard-shortcut">{metaKeyName}+G</span>
</button>
</li>
<li className="nav__dropdown-item">
<button
onClick={this.handleFindPrevious}
onFocus={this.handleFocusForEdit}
onBlur={this.handleBlur}
>
Find Previous
<span className="nav__keyboard-shortcut">{'\u21E7'}+{metaKeyName}+G</span>
</button>
</li>
</ul>
</li>
<li className={navDropdownState.sketch}>
<button
onClick={this.toggleDropdownForSketch}
onBlur={this.handleBlur}
onFocus={this.clearHideTimeout}
onMouseOver={() => {
if (this.state.dropdownOpen !== 'none') {
this.setDropdown('sketch');
}
}}
>
<span className="nav__item-header">Sketch</span>
<InlineSVG className="nav__item-header-triangle" src={triangleUrl} />
</button>
<ul className="nav__dropdown">
<li className="nav__dropdown-item">
<button
onClick={this.handleAddFile}
onFocus={this.handleFocusForSketch}
onBlur={this.handleBlur}
>
Add File
</button>
</li>
<li className="nav__dropdown-item">
<button
onClick={this.handleAddFolder}
onFocus={this.handleFocusForSketch}
onBlur={this.handleBlur}
>
Add Folder
</button>
</li>
<li className="nav__dropdown-item">
<button
onClick={this.handleRun}
onFocus={this.handleFocusForSketch}
onBlur={this.handleBlur}
>
Run
<span className="nav__keyboard-shortcut">{metaKeyName}+Enter</span>
</button>
</li>
<li className="nav__dropdown-item">
<button
onClick={this.handleStop}
onFocus={this.handleFocusForSketch}
onBlur={this.handleBlur}
>
Stop
<span className="nav__keyboard-shortcut">{'\u21E7'}+{metaKeyName}+Enter</span>
</button>
</li>
{/* <li className="nav__dropdown-item">
<button
onClick={this.handleStartAccessible}
onFocus={this.handleFocusForSketch}
onBlur={this.handleBlur}
>
Start Accessible
<span className="nav__keyboard-shortcut">{'\u21E7'}+{metaKeyName}+1</span>
</button>
</li>
<li className="nav__dropdown-item">
<button
onClick={this.handleStopAccessible}
onFocus={this.handleFocusForSketch}
onBlur={this.handleBlur}
>
Stop Accessible
<span className="nav__keyboard-shortcut">{'\u21E7'}+{metaKeyName}+2</span>
</button>
</li> */}
</ul>
</li>
<li className={navDropdownState.help}>
<button
onClick={this.toggleDropdownForHelp}
onBlur={this.handleBlur}
onFocus={this.clearHideTimeout}
onMouseOver={() => {
if (this.state.dropdownOpen !== 'none') {
this.setDropdown('help');
}
}}
>
<span className="nav__item-header">Help</span>
<InlineSVG className="nav__item-header-triangle" src={triangleUrl} />
</button>
<ul className="nav__dropdown">
<li className="nav__dropdown-item">
<button
onFocus={this.handleFocusForHelp}
onBlur={this.handleBlur}
onClick={this.handleKeyboardShortcuts}
>
Keyboard Shortcuts
</button>
</li>
<li className="nav__dropdown-item">
<a
href="https://p5js.org/reference/"
target="_blank"
rel="noopener noreferrer"
onFocus={this.handleFocusForHelp}
onBlur={this.handleBlur}
onClick={this.setDropdownForNone}
>Reference
</a>
</li>
<li className="nav__dropdown-item">
<Link
to="/about"
onFocus={this.handleFocusForHelp}
onBlur={this.handleBlur}
onClick={this.setDropdownForNone}
>
About
</Link>
</li>
</ul>
</li>
</ul>
);
}
renderUnauthenticatedUserMenu(navDropdownState) {
return (
<ul className="nav__items-right" title="user-menu">
<li className="nav__item">
<Link to="/login">
<span className="nav__item-header">Log in</span>
</Link>
</li>
<span className="nav__item-spacer">or</span>
<li className="nav__item">
<Link to="/signup">
<span className="nav__item-header">Sign up</span>
</Link>
</li>
</ul>
);
}
renderAuthenticatedUserMenu(navDropdownState) {
return (
<ul className="nav__items-right" title="user-menu">
<li className="nav__item">
<span>Hello, {this.props.user.username}!</span>
</li>
<span className="nav__item-spacer">|</span>
<li className={navDropdownState.account}>
<button
className="nav__item-header"
onClick={this.toggleDropdownForAccount}
onBlur={this.handleBlur}
onFocus={this.clearHideTimeout}
onMouseOver={() => {
if (this.state.dropdownOpen !== 'none') {
this.setDropdown('account');
}
}}
>
My Account
<InlineSVG className="nav__item-header-triangle" src={triangleUrl} />
</button>
<ul className="nav__dropdown">
<li className="nav__dropdown-item">
<Link
to={`/${this.props.user.username}/sketches`}
onFocus={this.handleFocusForAccount}
onBlur={this.handleBlur}
onClick={this.setDropdownForNone}
>
My sketches
</Link>
</li>
{__process.env.UI_COLLECTIONS_ENABLED &&
<li className="nav__dropdown-item">
<Link
to={`/${this.props.user.username}/collections`}
onFocus={this.handleFocusForAccount}
onBlur={this.handleBlur}
onClick={this.setDropdownForNone}
>
My collections
</Link>
</li>
}
<li className="nav__dropdown-item">
<Link
to={`/${this.props.user.username}/assets`}
onFocus={this.handleFocusForAccount}
onBlur={this.handleBlur}
onClick={this.setDropdownForNone}
>
My assets
</Link>
</li>
<li className="nav__dropdown-item">
<Link
to="/account"
onFocus={this.handleFocusForAccount}
onBlur={this.handleBlur}
onClick={this.setDropdownForNone}
>
Settings
</Link>
</li>
<li className="nav__dropdown-item">
<button
onClick={this.handleLogout}
onFocus={this.handleFocusForAccount}
onBlur={this.handleBlur}
>
Log out
</button>
</li>
</ul>
</li>
</ul>
);
}
renderUserMenu(navDropdownState) {
const isLoginEnabled = __process.env.LOGIN_ENABLED;
const isAuthenticated = this.props.user.authenticated;
if (isLoginEnabled && isAuthenticated) {
return this.renderAuthenticatedUserMenu(navDropdownState);
} else if (isLoginEnabled && !isAuthenticated) {
return this.renderUnauthenticatedUserMenu(navDropdownState);
}
return null;
}
renderLeftLayout(navDropdownState) {
switch (this.props.layout) {
case 'dashboard':
return this.renderDashboardMenu(navDropdownState);
case 'project':
default:
return this.renderProjectMenu(navDropdownState);
}
}
render() { render() {
const navDropdownState = { const navDropdownState = {
file: classNames({ file: classNames({
@ -245,362 +682,11 @@ class Nav extends React.PureComponent {
'nav__item--open': this.state.dropdownOpen === 'account' 'nav__item--open': this.state.dropdownOpen === 'account'
}) })
}; };
return ( return (
<nav className="nav" title="main-navigation" ref={(node) => { this.node = node; }}> <nav className="nav" title="main-navigation" ref={(node) => { this.node = node; }}>
<ul className="nav__items-left" title="project-menu"> {this.renderLeftLayout(navDropdownState)}
<li className="nav__item-logo"> {this.renderUserMenu(navDropdownState)}
<InlineSVG src={logoUrl} alt="p5.js logo" className="svg__logo" />
</li>
<li className={navDropdownState.file}>
<button
onClick={this.toggleDropdownForFile}
onBlur={this.handleBlur}
onFocus={this.clearHideTimeout}
onMouseOver={() => {
if (this.state.dropdownOpen !== 'none') {
this.setDropdown('file');
}
}}
>
<span className="nav__item-header">File</span>
<InlineSVG className="nav__item-header-triangle" src={triangleUrl} />
</button>
<ul className="nav__dropdown">
<li className="nav__dropdown-item">
<button
onClick={this.handleNew}
onFocus={this.handleFocusForFile}
onBlur={this.handleBlur}
>
New
</button>
</li>
{ __process.env.LOGIN_ENABLED && (!this.props.project.owner || this.isUserOwner()) &&
<li className="nav__dropdown-item">
<button
onClick={this.handleSave}
onFocus={this.handleFocusForFile}
onBlur={this.handleBlur}
>
Save
<span className="nav__keyboard-shortcut">{metaKeyName}+s</span>
</button>
</li> }
{ this.props.project.id && this.props.user.authenticated &&
<li className="nav__dropdown-item">
<button
onClick={this.handleDuplicate}
onFocus={this.handleFocusForFile}
onBlur={this.handleBlur}
>
Duplicate
</button>
</li> }
{ this.props.project.id &&
<li className="nav__dropdown-item">
<button
onClick={this.handleShare}
onFocus={this.handleFocusForFile}
onBlur={this.handleBlur}
>
Share
</button>
</li> }
{ this.props.project.id &&
<li className="nav__dropdown-item">
<button
onClick={this.handleDownload}
onFocus={this.handleFocusForFile}
onBlur={this.handleBlur}
>
Download
</button>
</li> }
{ this.props.user.authenticated &&
<li className="nav__dropdown-item">
<Link
to={`/${this.props.user.username}/sketches`}
onFocus={this.handleFocusForFile}
onBlur={this.handleBlur}
onClick={this.setDropdownForNone}
>
Open
</Link>
</li> }
{ __process.env.EXAMPLES_ENABLED &&
<li className="nav__dropdown-item">
<Link
to="/p5/sketches"
onFocus={this.handleFocusForFile}
onBlur={this.handleBlur}
onClick={this.setDropdownForNone}
>
Examples
</Link>
</li> }
</ul>
</li>
<li className={navDropdownState.edit}>
<button
onClick={this.toggleDropdownForEdit}
onBlur={this.handleBlur}
onFocus={this.clearHideTimeout}
onMouseOver={() => {
if (this.state.dropdownOpen !== 'none') {
this.setDropdown('edit');
}
}}
>
<span className="nav__item-header">Edit</span>
<InlineSVG className="nav__item-header-triangle" src={triangleUrl} />
</button>
<ul className="nav__dropdown" >
<li className="nav__dropdown-item">
<button
onClick={() => {
this.props.cmController.tidyCode();
this.setDropdown('none');
}}
onFocus={this.handleFocusForEdit}
onBlur={this.handleBlur}
>
Tidy Code
<span className="nav__keyboard-shortcut">{'\u21E7'}+Tab</span>
</button>
</li>
<li className="nav__dropdown-item">
<button
onClick={this.handleFind}
onFocus={this.handleFocusForEdit}
onBlur={this.handleBlur}
>
Find
<span className="nav__keyboard-shortcut">{metaKeyName}+F</span>
</button>
</li>
<li className="nav__dropdown-item">
<button
onClick={this.handleFindNext}
onFocus={this.handleFocusForEdit}
onBlur={this.handleBlur}
>
Find Next
<span className="nav__keyboard-shortcut">{metaKeyName}+G</span>
</button>
</li>
<li className="nav__dropdown-item">
<button
onClick={this.handleFindPrevious}
onFocus={this.handleFocusForEdit}
onBlur={this.handleBlur}
>
Find Previous
<span className="nav__keyboard-shortcut">{'\u21E7'}+{metaKeyName}+G</span>
</button>
</li>
</ul>
</li>
<li className={navDropdownState.sketch}>
<button
onClick={this.toggleDropdownForSketch}
onBlur={this.handleBlur}
onFocus={this.clearHideTimeout}
onMouseOver={() => {
if (this.state.dropdownOpen !== 'none') {
this.setDropdown('sketch');
}
}}
>
<span className="nav__item-header">Sketch</span>
<InlineSVG className="nav__item-header-triangle" src={triangleUrl} />
</button>
<ul className="nav__dropdown">
<li className="nav__dropdown-item">
<button
onClick={this.handleAddFile}
onFocus={this.handleFocusForSketch}
onBlur={this.handleBlur}
>
Add File
</button>
</li>
<li className="nav__dropdown-item">
<button
onClick={this.handleAddFolder}
onFocus={this.handleFocusForSketch}
onBlur={this.handleBlur}
>
Add Folder
</button>
</li>
<li className="nav__dropdown-item">
<button
onClick={this.handleRun}
onFocus={this.handleFocusForSketch}
onBlur={this.handleBlur}
>
Run
<span className="nav__keyboard-shortcut">{metaKeyName}+Enter</span>
</button>
</li>
<li className="nav__dropdown-item">
<button
onClick={this.handleStop}
onFocus={this.handleFocusForSketch}
onBlur={this.handleBlur}
>
Stop
<span className="nav__keyboard-shortcut">{'\u21E7'}+{metaKeyName}+Enter</span>
</button>
</li>
{/* <li className="nav__dropdown-item">
<button
onClick={this.handleStartAccessible}
onFocus={this.handleFocusForSketch}
onBlur={this.handleBlur}
>
Start Accessible
<span className="nav__keyboard-shortcut">{'\u21E7'}+{metaKeyName}+1</span>
</button>
</li>
<li className="nav__dropdown-item">
<button
onClick={this.handleStopAccessible}
onFocus={this.handleFocusForSketch}
onBlur={this.handleBlur}
>
Stop Accessible
<span className="nav__keyboard-shortcut">{'\u21E7'}+{metaKeyName}+2</span>
</button>
</li> */}
</ul>
</li>
<li className={navDropdownState.help}>
<button
onClick={this.toggleDropdownForHelp}
onBlur={this.handleBlur}
onFocus={this.clearHideTimeout}
onMouseOver={() => {
if (this.state.dropdownOpen !== 'none') {
this.setDropdown('help');
}
}}
>
<span className="nav__item-header">Help</span>
<InlineSVG className="nav__item-header-triangle" src={triangleUrl} />
</button>
<ul className="nav__dropdown">
<li className="nav__dropdown-item">
<button
onFocus={this.handleFocusForHelp}
onBlur={this.handleBlur}
onClick={this.handleKeyboardShortcuts}
>
Keyboard Shortcuts
</button>
</li>
<li className="nav__dropdown-item">
<a
href="https://p5js.org/reference/"
target="_blank"
rel="noopener noreferrer"
onFocus={this.handleFocusForHelp}
onBlur={this.handleBlur}
onClick={this.setDropdownForNone}
>Reference
</a>
</li>
<li className="nav__dropdown-item">
<Link
to="/about"
onFocus={this.handleFocusForHelp}
onBlur={this.handleBlur}
onClick={this.setDropdownForNone}
>
About
</Link>
</li>
</ul>
</li>
</ul>
{ __process.env.LOGIN_ENABLED && !this.props.user.authenticated &&
<ul className="nav__items-right" title="user-menu">
<li>
<Link to="/login">
<span className="nav__item-header">Log in</span>
</Link>
</li>
<span className="nav__item-spacer">or</span>
<li>
<Link to="/signup">
<span className="nav__item-header">Sign up</span>
</Link>
</li>
</ul>}
{ __process.env.LOGIN_ENABLED && this.props.user.authenticated &&
<ul className="nav__items-right" title="user-menu">
<li className="nav__item">
<span>Hello, {this.props.user.username}!</span>
</li>
<span className="nav__item-spacer">|</span>
<li className={navDropdownState.account}>
<button
className="nav__item-header"
onClick={this.toggleDropdownForAccount}
onBlur={this.handleBlur}
onFocus={this.clearHideTimeout}
onMouseOver={() => {
if (this.state.dropdownOpen !== 'none') {
this.setDropdown('account');
}
}}
>
My Account
<InlineSVG className="nav__item-header-triangle" src={triangleUrl} />
</button>
<ul className="nav__dropdown">
<li className="nav__dropdown-item">
<Link
to={`/${this.props.user.username}/sketches`}
onFocus={this.handleFocusForAccount}
onBlur={this.handleBlur}
onClick={this.setDropdownForNone}
>
My sketches
</Link>
</li>
<li className="nav__dropdown-item">
<Link
to="/assets"
onFocus={this.handleFocusForAccount}
onBlur={this.handleBlur}
onClick={this.setDropdownForNone}
>
My assets
</Link>
</li>
<li className="nav__dropdown-item">
<Link
to="/account"
onFocus={this.handleFocusForAccount}
onBlur={this.handleBlur}
onClick={this.setDropdownForNone}
>
Settings
</Link>
</li>
<li className="nav__dropdown-item">
<button
onClick={this.handleLogout}
onFocus={this.handleFocusForAccount}
onBlur={this.handleBlur}
>
Log out
</button>
</li>
</ul>
</li>
</ul> }
{/* {/*
<div className="nav__announce"> <div className="nav__announce">
This is a preview version of the editor, that has not yet been officially released. This is a preview version of the editor, that has not yet been officially released.
@ -639,7 +725,7 @@ Nav.propTypes = {
showShareModal: PropTypes.func.isRequired, showShareModal: PropTypes.func.isRequired,
showErrorModal: PropTypes.func.isRequired, showErrorModal: PropTypes.func.isRequired,
unsavedChanges: PropTypes.bool.isRequired, unsavedChanges: PropTypes.bool.isRequired,
warnIfUnsavedChanges: PropTypes.func.isRequired, warnIfUnsavedChanges: PropTypes.func,
showKeyboardShortcutModal: PropTypes.func.isRequired, showKeyboardShortcutModal: PropTypes.func.isRequired,
cmController: PropTypes.shape({ cmController: PropTypes.shape({
tidyCode: PropTypes.func, tidyCode: PropTypes.func,
@ -653,6 +739,7 @@ Nav.propTypes = {
setAllAccessibleOutput: PropTypes.func.isRequired, setAllAccessibleOutput: PropTypes.func.isRequired,
newFile: PropTypes.func.isRequired, newFile: PropTypes.func.isRequired,
newFolder: PropTypes.func.isRequired, newFolder: PropTypes.func.isRequired,
layout: PropTypes.oneOf(['dashboard', 'project']),
rootFile: PropTypes.shape({ rootFile: PropTypes.shape({
id: PropTypes.string.isRequired id: PropTypes.string.isRequired
}).isRequired }).isRequired
@ -663,7 +750,9 @@ Nav.defaultProps = {
id: undefined, id: undefined,
owner: undefined owner: undefined
}, },
cmController: {} cmController: {},
layout: 'project',
warnIfUnsavedChanges: undefined
}; };
function mapStateToProps(state) { function mapStateToProps(state) {

View file

@ -0,0 +1,40 @@
import PropTypes from 'prop-types';
import React from 'react';
import InlineSVG from 'react-inlinesvg';
const logoUrl = require('../images/p5js-logo-small.svg');
const arrowUrl = require('../images/triangle-arrow-left.svg');
class NavBasic extends React.PureComponent {
static defaultProps = {
onBack: null
}
render() {
return (
<nav className="nav" title="main-navigation" ref={(node) => { this.node = node; }}>
<ul className="nav__items-left" title="project-menu">
<li className="nav__item-logo">
<InlineSVG src={logoUrl} alt="p5.js logo" className="svg__logo" />
</li>
{ this.props.onBack && (
<li className="nav__item">
<button onClick={this.props.onBack}>
<span className="nav__item-header">
<InlineSVG src={arrowUrl} alt="Left arrow" />
</span>
Back to the editor
</button>
</li>)
}
</ul>
</nav>
);
}
}
NavBasic.propTypes = {
onBack: PropTypes.func,
};
export default NavBasic;

View file

@ -0,0 +1,27 @@
import React from 'react';
import { connect } from 'react-redux';
import { browserHistory } from 'react-router';
const RedirectToUser = ({ username, url = '/:username/sketches' }) => {
React.useEffect(() => {
if (username == null) {
return;
}
browserHistory.replace(url.replace(':username', username));
}, [username]);
return null;
};
function mapStateToProps(state) {
return {
username: state.user ? state.user.username : null,
};
}
const ConnectedRedirectToUser = connect(mapStateToProps)(RedirectToUser);
const createRedirectWithUsername = url => props => <ConnectedRedirectToUser {...props} url={url} />;
export default createRedirectWithUsername;

View file

@ -20,6 +20,9 @@ export const AUTH_ERROR = 'AUTH_ERROR';
export const SETTINGS_UPDATED = 'SETTINGS_UPDATED'; export const SETTINGS_UPDATED = 'SETTINGS_UPDATED';
export const API_KEY_CREATED = 'API_KEY_CREATED';
export const API_KEY_REMOVED = 'API_KEY_REMOVED';
export const SET_PROJECT_NAME = 'SET_PROJECT_NAME'; export const SET_PROJECT_NAME = 'SET_PROJECT_NAME';
export const RENAME_PROJECT = 'RENAME_PROJECT'; export const RENAME_PROJECT = 'RENAME_PROJECT';
@ -33,6 +36,14 @@ export const HIDE_EDIT_PROJECT_NAME = 'HIDE_EDIT_PROJECT_NAME';
export const SET_PROJECT = 'SET_PROJECT'; export const SET_PROJECT = 'SET_PROJECT';
export const SET_PROJECTS = 'SET_PROJECTS'; export const SET_PROJECTS = 'SET_PROJECTS';
export const SET_COLLECTIONS = 'SET_COLLECTIONS';
export const CREATE_COLLECTION = 'CREATED_COLLECTION';
export const DELETE_COLLECTION = 'DELETE_COLLECTION';
export const ADD_TO_COLLECTION = 'ADD_TO_COLLECTION';
export const REMOVE_FROM_COLLECTION = 'REMOVE_FROM_COLLECTION';
export const EDIT_COLLECTION = 'EDIT_COLLECTION';
export const DELETE_PROJECT = 'DELETE_PROJECT'; export const DELETE_PROJECT = 'DELETE_PROJECT';
export const SET_SELECTED_FILE = 'SET_SELECTED_FILE'; export const SET_SELECTED_FILE = 'SET_SELECTED_FILE';
@ -69,6 +80,8 @@ export const SHOW_NEW_FOLDER_MODAL = 'SHOW_NEW_FOLDER_MODAL';
export const CLOSE_NEW_FOLDER_MODAL = 'CLOSE_NEW_FOLDER_MODAL'; export const CLOSE_NEW_FOLDER_MODAL = 'CLOSE_NEW_FOLDER_MODAL';
export const SHOW_FOLDER_CHILDREN = 'SHOW_FOLDER_CHILDREN'; export const SHOW_FOLDER_CHILDREN = 'SHOW_FOLDER_CHILDREN';
export const HIDE_FOLDER_CHILDREN = 'HIDE_FOLDER_CHILDREN'; export const HIDE_FOLDER_CHILDREN = 'HIDE_FOLDER_CHILDREN';
export const OPEN_UPLOAD_FILE_MODAL = 'OPEN_UPLOAD_FILE_MODAL';
export const CLOSE_UPLOAD_FILE_MODAL = 'CLOSE_UPLOAD_FILE_MODAL';
export const SHOW_SHARE_MODAL = 'SHOW_SHARE_MODAL'; export const SHOW_SHARE_MODAL = 'SHOW_SHARE_MODAL';
export const CLOSE_SHARE_MODAL = 'CLOSE_SHARE_MODAL'; export const CLOSE_SHARE_MODAL = 'CLOSE_SHARE_MODAL';
@ -116,6 +129,7 @@ export const CLEAR_PERSISTED_STATE = 'CLEAR_PERSISTED_STATE';
export const HIDE_RUNTIME_ERROR_WARNING = 'HIDE_RUNTIME_ERROR_WARNING'; export const HIDE_RUNTIME_ERROR_WARNING = 'HIDE_RUNTIME_ERROR_WARNING';
export const SHOW_RUNTIME_ERROR_WARNING = 'SHOW_RUNTIME_ERROR_WARNING'; export const SHOW_RUNTIME_ERROR_WARNING = 'SHOW_RUNTIME_ERROR_WARNING';
export const SET_ASSETS = 'SET_ASSETS'; export const SET_ASSETS = 'SET_ASSETS';
export const DELETE_ASSET = 'DELETE_ASSET';
export const TOGGLE_DIRECTION = 'TOGGLE_DIRECTION'; export const TOGGLE_DIRECTION = 'TOGGLE_DIRECTION';
export const SET_SORTING = 'SET_SORTING'; export const SET_SORTING = 'SET_SORTING';

6
client/images/check.svg Normal file
View file

@ -0,0 +1,6 @@
<svg width="81px" height="65px" viewBox="0 0 81 65" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<g id="Page-1" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
<path d="M45.437888,42.4740871 L45.437888,-12.5259129 L62.437888,-12.5259129 L62.437888,42.4740871 L62.437888,59.4740871 L18.437888,59.4740871 L18.437888,42.4740871 L45.437888,42.4740871 Z" fill="#D8D8D8" fill-rule="nonzero" transform="translate(40.437888, 23.474087) rotate(42.000000) translate(-40.437888, -23.474087) "></path>
</g>
</svg>

After

Width:  |  Height:  |  Size: 583 B

View file

@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 22.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="check-encircled" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px"
y="0px" viewBox="0 0 100 100" style="enable-background:new 0 0 100 100;" xml:space="preserve">
<style type="text/css">
.st0{fill:#FFFFFF;}
</style>
<path id="circle" d="M50,26.5c-14.4,0-26,11.5-25.9,25.9c0,14.5,11.6,26,26,26c14.5,0,25.9-11.5,25.9-26C76,38,64.5,26.5,50,26.5z
M47.6,66.6L34,53.4l5.6-5.5l7.1,6.8l14-15.4l6.1,5.5L47.6,66.6z"/>
<polygon id="check" class="st0 counter-form" points="46.7,54.7 39.6,47.9 34,53.4 47.6,66.6 66.8,44.8 60.7,39.3 "/>
</svg>

After

Width:  |  Height:  |  Size: 727 B

12
client/images/close.svg Normal file
View file

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 22.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="close" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 100 100" style="enable-background:new 0 0 100 100;" xml:space="preserve">
<style type="text/css">
.st0{fill:#FFFFFF;}
</style>
<path id="circle" d="M50,26.5c-14.4,0-26,11.5-25.9,25.9c0,14.5,11.6,26,26,26c14.5,0,25.9-11.5,25.9-26C76,38,64.5,26.5,50,26.5z
M63.4,60.2L58,65.6l-7.9-8L42,65.7l-5.4-5.4l8.1-8l-8.1-8l5.4-5.4l8,8.1l8-8l5.4,5.4l-8,7.8L63.4,60.2z"/>
<polygon id="x" class="st0 counter-form" points="58,39 50,47 42,38.9 36.6,44.3 44.7,52.3 36.6,60.3 42,65.7 50.1,57.6 58,65.6 63.4,60.2
55.4,52.2 63.4,44.4 "/>
</svg>

After

Width:  |  Height:  |  Size: 801 B

View file

@ -1,7 +1,6 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?> <?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg width="14px" height="9px" viewBox="0 0 14 9" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"> <svg width="14px" height="9px" viewBox="0 0 14 9" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<!-- Generator: Sketch 39 (31667) - http://www.bohemiancoding.com/sketch --> <!-- Generator: Sketch 39 (31667) - http://www.bohemiancoding.com/sketch -->
<title>arrow shape copy 2</title>
<!-- <desc>Created with Sketch.</desc> --> <!-- <desc>Created with Sketch.</desc> -->
<defs></defs> <defs></defs>
<g id="IDEs" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd" opacity="0.539999962"> <g id="IDEs" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd" opacity="0.539999962">

Before

Width:  |  Height:  |  Size: 1,017 B

After

Width:  |  Height:  |  Size: 979 B

View file

@ -1,7 +1,6 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?> <?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg width="10px" height="15px" viewBox="0 0 10 15" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"> <svg width="10px" height="15px" viewBox="0 0 10 15" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<!-- Generator: Sketch 3.8.3 (29802) - http://www.bohemiancoding.com/sketch --> <!-- Generator: Sketch 3.8.3 (29802) - http://www.bohemiancoding.com/sketch -->
<title>arrow shape copy</title>
<!-- <desc>Created with Sketch.</desc> --> <!-- <desc>Created with Sketch.</desc> -->
<defs></defs> <defs></defs>
<g id="IDEs" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd" opacity="0.539999962"> <g id="IDEs" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd" opacity="0.539999962">

Before

Width:  |  Height:  |  Size: 925 B

After

Width:  |  Height:  |  Size: 889 B

View file

@ -1,7 +1,6 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?> <?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg width="10px" height="15px" viewBox="0 0 10 15" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"> <svg width="10px" height="15px" viewBox="0 0 10 15" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<!-- Generator: Sketch 3.8.3 (29802) - http://www.bohemiancoding.com/sketch --> <!-- Generator: Sketch 3.8.3 (29802) - http://www.bohemiancoding.com/sketch -->
<title>arrow shape copy</title>
<!-- <desc>Created with Sketch.</desc> --> <!-- <desc>Created with Sketch.</desc> -->
<defs></defs> <defs></defs>
<g id="IDEs" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd" opacity="0.539999962"> <g id="IDEs" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd" opacity="0.539999962">

Before

Width:  |  Height:  |  Size: 924 B

After

Width:  |  Height:  |  Size: 888 B

View file

@ -0,0 +1,14 @@
<svg width="10px" height="10px" viewBox="0 0 5 5" version="1.1"
xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink">
<title>Left Arrow</title>
<desc>Created with Sketch.</desc>
<defs></defs>
<g id="Symbols" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
<g id="environment" transform="translate(-26.000000, -42.000000)" fill="#B5B5B5">
<g id="libraries" transform="translate(21.000000, 32.000000)">
<polygon id="Triangle-3" transform="translate(7.500000, 12.500000) rotate(270.000000) translate(-7.500000, -12.500000) " points="7.5 10 10 15 5 15"></polygon>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 665 B

View file

@ -1,7 +1,6 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?> <?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg width="14px" height="9px" viewBox="0 0 14 9" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"> <svg width="14px" height="9px" viewBox="0 0 14 9" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<!-- Generator: Sketch 39 (31667) - http://www.bohemiancoding.com/sketch --> <!-- Generator: Sketch 39 (31667) - http://www.bohemiancoding.com/sketch -->
<title>arrow shape copy</title>
<!-- <desc>Created with Sketch.</desc> --> <!-- <desc>Created with Sketch.</desc> -->
<defs></defs> <defs></defs>
<g id="IDEs" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd" opacity="0.539999962"> <g id="IDEs" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd" opacity="0.539999962">

Before

Width:  |  Height:  |  Size: 909 B

After

Width:  |  Height:  |  Size: 873 B

View file

@ -18,7 +18,10 @@ class App extends React.Component {
} }
componentWillReceiveProps(nextProps) { componentWillReceiveProps(nextProps) {
if (nextProps.location !== this.props.location) { const locationWillChange = nextProps.location !== this.props.location;
const shouldSkipRemembering = nextProps.location.state && nextProps.location.state.skipSavingPath === true;
if (locationWillChange && !shouldSkipRemembering) {
this.props.setPreviousPath(this.props.location.pathname); this.props.setPreviousPath(this.props.location.pathname);
} }
} }
@ -42,7 +45,10 @@ class App extends React.Component {
App.propTypes = { App.propTypes = {
children: PropTypes.element, children: PropTypes.element,
location: PropTypes.shape({ location: PropTypes.shape({
pathname: PropTypes.string pathname: PropTypes.string,
state: PropTypes.shape({
skipSavingPath: PropTypes.bool,
}),
}).isRequired, }).isRequired,
setPreviousPath: PropTypes.func.isRequired, setPreviousPath: PropTypes.func.isRequired,
theme: PropTypes.string, theme: PropTypes.string,

View file

@ -64,10 +64,12 @@ class Overlay extends React.Component {
const { const {
ariaLabel, ariaLabel,
title, title,
children children,
actions,
isFixedHeight,
} = this.props; } = this.props;
return ( return (
<div className="overlay"> <div className={`overlay ${isFixedHeight ? 'overlay--is-fixed-height' : ''}`}>
<div className="overlay__content"> <div className="overlay__content">
<section <section
role="main" role="main"
@ -77,9 +79,12 @@ class Overlay extends React.Component {
> >
<header className="overlay__header"> <header className="overlay__header">
<h2 className="overlay__title">{title}</h2> <h2 className="overlay__title">{title}</h2>
<button className="overlay__close-button" onClick={this.close} > <div className="overlay__actions">
<InlineSVG src={exitUrl} alt="close overlay" /> {actions}
</button> <button className="overlay__close-button" onClick={this.close} >
<InlineSVG src={exitUrl} alt="close overlay" />
</button>
</div>
</header> </header>
{children} {children}
</section> </section>
@ -91,18 +96,22 @@ class Overlay extends React.Component {
Overlay.propTypes = { Overlay.propTypes = {
children: PropTypes.element, children: PropTypes.element,
actions: PropTypes.element,
closeOverlay: PropTypes.func, closeOverlay: PropTypes.func,
title: PropTypes.string, title: PropTypes.string,
ariaLabel: PropTypes.string, ariaLabel: PropTypes.string,
previousPath: PropTypes.string previousPath: PropTypes.string,
isFixedHeight: PropTypes.bool,
}; };
Overlay.defaultProps = { Overlay.defaultProps = {
children: null, children: null,
actions: null,
title: 'Modal', title: 'Modal',
closeOverlay: null, closeOverlay: null,
ariaLabel: 'modal', ariaLabel: 'modal',
previousPath: '/' previousPath: '/',
isFixedHeight: false,
}; };
export default Overlay; export default Overlay;

View file

@ -1,9 +1,11 @@
import React from 'react'; import React from 'react';
const Loader = () => ( const Loader = () => (
<div className="loader"> <div className="loader-container">
<div className="loader__circle1" /> <div className="loader">
<div className="loader__circle2" /> <div className="loader__circle1" />
<div className="loader__circle2" />
</div>
</div> </div>
); );
export default Loader; export default Loader;

View file

@ -30,8 +30,23 @@ export function getAssets() {
}; };
} }
export function deleteAsset(assetKey, userId) { export function deleteAsset(assetKey) {
return { return {
type: 'PLACEHOLDER' type: ActionTypes.DELETE_ASSET,
key: assetKey
};
}
export function deleteAssetRequest(assetKey) {
return (dispatch) => {
axios.delete(`${ROOT_URL}/S3/${assetKey}`, { withCredentials: true })
.then((response) => {
dispatch(deleteAsset(assetKey));
})
.catch(() => {
dispatch({
type: ActionTypes.ERROR
});
});
}; };
} }

View file

@ -0,0 +1,173 @@
import axios from 'axios';
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;
// eslint-disable-next-line
export function getCollections(username) {
return (dispatch) => {
dispatch(startLoader());
let url;
if (username) {
url = `${ROOT_URL}/${username}/collections`;
} else {
url = `${ROOT_URL}/collections`;
}
axios.get(url, { withCredentials: true })
.then((response) => {
dispatch({
type: ActionTypes.SET_COLLECTIONS,
collections: response.data
});
dispatch(stopLoader());
})
.catch((response) => {
dispatch({
type: ActionTypes.ERROR,
error: response.data
});
dispatch(stopLoader());
});
};
}
export function createCollection(collection) {
return (dispatch) => {
dispatch(startLoader());
const url = `${ROOT_URL}/collections`;
return axios.post(url, collection, { withCredentials: true })
.then((response) => {
dispatch({
type: ActionTypes.CREATE_COLLECTION
});
dispatch(stopLoader());
const collectionName = response.data.name;
dispatch(setToastText(`Created "${collectionName}"`));
dispatch(showToast(TOAST_DISPLAY_TIME_MS));
return response.data;
})
.catch((response) => {
dispatch({
type: ActionTypes.ERROR,
error: response.data
});
dispatch(stopLoader());
return response.data;
});
};
}
export function addToCollection(collectionId, projectId) {
return (dispatch) => {
dispatch(startLoader());
const url = `${ROOT_URL}/collections/${collectionId}/${projectId}`;
return axios.post(url, { withCredentials: true })
.then((response) => {
dispatch({
type: ActionTypes.ADD_TO_COLLECTION,
payload: response.data
});
dispatch(stopLoader());
const collectionName = response.data.name;
dispatch(setToastText(`Added to "${collectionName}`));
dispatch(showToast(TOAST_DISPLAY_TIME_MS));
return response.data;
})
.catch((response) => {
dispatch({
type: ActionTypes.ERROR,
error: response.data
});
dispatch(stopLoader());
return response.data;
});
};
}
export function removeFromCollection(collectionId, projectId) {
return (dispatch) => {
dispatch(startLoader());
const url = `${ROOT_URL}/collections/${collectionId}/${projectId}`;
return axios.delete(url, { withCredentials: true })
.then((response) => {
dispatch({
type: ActionTypes.REMOVE_FROM_COLLECTION,
payload: response.data
});
dispatch(stopLoader());
const collectionName = response.data.name;
dispatch(setToastText(`Removed from "${collectionName}`));
dispatch(showToast(TOAST_DISPLAY_TIME_MS));
return response.data;
})
.catch((response) => {
dispatch({
type: ActionTypes.ERROR,
error: response.data
});
dispatch(stopLoader());
return response.data;
});
};
}
export function editCollection(collectionId, { name, description }) {
return (dispatch) => {
const url = `${ROOT_URL}/collections/${collectionId}`;
return axios.patch(url, { name, description }, { withCredentials: true })
.then((response) => {
dispatch({
type: ActionTypes.EDIT_COLLECTION,
payload: response.data
});
return response.data;
})
.catch((response) => {
dispatch({
type: ActionTypes.ERROR,
error: response.data
});
return response.data;
});
};
}
export function deleteCollection(collectionId) {
return (dispatch) => {
const url = `${ROOT_URL}/collections/${collectionId}`;
return axios.delete(url, { withCredentials: true })
.then((response) => {
dispatch({
type: ActionTypes.DELETE_COLLECTION,
payload: response.data,
collectionId,
});
return response.data;
})
.catch((response) => {
dispatch({
type: ActionTypes.ERROR,
error: response.data
});
return response.data;
});
};
}

View file

@ -3,7 +3,7 @@ import objectID from 'bson-objectid';
import blobUtil from 'blob-util'; import blobUtil from 'blob-util';
import { reset } from 'redux-form'; import { reset } from 'redux-form';
import * as ActionTypes from '../../../constants'; import * as ActionTypes from '../../../constants';
import { setUnsavedChanges } from './ide'; import { setUnsavedChanges, closeNewFolderModal, closeNewFileModal } from './ide';
import { setProjectSavedTime } from './project'; import { setProjectSavedTime } from './project';
const __process = (typeof global !== 'undefined' ? global : window).process; const __process = (typeof global !== 'undefined' ? global : window).process;
@ -58,6 +58,7 @@ export function createFile(formProps) {
parentId parentId
}); });
dispatch(setProjectSavedTime(response.data.project.updatedAt)); dispatch(setProjectSavedTime(response.data.project.updatedAt));
dispatch(closeNewFileModal());
dispatch(reset('new-file')); dispatch(reset('new-file'));
// dispatch({ // dispatch({
// type: ActionTypes.HIDE_MODAL // type: ActionTypes.HIDE_MODAL
@ -85,6 +86,7 @@ export function createFile(formProps) {
// type: ActionTypes.HIDE_MODAL // type: ActionTypes.HIDE_MODAL
// }); // });
dispatch(setUnsavedChanges(true)); dispatch(setUnsavedChanges(true));
dispatch(closeNewFileModal());
} }
}; };
} }
@ -109,9 +111,7 @@ export function createFolder(formProps) {
parentId parentId
}); });
dispatch(setProjectSavedTime(response.data.project.updatedAt)); dispatch(setProjectSavedTime(response.data.project.updatedAt));
dispatch({ dispatch(closeNewFolderModal());
type: ActionTypes.CLOSE_NEW_FOLDER_MODAL
});
}) })
.catch(response => dispatch({ .catch(response => dispatch({
type: ActionTypes.ERROR, type: ActionTypes.ERROR,
@ -130,9 +130,7 @@ export function createFolder(formProps) {
fileType: 'folder', fileType: 'folder',
children: [] children: []
}); });
dispatch({ dispatch(closeNewFolderModal());
type: ActionTypes.CLOSE_NEW_FOLDER_MODAL
});
} }
}; };
} }

View file

@ -75,6 +75,19 @@ export function closeNewFileModal() {
}; };
} }
export function openUploadFileModal(parentId) {
return {
type: ActionTypes.OPEN_UPLOAD_FILE_MODAL,
parentId
};
}
export function closeUploadFileModal() {
return {
type: ActionTypes.CLOSE_UPLOAD_FILE_MODAL
};
}
export function expandSidebar() { export function expandSidebar() {
return { return {
type: ActionTypes.EXPAND_SIDEBAR type: ActionTypes.EXPAND_SIDEBAR

View file

@ -26,13 +26,14 @@ export function toggleDirectionForField(field) {
}; };
} }
export function setSearchTerm(searchTerm) { export function setSearchTerm(scope, searchTerm) {
return { return {
type: ActionTypes.SET_SEARCH_TERM, type: ActionTypes.SET_SEARCH_TERM,
query: searchTerm query: searchTerm,
scope,
}; };
} }
export function resetSearchTerm() { export function resetSearchTerm(scope) {
return setSearchTerm(''); return setSearchTerm(scope, '');
} }

View file

@ -66,11 +66,11 @@ export function dropzoneAcceptCallback(userId, file, done) {
done(); done();
}) })
.catch((response) => { .catch((response) => {
file.custom_status = 'rejected'; // eslint-disable-line file.custom_status = 'rejected'; // eslint-disable-line
if (response.data.responseText && response.data.responseText.message) { if (response.data && response.data.responseText && response.data.responseText.message) {
done(response.data.responseText.message); done(response.data.responseText.message);
} }
done('error preparing the upload'); done('Error: Reached upload limit.');
}); });
} }
}; };

View file

@ -34,7 +34,7 @@ function About(props) {
rel="noopener noreferrer" rel="noopener noreferrer"
> >
<InlineSVG className="about__content-column-asterisk" src={asteriskUrl} alt="p5 asterisk" /> <InlineSVG className="about__content-column-asterisk" src={asteriskUrl} alt="p5 asterisk" />
Examples Examples
</a> </a>
</p> </p>
<p className="about__content-column-list"> <p className="about__content-column-list">
@ -44,7 +44,7 @@ function About(props) {
rel="noopener noreferrer" rel="noopener noreferrer"
> >
<InlineSVG className="about__content-column-asterisk" src={asteriskUrl} alt="p5 asterisk" /> <InlineSVG className="about__content-column-asterisk" src={asteriskUrl} alt="p5 asterisk" />
Learn Learn
</a> </a>
</p> </p>
</div> </div>
@ -57,7 +57,7 @@ function About(props) {
rel="noopener noreferrer" rel="noopener noreferrer"
> >
<InlineSVG className="about__content-column-asterisk" src={asteriskUrl} alt="p5 asterisk" /> <InlineSVG className="about__content-column-asterisk" src={asteriskUrl} alt="p5 asterisk" />
Libraries Libraries
</a> </a>
</p> </p>
<p className="about__content-column-list"> <p className="about__content-column-list">
@ -67,7 +67,7 @@ function About(props) {
rel="noopener noreferrer" rel="noopener noreferrer"
> >
<InlineSVG className="about__content-column-asterisk" src={asteriskUrl} alt="p5 asterisk" /> <InlineSVG className="about__content-column-asterisk" src={asteriskUrl} alt="p5 asterisk" />
Reference Reference
</a> </a>
</p> </p>
<p className="about__content-column-list"> <p className="about__content-column-list">
@ -77,7 +77,7 @@ function About(props) {
rel="noopener noreferrer" rel="noopener noreferrer"
> >
<InlineSVG className="about__content-column-asterisk" src={asteriskUrl} alt="p5 asterisk" /> <InlineSVG className="about__content-column-asterisk" src={asteriskUrl} alt="p5 asterisk" />
Forum Forum
</a> </a>
</p> </p>
</div> </div>

View file

@ -0,0 +1,165 @@
import PropTypes from 'prop-types';
import React from 'react';
import { Helmet } from 'react-helmet';
import { connect } from 'react-redux';
import { bindActionCreators } from 'redux';
import * as ProjectActions from '../actions/project';
import * as ProjectsActions from '../actions/projects';
import * as CollectionsActions from '../actions/collections';
import * as ToastActions from '../actions/toast';
import * as SortingActions from '../actions/sorting';
import getSortedCollections from '../selectors/collections';
import Loader from '../../App/components/loader';
import QuickAddList from './QuickAddList';
const projectInCollection = (project, collection) =>
collection.items.find(item => item.project.id === project.id) != null;
class CollectionList extends React.Component {
constructor(props) {
super(props);
if (props.projectId) {
props.getProject(props.projectId);
}
this.props.getCollections(this.props.username);
this.state = {
hasLoadedData: false,
};
}
componentDidUpdate(prevProps) {
if (prevProps.loading === true && this.props.loading === false) {
// eslint-disable-next-line react/no-did-update-set-state
this.setState({
hasLoadedData: true,
});
}
}
getTitle() {
if (this.props.username === this.props.user.username) {
return 'p5.js Web Editor | My collections';
}
return `p5.js Web Editor | ${this.props.username}'s collections`;
}
handleCollectionAdd = (collection) => {
this.props.addToCollection(collection.id, this.props.project.id);
}
handleCollectionRemove = (collection) => {
this.props.removeFromCollection(collection.id, this.props.project.id);
}
render() {
const { collections, project } = this.props;
const hasCollections = collections.length > 0;
const collectionWithSketchStatus = collections.map(collection => ({
...collection,
url: `/${collection.owner.username}/collections/${collection.id}`,
isAdded: projectInCollection(project, collection),
}));
let content = null;
if (this.props.loading && !this.state.hasLoadedData) {
content = <Loader />;
} else if (hasCollections) {
content = (
<QuickAddList
items={collectionWithSketchStatus}
onAdd={this.handleCollectionAdd}
onRemove={this.handleCollectionRemove}
/>
);
} else {
content = 'No collections';
}
return (
<div className="quick-add-wrapper">
<Helmet>
<title>{this.getTitle()}</title>
</Helmet>
{content}
</div>
);
}
}
const ProjectShape = PropTypes.shape({
id: PropTypes.string.isRequired,
name: PropTypes.string.isRequired,
createdAt: PropTypes.string.isRequired,
updatedAt: PropTypes.string.isRequired,
user: PropTypes.shape({
username: PropTypes.string.isRequired
}).isRequired,
});
const ItemsShape = PropTypes.shape({
createdAt: PropTypes.string.isRequired,
updatedAt: PropTypes.string.isRequired,
project: ProjectShape
});
CollectionList.propTypes = {
user: PropTypes.shape({
username: PropTypes.string,
authenticated: PropTypes.bool.isRequired
}).isRequired,
projectId: PropTypes.string.isRequired,
getCollections: PropTypes.func.isRequired,
getProject: PropTypes.func.isRequired,
addToCollection: PropTypes.func.isRequired,
removeFromCollection: PropTypes.func.isRequired,
collections: PropTypes.arrayOf(PropTypes.shape({
id: PropTypes.string.isRequired,
name: PropTypes.string.isRequired,
description: PropTypes.string,
createdAt: PropTypes.string.isRequired,
updatedAt: PropTypes.string.isRequired,
items: PropTypes.arrayOf(ItemsShape),
})).isRequired,
username: PropTypes.string,
loading: PropTypes.bool.isRequired,
project: PropTypes.shape({
id: PropTypes.string,
owner: PropTypes.shape({
id: PropTypes.string
})
})
};
CollectionList.defaultProps = {
project: {
id: undefined,
owner: undefined
},
username: undefined
};
function mapStateToProps(state, ownProps) {
return {
user: state.user,
collections: getSortedCollections(state),
sorting: state.sorting,
loading: state.loading,
project: ownProps.project || state.project,
projectId: ownProps && ownProps.params ? ownProps.prams.project_id : null,
};
}
function mapDispatchToProps(dispatch) {
return bindActionCreators(
Object.assign({}, CollectionsActions, ProjectsActions, ProjectActions, ToastActions, SortingActions),
dispatch
);
}
export default connect(mapStateToProps, mapDispatchToProps)(CollectionList);

View file

@ -0,0 +1,137 @@
import PropTypes from 'prop-types';
import React from 'react';
import { Helmet } from 'react-helmet';
import { connect } from 'react-redux';
import { bindActionCreators } from 'redux';
// import find from 'lodash/find';
import * as ProjectsActions from '../actions/projects';
import * as CollectionsActions from '../actions/collections';
import * as ToastActions from '../actions/toast';
import * as SortingActions from '../actions/sorting';
import getSortedSketches from '../selectors/projects';
import Loader from '../../App/components/loader';
import QuickAddList from './QuickAddList';
class SketchList extends React.Component {
constructor(props) {
super(props);
this.props.getProjects(this.props.username);
this.state = {
isInitialDataLoad: true,
};
}
componentWillReceiveProps(nextProps) {
if (this.props.sketches !== nextProps.sketches && Array.isArray(nextProps.sketches)) {
this.setState({
isInitialDataLoad: false,
});
}
}
getSketchesTitle() {
if (this.props.username === this.props.user.username) {
return 'p5.js Web Editor | My sketches';
}
return `p5.js Web Editor | ${this.props.username}'s sketches`;
}
handleCollectionAdd = (sketch) => {
this.props.addToCollection(this.props.collection.id, sketch.id);
}
handleCollectionRemove = (sketch) => {
this.props.removeFromCollection(this.props.collection.id, sketch.id);
}
inCollection = sketch => this.props.collection.items.find(item => item.project.id === sketch.id) != null
render() {
const hasSketches = this.props.sketches.length > 0;
const sketchesWithAddedStatus = this.props.sketches.map(sketch => ({
...sketch,
isAdded: this.inCollection(sketch),
url: `/${this.props.username}/sketches/${sketch.id}`,
}));
let content = null;
if (this.props.loading && this.state.isInitialDataLoad) {
content = <Loader />;
} else if (hasSketches) {
content = (
<QuickAddList
items={sketchesWithAddedStatus}
onAdd={this.handleCollectionAdd}
onRemove={this.handleCollectionRemove}
/>
);
} else {
content = 'No collections';
}
return (
<div className="quick-add-wrapper">
<Helmet>
<title>{this.getSketchesTitle()}</title>
</Helmet>
{content}
</div>
);
}
}
SketchList.propTypes = {
user: PropTypes.shape({
username: PropTypes.string,
authenticated: PropTypes.bool.isRequired
}).isRequired,
getProjects: PropTypes.func.isRequired,
sketches: PropTypes.arrayOf(PropTypes.shape({
id: PropTypes.string.isRequired,
name: PropTypes.string.isRequired,
createdAt: PropTypes.string.isRequired,
updatedAt: PropTypes.string.isRequired
})).isRequired,
collection: PropTypes.shape({
id: PropTypes.string.isRequired,
name: PropTypes.string.isRequired,
items: PropTypes.arrayOf(PropTypes.shape({
project: PropTypes.shape({
id: PropTypes.string.isRequired,
}),
})),
}).isRequired,
username: PropTypes.string,
loading: PropTypes.bool.isRequired,
sorting: PropTypes.shape({
field: PropTypes.string.isRequired,
direction: PropTypes.string.isRequired
}).isRequired,
addToCollection: PropTypes.func.isRequired,
removeFromCollection: PropTypes.func.isRequired,
};
SketchList.defaultProps = {
username: undefined
};
function mapStateToProps(state) {
return {
user: state.user,
sketches: getSortedSketches(state),
sorting: state.sorting,
loading: state.loading,
project: state.project
};
}
function mapDispatchToProps(dispatch) {
return bindActionCreators(
Object.assign({}, ProjectsActions, CollectionsActions, ToastActions, SortingActions),
dispatch
);
}
export default connect(mapStateToProps, mapDispatchToProps)(SketchList);

View file

@ -5,9 +5,146 @@ import { bindActionCreators } from 'redux';
import { Link } from 'react-router'; import { Link } from 'react-router';
import { Helmet } from 'react-helmet'; import { Helmet } from 'react-helmet';
import prettyBytes from 'pretty-bytes'; import prettyBytes from 'pretty-bytes';
import InlineSVG from 'react-inlinesvg';
import Loader from '../../App/components/loader'; import Loader from '../../App/components/loader';
import * as AssetActions from '../actions/assets'; import * as AssetActions from '../actions/assets';
import downFilledTriangle from '../../../images/down-filled-triangle.svg';
class AssetListRowBase extends React.Component {
constructor(props) {
super(props);
this.state = {
isFocused: false,
optionsOpen: false
};
}
onFocusComponent = () => {
this.setState({ isFocused: true });
}
onBlurComponent = () => {
this.setState({ isFocused: false });
setTimeout(() => {
if (!this.state.isFocused) {
this.closeOptions();
}
}, 200);
}
openOptions = () => {
this.setState({
optionsOpen: true
});
}
closeOptions = () => {
this.setState({
optionsOpen: false
});
}
toggleOptions = () => {
if (this.state.optionsOpen) {
this.closeOptions();
} else {
this.openOptions();
}
}
handleDropdownOpen = () => {
this.closeOptions();
this.openOptions();
}
handleAssetDelete = () => {
const { key, name } = this.props.asset;
this.closeOptions();
if (window.confirm(`Are you sure you want to delete "${name}"?`)) {
this.props.deleteAssetRequest(key);
}
}
render() {
const { asset, username } = this.props;
const { optionsOpen } = this.state;
return (
<tr className="asset-table__row" key={asset.key}>
<th scope="row">
<Link to={asset.url} target="_blank">
{asset.name}
</Link>
</th>
<td>{prettyBytes(asset.size)}</td>
<td>
{ asset.sketchId && <Link to={`/${username}/sketches/${asset.sketchId}`}>{asset.sketchName}</Link> }
</td>
<td className="asset-table__dropdown-column">
<button
className="asset-table__dropdown-button"
onClick={this.toggleOptions}
onBlur={this.onBlurComponent}
onFocus={this.onFocusComponent}
>
<InlineSVG src={downFilledTriangle} alt="Menu" />
</button>
{optionsOpen &&
<ul
className="asset-table__action-dialogue"
>
<li>
<button
className="asset-table__action-option"
onClick={this.handleAssetDelete}
onBlur={this.onBlurComponent}
onFocus={this.onFocusComponent}
>
Delete
</button>
</li>
<li>
<Link
to={asset.url}
target="_blank"
onBlur={this.onBlurComponent}
onFocus={this.onFocusComponent}
className="asset-table__action-option"
>
Open in New Tab
</Link>
</li>
</ul>}
</td>
</tr>
);
}
}
AssetListRowBase.propTypes = {
asset: PropTypes.shape({
key: PropTypes.string.isRequired,
url: PropTypes.string.isRequired,
sketchId: PropTypes.string,
sketchName: PropTypes.string,
name: PropTypes.string.isRequired,
size: PropTypes.number.isRequired
}).isRequired,
deleteAssetRequest: PropTypes.func.isRequired,
username: PropTypes.string.isRequired
};
function mapStateToPropsAssetListRow(state) {
return {
username: state.user.username
};
}
function mapDispatchToPropsAssetListRow(dispatch) {
return bindActionCreators(AssetActions, dispatch);
}
const AssetListRow = connect(mapStateToPropsAssetListRow, mapDispatchToPropsAssetListRow)(AssetListRowBase);
class AssetList extends React.Component { class AssetList extends React.Component {
constructor(props) { constructor(props) {
@ -16,10 +153,7 @@ class AssetList extends React.Component {
} }
getAssetsTitle() { getAssetsTitle() {
if (!this.props.username || this.props.username === this.props.user.username) { return 'p5.js Web Editor | My assets';
return 'p5.js Web Editor | My assets';
}
return `p5.js Web Editor | ${this.props.username}'s assets`;
} }
hasAssets() { hasAssets() {
@ -39,14 +173,9 @@ class AssetList extends React.Component {
} }
render() { render() {
const username = this.props.username !== undefined ? this.props.username : this.props.user.username; const { assetList } = this.props;
const { assetList, totalSize } = this.props;
return ( return (
<div className="asset-table-container"> <div className="asset-table-container">
{/* Eventually, this copy should be Total / 250 MB Used */}
{this.hasAssets() &&
<p className="asset-table__total">{`${prettyBytes(totalSize)} Total`}</p>
}
<Helmet> <Helmet>
<title>{this.getAssetsTitle()}</title> <title>{this.getAssetsTitle()}</title>
</Helmet> </Helmet>
@ -58,20 +187,12 @@ class AssetList extends React.Component {
<tr> <tr>
<th>Name</th> <th>Name</th>
<th>Size</th> <th>Size</th>
<th>View</th>
<th>Sketch</th> <th>Sketch</th>
<th scope="col"></th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{assetList.map(asset => {assetList.map(asset => <AssetListRow asset={asset} key={asset.key} />)}
(
<tr className="asset-table__row" key={asset.key}>
<td>{asset.name}</td>
<td>{prettyBytes(asset.size)}</td>
<td><Link to={asset.url} target="_blank">View</Link></td>
<td><Link to={`/${username}/sketches/${asset.sketchId}`}>{asset.sketchName}</Link></td>
</tr>
))}
</tbody> </tbody>
</table>} </table>}
</div> </div>
@ -83,15 +204,13 @@ AssetList.propTypes = {
user: PropTypes.shape({ user: PropTypes.shape({
username: PropTypes.string username: PropTypes.string
}).isRequired, }).isRequired,
username: PropTypes.string.isRequired,
assetList: PropTypes.arrayOf(PropTypes.shape({ assetList: PropTypes.arrayOf(PropTypes.shape({
key: PropTypes.string.isRequired, key: PropTypes.string.isRequired,
name: PropTypes.string.isRequired, name: PropTypes.string.isRequired,
url: PropTypes.string.isRequired, url: PropTypes.string.isRequired,
sketchName: PropTypes.string.isRequired, sketchName: PropTypes.string,
sketchId: PropTypes.string.isRequired sketchId: PropTypes.string
})).isRequired, })).isRequired,
totalSize: PropTypes.number.isRequired,
getAssets: PropTypes.func.isRequired, getAssets: PropTypes.func.isRequired,
loading: PropTypes.bool.isRequired loading: PropTypes.bool.isRequired
}; };
@ -100,7 +219,6 @@ function mapStateToProps(state) {
return { return {
user: state.user, user: state.user,
assetList: state.assets.list, assetList: state.assets.list,
totalSize: state.assets.totalSize,
loading: state.loading loading: state.loading
}; };
} }

View file

@ -0,0 +1,51 @@
import PropTypes from 'prop-types';
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;
const MAX_SIZE_B = limit;
const formatPercent = (percent) => {
const percentUsed = percent * 100;
if (percentUsed < 1) {
return '0%';
}
return `${Math.round(percentUsed)}%`;
};
/* Eventually, this copy should be Total / 250 MB Used */
const AssetSize = ({ totalSize }) => {
if (totalSize === undefined) {
return null;
}
const currentSize = prettyBytes(totalSize);
const sizeLimit = prettyBytes(MAX_SIZE_B);
const percentValue = totalSize / MAX_SIZE_B;
const percent = formatPercent(percentValue);
const percentSize = percentValue < 1 ? percentValue : 1;
return (
<div className="asset-size" style={{ '--percent': percentSize }}>
<div className="asset-size-bar" />
<p className="asset-current">{currentSize} ({percent})</p>
<p className="asset-max">Max: {sizeLimit}</p>
</div>
);
};
AssetSize.propTypes = {
totalSize: PropTypes.number.isRequired,
};
function mapStateToProps(state) {
return {
user: state.user,
totalSize: state.user.totalSize || state.assets.totalSize,
};
}
export default connect(mapStateToProps)(AssetSize);

View file

@ -0,0 +1,221 @@
import PropTypes from 'prop-types';
import React from 'react';
import { Helmet } from 'react-helmet';
import InlineSVG from 'react-inlinesvg';
import { connect } from 'react-redux';
import { bindActionCreators } from 'redux';
import classNames from 'classnames';
import find from 'lodash/find';
import * as ProjectActions from '../../actions/project';
import * as ProjectsActions from '../../actions/projects';
import * as CollectionsActions from '../../actions/collections';
import * as ToastActions from '../../actions/toast';
import * as SortingActions from '../../actions/sorting';
import getSortedCollections from '../../selectors/collections';
import Loader from '../../../App/components/loader';
import Overlay from '../../../App/components/Overlay';
import AddToCollectionSketchList from '../AddToCollectionSketchList';
import { SketchSearchbar } from '../Searchbar';
import CollectionListRow from './CollectionListRow';
const arrowUp = require('../../../../images/sort-arrow-up.svg');
const arrowDown = require('../../../../images/sort-arrow-down.svg');
class CollectionList extends React.Component {
constructor(props) {
super(props);
if (props.projectId) {
props.getProject(props.projectId);
}
this.props.getCollections(this.props.username);
this.props.resetSorting();
this.state = {
hasLoadedData: false,
addingSketchesToCollectionId: null,
};
}
componentDidUpdate(prevProps, prevState) {
if (prevProps.loading === true && this.props.loading === false) {
// eslint-disable-next-line react/no-did-update-set-state
this.setState({
hasLoadedData: true,
});
}
}
getTitle() {
if (this.props.username === this.props.user.username) {
return 'p5.js Web Editor | My collections';
}
return `p5.js Web Editor | ${this.props.username}'s collections`;
}
showAddSketches = (collectionId) => {
this.setState({
addingSketchesToCollectionId: collectionId,
});
}
hideAddSketches = () => {
this.setState({
addingSketchesToCollectionId: null,
});
}
hasCollections() {
return (!this.props.loading || this.state.hasLoadedData) && this.props.collections.length > 0;
}
_renderLoader() {
if (this.props.loading && !this.state.hasLoadedData) return <Loader />;
return null;
}
_renderEmptyTable() {
if (!this.props.loading && this.props.collections.length === 0) {
return (<p className="sketches-table__empty">No collections.</p>);
}
return null;
}
_renderFieldHeader = (fieldName, displayName) => {
const { field, direction } = this.props.sorting;
const headerClass = classNames({
'sketches-table__header': true,
'sketches-table__header--selected': field === fieldName
});
return (
<th scope="col">
<button className="sketch-list__sort-button" onClick={() => this.props.toggleDirectionForField(fieldName)}>
<span className={headerClass}>{displayName}</span>
{field === fieldName && direction === SortingActions.DIRECTION.ASC &&
<InlineSVG src={arrowUp} />
}
{field === fieldName && direction === SortingActions.DIRECTION.DESC &&
<InlineSVG src={arrowDown} />
}
</button>
</th>
);
}
render() {
const username = this.props.username !== undefined ? this.props.username : this.props.user.username;
return (
<div className="sketches-table-container">
<Helmet>
<title>{this.getTitle()}</title>
</Helmet>
{this._renderLoader()}
{this._renderEmptyTable()}
{this.hasCollections() &&
<table className="sketches-table" summary="table containing all collections">
<thead>
<tr>
{this._renderFieldHeader('name', 'Name')}
{this._renderFieldHeader('createdAt', 'Date Created')}
{this._renderFieldHeader('updatedAt', 'Date Updated')}
{this._renderFieldHeader('numItems', '# sketches')}
<th scope="col"></th>
</tr>
</thead>
<tbody>
{this.props.collections.map(collection =>
(<CollectionListRow
key={collection.id}
collection={collection}
user={this.props.user}
username={username}
project={this.props.project}
onAddSketches={() => this.showAddSketches(collection.id)}
/>))}
</tbody>
</table>}
{
this.state.addingSketchesToCollectionId && (
<Overlay
title="Add sketch"
actions={<SketchSearchbar />}
closeOverlay={this.hideAddSketches}
isFixedHeight
>
<div className="collection-add-sketch">
<AddToCollectionSketchList
username={this.props.username}
collection={find(this.props.collections, { id: this.state.addingSketchesToCollectionId })}
/>
</div>
</Overlay>
)
}
</div>
);
}
}
CollectionList.propTypes = {
user: PropTypes.shape({
username: PropTypes.string,
authenticated: PropTypes.bool.isRequired
}).isRequired,
projectId: PropTypes.string,
getCollections: PropTypes.func.isRequired,
getProject: PropTypes.func.isRequired,
collections: PropTypes.arrayOf(PropTypes.shape({
id: PropTypes.string.isRequired,
name: PropTypes.string.isRequired,
description: PropTypes.string,
createdAt: PropTypes.string.isRequired,
updatedAt: PropTypes.string.isRequired,
})).isRequired,
username: PropTypes.string,
loading: PropTypes.bool.isRequired,
toggleDirectionForField: PropTypes.func.isRequired,
resetSorting: PropTypes.func.isRequired,
sorting: PropTypes.shape({
field: PropTypes.string.isRequired,
direction: PropTypes.string.isRequired
}).isRequired,
project: PropTypes.shape({
id: PropTypes.string,
owner: PropTypes.shape({
id: PropTypes.string
})
})
};
CollectionList.defaultProps = {
projectId: undefined,
project: {
id: undefined,
owner: undefined
},
username: undefined
};
function mapStateToProps(state, ownProps) {
return {
user: state.user,
collections: getSortedCollections(state),
sorting: state.sorting,
loading: state.loading,
project: state.project,
projectId: ownProps && ownProps.params ? ownProps.params.project_id : null,
};
}
function mapDispatchToProps(dispatch) {
return bindActionCreators(
Object.assign({}, CollectionsActions, ProjectsActions, ProjectActions, ToastActions, SortingActions),
dispatch
);
}
export default connect(mapStateToProps, mapDispatchToProps)(CollectionList);

View file

@ -0,0 +1,252 @@
import format from 'date-fns/format';
import PropTypes from 'prop-types';
import React from 'react';
import InlineSVG from 'react-inlinesvg';
import { connect } from 'react-redux';
import { Link } from 'react-router';
import { bindActionCreators } from 'redux';
import * as ProjectActions from '../../actions/project';
import * as CollectionsActions from '../../actions/collections';
import * as IdeActions from '../../actions/ide';
import * as ToastActions from '../../actions/toast';
const downFilledTriangle = require('../../../../images/down-filled-triangle.svg');
class CollectionListRowBase extends React.Component {
static projectInCollection(project, collection) {
return collection.items.find(item => item.project.id === project.id) != null;
}
constructor(props) {
super(props);
this.state = {
optionsOpen: false,
isFocused: false,
renameOpen: false,
renameValue: '',
};
}
onFocusComponent = () => {
this.setState({ isFocused: true });
}
onBlurComponent = () => {
this.setState({ isFocused: false });
setTimeout(() => {
if (!this.state.isFocused) {
this.closeAll();
}
}, 200);
}
openOptions = () => {
this.setState({
optionsOpen: true
});
}
closeOptions = () => {
this.setState({
optionsOpen: false
});
}
toggleOptions = () => {
if (this.state.optionsOpen) {
this.closeOptions();
} else {
this.openOptions();
}
}
closeAll = () => {
this.setState({
optionsOpen: false,
renameOpen: false,
});
}
handleAddSketches = () => {
this.closeAll();
this.props.onAddSketches();
}
handleDropdownOpen = () => {
this.closeAll();
this.openOptions();
}
handleCollectionDelete = () => {
this.closeAll();
if (window.confirm(`Are you sure you want to delete "${this.props.collection.name}"?`)) {
this.props.deleteCollection(this.props.collection.id);
}
}
handleRenameOpen = () => {
this.closeAll();
this.setState({
renameOpen: true,
renameValue: this.props.collection.name,
});
}
handleRenameChange = (e) => {
this.setState({
renameValue: e.target.value
});
}
handleRenameEnter = (e) => {
const isValid = this.state.renameValue !== '';
if (e.key === 'Enter') {
if (isValid) {
this.props.editCollection(this.props.collection.id, { name: this.state.renameValue });
}
// this.resetName();
this.closeAll();
}
}
// resetName = () => {
// this.setState({
// renameValue: this.props.collection.name
// });
// }
renderActions = () => {
const { optionsOpen } = this.state;
const userIsOwner = this.props.user.username === this.props.username;
return (
<React.Fragment>
<button
className="sketch-list__dropdown-button"
onClick={this.toggleOptions}
onBlur={this.onBlurComponent}
onFocus={this.onFocusComponent}
>
<InlineSVG src={downFilledTriangle} alt="Menu" />
</button>
{optionsOpen &&
<ul
className="sketch-list__action-dialogue"
>
<li>
<button
className="sketch-list__action-option"
onClick={this.handleAddSketches}
onBlur={this.onBlurComponent}
onFocus={this.onFocusComponent}
>
Add sketch
</button>
</li>
{userIsOwner &&
<li>
<button
className="sketch-list__action-option"
onClick={this.handleCollectionDelete}
onBlur={this.onBlurComponent}
onFocus={this.onFocusComponent}
>
Delete
</button>
</li>}
{userIsOwner &&
<li>
<button
className="sketch-list__action-option"
onClick={this.handleRenameOpen}
onBlur={this.onBlurComponent}
onFocus={this.onFocusComponent}
>
Rename
</button>
</li>}
</ul>
}
</React.Fragment>
);
}
renderCollectionName = () => {
const { collection, username } = this.props;
const { renameOpen, renameValue } = this.state;
return (
<React.Fragment>
<Link to={{ pathname: `/${username}/collections/${collection.id}`, state: { skipSavingPath: true } }}>
{renameOpen ? '' : collection.name}
</Link>
{renameOpen
&&
<input
value={renameValue}
onChange={this.handleRenameChange}
onKeyUp={this.handleRenameEnter}
// onBlur={this.resetName}
onClick={e => e.stopPropagation()}
/>
}
</React.Fragment>
);
}
render() {
const { collection } = this.props;
return (
<tr
className="sketches-table__row"
key={collection.id}
>
<th scope="row">
<span className="sketches-table__name">
{this.renderCollectionName()}
</span>
</th>
<td>{format(new Date(collection.createdAt), 'MMM D, YYYY')}</td>
<td>{format(new Date(collection.updatedAt), 'MMM D, YYYY')}</td>
<td>{(collection.items || []).length}</td>
<td className="sketch-list__dropdown-column">
{this.renderActions()}
</td>
</tr>
);
}
}
CollectionListRowBase.propTypes = {
collection: PropTypes.shape({
id: PropTypes.string.isRequired,
name: PropTypes.string.isRequired,
owner: PropTypes.shape({
username: PropTypes.string.isRequired,
}).isRequired,
createdAt: PropTypes.string.isRequired,
updatedAt: PropTypes.string.isRequired,
items: PropTypes.arrayOf(PropTypes.shape({
project: PropTypes.shape({
id: PropTypes.string.isRequired
})
}))
}).isRequired,
username: PropTypes.string.isRequired,
user: PropTypes.shape({
username: PropTypes.string,
authenticated: PropTypes.bool.isRequired
}).isRequired,
deleteCollection: PropTypes.func.isRequired,
editCollection: PropTypes.func.isRequired,
onAddSketches: PropTypes.func.isRequired,
};
function mapDispatchToPropsSketchListRow(dispatch) {
return bindActionCreators(Object.assign({}, CollectionsActions, ProjectActions, IdeActions, ToastActions), dispatch);
}
export default connect(null, mapDispatchToPropsSketchListRow)(CollectionListRowBase);

View file

@ -0,0 +1 @@
export { default } from './CollectionList';

View file

@ -0,0 +1,93 @@
import PropTypes from 'prop-types';
import React from 'react';
import InlineSVG from 'react-inlinesvg';
const editIconUrl = require('../../../images/pencil.svg');
function EditIcon() {
return <InlineSVG className="editable-input__icon" src={editIconUrl} alt="Edit" />;
}
function EditableInput({
validate, value, emptyPlaceholder, InputComponent, inputProps, onChange
}) {
const [isEditing, setIsEditing] = React.useState(false);
const [currentValue, setCurrentValue] = React.useState(value || '');
const displayValue = currentValue || emptyPlaceholder;
const hasValue = currentValue !== '';
const classes = `editable-input editable-input--${isEditing ? 'is-editing' : 'is-not-editing'} editable-input--${hasValue ? 'has-value' : 'has-placeholder'}`;
const inputRef = React.createRef();
React.useEffect(() => {
if (isEditing) {
inputRef.current.focus();
}
}, [isEditing]);
function beginEditing() {
setIsEditing(true);
}
function doneEditing() {
setIsEditing(false);
const isValid = typeof validate === 'function' && validate(currentValue);
if (isValid) {
onChange(currentValue);
} else {
setCurrentValue(value);
}
}
function updateValue(event) {
setCurrentValue(event.target.value);
}
function checkForKeyAction(event) {
if (event.key === 'Enter') {
doneEditing();
}
}
return (
<span className={classes}>
<button className="editable-input__label" onClick={beginEditing}>
<span>{displayValue}</span>
<EditIcon />
</button>
<InputComponent
className="editable-input__input"
type="text"
{...inputProps}
disabled={!isEditing}
onBlur={doneEditing}
onChange={updateValue}
onKeyPress={checkForKeyAction}
ref={inputRef}
value={currentValue}
/>
</span >
);
}
EditableInput.defaultProps = {
emptyPlaceholder: 'No value',
InputComponent: 'input',
inputProps: {},
validate: () => true,
value: '',
};
EditableInput.propTypes = {
emptyPlaceholder: PropTypes.string,
InputComponent: PropTypes.elementType,
// eslint-disable-next-line react/forbid-prop-types
inputProps: PropTypes.object,
onChange: PropTypes.func.isRequired,
validate: PropTypes.func,
value: PropTypes.string,
};
export default EditableInput;

View file

@ -30,7 +30,7 @@ class FileUploader extends React.Component {
thumbnailWidth: 200, thumbnailWidth: 200,
thumbnailHeight: 200, thumbnailHeight: 200,
acceptedFiles: fileExtensionsAndMimeTypes, acceptedFiles: fileExtensionsAndMimeTypes,
dictDefaultMessage: 'Drop files here to upload or click to use the file browser', dictDefaultMessage: 'Drop files here or click to use the file browser',
accept: this.props.dropzoneAcceptCallback.bind(this, userId), accept: this.props.dropzoneAcceptCallback.bind(this, userId),
sending: this.props.dropzoneSendingCallback, sending: this.props.dropzoneSendingCallback,
complete: this.props.dropzoneCompleteCallback complete: this.props.dropzoneCompleteCallback

View file

@ -22,16 +22,18 @@ class NewFileForm extends React.Component {
handleSubmit(this.createFile)(data); handleSubmit(this.createFile)(data);
}} }}
> >
<label className="new-file-form__name-label" htmlFor="name">Name:</label> <div className="new-file-form__input-wrapper">
<input <label className="new-file-form__name-label" htmlFor="name">Name:</label>
className="new-file-form__name-input" <input
id="name" className="new-file-form__name-input"
type="text" id="name"
placeholder="Name" type="text"
{...domOnlyProps(name)} placeholder="Name"
ref={(element) => { this.fileName = element; }} {...domOnlyProps(name)}
/> ref={(element) => { this.fileName = element; }}
<input type="submit" value="Add File" aria-label="add file" /> />
<input type="submit" value="Add File" aria-label="add file" />
</div>
{name.touched && name.error && <span className="form-error">{name.error}</span>} {name.touched && name.error && <span className="form-error">{name.error}</span>}
</form> </form>
); );

View file

@ -1,10 +1,12 @@
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import React from 'react'; import React from 'react';
import { connect } from 'react-redux';
import { bindActionCreators, compose } from 'redux';
import { reduxForm } from 'redux-form'; import { reduxForm } from 'redux-form';
import classNames from 'classnames';
import InlineSVG from 'react-inlinesvg'; import InlineSVG from 'react-inlinesvg';
import NewFileForm from './NewFileForm'; import NewFileForm from './NewFileForm';
import FileUploader from './FileUploader'; import { closeNewFileModal } from '../actions/ide';
import { createFile } from '../actions/files';
import { CREATE_FILE_REGEX } from '../../../../server/utils/fileUtils'; import { CREATE_FILE_REGEX } from '../../../../server/utils/fileUtils';
const exitUrl = require('../../../images/exit.svg'); const exitUrl = require('../../../images/exit.svg');
@ -28,16 +30,12 @@ class NewFileModal extends React.Component {
} }
render() { render() {
const modalClass = classNames({
'modal': true,
'modal--reduced': !this.props.canUploadMedia
});
return ( return (
<section className={modalClass} ref={(element) => { this.modal = element; }}> <section className="modal" ref={(element) => { this.modal = element; }}>
<div className="modal-content"> <div className="modal-content">
<div className="modal__header"> <div className="modal__header">
<h2 className="modal__title">Add File</h2> <h2 className="modal__title">Create File</h2>
<button className="modal__exit-button" onClick={this.props.closeModal}> <button className="modal__exit-button" onClick={this.props.closeNewFileModal}>
<InlineSVG src={exitUrl} alt="Close New File Modal" /> <InlineSVG src={exitUrl} alt="Close New File Modal" />
</button> </button>
</div> </div>
@ -45,17 +43,6 @@ class NewFileModal extends React.Component {
focusOnModal={this.focusOnModal} focusOnModal={this.focusOnModal}
{...this.props} {...this.props}
/> />
{(() => {
if (this.props.canUploadMedia) {
return (
<div>
<p className="modal__divider">OR</p>
<FileUploader />
</div>
);
}
return '';
})()}
</div> </div>
</section> </section>
); );
@ -63,8 +50,8 @@ class NewFileModal extends React.Component {
} }
NewFileModal.propTypes = { NewFileModal.propTypes = {
closeModal: PropTypes.func.isRequired, createFile: PropTypes.func.isRequired,
canUploadMedia: PropTypes.bool.isRequired closeNewFileModal: PropTypes.func.isRequired
}; };
function validate(formProps) { function validate(formProps) {
@ -79,9 +66,19 @@ function validate(formProps) {
return errors; return errors;
} }
function mapStateToProps() {
return {};
}
export default reduxForm({ function mapDispatchToProps(dispatch) {
form: 'new-file', return bindActionCreators({ createFile, closeNewFileModal }, dispatch);
fields: ['name'], }
validate
})(NewFileModal); export default compose(
connect(mapStateToProps, mapDispatchToProps),
reduxForm({
form: 'new-file',
fields: ['name'],
validate
})
)(NewFileModal);

View file

@ -20,21 +20,21 @@ class NewFolderForm extends React.Component {
<form <form
className="new-folder-form" className="new-folder-form"
onSubmit={(data) => { onSubmit={(data) => {
if (handleSubmit(this.createFolder)(data)) { handleSubmit(this.createFolder)(data);
this.props.closeModal();
}
}} }}
> >
<label className="new-folder-form__name-label" htmlFor="name">Name:</label> <div className="new-folder-form__input-wrapper">
<input <label className="new-folder-form__name-label" htmlFor="name">Name:</label>
className="new-folder-form__name-input" <input
id="name" className="new-folder-form__name-input"
type="text" id="name"
placeholder="Name" type="text"
ref={(element) => { this.fileName = element; }} placeholder="Name"
{...domOnlyProps(name)} ref={(element) => { this.fileName = element; }}
/> {...domOnlyProps(name)}
<input type="submit" value="Add Folder" aria-label="add folder" /> />
<input type="submit" value="Add Folder" aria-label="add folder" />
</div>
{name.touched && name.error && <span className="form-error">{name.error}</span>} {name.touched && name.error && <span className="form-error">{name.error}</span>}
</form> </form>
); );

View file

@ -16,7 +16,7 @@ class NewFolderModal extends React.Component {
<section className="modal" ref={(element) => { this.newFolderModal = element; }} > <section className="modal" ref={(element) => { this.newFolderModal = element; }} >
<div className="modal-content-folder"> <div className="modal-content-folder">
<div className="modal__header"> <div className="modal__header">
<h2 className="modal__title">Add Folder</h2> <h2 className="modal__title">Create Folder</h2>
<button className="modal__exit-button" onClick={this.props.closeModal}> <button className="modal__exit-button" onClick={this.props.closeModal}>
<InlineSVG src={exitUrl} alt="Close New Folder Modal" /> <InlineSVG src={exitUrl} alt="Close New Folder Modal" />
</button> </button>

View file

@ -98,9 +98,9 @@ class Preferences extends React.Component {
</Helmet> </Helmet>
<Tabs> <Tabs>
<TabList> <TabList>
<div className="preference__subheadings"> <div className="tabs__titles">
<Tab><h4 className="preference__subheading">General Settings</h4></Tab> <Tab><h4 className="tabs__title">General Settings</h4></Tab>
<Tab><h4 className="preference__subheading">Accessibility</h4></Tab> <Tab><h4 className="tabs__title">Accessibility</h4></Tab>
</div> </div>
</TabList> </TabList>
<TabPanel> <TabPanel>

View file

@ -0,0 +1,27 @@
import React from 'react';
import PropTypes from 'prop-types';
import InlineSVG from 'react-inlinesvg';
const check = require('../../../../images/check_encircled.svg');
const close = require('../../../../images/close.svg');
const Icons = ({ isAdded }) => {
const classes = [
'quick-add__icon',
isAdded ? 'quick-add__icon--in-collection' : 'quick-add__icon--not-in-collection'
].join(' ');
return (
<div className={classes}>
<InlineSVG className="quick-add__remove-icon" src={close} alt="Remove from collection" />
<InlineSVG className="quick-add__in-icon" src={check} alt="In collection" />
<InlineSVG className="quick-add__add-icon" src={close} alt="Add to collection" />
</div>
);
};
Icons.propTypes = {
isAdded: PropTypes.bool.isRequired,
};
export default Icons;

View file

@ -0,0 +1,71 @@
import React from 'react';
import PropTypes from 'prop-types';
import { Link } from 'react-router';
import Icons from './Icons';
const Item = ({
isAdded, onSelect, name, url
}) => (
<li className="quick-add__item" onClick={onSelect}> { /* eslint-disable-line */ }
<button className="quick-add__item-toggle" onClick={onSelect}>
<Icons isAdded={isAdded} />
</button>
<span className="quick-add__item-name">{name}</span>
<Link
className="quick-add__item-view"
to={url}
target="_blank"
onClick={e => e.stopPropogation()}
>
View
</Link>
</li>
);
const ItemType = PropTypes.shape({
name: PropTypes.string.isRequired,
url: PropTypes.string.isRequired,
isAdded: PropTypes.bool.isRequired,
});
Item.propTypes = {
name: PropTypes.string.isRequired,
url: PropTypes.string.isRequired,
isAdded: PropTypes.bool.isRequired,
onSelect: PropTypes.func.isRequired,
};
const QuickAddList = ({
items, onAdd, onRemove
}) => {
const handleAction = (item) => {
if (item.isAdded) {
onRemove(item);
} else {
onAdd(item);
}
};
return (
<ul className="quick-add">{items.map(item => (<Item
key={item.id}
{...item}
onSelect={
(event) => {
event.target.blur();
handleAction(item);
}
}
/>))}
</ul>
);
};
QuickAddList.propTypes = {
items: PropTypes.arrayOf(ItemType).isRequired,
onAdd: PropTypes.func.isRequired,
onRemove: PropTypes.func.isRequired,
};
export default QuickAddList;

View file

@ -0,0 +1 @@
export { default } from './QuickAddList.jsx';

View file

@ -0,0 +1,24 @@
import { bindActionCreators } from 'redux';
import { connect } from 'react-redux';
import * as SortingActions from '../../actions/sorting';
import Searchbar from './Searchbar';
const scope = 'collection';
function mapStateToProps(state) {
return {
searchLabel: 'Search collections...',
searchTerm: state.search[`${scope}SearchTerm`],
};
}
function mapDispatchToProps(dispatch) {
const actions = {
setSearchTerm: term => SortingActions.setSearchTerm(scope, term),
resetSearchTerm: () => SortingActions.resetSearchTerm(scope),
};
return bindActionCreators(Object.assign({}, actions), dispatch);
}
export default connect(mapStateToProps, mapDispatchToProps)(Searchbar);

View file

@ -1,12 +1,9 @@
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import React from 'react'; import React from 'react';
import InlineSVG from 'react-inlinesvg'; import InlineSVG from 'react-inlinesvg';
import { bindActionCreators } from 'redux';
import { connect } from 'react-redux';
import { throttle } from 'lodash'; import { throttle } from 'lodash';
import * as SortingActions from '../actions/sorting';
const searchIcon = require('../../../images/magnifyingglass.svg'); const searchIcon = require('../../../../images/magnifyingglass.svg');
class Searchbar extends React.Component { class Searchbar extends React.Component {
constructor(props) { constructor(props) {
@ -46,7 +43,7 @@ class Searchbar extends React.Component {
render() { render() {
const { searchValue } = this.state; const { searchValue } = this.state;
return ( return (
<div className="searchbar"> <div className={`searchbar ${searchValue === '' ? 'searchbar--is-empty' : ''}`}>
<div className="searchbar__button"> <div className="searchbar__button">
<InlineSVG className="searchbar__icon" src={searchIcon} /> <InlineSVG className="searchbar__icon" src={searchIcon} />
</div> </div>
@ -54,7 +51,7 @@ class Searchbar extends React.Component {
className="searchbar__input" className="searchbar__input"
type="text" type="text"
value={searchValue} value={searchValue}
placeholder="Search files..." placeholder={this.props.searchLabel}
onChange={this.handleSearchChange} onChange={this.handleSearchChange}
onKeyUp={this.handleSearchEnter} onKeyUp={this.handleSearchEnter}
/> />
@ -71,17 +68,12 @@ class Searchbar extends React.Component {
Searchbar.propTypes = { Searchbar.propTypes = {
searchTerm: PropTypes.string.isRequired, searchTerm: PropTypes.string.isRequired,
setSearchTerm: PropTypes.func.isRequired, setSearchTerm: PropTypes.func.isRequired,
resetSearchTerm: PropTypes.func.isRequired resetSearchTerm: PropTypes.func.isRequired,
searchLabel: PropTypes.string,
}; };
function mapStateToProps(state) { Searchbar.defaultProps = {
return { searchLabel: 'Search sketches...',
searchTerm: state.search.searchTerm };
};
}
function mapDispatchToProps(dispatch) { export default Searchbar;
return bindActionCreators(Object.assign({}, SortingActions), dispatch);
}
export default connect(mapStateToProps, mapDispatchToProps)(Searchbar);

View file

@ -0,0 +1,23 @@
import { bindActionCreators } from 'redux';
import { connect } from 'react-redux';
import * as SortingActions from '../../actions/sorting';
import Searchbar from './Searchbar';
const scope = 'sketch';
function mapStateToProps(state) {
return {
searchTerm: state.search[`${scope}SearchTerm`],
};
}
function mapDispatchToProps(dispatch) {
const actions = {
setSearchTerm: term => SortingActions.setSearchTerm(scope, term),
resetSearchTerm: () => SortingActions.resetSearchTerm(scope),
};
return bindActionCreators(Object.assign({}, actions), dispatch);
}
export default connect(mapStateToProps, mapDispatchToProps)(Searchbar);

View file

@ -0,0 +1,2 @@
export { default as CollectionSearchbar } from './Collection.jsx';
export { default as SketchSearchbar } from './Sketch.jsx';

View file

@ -97,7 +97,7 @@ class Sidebar extends React.Component {
onBlur={this.onBlurComponent} onBlur={this.onBlurComponent}
onFocus={this.onFocusComponent} onFocus={this.onFocusComponent}
> >
Add folder Create folder
</button> </button>
</li> </li>
<li> <li>
@ -110,7 +110,20 @@ class Sidebar extends React.Component {
onBlur={this.onBlurComponent} onBlur={this.onBlurComponent}
onFocus={this.onFocusComponent} onFocus={this.onFocusComponent}
> >
Add file Create file
</button>
</li>
<li>
<button
aria-label="upload file"
onClick={() => {
this.props.openUploadFileModal(rootFile.id);
setTimeout(this.props.closeProjectOptions, 0);
}}
onBlur={this.onBlurComponent}
onFocus={this.onFocusComponent}
>
Upload file
</button> </button>
</li> </li>
</ul> </ul>
@ -137,6 +150,7 @@ Sidebar.propTypes = {
openProjectOptions: PropTypes.func.isRequired, openProjectOptions: PropTypes.func.isRequired,
closeProjectOptions: PropTypes.func.isRequired, closeProjectOptions: PropTypes.func.isRequired,
newFolder: PropTypes.func.isRequired, newFolder: PropTypes.func.isRequired,
openUploadFileModal: PropTypes.func.isRequired,
owner: PropTypes.shape({ owner: PropTypes.shape({
id: PropTypes.string id: PropTypes.string
}), }),

View file

@ -10,11 +10,14 @@ import classNames from 'classnames';
import slugify from 'slugify'; import slugify from 'slugify';
import * as ProjectActions from '../actions/project'; import * as ProjectActions from '../actions/project';
import * as ProjectsActions from '../actions/projects'; import * as ProjectsActions from '../actions/projects';
import * as CollectionsActions from '../actions/collections';
import * as ToastActions from '../actions/toast'; import * as ToastActions from '../actions/toast';
import * as SortingActions from '../actions/sorting'; import * as SortingActions from '../actions/sorting';
import * as IdeActions from '../actions/ide'; import * as IdeActions from '../actions/ide';
import getSortedSketches from '../selectors/projects'; import getSortedSketches from '../selectors/projects';
import Loader from '../../App/components/loader'; import Loader from '../../App/components/loader';
import Overlay from '../../App/components/Overlay';
import AddToCollectionList from './AddToCollectionList';
const arrowUp = require('../../../images/sort-arrow-up.svg'); const arrowUp = require('../../../images/sort-arrow-up.svg');
const arrowDown = require('../../../images/sort-arrow-down.svg'); const arrowDown = require('../../../images/sort-arrow-down.svg');
@ -27,9 +30,10 @@ class SketchListRowBase extends React.Component {
optionsOpen: false, optionsOpen: false,
renameOpen: false, renameOpen: false,
renameValue: props.sketch.name, renameValue: props.sketch.name,
isFocused: false isFocused: false,
}; };
} }
onFocusComponent = () => { onFocusComponent = () => {
this.setState({ isFocused: true }); this.setState({ isFocused: true });
} }
@ -133,105 +137,146 @@ class SketchListRowBase extends React.Component {
} }
} }
render() { renderViewButton = sketchURL => (
const { sketch, username } = this.props; <td className="sketch-list__dropdown-column">
const { renameOpen, optionsOpen, renameValue } = this.state; <Link to={sketchURL}>View</Link>
</td>
)
renderDropdown = () => {
const { optionsOpen } = this.state;
const userIsOwner = this.props.user.username === this.props.username; const userIsOwner = this.props.user.username === this.props.username;
return (
<td className="sketch-list__dropdown-column">
<button
className="sketch-list__dropdown-button"
onClick={this.toggleOptions}
onBlur={this.onBlurComponent}
onFocus={this.onFocusComponent}
>
<InlineSVG src={downFilledTriangle} alt="Menu" />
</button>
{optionsOpen &&
<ul
className="sketch-list__action-dialogue"
>
{userIsOwner &&
<li>
<button
className="sketch-list__action-option"
onClick={this.handleRenameOpen}
onBlur={this.onBlurComponent}
onFocus={this.onFocusComponent}
>
Rename
</button>
</li>}
<li>
<button
className="sketch-list__action-option"
onClick={this.handleSketchDownload}
onBlur={this.onBlurComponent}
onFocus={this.onFocusComponent}
>
Download
</button>
</li>
{this.props.user.authenticated &&
<li>
<button
className="sketch-list__action-option"
onClick={this.handleSketchDuplicate}
onBlur={this.onBlurComponent}
onFocus={this.onFocusComponent}
>
Duplicate
</button>
</li>}
{this.props.user.authenticated &&
<li>
<button
className="sketch-list__action-option"
onClick={() => {
this.props.onAddToCollection();
this.closeAll();
}}
onBlur={this.onBlurComponent}
onFocus={this.onFocusComponent}
>
Add to collection
</button>
</li>}
{ /* <li>
<button
className="sketch-list__action-option"
onClick={this.handleSketchShare}
onBlur={this.onBlurComponent}
onFocus={this.onFocusComponent}
>
Share
</button>
</li> */ }
{userIsOwner &&
<li>
<button
className="sketch-list__action-option"
onClick={this.handleSketchDelete}
onBlur={this.onBlurComponent}
onFocus={this.onFocusComponent}
>
Delete
</button>
</li>}
</ul>}
</td>
);
}
render() {
const {
sketch,
username,
} = this.props;
const { renameOpen, renameValue } = this.state;
let url = `/${username}/sketches/${sketch.id}`; let url = `/${username}/sketches/${sketch.id}`;
if (username === 'p5') { if (username === 'p5') {
url = `/${username}/sketches/${slugify(sketch.name, '_')}`; url = `/${username}/sketches/${slugify(sketch.name, '_')}`;
} }
const name = (
<React.Fragment>
<Link to={url}>
{renameOpen ? '' : sketch.name}
</Link>
{renameOpen
&&
<input
value={renameValue}
onChange={this.handleRenameChange}
onKeyUp={this.handleRenameEnter}
onBlur={this.resetSketchName}
onClick={e => e.stopPropagation()}
/>
}
</React.Fragment>
);
return ( return (
<tr <React.Fragment>
className="sketches-table__row" <tr
key={sketch.id} className="sketches-table__row"
> key={sketch.id}
<th scope="row"> onClick={this.handleRowClick}
<Link to={url}> >
{renameOpen ? '' : sketch.name} <th scope="row">
</Link> {name}
{renameOpen </th>
&& <td>{format(new Date(sketch.createdAt), 'MMM D, YYYY h:mm A')}</td>
<input <td>{format(new Date(sketch.updatedAt), 'MMM D, YYYY h:mm A')}</td>
value={renameValue} {this.renderDropdown()}
onChange={this.handleRenameChange} </tr>
onKeyUp={this.handleRenameEnter} </React.Fragment>);
onBlur={this.resetSketchName}
onClick={e => e.stopPropagation()}
/>
}
</th>
<td>{format(new Date(sketch.createdAt), 'MMM D, YYYY h:mm A')}</td>
<td>{format(new Date(sketch.updatedAt), 'MMM D, YYYY h:mm A')}</td>
<td className="sketch-list__dropdown-column">
<button
className="sketch-list__dropdown-button"
onClick={this.toggleOptions}
onBlur={this.onBlurComponent}
onFocus={this.onFocusComponent}
>
<InlineSVG src={downFilledTriangle} alt="Menu" />
</button>
{optionsOpen &&
<ul
className="sketch-list__action-dialogue"
>
{userIsOwner &&
<li>
<button
className="sketch-list__action-option"
onClick={this.handleRenameOpen}
onBlur={this.onBlurComponent}
onFocus={this.onFocusComponent}
>
Rename
</button>
</li>}
<li>
<button
className="sketch-list__action-option"
onClick={this.handleSketchDownload}
onBlur={this.onBlurComponent}
onFocus={this.onFocusComponent}
>
Download
</button>
</li>
{this.props.user.authenticated &&
<li>
<button
className="sketch-list__action-option"
onClick={this.handleSketchDuplicate}
onBlur={this.onBlurComponent}
onFocus={this.onFocusComponent}
>
Duplicate
</button>
</li>}
{ /* <li>
<button
className="sketch-list__action-option"
onClick={this.handleSketchShare}
onBlur={this.onBlurComponent}
onFocus={this.onFocusComponent}
>
Share
</button>
</li> */ }
{userIsOwner &&
<li>
<button
className="sketch-list__action-option"
onClick={this.handleSketchDelete}
onBlur={this.onBlurComponent}
onFocus={this.onFocusComponent}
>
Delete
</button>
</li>}
</ul>}
</td>
</tr>);
} }
} }
@ -251,7 +296,8 @@ SketchListRowBase.propTypes = {
showShareModal: PropTypes.func.isRequired, showShareModal: PropTypes.func.isRequired,
cloneProject: PropTypes.func.isRequired, cloneProject: PropTypes.func.isRequired,
exportProjectAsZip: PropTypes.func.isRequired, exportProjectAsZip: PropTypes.func.isRequired,
changeProjectName: PropTypes.func.isRequired changeProjectName: PropTypes.func.isRequired,
onAddToCollection: PropTypes.func.isRequired,
}; };
function mapDispatchToPropsSketchListRow(dispatch) { function mapDispatchToPropsSketchListRow(dispatch) {
@ -266,6 +312,18 @@ class SketchList extends React.Component {
this.props.getProjects(this.props.username); this.props.getProjects(this.props.username);
this.props.resetSorting(); this.props.resetSorting();
this._renderFieldHeader = this._renderFieldHeader.bind(this); this._renderFieldHeader = this._renderFieldHeader.bind(this);
this.state = {
isInitialDataLoad: true,
};
}
componentWillReceiveProps(nextProps) {
if (this.props.sketches !== nextProps.sketches && Array.isArray(nextProps.sketches)) {
this.setState({
isInitialDataLoad: false,
});
}
} }
getSketchesTitle() { getSketchesTitle() {
@ -276,16 +334,20 @@ class SketchList extends React.Component {
} }
hasSketches() { hasSketches() {
return !this.props.loading && this.props.sketches.length > 0; return !this.isLoading() && this.props.sketches.length > 0;
}
isLoading() {
return this.props.loading && this.state.isInitialDataLoad;
} }
_renderLoader() { _renderLoader() {
if (this.props.loading) return <Loader />; if (this.isLoading()) return <Loader />;
return null; return null;
} }
_renderEmptyTable() { _renderEmptyTable() {
if (!this.props.loading && this.props.sketches.length === 0) { if (!this.isLoading() && this.props.sketches.length === 0) {
return (<p className="sketches-table__empty">No sketches.</p>); return (<p className="sketches-table__empty">No sketches.</p>);
} }
return null; return null;
@ -338,9 +400,26 @@ class SketchList extends React.Component {
sketch={sketch} sketch={sketch}
user={this.props.user} user={this.props.user}
username={username} username={username}
onAddToCollection={() => {
this.setState({ sketchToAddToCollection: sketch });
}}
/>))} />))}
</tbody> </tbody>
</table>} </table>}
{
this.state.sketchToAddToCollection &&
<Overlay
isFixedHeight
title="Add to collection"
closeOverlay={() => this.setState({ sketchToAddToCollection: null })}
>
<AddToCollectionList
project={this.state.sketchToAddToCollection}
username={this.props.username}
user={this.props.user}
/>
</Overlay>
}
</div> </div>
); );
} }
@ -366,19 +445,9 @@ SketchList.propTypes = {
field: PropTypes.string.isRequired, field: PropTypes.string.isRequired,
direction: PropTypes.string.isRequired direction: PropTypes.string.isRequired
}).isRequired, }).isRequired,
project: PropTypes.shape({
id: PropTypes.string,
owner: PropTypes.shape({
id: PropTypes.string
})
})
}; };
SketchList.defaultProps = { SketchList.defaultProps = {
project: {
id: undefined,
owner: undefined
},
username: undefined username: undefined
}; };
@ -393,7 +462,10 @@ function mapStateToProps(state) {
} }
function mapDispatchToProps(dispatch) { function mapDispatchToProps(dispatch) {
return bindActionCreators(Object.assign({}, ProjectsActions, ToastActions, SortingActions), dispatch); return bindActionCreators(
Object.assign({}, ProjectsActions, CollectionsActions, ToastActions, SortingActions),
dispatch
);
} }
export default connect(mapStateToProps, mapDispatchToProps)(SketchList); export default connect(mapStateToProps, mapDispatchToProps)(SketchList);

View file

@ -32,7 +32,7 @@ class Toolbar extends React.Component {
} }
validateProjectName() { validateProjectName() {
if (this.props.project.name === '') { if ((this.props.project.name.trim()).length === 0) {
this.props.setProjectName(this.originalProjectName); this.props.setProjectName(this.originalProjectName);
} }
} }

View file

@ -0,0 +1,68 @@
import React from 'react';
import PropTypes from 'prop-types';
import { connect } from 'react-redux';
import { Link } from 'react-router';
import InlineSVG from 'react-inlinesvg';
import prettyBytes from 'pretty-bytes';
import FileUploader from './FileUploader';
import { getreachedTotalSizeLimit } from '../selectors/users';
import exitUrl from '../../../images/exit.svg';
const __process = (typeof global !== 'undefined' ? global : window).process;
const limit = __process.env.UPLOAD_LIMIT || 250000000;
const limitText = prettyBytes(limit);
class UploadFileModal extends React.Component {
propTypes = {
reachedTotalSizeLimit: PropTypes.bool.isRequired,
closeModal: PropTypes.func.isRequired
}
componentDidMount() {
this.focusOnModal();
}
focusOnModal = () => {
this.modal.focus();
}
render() {
return (
<section className="modal" ref={(element) => { this.modal = element; }}>
<div className="modal-content">
<div className="modal__header">
<h2 className="modal__title">Upload File</h2>
<button className="modal__exit-button" onClick={this.props.closeModal}>
<InlineSVG src={exitUrl} alt="Close New File Modal" />
</button>
</div>
{ this.props.reachedTotalSizeLimit &&
<p>
{
`Error: You cannot upload any more files. You have reached the total size limit of ${limitText}.
If you would like to upload more, please remove the ones you aren't using anymore by
in your `
}
<Link to="/assets" onClick={this.props.closeModal}>assets</Link>
.
</p>
}
{ !this.props.reachedTotalSizeLimit &&
<div>
<FileUploader />
</div>
}
</div>
</section>
);
}
}
function mapStateToProps(state) {
return {
reachedTotalSizeLimit: getreachedTotalSizeLimit(state)
};
}
export default connect(mapStateToProps)(UploadFileModal);

View file

@ -12,6 +12,7 @@ import Toolbar from '../components/Toolbar';
import Preferences from '../components/Preferences'; import Preferences from '../components/Preferences';
import NewFileModal from '../components/NewFileModal'; import NewFileModal from '../components/NewFileModal';
import NewFolderModal from '../components/NewFolderModal'; import NewFolderModal from '../components/NewFolderModal';
import UploadFileModal from '../components/UploadFileModal';
import ShareModal from '../components/ShareModal'; import ShareModal from '../components/ShareModal';
import KeyboardShortcutModal from '../components/KeyboardShortcutModal'; import KeyboardShortcutModal from '../components/KeyboardShortcutModal';
import ErrorModal from '../components/ErrorModal'; import ErrorModal from '../components/ErrorModal';
@ -28,11 +29,10 @@ import * as ToastActions from '../actions/toast';
import * as ConsoleActions from '../actions/console'; import * as ConsoleActions from '../actions/console';
import { getHTMLFile } from '../reducers/files'; import { getHTMLFile } from '../reducers/files';
import Overlay from '../../App/components/Overlay'; import Overlay from '../../App/components/Overlay';
import SketchList from '../components/SketchList';
import Searchbar from '../components/Searchbar';
import AssetList from '../components/AssetList';
import About from '../components/About'; import About from '../components/About';
import AddToCollectionList from '../components/AddToCollectionList';
import Feedback from '../components/Feedback'; import Feedback from '../components/Feedback';
import { CollectionSearchbar } from '../components/Searchbar';
class IDEView extends React.Component { class IDEView extends React.Component {
constructor(props) { constructor(props) {
@ -239,6 +239,8 @@ class IDEView extends React.Component {
newFolder={this.props.newFolder} newFolder={this.props.newFolder}
user={this.props.user} user={this.props.user}
owner={this.props.project.owner} owner={this.props.project.owner}
openUploadFileModal={this.props.openUploadFileModal}
closeUploadFileModal={this.props.closeUploadFileModal}
/> />
<SplitPane <SplitPane
split="vertical" split="vertical"
@ -314,12 +316,12 @@ class IDEView extends React.Component {
{( {(
( (
(this.props.preferences.textOutput || (this.props.preferences.textOutput ||
this.props.preferences.gridOutput || this.props.preferences.gridOutput ||
this.props.preferences.soundOutput this.props.preferences.soundOutput
) && ) &&
this.props.ide.isPlaying this.props.ide.isPlaying
) || ) ||
this.props.ide.isAccessibleOutputPlaying this.props.ide.isAccessibleOutputPlaying
) )
} }
</div> </div>
@ -351,42 +353,18 @@ class IDEView extends React.Component {
</SplitPane> </SplitPane>
</div> </div>
{ this.props.ide.modalIsVisible && { this.props.ide.modalIsVisible &&
<NewFileModal <NewFileModal />
canUploadMedia={this.props.user.authenticated}
closeModal={this.props.closeNewFileModal}
createFile={this.props.createFile}
/>
} }
{ this.props.ide.newFolderModalVisible && {this.props.ide.newFolderModalVisible &&
<NewFolderModal <NewFolderModal
closeModal={this.props.closeNewFolderModal} closeModal={this.props.closeNewFolderModal}
createFolder={this.props.createFolder} createFolder={this.props.createFolder}
/> />
} }
{ this.props.location.pathname.match(/sketches$/) && {this.props.ide.uploadFileModalVisible &&
<Overlay <UploadFileModal
ariaLabel="project list" closeModal={this.props.closeUploadFileModal}
title="Open a Sketch" />
previousPath={this.props.ide.previousPath}
>
<Searchbar />
<SketchList
username={this.props.params.username}
user={this.props.user}
/>
</Overlay>
}
{ this.props.location.pathname.match(/assets$/) &&
<Overlay
title="Assets"
ariaLabel="asset list"
previousPath={this.props.ide.previousPath}
>
<AssetList
username={this.props.params.username}
user={this.props.user}
/>
</Overlay>
} }
{ this.props.location.pathname === '/about' && { this.props.location.pathname === '/about' &&
<Overlay <Overlay
@ -397,7 +375,7 @@ class IDEView extends React.Component {
<About previousPath={this.props.ide.previousPath} /> <About previousPath={this.props.ide.previousPath} />
</Overlay> </Overlay>
} }
{ this.props.location.pathname === '/feedback' && {this.props.location.pathname === '/feedback' &&
<Overlay <Overlay
title="Submit Feedback" title="Submit Feedback"
previousPath={this.props.ide.previousPath} previousPath={this.props.ide.previousPath}
@ -406,7 +384,22 @@ class IDEView extends React.Component {
<Feedback previousPath={this.props.ide.previousPath} /> <Feedback previousPath={this.props.ide.previousPath} />
</Overlay> </Overlay>
} }
{ this.props.ide.shareModalVisible && {this.props.location.pathname.match(/add-to-collection$/) &&
<Overlay
ariaLabel="add to collection"
title="Add to collection"
previousPath={this.props.ide.previousPath}
actions={<CollectionSearchbar />}
isFixedHeight
>
<AddToCollectionList
projectId={this.props.params.project_id}
username={this.props.params.username}
user={this.props.user}
/>
</Overlay>
}
{this.props.ide.shareModalVisible &&
<Overlay <Overlay
title="Share" title="Share"
ariaLabel="share" ariaLabel="share"
@ -419,7 +412,7 @@ class IDEView extends React.Component {
/> />
</Overlay> </Overlay>
} }
{ this.props.ide.keyboardShortcutVisible && {this.props.ide.keyboardShortcutVisible &&
<Overlay <Overlay
title="Keyboard Shortcuts" title="Keyboard Shortcuts"
ariaLabel="keyboard shortcuts" ariaLabel="keyboard shortcuts"
@ -428,7 +421,7 @@ class IDEView extends React.Component {
<KeyboardShortcutModal /> <KeyboardShortcutModal />
</Overlay> </Overlay>
} }
{ this.props.ide.errorType && {this.props.ide.errorType &&
<Overlay <Overlay
title="Error" title="Error"
ariaLabel="error" ariaLabel="error"
@ -486,6 +479,7 @@ IDEView.propTypes = {
justOpenedProject: PropTypes.bool.isRequired, justOpenedProject: PropTypes.bool.isRequired,
errorType: PropTypes.string, errorType: PropTypes.string,
runtimeErrorWarningVisible: PropTypes.bool.isRequired, runtimeErrorWarningVisible: PropTypes.bool.isRequired,
uploadFileModalVisible: PropTypes.bool.isRequired
}).isRequired, }).isRequired,
stopSketch: PropTypes.func.isRequired, stopSketch: PropTypes.func.isRequired,
project: PropTypes.shape({ project: PropTypes.shape({
@ -543,7 +537,6 @@ IDEView.propTypes = {
}).isRequired, }).isRequired,
dispatchConsoleEvent: PropTypes.func.isRequired, dispatchConsoleEvent: PropTypes.func.isRequired,
newFile: PropTypes.func.isRequired, newFile: PropTypes.func.isRequired,
closeNewFileModal: PropTypes.func.isRequired,
expandSidebar: PropTypes.func.isRequired, expandSidebar: PropTypes.func.isRequired,
collapseSidebar: PropTypes.func.isRequired, collapseSidebar: PropTypes.func.isRequired,
cloneProject: PropTypes.func.isRequired, cloneProject: PropTypes.func.isRequired,
@ -556,7 +549,6 @@ IDEView.propTypes = {
newFolder: PropTypes.func.isRequired, newFolder: PropTypes.func.isRequired,
closeNewFolderModal: PropTypes.func.isRequired, closeNewFolderModal: PropTypes.func.isRequired,
createFolder: PropTypes.func.isRequired, createFolder: PropTypes.func.isRequired,
createFile: PropTypes.func.isRequired,
closeShareModal: PropTypes.func.isRequired, closeShareModal: PropTypes.func.isRequired,
showEditorOptions: PropTypes.func.isRequired, showEditorOptions: PropTypes.func.isRequired,
closeEditorOptions: PropTypes.func.isRequired, closeEditorOptions: PropTypes.func.isRequired,
@ -588,6 +580,8 @@ IDEView.propTypes = {
showRuntimeErrorWarning: PropTypes.func.isRequired, showRuntimeErrorWarning: PropTypes.func.isRequired,
hideRuntimeErrorWarning: PropTypes.func.isRequired, hideRuntimeErrorWarning: PropTypes.func.isRequired,
startSketch: PropTypes.func.isRequired, startSketch: PropTypes.func.isRequired,
openUploadFileModal: PropTypes.func.isRequired,
closeUploadFileModal: PropTypes.func.isRequired
}; };
function mapStateToProps(state) { function mapStateToProps(state) {

View file

@ -10,6 +10,8 @@ const assets = (state = initialState, action) => {
switch (action.type) { switch (action.type) {
case ActionTypes.SET_ASSETS: case ActionTypes.SET_ASSETS:
return { list: action.assets, totalSize: action.totalSize }; return { list: action.assets, totalSize: action.totalSize };
case ActionTypes.DELETE_ASSET:
return { list: state.list.filter(asset => asset.key !== action.key) };
default: default:
return state; return state;
} }

View file

@ -0,0 +1,28 @@
import * as ActionTypes from '../../../constants';
const sketches = (state = [], action) => {
switch (action.type) {
case ActionTypes.SET_COLLECTIONS:
return action.collections;
case ActionTypes.DELETE_COLLECTION:
return state.filter(({ id }) => action.collectionId !== id);
// The API returns the complete new edited collection
// with any items added or removed
case ActionTypes.EDIT_COLLECTION:
case ActionTypes.ADD_TO_COLLECTION:
case ActionTypes.REMOVE_FROM_COLLECTION:
return state.map((collection) => {
if (collection.id === action.payload.id) {
return action.payload;
}
return collection;
});
default:
return state;
}
};
export default sketches;

View file

@ -9,11 +9,11 @@ const initialState = {
preferencesIsVisible: false, preferencesIsVisible: false,
projectOptionsVisible: false, projectOptionsVisible: false,
newFolderModalVisible: false, newFolderModalVisible: false,
uploadFileModalVisible: false,
shareModalVisible: false, shareModalVisible: false,
shareModalProjectId: 'abcd', shareModalProjectId: 'abcd',
shareModalProjectName: 'My Cute Sketch', shareModalProjectName: 'My Cute Sketch',
shareModalProjectUsername: 'p5_user', shareModalProjectUsername: 'p5_user',
sketchlistModalVisible: false,
editorOptionsVisible: false, editorOptionsVisible: false,
keyboardShortcutVisible: false, keyboardShortcutVisible: false,
unsavedChanges: false, unsavedChanges: false,
@ -106,6 +106,10 @@ const ide = (state = initialState, action) => {
return Object.assign({}, state, { runtimeErrorWarningVisible: false }); return Object.assign({}, state, { runtimeErrorWarningVisible: false });
case ActionTypes.SHOW_RUNTIME_ERROR_WARNING: case ActionTypes.SHOW_RUNTIME_ERROR_WARNING:
return Object.assign({}, state, { runtimeErrorWarningVisible: true }); return Object.assign({}, state, { runtimeErrorWarningVisible: true });
case ActionTypes.OPEN_UPLOAD_FILE_MODAL:
return Object.assign({}, state, { uploadFileModalVisible: true, parentId: action.parentId });
case ActionTypes.CLOSE_UPLOAD_FILE_MODAL:
return Object.assign({}, state, { uploadFileModalVisible: false });
default: default:
return state; return state;
} }

View file

@ -1,14 +1,8 @@
import friendlyWords from 'friendly-words';
import * as ActionTypes from '../../../constants'; import * as ActionTypes from '../../../constants';
import { generateProjectName } from '../../../utils/generateRandomName';
const generateRandomName = () => {
const adj = friendlyWords.predicates[Math.floor(Math.random() * friendlyWords.predicates.length)];
const obj = friendlyWords.objects[Math.floor(Math.random() * friendlyWords.objects.length)];
return `${adj} ${obj}`;
};
const initialState = () => { const initialState = () => {
const generatedString = generateRandomName(); const generatedString = generateProjectName();
const generatedName = generatedString.charAt(0).toUpperCase() + generatedString.slice(1); const generatedName = generatedString.charAt(0).toUpperCase() + generatedString.slice(1);
return { return {
name: generatedName, name: generatedName,

View file

@ -1,13 +1,14 @@
import * as ActionTypes from '../../../constants'; import * as ActionTypes from '../../../constants';
const initialState = { const initialState = {
searchTerm: '' collectionSearchTerm: '',
sketchSearchTerm: ''
}; };
export default (state = initialState, action) => { export default (state = initialState, action) => {
switch (action.type) { switch (action.type) {
case ActionTypes.SET_SEARCH_TERM: case ActionTypes.SET_SEARCH_TERM:
return { ...state, searchTerm: action.query }; return { ...state, [`${action.scope}SearchTerm`]: action.query };
default: default:
return state; return state;
} }

View file

@ -0,0 +1,56 @@
import { createSelector } from 'reselect';
import differenceInMilliseconds from 'date-fns/difference_in_milliseconds';
import find from 'lodash/find';
import orderBy from 'lodash/orderBy';
import { DIRECTION } from '../actions/sorting';
const getCollections = state => state.collections;
const getField = state => state.sorting.field;
const getDirection = state => state.sorting.direction;
const getSearchTerm = state => state.search.collectionSearchTerm;
const getFilteredCollections = createSelector(
getCollections,
getSearchTerm,
(collections, search) => {
if (search) {
const searchStrings = collections.map((collection) => {
const smallCollection = {
name: collection.name
};
return { ...collection, searchString: Object.values(smallCollection).join(' ').toLowerCase() };
});
return searchStrings.filter(collection => collection.searchString.includes(search.toLowerCase()));
}
return collections;
}
);
const getSortedCollections = createSelector(
getFilteredCollections,
getField,
getDirection,
(collections, field, direction) => {
if (field === 'name') {
if (direction === DIRECTION.DESC) {
return orderBy(collections, 'name', 'desc');
}
return orderBy(collections, 'name', 'asc');
}
const sortedCollections = [...collections].sort((a, b) => {
const result =
direction === DIRECTION.ASC
? differenceInMilliseconds(new Date(a[field]), new Date(b[field]))
: differenceInMilliseconds(new Date(b[field]), new Date(a[field]));
return result;
});
return sortedCollections;
}
);
export function getCollection(state, id) {
return find(getCollections(state), { id });
}
export default getSortedCollections;

View file

@ -6,7 +6,7 @@ import { DIRECTION } from '../actions/sorting';
const getSketches = state => state.sketches; const getSketches = state => state.sketches;
const getField = state => state.sorting.field; const getField = state => state.sorting.field;
const getDirection = state => state.sorting.direction; const getDirection = state => state.sorting.direction;
const getSearchTerm = state => state.search.searchTerm; const getSearchTerm = state => state.search.sketchSearchTerm;
const getFilteredSketches = createSelector( const getFilteredSketches = createSelector(
getSketches, getSketches,

View file

@ -0,0 +1,30 @@
import { createSelector } from 'reselect';
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;
export const getCanUploadMedia = createSelector(
getAuthenticated,
getTotalSize,
(authenticated, totalSize) => {
if (!authenticated) return false;
// eventually do the same thing for verified when
// email verification actually works
if (totalSize > limit) return false;
return true;
}
);
export const getreachedTotalSizeLimit = createSelector(
getTotalSize,
getAssetsTotalSize,
(totalSize, assetsTotalSize) => {
const currentSize = totalSize || assetsTotalSize;
if (currentSize && currentSize > limit) return true;
// if (totalSize > 1000) return true;
return false;
}
);

View file

@ -218,3 +218,31 @@ export function updateSettings(formValues) {
}) })
.catch(response => Promise.reject(new Error(response.data.error))); .catch(response => Promise.reject(new Error(response.data.error)));
} }
export function createApiKeySuccess(user) {
return {
type: ActionTypes.API_KEY_CREATED,
user
};
}
export function createApiKey(label) {
return dispatch =>
axios.post(`${ROOT_URL}/account/api-keys`, { label }, { withCredentials: true })
.then((response) => {
dispatch(createApiKeySuccess(response.data));
})
.catch(response => Promise.reject(new Error(response.data.error)));
}
export function removeApiKey(keyId) {
return dispatch =>
axios.delete(`${ROOT_URL}/account/api-keys/${keyId}`, { withCredentials: true })
.then((response) => {
dispatch({
type: ActionTypes.API_KEY_REMOVED,
user: response.data
});
})
.catch(response => Promise.reject(new Error(response.data.error)));
}

View file

@ -0,0 +1,123 @@
import PropTypes from 'prop-types';
import React from 'react';
import InlineSVG from 'react-inlinesvg';
import CopyableInput from '../../IDE/components/CopyableInput';
import APIKeyList from './APIKeyList';
const plusIcon = require('../../../images/plus-icon.svg');
export const APIKeyPropType = PropTypes.shape({
id: PropTypes.object.isRequired,
token: PropTypes.object,
label: PropTypes.string.isRequired,
createdAt: PropTypes.string.isRequired,
lastUsedAt: PropTypes.string,
});
class APIKeyForm extends React.Component {
constructor(props) {
super(props);
this.state = { keyLabel: '' };
this.addKey = this.addKey.bind(this);
this.removeKey = this.removeKey.bind(this);
this.renderApiKeys = this.renderApiKeys.bind(this);
}
addKey(event) {
event.preventDefault();
const { keyLabel } = this.state;
this.setState({
keyLabel: ''
});
this.props.createApiKey(keyLabel);
return false;
}
removeKey(key) {
const message = `Are you sure you want to delete "${key.label}"?`;
if (window.confirm(message)) {
this.props.removeApiKey(key.id);
}
}
renderApiKeys() {
const hasApiKeys = this.props.apiKeys && this.props.apiKeys.length > 0;
if (hasApiKeys) {
return (
<APIKeyList apiKeys={this.props.apiKeys} onRemove={this.removeKey} />
);
}
return <p>You have no exsiting tokens.</p>;
}
render() {
const keyWithToken = this.props.apiKeys.find(k => !!k.token);
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.
</p>
<div className="api-key-form__section">
<h3 className="api-key-form__title">Create new token</h3>
<form className="form form--inline" onSubmit={this.addKey}>
<label htmlFor="keyLabel" className="form__label form__label--hidden ">What is this token for?</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"
type="text"
value={this.state.keyLabel}
/>
<button
className="api-key-form__create-button"
disabled={this.state.keyLabel === ''}
type="submit"
>
<InlineSVG src={plusIcon} className="api-key-form__create-icon" />
Create
</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>
)
}
</div>
<div className="api-key-form__section">
<h3 className="api-key-form__title">Existing tokens</h3>
{this.renderApiKeys()}
</div>
</div>
);
}
}
APIKeyForm.propTypes = {
apiKeys: PropTypes.arrayOf(PropTypes.shape(APIKeyPropType)).isRequired,
createApiKey: PropTypes.func.isRequired,
removeApiKey: PropTypes.func.isRequired,
};
export default APIKeyForm;

View file

@ -0,0 +1,52 @@
import PropTypes from 'prop-types';
import React from 'react';
import InlineSVG from 'react-inlinesvg';
import format from 'date-fns/format';
import distanceInWordsToNow from 'date-fns/distance_in_words_to_now';
import orderBy from 'lodash/orderBy';
import { APIKeyPropType } from './APIKeyForm';
const trashCan = require('../../../images/trash-can.svg');
function APIKeyList({ apiKeys, onRemove }) {
return (
<table className="api-key-list">
<thead>
<tr>
<th>Name</th>
<th>Created on</th>
<th>Last used</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{orderBy(apiKeys, ['createdAt'], ['desc']).map((key) => {
const lastUsed = key.lastUsedAt ?
distanceInWordsToNow(new Date(key.lastUsedAt), { addSuffix: true }) :
'Never';
return (
<tr key={key.id}>
<td>{key.label}</td>
<td>{format(new Date(key.createdAt), 'MMM D, YYYY h:mm A')}</td>
<td>{lastUsed}</td>
<td className="api-key-list__action">
<button className="api-key-list__delete-button" onClick={() => onRemove(key)}>
<InlineSVG src={trashCan} alt="Delete Key" />
</button>
</td>
</tr>
);
})}
</tbody>
</table>
);
}
APIKeyList.propTypes = {
apiKeys: PropTypes.arrayOf(PropTypes.shape(APIKeyPropType)).isRequired,
onRemove: PropTypes.func.isRequired,
};
export default APIKeyList;

View file

@ -0,0 +1,422 @@
import format from 'date-fns/format';
import PropTypes from 'prop-types';
import React, { useState, useRef, useEffect } from 'react';
import { Helmet } from 'react-helmet';
import InlineSVG from 'react-inlinesvg';
import { connect } from 'react-redux';
import { Link } from 'react-router';
import { bindActionCreators } from 'redux';
import classNames from 'classnames';
import * as ProjectActions from '../../IDE/actions/project';
import * as ProjectsActions from '../../IDE/actions/projects';
import * as CollectionsActions from '../../IDE/actions/collections';
import * as ToastActions from '../../IDE/actions/toast';
import * as SortingActions from '../../IDE/actions/sorting';
import * as IdeActions from '../../IDE/actions/ide';
import { getCollection } from '../../IDE/selectors/collections';
import Loader from '../../App/components/loader';
import EditableInput from '../../IDE/components/EditableInput';
import Overlay from '../../App/components/Overlay';
import AddToCollectionSketchList from '../../IDE/components/AddToCollectionSketchList';
import CopyableInput from '../../IDE/components/CopyableInput';
import { SketchSearchbar } from '../../IDE/components/Searchbar';
import dropdownArrow from '../../../images/down-arrow.svg';
const arrowUp = require('../../../images/sort-arrow-up.svg');
const arrowDown = require('../../../images/sort-arrow-down.svg');
const removeIcon = require('../../../images/close.svg');
const ShareURL = ({ value }) => {
const [showURL, setShowURL] = useState(false);
const node = useRef();
const handleClickOutside = (e) => {
if (node.current.contains(e.target)) {
return;
}
setShowURL(false);
};
useEffect(() => {
if (showURL) {
document.addEventListener('mousedown', handleClickOutside);
} else {
document.removeEventListener('mousedown', handleClickOutside);
}
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, [showURL]);
return (
<div className="collection-share" ref={node}>
<button
className="collection-share__button"
onClick={() => setShowURL(!showURL)}
>
<span>Share</span>
<InlineSVG className="collection-share__arrow" src={dropdownArrow} />
</button>
{ showURL &&
<div className="collection__share-dropdown">
<CopyableInput value={value} label="Link to Collection" />
</div>
}
</div>
);
};
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);
}
}
render() {
const { item } = this.props;
const sketchOwnerUsername = item.project.user.username;
const sketchUrl = `/${item.project.user.username}/sketches/${item.project.id}`;
return (
<tr
className="sketches-table__row"
>
<th scope="row">
<Link to={sketchUrl}>
{item.project.name}
</Link>
</th>
<td>{format(new Date(item.createdAt), 'MMM D, YYYY h:mm A')}</td>
<td>{sketchOwnerUsername}</td>
<td className="collection-row__action-column ">
<button
className="collection-row__remove-button"
onClick={this.handleSketchRemove}
>
<InlineSVG src={removeIcon} alt="Remove" />
</button>
</td>
</tr>);
}
}
CollectionItemRowBase.propTypes = {
collection: PropTypes.shape({
id: PropTypes.string.isRequired,
name: PropTypes.string.isRequired
}).isRequired,
item: PropTypes.shape({
createdAt: PropTypes.string.isRequired,
project: PropTypes.shape({
id: PropTypes.string.isRequired,
name: PropTypes.string.isRequired,
user: PropTypes.shape({
username: PropTypes.string.isRequired
})
}).isRequired,
}).isRequired,
user: PropTypes.shape({
username: PropTypes.string,
authenticated: PropTypes.bool.isRequired
}).isRequired,
removeFromCollection: PropTypes.func.isRequired
};
function mapDispatchToPropsSketchListRow(dispatch) {
return bindActionCreators(Object.assign({}, CollectionsActions, ProjectActions, IdeActions), dispatch);
}
const CollectionItemRow = connect(null, mapDispatchToPropsSketchListRow)(CollectionItemRowBase);
class Collection extends React.Component {
constructor(props) {
super(props);
this.props.getCollections(this.props.username);
this.props.resetSorting();
this._renderFieldHeader = this._renderFieldHeader.bind(this);
this.showAddSketches = this.showAddSketches.bind(this);
this.hideAddSketches = this.hideAddSketches.bind(this);
this.state = {
isAddingSketches: false,
};
}
getTitle() {
if (this.props.username === this.props.user.username) {
return 'p5.js Web Editor | My collections';
}
return `p5.js Web Editor | ${this.props.username}'s collections`;
}
getUsername() {
return this.props.username !== undefined ? this.props.username : this.props.user.username;
}
getCollectionName() {
return this.props.collection.name;
}
isOwner() {
let isOwner = false;
if (this.props.user != null &&
this.props.user.username &&
this.props.collection.owner.username === this.props.user.username) {
isOwner = true;
}
return isOwner;
}
hasCollection() {
return !this.props.loading && this.props.collection != null;
}
hasCollectionItems() {
return this.hasCollection() && this.props.collection.items.length > 0;
}
_renderLoader() {
if (this.props.loading) return <Loader />;
return null;
}
_renderCollectionMetadata() {
const {
id, name, description, items, owner
} = this.props.collection;
const hostname = window.location.origin;
const { username } = this.props;
const baseURL = `${hostname}/${username}/collections/`;
const handleEditCollectionName = (value) => {
if (value === name) {
return;
}
this.props.editCollection(id, { name: value });
};
const handleEditCollectionDescription = (value) => {
if (value === description) {
return;
}
this.props.editCollection(id, { description: value });
};
//
// TODO: Implement UI for editing slug
//
// const handleEditCollectionSlug = (value) => {
// if (value === slug) {
// return;
// }
//
// this.props.editCollection(id, { slug: value });
// };
return (
<div className={`collection-metadata ${this.isOwner() ? 'collection-metadata--is-owner' : ''}`}>
<div className="collection-metadata__columns">
<div className="collection-metadata__column--left">
<h2 className="collection-metadata__name">
{
this.isOwner() ?
<EditableInput value={name} onChange={handleEditCollectionName} validate={value => value !== ''} /> :
name
}
</h2>
<p className="collection-metadata__description">
{
this.isOwner() ?
<EditableInput
InputComponent="textarea"
value={description}
onChange={handleEditCollectionDescription}
emptyPlaceholder="Add description"
/> :
description
}
</p>
<p className="collection-metadata__user">Collection by{' '}
<Link to={`${hostname}/${username}/sketches`}>{owner.username}</Link>
</p>
<p className="collection-metadata__user">{items.length} sketch{items.length === 1 ? '' : 'es'}</p>
</div>
<div className="collection-metadata__column--right">
<p className="collection-metadata__share">
<ShareURL value={`${baseURL}${id}`} />
</p>
{
this.isOwner() &&
<button className="collection-metadata__add-button" onClick={this.showAddSketches}>
Add Sketch
</button>
}
</div>
</div>
</div>
);
}
showAddSketches() {
this.setState({
isAddingSketches: true,
});
}
hideAddSketches() {
this.setState({
isAddingSketches: false,
});
}
_renderEmptyTable() {
const isLoading = this.props.loading;
const hasCollectionItems = this.props.collection != null &&
this.props.collection.items.length > 0;
if (!isLoading && !hasCollectionItems) {
return (<p className="collection-empty-message">No sketches in collection</p>);
}
return null;
}
_renderFieldHeader(fieldName, displayName) {
const { field, direction } = this.props.sorting;
const headerClass = classNames({
'sketches-table__header': true,
'sketches-table__header--selected': field === fieldName
});
return (
<th scope="col">
<button className="sketch-list__sort-button" onClick={() => this.props.toggleDirectionForField(fieldName)}>
<span className={headerClass}>{displayName}</span>
{field === fieldName && direction === SortingActions.DIRECTION.ASC &&
<InlineSVG src={arrowUp} />
}
{field === fieldName && direction === SortingActions.DIRECTION.DESC &&
<InlineSVG src={arrowDown} />
}
</button>
</th>
);
}
render() {
const title = this.hasCollection() ? this.getCollectionName() : null;
return (
<section className="collection-container" data-has-items={this.hasCollectionItems() ? 'true' : 'false'}>
<Helmet>
<title>{this.getTitle()}</title>
</Helmet>
{this._renderLoader()}
{this.hasCollection() && this._renderCollectionMetadata()}
<div className="collection-table-wrapper">
{this._renderEmptyTable()}
{this.hasCollectionItems() &&
<table className="sketches-table" summary="table containing all collections">
<thead>
<tr>
{this._renderFieldHeader('name', 'Name')}
{this._renderFieldHeader('createdAt', 'Date Added')}
{this._renderFieldHeader('user', 'Owner')}
<th scope="col"></th>
</tr>
</thead>
<tbody>
{this.props.collection.items.map(item =>
(<CollectionItemRow
key={item.id}
item={item}
user={this.props.user}
username={this.getUsername()}
collection={this.props.collection}
/>))}
</tbody>
</table>
}
{
this.state.isAddingSketches && (
<Overlay
title="Add sketch"
actions={<SketchSearchbar />}
closeOverlay={this.hideAddSketches}
isFixedHeight
>
<div className="collection-add-sketch">
<AddToCollectionSketchList username={this.props.username} collection={this.props.collection} />
</div>
</Overlay>
)
}
</div>
</section>
);
}
}
Collection.propTypes = {
user: PropTypes.shape({
username: PropTypes.string,
authenticated: PropTypes.bool.isRequired
}).isRequired,
getCollections: PropTypes.func.isRequired,
collection: PropTypes.shape({
id: PropTypes.string.isRequired,
name: PropTypes.string.isRequired,
slug: PropTypes.string,
description: PropTypes.string,
owner: PropTypes.shape({
username: PropTypes.string.isRequired,
}).isRequired,
items: PropTypes.arrayOf(PropTypes.shape({})),
}).isRequired,
username: PropTypes.string,
loading: PropTypes.bool.isRequired,
toggleDirectionForField: PropTypes.func.isRequired,
editCollection: PropTypes.func.isRequired,
resetSorting: PropTypes.func.isRequired,
sorting: PropTypes.shape({
field: PropTypes.string.isRequired,
direction: PropTypes.string.isRequired
}).isRequired
};
Collection.defaultProps = {
username: undefined
};
function mapStateToProps(state, ownProps) {
return {
user: state.user,
collection: getCollection(state, ownProps.collectionId),
sorting: state.sorting,
loading: state.loading,
project: state.project
};
}
function mapDispatchToProps(dispatch) {
return bindActionCreators(
Object.assign({}, CollectionsActions, ProjectsActions, ToastActions, SortingActions),
dispatch
);
}
export default connect(mapStateToProps, mapDispatchToProps)(Collection);

View file

@ -0,0 +1,128 @@
import PropTypes from 'prop-types';
import React from 'react';
import { Helmet } from 'react-helmet';
import { connect } from 'react-redux';
import { browserHistory } from 'react-router';
import { bindActionCreators } from 'redux';
import * as CollectionsActions from '../../IDE/actions/collections';
import { generateCollectionName } from '../../../utils/generateRandomName';
class CollectionCreate extends React.Component {
constructor() {
super();
const name = generateCollectionName();
this.state = {
generatedCollectionName: name,
collection: {
name,
description: ''
}
};
}
getTitle() {
return 'p5.js Web Editor | Create collection';
}
handleTextChange = field => (evt) => {
this.setState({
collection: {
...this.state.collection,
[field]: evt.target.value,
}
});
}
handleCreateCollection = (event) => {
event.preventDefault();
this.props.createCollection(this.state.collection)
.then(({ id, owner }) => {
const pathname = `/${owner.username}/collections/${id}`;
const location = { pathname, state: { skipSavingPath: true } };
browserHistory.replace(location);
})
.catch((error) => {
console.error('Error creating collection', error);
this.setState({
creationError: error,
});
});
}
render() {
const { generatedCollectionName, creationError } = this.state;
const { name, description } = this.state.collection;
const invalid = name === '' || name == null;
return (
<div className="collection-create">
<Helmet>
<title>{this.getTitle()}</title>
</Helmet>
<div className="sketches-table-container">
<form className="form" onSubmit={this.handleCreateCollection}>
{creationError && <span className="form-error">Couldn&apos;t create collection</span>}
<p className="form__field">
<label htmlFor="name" className="form__label">Collection name</label>
<input
className="form__input"
aria-label="name"
type="text"
id="name"
value={name}
placeholder={generatedCollectionName}
onChange={this.handleTextChange('name')}
/>
{invalid && <span className="form-error">Collection name is required</span>}
</p>
<p className="form__field">
<label htmlFor="description" className="form__label">Description (optional)</label>
<textarea
className="form__input form__input-flexible-height"
aria-label="description"
type="text"
id="description"
value={description}
onChange={this.handleTextChange('description')}
placeholder="My fave sketches"
rows="4"
/>
</p>
<input type="submit" disabled={invalid} value="Create collection" aria-label="create collection" />
</form>
</div>
</div>
);
}
}
CollectionCreate.propTypes = {
user: PropTypes.shape({
username: PropTypes.string,
authenticated: PropTypes.bool.isRequired
}).isRequired,
createCollection: PropTypes.func.isRequired,
collection: PropTypes.shape({}).isRequired, // TODO
sorting: PropTypes.shape({
field: PropTypes.string.isRequired,
direction: PropTypes.string.isRequired
}).isRequired
};
function mapStateToProps(state, ownProps) {
return {
user: state.user,
};
}
function mapDispatchToProps(dispatch) {
return bindActionCreators(Object.assign({}, CollectionsActions), dispatch);
}
export default connect(mapStateToProps, mapDispatchToProps)(CollectionCreate);

View file

@ -0,0 +1,47 @@
import PropTypes from 'prop-types';
import React from 'react';
import { Link } from 'react-router';
const TabKey = {
assets: 'assets',
collections: 'collections',
sketches: 'sketches',
};
const Tab = ({ children, isSelected, to }) => {
const selectedClassName = 'dashboard-header__tab--selected';
const location = { pathname: to, state: { skipSavingPath: true } };
const content = isSelected ? <span>{children}</span> : <Link to={location}>{children}</Link>;
return (
<li className={`dashboard-header__tab ${isSelected && selectedClassName}`}>
<h4 className="dashboard-header__tab__title">
{content}
</h4>
</li>
);
};
Tab.propTypes = {
children: PropTypes.string.isRequired,
isSelected: PropTypes.bool.isRequired,
to: PropTypes.string.isRequired,
};
const DashboardTabSwitcher = ({ currentTab, isOwner, username }) => (
<ul className="dashboard-header__switcher">
<div className="dashboard-header__tabs">
<Tab to={`/${username}/sketches`} isSelected={currentTab === TabKey.sketches}>Sketches</Tab>
<Tab to={`/${username}/collections`} isSelected={currentTab === TabKey.collections}>Collections</Tab>
{isOwner && <Tab to={`/${username}/assets`} isSelected={currentTab === TabKey.assets}>Assets</Tab>}
</div>
</ul>
);
DashboardTabSwitcher.propTypes = {
currentTab: PropTypes.string.isRequired,
isOwner: PropTypes.bool.isRequired,
username: PropTypes.string.isRequired,
};
export { DashboardTabSwitcher as default, TabKey };

View file

@ -2,54 +2,72 @@ import PropTypes from 'prop-types';
import React from 'react'; import React from 'react';
import { reduxForm } from 'redux-form'; import { reduxForm } from 'redux-form';
import { bindActionCreators } from 'redux'; import { bindActionCreators } from 'redux';
import { browserHistory } from 'react-router'; import { Tab, Tabs, TabList, TabPanel } from 'react-tabs';
import InlineSVG from 'react-inlinesvg';
import axios from 'axios'; import axios from 'axios';
import { Helmet } from 'react-helmet'; import { Helmet } from 'react-helmet';
import { updateSettings, initiateVerification } from '../actions'; import { updateSettings, initiateVerification, createApiKey, removeApiKey } from '../actions';
import AccountForm from '../components/AccountForm'; import AccountForm from '../components/AccountForm';
import { validateSettings } from '../../../utils/reduxFormUtils'; import { validateSettings } from '../../../utils/reduxFormUtils';
import GithubButton from '../components/GithubButton'; import GithubButton from '../components/GithubButton';
import GoogleButton from '../components/GoogleButton';
import APIKeyForm from '../components/APIKeyForm';
import Nav from '../../../components/Nav';
const exitUrl = require('../../../images/exit.svg'); const __process = (typeof global !== 'undefined' ? global : window).process;
const logoUrl = require('../../../images/p5js-logo.svg'); const ROOT_URL = __process.env.API_URL;
function SocialLoginPanel(props) {
return (
<React.Fragment>
<AccountForm {...props} />
<h2 className="form-container__divider">Social Login</h2>
<p className="account__social-text">
Use your GitHub or Google account to log into the p5.js Web Editor.
</p>
<GithubButton buttonText="Login with GitHub" />
<GoogleButton buttonText="Login with Google" />
</React.Fragment>
);
}
class AccountView extends React.Component { class AccountView extends React.Component {
constructor(props) { componentDidMount() {
super(props); document.body.className = this.props.theme;
this.closeAccountPage = this.closeAccountPage.bind(this);
this.gotoHomePage = this.gotoHomePage.bind(this);
}
closeAccountPage() {
browserHistory.push(this.props.previousPath);
}
gotoHomePage() {
browserHistory.push('/');
} }
render() { render() {
const accessTokensUIEnabled = window.process.env.UI_ACCESS_TOKEN_ENABLED;
return ( return (
<div className="form-container"> <div className="account-settings__container">
<Helmet> <Helmet>
<title>p5.js Web Editor | Account</title> <title>p5.js Web Editor | Account Settings</title>
</Helmet> </Helmet>
<div className="form-container__header">
<button className="form-container__logo-button" onClick={this.gotoHomePage}> <Nav layout="dashboard" />
<InlineSVG src={logoUrl} alt="p5js Logo" />
</button> <section className="account-settings">
<button className="form-container__exit-button" onClick={this.closeAccountPage}> <header className="account-settings__header">
<InlineSVG src={exitUrl} alt="Close Account Page" /> <h1 className="account-settings__title">Account Settings</h1>
</button> </header>
</div> {accessTokensUIEnabled &&
<div className="form-container__content"> <Tabs className="account__tabs">
<h2 className="form-container__title">My Account</h2> <TabList>
<AccountForm {...this.props} /> <div className="tabs__titles">
<h2 className="form-container__divider">Or</h2> <Tab><h4 className="tabs__title">Account</h4></Tab>
<GithubButton buttonText="Login with Github" /> {accessTokensUIEnabled && <Tab><h4 className="tabs__title">Access Tokens</h4></Tab>}
</div> </div>
</TabList>
<TabPanel>
<SocialLoginPanel {...this.props} />
</TabPanel>
<TabPanel>
<APIKeyForm {...this.props} />
</TabPanel>
</Tabs>
}
{ !accessTokensUIEnabled && <SocialLoginPanel {...this.props} /> }
</section>
</div> </div>
); );
} }
@ -58,13 +76,17 @@ class AccountView extends React.Component {
function mapStateToProps(state) { function mapStateToProps(state) {
return { return {
initialValues: state.user, // <- initialValues for reduxForm initialValues: state.user, // <- initialValues for reduxForm
previousPath: state.ide.previousPath,
user: state.user, user: state.user,
previousPath: state.ide.previousPath apiKeys: state.user.apiKeys,
theme: state.preferences.theme
}; };
} }
function mapDispatchToProps(dispatch) { function mapDispatchToProps(dispatch) {
return bindActionCreators({ updateSettings, initiateVerification }, dispatch); return bindActionCreators({
updateSettings, initiateVerification, createApiKey, removeApiKey
}, dispatch);
} }
function asyncValidate(formProps, dispatch, props) { function asyncValidate(formProps, dispatch, props) {
@ -73,7 +95,7 @@ function asyncValidate(formProps, dispatch, props) {
const queryParams = {}; const queryParams = {};
queryParams[fieldToValidate] = formProps[fieldToValidate]; queryParams[fieldToValidate] = formProps[fieldToValidate];
queryParams.check_type = fieldToValidate; queryParams.check_type = fieldToValidate;
return axios.get('/api/signup/duplicate_check', { params: queryParams }) return axios.get(`${ROOT_URL}/signup/duplicate_check`, { params: queryParams })
.then((response) => { .then((response) => {
if (response.data.exists) { if (response.data.exists) {
const error = {}; const error = {};
@ -87,6 +109,7 @@ function asyncValidate(formProps, dispatch, props) {
AccountView.propTypes = { AccountView.propTypes = {
previousPath: PropTypes.string.isRequired, previousPath: PropTypes.string.isRequired,
theme: PropTypes.string.isRequired
}; };
export default reduxForm({ export default reduxForm({

View file

@ -0,0 +1,91 @@
import PropTypes from 'prop-types';
import React from 'react';
import { connect } from 'react-redux';
import Nav from '../../../components/Nav';
import CollectionCreate from '../components/CollectionCreate';
import Collection from '../components/Collection';
class CollectionView extends React.Component {
static defaultProps = {
user: null,
};
componentDidMount() {
document.body.className = this.props.theme;
}
ownerName() {
if (this.props.params.username) {
return this.props.params.username;
}
return this.props.user.username;
}
pageTitle() {
if (this.isCreatePage()) {
return 'Create collection';
}
return 'collection';
}
isOwner() {
return this.props.user.username === this.props.params.username;
}
isCreatePage() {
const path = this.props.location.pathname;
return /create$/.test(path);
}
renderContent() {
if (this.isCreatePage() && this.isOwner()) {
return <CollectionCreate />;
}
return (
<Collection
collectionId={this.props.params.collection_id}
username={this.props.params.username}
/>
);
}
render() {
return (
<div className="dashboard">
<Nav layout="dashboard" />
{this.renderContent()}
</div>
);
}
}
function mapStateToProps(state) {
return {
user: state.user,
theme: state.preferences.theme
};
}
function mapDispatchToProps(dispatch) {
}
CollectionView.propTypes = {
location: PropTypes.shape({
pathname: PropTypes.string.isRequired,
}).isRequired,
params: PropTypes.shape({
collection_id: PropTypes.string.isRequired,
username: PropTypes.string.isRequired,
}).isRequired,
theme: PropTypes.string.isRequired,
user: PropTypes.shape({
username: PropTypes.string.isRequired,
}),
};
export default connect(mapStateToProps, mapDispatchToProps)(CollectionView);

View file

@ -0,0 +1,178 @@
import PropTypes from 'prop-types';
import React from 'react';
import { connect } from 'react-redux';
import { bindActionCreators } from 'redux';
import { browserHistory, Link } from 'react-router';
import { updateSettings, initiateVerification, createApiKey, removeApiKey } from '../actions';
import Nav from '../../../components/Nav';
import Overlay from '../../App/components/Overlay';
import AssetList from '../../IDE/components/AssetList';
import AssetSize from '../../IDE/components/AssetSize';
import CollectionList from '../../IDE/components/CollectionList';
import SketchList from '../../IDE/components/SketchList';
import { CollectionSearchbar, SketchSearchbar } from '../../IDE/components/Searchbar';
import CollectionCreate from '../components/CollectionCreate';
import DashboardTabSwitcher, { TabKey } from '../components/DashboardTabSwitcher';
class DashboardView extends React.Component {
static defaultProps = {
user: null,
};
constructor(props) {
super(props);
this.closeAccountPage = this.closeAccountPage.bind(this);
this.gotoHomePage = this.gotoHomePage.bind(this);
}
componentDidMount() {
document.body.className = this.props.theme;
}
closeAccountPage() {
browserHistory.push(this.props.previousPath);
}
gotoHomePage() {
browserHistory.push('/');
}
selectedTabKey() {
const path = this.props.location.pathname;
if (/assets/.test(path)) {
return TabKey.assets;
} else if (/collections/.test(path)) {
return TabKey.collections;
}
return TabKey.sketches;
}
ownerName() {
if (this.props.params.username) {
return this.props.params.username;
}
return this.props.user.username;
}
isOwner() {
return this.props.user.username === this.props.params.username;
}
isCollectionCreate() {
const path = this.props.location.pathname;
return /collections\/create$/.test(path);
}
returnToDashboard = () => {
browserHistory.push(`/${this.ownerName()}/collections`);
}
renderActionButton(tabKey, username) {
switch (tabKey) {
case TabKey.assets:
return this.isOwner() && <AssetSize />;
case TabKey.collections:
return this.isOwner() && (
<React.Fragment>
<Link className="dashboard__action-button" to={`/${username}/collections/create`}>
Create collection
</Link>
<CollectionSearchbar />
</React.Fragment>);
case TabKey.sketches:
default:
return (
<React.Fragment>
{this.isOwner() && <Link className="dashboard__action-button" to="/">New sketch</Link>}
<SketchSearchbar />
</React.Fragment>
);
}
}
renderContent(tabKey, username) {
switch (tabKey) {
case TabKey.assets:
return <AssetList username={username} />;
case TabKey.collections:
return <CollectionList username={username} />;
case TabKey.sketches:
default:
return <SketchList username={username} />;
}
}
render() {
const currentTab = this.selectedTabKey();
const isOwner = this.isOwner();
const { username } = this.props.params;
const actions = this.renderActionButton(currentTab, username);
return (
<div className="dashboard">
<Nav layout="dashboard" />
<section className="dashboard-header">
<div className="dashboard-header__header">
<h2 className="dashboard-header__header__title">{this.ownerName()}</h2>
<div className="dashboard-header__nav">
<DashboardTabSwitcher currentTab={currentTab} isOwner={isOwner} username={username} />
{actions &&
<div className="dashboard-header__actions">
{actions}
</div>
}
</div>
</div>
<div className="dashboard-content">
{this.renderContent(currentTab, username)}
</div>
</section>
{this.isCollectionCreate() &&
<Overlay
title="Create collection"
closeOverlay={this.returnToDashboard}
>
<CollectionCreate />
</Overlay>
}
</div>
);
}
}
function mapStateToProps(state) {
return {
previousPath: state.ide.previousPath,
user: state.user,
theme: state.preferences.theme,
};
}
function mapDispatchToProps(dispatch) {
return bindActionCreators({
updateSettings, initiateVerification, createApiKey, removeApiKey
}, dispatch);
}
DashboardView.propTypes = {
location: PropTypes.shape({
pathname: PropTypes.string.isRequired,
}).isRequired,
params: PropTypes.shape({
username: PropTypes.string.isRequired,
}).isRequired,
previousPath: PropTypes.string.isRequired,
theme: PropTypes.string.isRequired,
user: PropTypes.shape({
username: PropTypes.string,
}),
};
export default connect(mapStateToProps, mapDispatchToProps)(DashboardView);

View file

@ -3,13 +3,10 @@ import React from 'react';
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import { bindActionCreators } from 'redux'; import { bindActionCreators } from 'redux';
import { browserHistory } from 'react-router'; import { browserHistory } from 'react-router';
import InlineSVG from 'react-inlinesvg';
import get from 'lodash/get'; import get from 'lodash/get';
import { Helmet } from 'react-helmet'; import { Helmet } from 'react-helmet';
import { verifyEmailConfirmation } from '../actions'; import { verifyEmailConfirmation } from '../actions';
import Nav from '../../../components/Nav';
const exitUrl = require('../../../images/exit.svg');
const logoUrl = require('../../../images/p5js-logo.svg');
class EmailVerificationView extends React.Component { class EmailVerificationView extends React.Component {
@ -17,12 +14,6 @@ class EmailVerificationView extends React.Component {
emailVerificationTokenState: null, emailVerificationTokenState: null,
} }
constructor(props) {
super(props);
this.closeLoginPage = this.closeLoginPage.bind(this);
this.gotoHomePage = this.gotoHomePage.bind(this);
}
componentWillMount() { componentWillMount() {
const verificationToken = this.verificationToken(); const verificationToken = this.verificationToken();
if (verificationToken != null) { if (verificationToken != null) {
@ -32,14 +23,6 @@ class EmailVerificationView extends React.Component {
verificationToken = () => get(this.props, 'location.query.t', null); verificationToken = () => get(this.props, 'location.query.t', null);
closeLoginPage() {
browserHistory.push(this.props.previousPath);
}
gotoHomePage() {
browserHistory.push('/');
}
render() { render() {
let status = null; let status = null;
const { const {
@ -48,7 +31,7 @@ class EmailVerificationView extends React.Component {
if (this.verificationToken() == null) { if (this.verificationToken() == null) {
status = ( status = (
<p>That link is invalid</p> <p>That link is invalid.</p>
); );
} else if (emailVerificationTokenState === 'checking') { } else if (emailVerificationTokenState === 'checking') {
status = ( status = (
@ -58,6 +41,7 @@ class EmailVerificationView extends React.Component {
status = ( status = (
<p>All done, your email address has been verified.</p> <p>All done, your email address has been verified.</p>
); );
setTimeout(() => browserHistory.push('/'), 1000);
} else if (emailVerificationTokenState === 'invalid') { } else if (emailVerificationTokenState === 'invalid') {
status = ( status = (
<p>Something went wrong.</p> <p>Something went wrong.</p>
@ -65,21 +49,16 @@ class EmailVerificationView extends React.Component {
} }
return ( return (
<div className="form-container"> <div className="email-verification">
<Helmet> <Nav layout="dashboard" />
<title>p5.js Web Editor | Email Verification</title> <div className="form-container">
</Helmet> <Helmet>
<div className="form-container__header"> <title>p5.js Web Editor | Email Verification</title>
<button className="form-container__logo-button" onClick={this.gotoHomePage}> </Helmet>
<InlineSVG src={logoUrl} alt="p5js Logo" /> <div className="form-container__content">
</button> <h2 className="form-container__title">Verify your email</h2>
<button className="form-container__exit-button" onClick={this.closeLoginPage}> {status}
<InlineSVG src={exitUrl} alt="Close Login Page" /> </div>
</button>
</div>
<div className="form-container__content">
<h2 className="form-container__title">Verify your email</h2>
{status}
</div> </div>
</div> </div>
); );
@ -89,7 +68,6 @@ class EmailVerificationView extends React.Component {
function mapStateToProps(state) { function mapStateToProps(state) {
return { return {
emailVerificationTokenState: state.user.emailVerificationTokenState, emailVerificationTokenState: state.user.emailVerificationTokenState,
previousPath: state.ide.previousPath
}; };
} }
@ -101,7 +79,6 @@ function mapDispatchToProps(dispatch) {
EmailVerificationView.propTypes = { EmailVerificationView.propTypes = {
previousPath: PropTypes.string.isRequired,
emailVerificationTokenState: PropTypes.oneOf([ emailVerificationTokenState: PropTypes.oneOf([
'checking', 'verified', 'invalid' 'checking', 'verified', 'invalid'
]), ]),

View file

@ -2,16 +2,13 @@ import PropTypes from 'prop-types';
import React from 'react'; import React from 'react';
import { reduxForm } from 'redux-form'; import { reduxForm } from 'redux-form';
import { Link, browserHistory } from 'react-router'; import { Link, browserHistory } from 'react-router';
import InlineSVG from 'react-inlinesvg';
import { Helmet } from 'react-helmet'; import { Helmet } from 'react-helmet';
import { validateAndLoginUser } from '../actions'; import { validateAndLoginUser } from '../actions';
import LoginForm from '../components/LoginForm'; import LoginForm from '../components/LoginForm';
import { validateLogin } from '../../../utils/reduxFormUtils'; import { validateLogin } from '../../../utils/reduxFormUtils';
import GithubButton from '../components/GithubButton'; import GithubButton from '../components/GithubButton';
import GoogleButton from '../components/GoogleButton'; import GoogleButton from '../components/GoogleButton';
import Nav from '../../../components/Nav';
const exitUrl = require('../../../images/exit.svg');
const logoUrl = require('../../../images/p5js-logo.svg');
class LoginView extends React.Component { class LoginView extends React.Component {
constructor(props) { constructor(props) {
@ -34,32 +31,27 @@ class LoginView extends React.Component {
return null; return null;
} }
return ( return (
<div className="form-container"> <div className="login">
<Helmet> <Nav layout="dashboard" />
<title>p5.js Web Editor | Login</title> <div className="form-container">
</Helmet> <Helmet>
<div className="form-container__header"> <title>p5.js Web Editor | Login</title>
<button className="form-container__logo-button" onClick={this.gotoHomePage}> </Helmet>
<InlineSVG src={logoUrl} alt="p5js Logo" /> <div className="form-container__content">
</button> <h2 className="form-container__title">Log In</h2>
<button className="form-container__exit-button" onClick={this.closeLoginPage}> <LoginForm {...this.props} />
<InlineSVG src={exitUrl} alt="Close Login Page" /> <h2 className="form-container__divider">Or</h2>
</button> <GithubButton buttonText="Login with Github" />
</div> <GoogleButton buttonText="Login with Google" />
<div className="form-container__content"> <p className="form__navigation-options">
<h2 className="form-container__title">Log In</h2> Don&apos;t have an account?&nbsp;
<LoginForm {...this.props} /> <Link className="form__signup-button" to="/signup">Sign Up</Link>
<h2 className="form-container__divider">Or</h2> </p>
<GithubButton buttonText="Login with Github" /> <p className="form__navigation-options">
<GoogleButton buttonText="Login with Google" /> Forgot your password?&nbsp;
<p className="form__navigation-options"> <Link className="form__reset-password-button" to="/reset-password">Reset your password</Link>
Don&apos;t have an account?&nbsp; </p>
<Link className="form__signup-button" to="/signup">Sign Up</Link> </div>
</p>
<p className="form__navigation-options">
Forgot your password?&nbsp;
<Link className="form__reset-password-button" to="/reset-password">Reset your password</Link>
</p>
</div> </div>
</div> </div>
); );

View file

@ -2,60 +2,36 @@ import PropTypes from 'prop-types';
import React from 'react'; import React from 'react';
import { reduxForm } from 'redux-form'; import { reduxForm } from 'redux-form';
import classNames from 'classnames'; import classNames from 'classnames';
import { browserHistory } from 'react-router';
import InlineSVG from 'react-inlinesvg';
import { bindActionCreators } from 'redux'; import { bindActionCreators } from 'redux';
import { Helmet } from 'react-helmet'; import { Helmet } from 'react-helmet';
import NewPasswordForm from '../components/NewPasswordForm'; import NewPasswordForm from '../components/NewPasswordForm';
import * as UserActions from '../actions'; import * as UserActions from '../actions';
import Nav from '../../../components/Nav';
const exitUrl = require('../../../images/exit.svg'); function NewPasswordView(props) {
const logoUrl = require('../../../images/p5js-logo.svg'); const newPasswordClass = classNames({
'new-password': true,
class NewPasswordView extends React.Component { 'new-password--invalid': props.user.resetPasswordInvalid,
constructor(props) { 'form-container': true,
super(props); 'user': true
this.gotoHomePage = this.gotoHomePage.bind(this); });
} return (
<div className="new-password-container">
componentDidMount() { <Nav layout="dashboard" />
// need to check if this is a valid token
this.props.validateResetPasswordToken(this.props.params.reset_password_token);
}
gotoHomePage() {
browserHistory.push('/');
}
render() {
const newPasswordClass = classNames({
'new-password': true,
'new-password--invalid': this.props.user.resetPasswordInvalid,
'form-container': true
});
return (
<div className={newPasswordClass}> <div className={newPasswordClass}>
<Helmet> <Helmet>
<title>p5.js Web Editor | New Password</title> <title>p5.js Web Editor | New Password</title>
</Helmet> </Helmet>
<div className="form-container__header">
<button className="form-container__logo-button" onClick={this.gotoHomePage}>
<InlineSVG src={logoUrl} alt="p5js Logo" />
</button>
<button className="form-container__exit-button" onClick={this.gotoHomePage}>
<InlineSVG src={exitUrl} alt="Close NewPassword Page" />
</button>
</div>
<div className="form-container__content"> <div className="form-container__content">
<h2 className="form-container__title">Set a New Password</h2> <h2 className="form-container__title">Set a New Password</h2>
<NewPasswordForm {...this.props} /> <NewPasswordForm {...props} />
<p className="new-password__invalid"> <p className="new-password__invalid">
The password reset token is invalid or has expired. The password reset token is invalid or has expired.
</p> </p>
</div> </div>
</div> </div>
); </div>
} );
} }
NewPasswordView.propTypes = { NewPasswordView.propTypes = {

View file

@ -1,55 +1,33 @@
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import React from 'react'; import React from 'react';
import { Link, browserHistory } from 'react-router'; import { Link } from 'react-router';
import classNames from 'classnames'; import classNames from 'classnames';
import InlineSVG from 'react-inlinesvg';
import { bindActionCreators } from 'redux'; import { bindActionCreators } from 'redux';
import { reduxForm } from 'redux-form'; import { reduxForm } from 'redux-form';
import { Helmet } from 'react-helmet'; import { Helmet } from 'react-helmet';
import * as UserActions from '../actions'; import * as UserActions from '../actions';
import ResetPasswordForm from '../components/ResetPasswordForm'; import ResetPasswordForm from '../components/ResetPasswordForm';
import { validateResetPassword } from '../../../utils/reduxFormUtils'; import { validateResetPassword } from '../../../utils/reduxFormUtils';
import Nav from '../../../components/Nav';
const exitUrl = require('../../../images/exit.svg'); function ResetPasswordView(props) {
const logoUrl = require('../../../images/p5js-logo.svg'); const resetPasswordClass = classNames({
'reset-password': true,
class ResetPasswordView extends React.Component { 'reset-password--submitted': props.user.resetPasswordInitiate,
constructor(props) { 'form-container': true,
super(props); 'user': true
this.gotoHomePage = this.gotoHomePage.bind(this); });
} return (
<div className="reset-password-container">
componentWillMount() { <Nav layout="dashboard" />
this.props.resetPasswordReset();
}
gotoHomePage() {
browserHistory.push('/');
}
render() {
const resetPasswordClass = classNames({
'reset-password': true,
'reset-password--submitted': this.props.user.resetPasswordInitiate,
'form-container': true
});
return (
<div className={resetPasswordClass}> <div className={resetPasswordClass}>
<Helmet> <Helmet>
<title>p5.js Web Editor | Reset Password</title> <title>p5.js Web Editor | Reset Password</title>
</Helmet> </Helmet>
<div className="form-container__header">
<button className="form-container__logo-button" onClick={this.gotoHomePage}>
<InlineSVG src={logoUrl} alt="p5js Logo" />
</button>
<button className="form-container__exit-button" onClick={this.gotoHomePage}>
<InlineSVG src={exitUrl} alt="Close ResetPassword Page" />
</button>
</div>
<div className="form-container__content"> <div className="form-container__content">
<h2 className="form-container__title">Reset Your Password</h2> <h2 className="form-container__title">Reset Your Password</h2>
<ResetPasswordForm {...this.props} /> <ResetPasswordForm {...props} />
<p className="reset-password__submitted"> <p className="reset-password__submitted">
Your password reset email should arrive shortly. If you don&apos;t see it, check 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. in your spam folder as sometimes it can end up there.
@ -61,8 +39,8 @@ class ResetPasswordView extends React.Component {
</p> </p>
</div> </div>
</div> </div>
); </div>
} );
} }
ResetPasswordView.propTypes = { ResetPasswordView.propTypes = {

View file

@ -4,27 +4,17 @@ import { bindActionCreators } from 'redux';
import axios from 'axios'; import axios from 'axios';
import { Link, browserHistory } from 'react-router'; import { Link, browserHistory } from 'react-router';
import { Helmet } from 'react-helmet'; import { Helmet } from 'react-helmet';
import InlineSVG from 'react-inlinesvg';
import { reduxForm } from 'redux-form'; import { reduxForm } from 'redux-form';
import * as UserActions from '../actions'; import * as UserActions from '../actions';
import SignupForm from '../components/SignupForm'; import SignupForm from '../components/SignupForm';
import { validateSignup } from '../../../utils/reduxFormUtils'; import { validateSignup } from '../../../utils/reduxFormUtils';
import Nav from '../../../components/Nav';
const exitUrl = require('../../../images/exit.svg'); const __process = (typeof global !== 'undefined' ? global : window).process;
const logoUrl = require('../../../images/p5js-logo.svg'); const ROOT_URL = __process.env.API_URL;
class SignupView extends React.Component { class SignupView extends React.Component {
constructor(props) { gotoHomePage = () => {
super(props);
this.closeSignupPage = this.closeSignupPage.bind(this);
this.gotoHomePage = this.gotoHomePage.bind(this);
}
closeSignupPage() {
browserHistory.push(this.props.previousPath);
}
gotoHomePage() {
browserHistory.push('/'); browserHistory.push('/');
} }
@ -34,25 +24,20 @@ class SignupView extends React.Component {
return null; return null;
} }
return ( return (
<div className="form-container"> <div className="signup">
<Helmet> <Nav layout="dashboard" />
<title>p5.js Web Editor | Signup</title> <div className="form-container">
</Helmet> <Helmet>
<div className="form-container__header"> <title>p5.js Web Editor | Signup</title>
<button className="form-container__logo-button" onClick={this.gotoHomePage}> </Helmet>
<InlineSVG src={logoUrl} alt="p5js Logo" /> <div className="form-container__content">
</button> <h2 className="form-container__title">Sign Up</h2>
<button className="form-container__exit-button" onClick={this.closeSignupPage}> <SignupForm {...this.props} />
<InlineSVG src={exitUrl} alt="Close Signup Page" /> <p className="form__navigation-options">
</button> Already have an account?&nbsp;
</div> <Link className="form__login-button" to="/login">Log In</Link>
<div className="form-container__content"> </p>
<h2 className="form-container__title">Sign Up</h2> </div>
<SignupForm {...this.props} />
<p className="form__navigation-options">
Already have an account?&nbsp;
<Link className="form__login-button" to="/login">Log In</Link>
</p>
</div> </div>
</div> </div>
); );
@ -95,7 +80,7 @@ function asyncValidate(formProps, dispatch, props) {
const queryParams = {}; const queryParams = {};
queryParams[fieldToValidate] = formProps[fieldToValidate]; queryParams[fieldToValidate] = formProps[fieldToValidate];
queryParams.check_type = fieldToValidate; queryParams.check_type = fieldToValidate;
return axios.get('/api/signup/duplicate_check', { params: queryParams }) return axios.get(`${ROOT_URL}/signup/duplicate_check`, { params: queryParams })
.then((response) => { .then((response) => {
if (response.data.exists) { if (response.data.exists) {
errors[fieldToValidate] = response.data.message; errors[fieldToValidate] = response.data.message;
@ -118,9 +103,9 @@ function onSubmitFail(errors) {
SignupView.propTypes = { SignupView.propTypes = {
previousPath: PropTypes.string.isRequired, previousPath: PropTypes.string.isRequired,
user: { user: PropTypes.shape({
authenticated: PropTypes.bool authenticated: PropTypes.bool
} })
}; };
SignupView.defaultProps = { SignupView.defaultProps = {

View file

@ -31,6 +31,10 @@ const user = (state = { authenticated: false }, action) => {
return Object.assign({}, state, { emailVerificationTokenState: 'invalid' }); return Object.assign({}, state, { emailVerificationTokenState: 'invalid' });
case ActionTypes.SETTINGS_UPDATED: case ActionTypes.SETTINGS_UPDATED:
return { ...state, ...action.user }; return { ...state, ...action.user };
case ActionTypes.API_KEY_REMOVED:
return { ...state, ...action.user };
case ActionTypes.API_KEY_CREATED:
return { ...state, ...action.user };
default: default:
return state; return state;
} }

View file

@ -13,6 +13,7 @@ import assets from './modules/IDE/reducers/assets';
import search from './modules/IDE/reducers/search'; import search from './modules/IDE/reducers/search';
import sorting from './modules/IDE/reducers/sorting'; import sorting from './modules/IDE/reducers/sorting';
import loading from './modules/IDE/reducers/loading'; import loading from './modules/IDE/reducers/loading';
import collections from './modules/IDE/reducers/collections';
const rootReducer = combineReducers({ const rootReducer = combineReducers({
form, form,
@ -28,7 +29,8 @@ const rootReducer = combineReducers({
toast, toast,
console, console,
assets, assets,
loading loading,
collections
}); });
export default rootReducer; export default rootReducer;

View file

@ -9,9 +9,12 @@ import ResetPasswordView from './modules/User/pages/ResetPasswordView';
import EmailVerificationView from './modules/User/pages/EmailVerificationView'; import EmailVerificationView from './modules/User/pages/EmailVerificationView';
import NewPasswordView from './modules/User/pages/NewPasswordView'; import NewPasswordView from './modules/User/pages/NewPasswordView';
import AccountView from './modules/User/pages/AccountView'; import AccountView from './modules/User/pages/AccountView';
// import SketchListView from './modules/Sketch/pages/SketchListView'; import CollectionView from './modules/User/pages/CollectionView';
import DashboardView from './modules/User/pages/DashboardView';
import createRedirectWithUsername from './components/createRedirectWithUsername';
import { getUser } from './modules/User/actions'; import { getUser } from './modules/User/actions';
import { stopSketch } from './modules/IDE/actions/ide'; import { stopSketch } from './modules/IDE/actions/ide';
import { userIsAuthenticated, userIsNotAuthenticated, userIsAuthorized } from './utils/auth';
const checkAuth = (store) => { const checkAuth = (store) => {
store.dispatch(getUser()); store.dispatch(getUser());
@ -24,9 +27,9 @@ const onRouteChange = (store) => {
const routes = store => ( const routes = store => (
<Route path="/" component={App} onChange={() => { onRouteChange(store); }}> <Route path="/" component={App} onChange={() => { onRouteChange(store); }}>
<IndexRoute component={IDEView} onEnter={checkAuth(store)} /> <IndexRoute component={IDEView} onEnter={checkAuth(store)} />
<Route path="/login" component={LoginView} /> <Route path="/login" component={userIsNotAuthenticated(LoginView)} />
<Route path="/signup" component={SignupView} /> <Route path="/signup" component={userIsNotAuthenticated(SignupView)} />
<Route path="/reset-password" component={ResetPasswordView} /> <Route path="/reset-password" component={userIsNotAuthenticated(ResetPasswordView)} />
<Route path="/verify" component={EmailVerificationView} /> <Route path="/verify" component={EmailVerificationView} />
<Route <Route
path="/reset-password/:reset_password_token" path="/reset-password/:reset_password_token"
@ -35,11 +38,16 @@ const routes = store => (
<Route path="/projects/:project_id" component={IDEView} /> <Route path="/projects/:project_id" component={IDEView} />
<Route path="/:username/full/:project_id" component={FullView} /> <Route path="/:username/full/:project_id" component={FullView} />
<Route path="/full/:project_id" component={FullView} /> <Route path="/full/:project_id" component={FullView} />
<Route path="/sketches" component={IDEView} /> <Route path="/sketches" component={createRedirectWithUsername('/:username/sketches')} />
<Route path="/assets" component={IDEView} /> <Route path="/:username/assets" component={userIsAuthenticated(userIsAuthorized(DashboardView))} />
<Route path="/account" component={AccountView} /> <Route path="/assets" component={createRedirectWithUsername('/:username/assets')} />
<Route path="/account" component={userIsAuthenticated(AccountView)} />
<Route path="/:username/sketches/:project_id" component={IDEView} /> <Route path="/:username/sketches/:project_id" component={IDEView} />
<Route path="/:username/sketches" component={IDEView} /> <Route path="/:username/sketches/:project_id/add-to-collection" component={IDEView} />
<Route path="/:username/sketches" component={DashboardView} />
<Route path="/:username/collections" component={DashboardView} />
<Route path="/:username/collections/create" component={DashboardView} />
<Route path="/:username/collections/:collection_id" component={CollectionView} />
<Route path="/about" component={IDEView} /> <Route path="/about" component={IDEView} />
</Route> </Route>
); );

View file

@ -83,6 +83,10 @@
border: 2px solid getThemifyVariable('button-border-color'); border: 2px solid getThemifyVariable('button-border-color');
border-radius: 2px; border-radius: 2px;
padding: #{10 / $base-font-size}rem #{30 / $base-font-size}rem; padding: #{10 / $base-font-size}rem #{30 / $base-font-size}rem;
& g {
fill: getThemifyVariable('button-color');
opacity: 1;
}
&:enabled:hover { &:enabled:hover {
border-color: getThemifyVariable('button-background-hover-color'); border-color: getThemifyVariable('button-background-hover-color');
background-color: getThemifyVariable('button-background-hover-color'); background-color: getThemifyVariable('button-background-hover-color');
@ -102,27 +106,6 @@
} }
} }
%forms-button {
background-color: $form-button-background-color;
color: $form-button-color;
cursor: pointer;
border: 2px solid $form-button-color;
border-radius: 2px;
padding: #{8 / $base-font-size}rem #{25 / $base-font-size}rem;
line-height: 1;
margin: #{39 / $base-font-size}rem 0 #{24 / $base-font-size}rem 0;
&:enabled:hover {
border-color: $form-button-background-hover-color;
background-color: $form-button-background-hover-color;
color: $form-button-hover-color;
}
&:enabled:active {
border-color: $form-button-background-active-color;
background-color: $form-button-background-active-color;
color: $form-button-active-color;
}
}
%preferences-button { %preferences-button {
@extend %toolbar-button; @extend %toolbar-button;
@include themify() { @include themify() {

View file

@ -9,15 +9,22 @@ $orange: #ffa500;
$red: #ff0000; $red: #ff0000;
$lightsteelblue: #B0C4DE; $lightsteelblue: #B0C4DE;
$dodgerblue: #1E90FF; $dodgerblue: #1E90FF;
$primary-text-color: #333;
$icon-color: #8b8b8b; $icon-color: #8b8b8b;
$icon-hover-color: #333; $icon-hover-color: #333;
$p5-contrast-pink: #FFA9D9; $p5-contrast-pink: #FFA9D9;
// Grays
$dark: #333;
$middleGray: #7d7d7d;
$middleLight: #a6a6a6;
// Abstracts
$primary-text-color: $dark;
$themes: ( $themes: (
light: ( light: (
logo-color: $p5js-pink, logo-color: $p5js-pink,
primary-text-color: #333, primary-text-color: $primary-text-color,
dropzone-text-color: #333, dropzone-text-color: #333,
modal-button-color: #333, modal-button-color: #333,
heading-text-color: #333, heading-text-color: #333,
@ -65,8 +72,35 @@ $themes: (
keyboard-shortcut-color: #757575, keyboard-shortcut-color: #757575,
nav-hover-color: $p5js-pink, nav-hover-color: $p5js-pink,
error-color: $p5js-pink, error-color: $p5js-pink,
table-row-stripe-color: #d6d6d6,
codefold-icon-open: url(../images/triangle-arrow-down.svg), codefold-icon-open: url(../images/triangle-arrow-down.svg),
codefold-icon-closed: url(../images/triangle-arrow-right.svg) codefold-icon-closed: url(../images/triangle-arrow-right.svg),
primary-button-color: #fff,
primary-button-background-color: $p5js-pink,
table-button-color: $white,
table-button-background-color: #979797,
table-button-active-color: $white,
table-button-background-active-color: #00A1D3,
table-button-hover-color: $white,
table-button-background-hover-color: $p5js-pink,
progress-bar-background-color: #979797,
progress-bar-active-color: #f10046,
form-title-color: rgba(51, 51, 51, 0.87),
form-secondary-title-color: $middleGray,
form-input-text-color: $dark,
form-input-placeholder-text-color: $middleLight,
form-border-color: #b5b5b5,
form-button-background-color: $white,
form-button-color: #f10046,
form-button-background-hover-color: $p5js-pink,
form-button-background-active-color: #f10046,
form-button-hover-color: $white,
form-button-active-color: $white,
form-navigation-options-color: #999999
), ),
dark: ( dark: (
logo-color: $p5js-pink, logo-color: $p5js-pink,
@ -117,8 +151,33 @@ $themes: (
keyboard-shortcut-color: #B5B5B5, keyboard-shortcut-color: #B5B5B5,
nav-hover-color: $p5js-pink, nav-hover-color: $p5js-pink,
error-color: $p5js-pink, error-color: $p5js-pink,
table-row-stripe-color: #3f3f3f,
codefold-icon-open: url(../images/triangle-arrow-down-white.svg), codefold-icon-open: url(../images/triangle-arrow-down-white.svg),
codefold-icon-closed: url(../images/triangle-arrow-right-white.svg) codefold-icon-closed: url(../images/triangle-arrow-right-white.svg),
primary-button-color: #fff,
primary-button-background-color: $p5js-pink,
table-button-color: $white,
table-button-background-color: #979797,
table-button-active-color: $white,
table-button-background-active-color: #00A1D3,
table-button-hover-color: $white,
table-button-background-hover-color: $p5js-pink,
progress-bar-background-color: #979797,
progress-bar-active-color: #f10046,
form-title-color: $white,
form-secondary-title-color: #b5b5b5,
form-border-color: #b5b5b5,
form-button-background-color: $black,
form-button-color: #f10046,
form-button-background-hover-color: $p5js-pink,
form-button-background-active-color: #f10046,
form-button-hover-color: $white,
form-button-active-color: $white,
form-navigation-options-color: #999999
), ),
contrast: ( contrast: (
logo-color: $yellow, logo-color: $yellow,
@ -168,8 +227,33 @@ $themes: (
keyboard-shortcut-color: #e1e1e1, keyboard-shortcut-color: #e1e1e1,
nav-hover-color: $yellow, nav-hover-color: $yellow,
error-color: $p5-contrast-pink, error-color: $p5-contrast-pink,
table-row-stripe-color: #3f3f3f,
codefold-icon-open: url(../images/triangle-arrow-down-white.svg), codefold-icon-open: url(../images/triangle-arrow-down-white.svg),
codefold-icon-closed: url(../images/triangle-arrow-right-white.svg) codefold-icon-closed: url(../images/triangle-arrow-right-white.svg),
primary-button-color: #fff,
primary-button-background-color: $p5js-pink,
table-button-color: #333,
table-button-background-color: #C1C1C1,
table-button-active-color: #333,
table-button-background-active-color: #00FFFF,
table-button-hover-color: #333,
table-button-background-hover-color: $yellow,
progress-bar-background-color: #979797,
progress-bar-active-color: #f10046,
form-title-color: $white,
form-secondary-title-color: #b5b5b5,
form-border-color: #b5b5b5,
form-button-background-color: $black,
form-button-color: #f10046,
form-button-background-hover-color: $p5-contrast-pink,
form-button-background-active-color: #f10046,
form-button-hover-color: $white,
form-button-active-color: $white,
form-navigation-options-color: #999999
) )
); );
@ -179,15 +263,5 @@ $console-error-color: #ff5f52;
$toast-background-color: #4A4A4A; $toast-background-color: #4A4A4A;
$toast-text-color: $white; $toast-text-color: $white;
$form-title-color: rgba(51, 51, 51, 0.87);
$secondary-form-title-color: #b5b5b5;
$form-button-background-color: $white;
$form-button-color: #f10046;
$form-button-background-hover-color: $p5js-pink;
$form-button-background-active-color: #f10046;
$form-button-hover-color: $white;
$form-button-active-color: $white;
$form-navigation-options-color: #999999;
$about-play-background-color: rgba(255, 255, 255, 0.7); $about-play-background-color: rgba(255, 255, 255, 0.7);
$about-button-border-color: rgba(151, 151, 151, 0.7); $about-button-border-color: rgba(151, 151, 151, 0.7);

View file

@ -6,13 +6,13 @@ html, body {
font-size: #{$base-font-size}px; font-size: #{$base-font-size}px;
} }
body, input { body, input, textarea {
@include themify() { @include themify() {
color: getThemifyVariable('primary-text-color'); color: getThemifyVariable('primary-text-color');
} }
} }
body, input, button { body, input, textarea, button {
font-family: Montserrat, sans-serif; font-family: Montserrat, sans-serif;
} }
@ -31,7 +31,8 @@ input, button {
font-size: 1rem; font-size: 1rem;
} }
input { input,
textarea {
padding: #{5 / $base-font-size}rem; padding: #{5 / $base-font-size}rem;
border: 1px solid ; border: 1px solid ;
border-radius: 2px; border-radius: 2px;
@ -42,12 +43,18 @@ input {
} }
} }
button[type="submit"],
input[type="submit"] { input[type="submit"] {
@include themify() { @include themify() {
@extend %button; @extend %button;
} }
} }
button[type="submit"]:disabled,
input[type="submit"]:disabled {
cursor: not-allowed;
}
button { button {
@include themify() { @include themify() {
@extend %link; @extend %link;
@ -56,6 +63,10 @@ button {
border: none; border: none;
} }
h1 {
font-size: #{21 / $base-font-size}em;
}
h2 { h2 {
font-size: #{21 / $base-font-size}em; font-size: #{21 / $base-font-size}em;
} }

View file

@ -46,7 +46,9 @@
padding-left: #{20 / $base-font-size}rem; padding-left: #{20 / $base-font-size}rem;
width: #{720 / $base-font-size}rem; width: #{720 / $base-font-size}rem;
& a { & a {
color: $form-navigation-options-color; @include themify() {
color: getThemifyVariable('form-navigation-options-color');
}
} }
} }

View file

@ -0,0 +1,22 @@
.account-settings__container {
@include themify() {
color: getThemifyVariable('primary-text-color');
background-color: getThemifyVariable('background-color');
}
height: 100%;
}
.account-settings {
max-width: #{700 / $base-font-size}rem;
align-self: center;
padding: 0 #{10 / $base-font-size}rem;
margin: 0 auto;
}
.account__tabs {
padding-top: #{20 / $base-font-size}rem;
}
.account__social-text {
padding-bottom: #{15 / $base-font-size}rem;
}

View file

@ -0,0 +1,109 @@
.api-key-form__summary {
padding-top: #{25 / $base-font-size}rem;
@include themify() {
color: getThemifyVariable('heading-text-color');
}
}
.api-key-form__section {
padding-bottom: #{15 / $base-font-size}rem;
}
.api-key-form__title {
padding: #{15 / $base-font-size}rem 0;
font-size: #{21 / $base-font-size}rem;
font-weight: bold;
@include themify() {
color: getThemifyVariable('heading-text-color');
}
}
.api-key-form__create-button {
display: flex;
justify-content: center;
align-items: center;
}
.api-key-form__create-icon {
display: flex;
}
.api-key-form__create-button .isvg {
padding-right: 10px;
}
.api-key-list {
display: block;
max-width: 900px;
border-collapse: collapse;
table-layout: fixed;
thead tr th {
width: 30%;
}
thead tr th:last-child {
width: 10%;
}
th {
padding: #{5 / $base-font-size}rem;
}
td {
padding: #{15 / $base-font-size}rem #{5 / $base-font-size}rem;
}
tbody tr:nth-child(odd) {
@include themify() {
background: getThemifyVariable('table-row-stripe-color');
}
}
}
.api-key-list__action {
text-align: center;
}
.api-key-list__delete-button {
width:#{20 / $base-font-size}rem;
height:#{20 / $base-font-size}rem;
text-align: center;
@include themify() {
background-color: transparent;
border: none;
cursor: pointer;
padding: 0;
position: initial;
left: 0;
top: 0;
& g {
opacity: 1;
fill: getThemifyVariable('icon-color');
}
}
}
.api-key-list__delete-button:hover {
@include themify() {
& g {
opacity: 1;
fill: getThemifyVariable('icon-hover-color');
}
}
}
.api-key-form__new-token__title {
margin-bottom: #{10 / $base-font-size}rem;
font-size: #{18 / $base-font-size}rem;
font-weight: bold;
@include themify() {
color: getThemifyVariable('heading-text-color');
}
}
.api-key-form__new-token__info {
padding: #{10 / $base-font-size}rem 0;
}

View file

@ -1,60 +1,99 @@
.asset-table-container { .asset-table-container {
// flex: 1 1 0%;
overflow-y: auto; overflow-y: auto;
max-width: 100%; max-width: 100%;
width: #{1000 / $base-font-size}rem; min-height: 100%;
min-height: #{400 / $base-font-size}rem;
} }
.asset-table { .asset-table {
width: 100%; width: 100%;
padding: #{10 / $base-font-size}rem #{20 / $base-font-size}rem;
max-height: 100%; max-height: 100%;
border-spacing: 0; border-spacing: 0;
& .asset-list__delete-column { position: relative;
width: #{23 / $base-font-size}rem; & .asset-table__dropdown-column {
width: #{60 / $base-font-size}rem;
position: relative;
} }
}
& thead { .asset-table thead th {
font-size: #{12 / $base-font-size}rem; height: #{32 / $base-font-size}rem;
@include themify() { position: sticky;
color: getThemifyVariable('inactive-text-color') top: 0;
} @include themify() {
background-color: getThemifyVariable('background-color');
} }
}
& th { .asset-table thead th:nth-child(1){
height: #{32 / $base-font-size}rem; padding-left: #{12 / $base-font-size}rem;
font-weight: normal;
}
} }
.asset-table__row { .asset-table__row {
margin: #{10 / $base-font-size}rem; margin: #{10 / $base-font-size}rem;
height: #{72 / $base-font-size}rem; height: #{72 / $base-font-size}rem;
font-size: #{16 / $base-font-size}rem; font-size: #{16 / $base-font-size}rem;
}
&:nth-child(odd) { .asset-table__row:nth-child(odd) {
@include themify() { @include themify() {
background: getThemifyVariable('console-header-background-color'); background: getThemifyVariable('table-row-stripe-color');
}
} }
}
& a { .asset-table__row > th:nth-child(1) {
@include themify() { padding-left: #{12 / $base-font-size}rem;
color: getThemifyVariable('primary-text-color'); }
}
}
& td:first-child { .asset-table__row a {
padding-left: #{10 / $base-font-size}rem; @include themify() {
color: getThemifyVariable('primary-text-color');
} }
} }
.asset-table thead {
font-size: #{12 / $base-font-size}rem;
@include themify() {
color: getThemifyVariable('inactive-text-color')
}
}
.asset-table th {
font-weight: normal;
}
.asset-table__empty { .asset-table__empty {
text-align: center; text-align: center;
font-size: #{16 / $base-font-size}rem; font-size: #{16 / $base-font-size}rem;
padding: #{42 / $base-font-size}rem 0;
} }
.asset-table__total { .asset-table__total {
padding: 0 #{20 / $base-font-size}rem; padding: 0 #{20 / $base-font-size}rem;
position: sticky;
top: 0;
@include themify() {
background-color: getThemifyVariable('background-color');
}
}
.asset-table__dropdown-button {
width:#{25 / $base-font-size}rem;
height:#{25 / $base-font-size}rem;
@include themify() {
& polygon {
fill: getThemifyVariable('dropdown-color');
}
}
}
.asset-table__action-dialogue {
@extend %dropdown-open-right;
top: 63%;
right: calc(100% - 26px);
}
.asset-table__action-option {
font-size: #{12 / $base-font-size}rem;
} }

View file

@ -0,0 +1,48 @@
.asset-size {
position: relative;
flex: 1;
margin-bottom: #{18 / $base-font-size}rem;
font-size: #{14 / $base-font-size}rem;
}
.asset-size-bar {
position: relative;
content: ' ';
display: block;
width: 200px;
height: 20px;
border-radius: #{3 / $base-font-size}rem;
border: 1px solid transparent;
overflow: hidden;
@include themify() {
background-color: getThemifyVariable('progress-bar-background-color');
}
}
.asset-size-bar::before {
content: ' ';
display: block;
position: absolute;
top: 0;
left: 0;
bottom: 0;
width: calc(var(--percent) * 100%);
@include themify() {
background-color: getThemifyVariable('progress-bar-active-color');
}
}
.asset-current {
position: absolute;
top: #{28 / $base-font-size}rem;
left: 0;
}
.asset-max {
position: absolute;
top: 0;
left: #{210 / $base-font-size}rem;
}

View file

@ -0,0 +1,3 @@
.collection-create {
padding: #{24 / $base-font-size}rem;
}

View file

@ -0,0 +1,95 @@
.collection-popover {
position: absolute;
height: auto;
width: #{400 / $base-font-size}rem;
top: 63%;
right: calc(100% - 26px);
z-index: 9999;
display: flex;
flex-direction: column;
border-radius: #{6 / $base-font-size}rem;
@include themify() {
background-color: map-get($theme-map, 'modal-background-color');
border: 1px solid map-get($theme-map, 'modal-border-color');
box-shadow: 0 0 18px 0 getThemifyVariable('shadow-color');
color: getThemifyVariable('dropdown-color');
}
text-align: left;
}
.collection-popover__header {
display: flex;
margin-left: #{17 / $base-font-size}rem;
margin-right: #{17 / $base-font-size}rem;
}
.collection-popover__filter {
display: flex;
margin-bottom: #{8 / $base-font-size}rem;
}
.collection-popover__exit-button {
@include icon();
margin-left: auto;
}
.collection-popover__items {
height: #{70 * 4 / $base-font-size}rem;
overflow: auto;
}
.collection-popover__item {
display: flex;
align-items: center;
justify-content: space-between;
height: #{60 / $base-font-size}rem;
margin: 5px;
padding: 5px;
}
.collection-popover__item:nth-child(odd) {
@include themify() {
background: getThemifyVariable('table-row-stripe-color');
}
}
.collection-popover__item__info {
flex: 1;
width: 100%;
height: 100%;
display: flex;
align-items: center;
}
.collection-popover__item__info button,
.collection-popover__item__info button:hover {
flex: 1;
width: 100%;
height: 100%;
text-align: left;
color: black;
}
.collection-popover__item__view {
}
.collection-popover__item__view-button {
@extend %button;
}
.collection-popover__empty {
display: flex;
justify-content: center;
align-items: center;
height: 100%;
}

View file

@ -0,0 +1,174 @@
.collection-container {
padding: #{24 / $base-font-size}rem #{66 / $base-font-size}rem;
flex: 1;
display: flex;
flex-direction: column;
}
.collection-metadata {
width: #{1012 / $base-font-size}rem;
margin: 0 auto;
margin-bottom: #{24 / $base-font-size}rem;
}
.collection-metadata__columns {
display: flex;
}
.collection-metadata__column--left,
.collection-metadata__column--right {
flex: 1;
}
.collection-metadata__column--right {
align-self: flex-end;
display: flex;
flex-direction: row;
justify-content: flex-end;
}
.collection-metadata__column--right > * {
margin-left: #{10 / $base-font-size}rem;
}
.collection-metadata__name {
// padding: #{8 / $base-font-size}rem 0;
}
.collection-metadata__name .editable-input__label span {
padding: 0.83333rem 0;
@include themify() {
color: getThemifyVariable('primary-text-color');
}
}
.collection-metadata__name,
.collection-metadata__name .editable-input__input {
font-size: 1.75rem;
font-weight: bold;
}
.collection-metadata__user {
padding-top: #{8 / $base-font-size}rem;
font-size: 14px;
}
.collection-metadata--is-owner .collection-metadata__user {
padding-left: #{8 / $base-font-size}rem;
}
.collection-metadata__description {
margin-top: #{8 / $base-font-size}rem;
text-align: left;
font-size: 14px;
}
.collection-metadata__description .editable-input__label {
text-align: left;
}
.collection-metadata__description .editable-input--has-value .editable-input__label {
@include themify() {
color: getThemifyVariable('primary-text-color');
}
}
.collection-metadata__description .editable-input__input {
width: 100%;
}
.collection-add-sketch {
min-width: #{600 / $base-font-size}rem;
overflow: scroll;
}
.collection-share {
text-align: right;
position: relative;
}
.collection-share__button {
@extend %button;
display: flex;
align-items: center;
}
.collection-share__arrow {
margin-left: #{5 / $base-font-size}rem;
}
.collection-share .copyable-input {
padding-bottom: 0;
}
.collection__share-dropdown {
@extend %dropdown-open-right;
padding: #{20 / $base-font-size}rem;
width: #{350 / $base-font-size}rem;
}
.collection-metadata__add-button {
@extend %button;
flex-grow: 0;
}
.collection-table-wrapper {
width: #{1012 / $base-font-size}rem;
margin: 0 auto;
flex: 1;
@include themify() {
border: 1px solid getThemifyVariable('modal-border-color');
}
}
[data-has-items=false] .collection-table-wrapper {
display: flex;
justify-content: center;
align-items: center;
}
.collection-empty-message {
text-align: center;
font-size: #{16 / $base-font-size}rem;
}
.collection-row__action-column {
width: #{60 / $base-font-size}rem;
position: relative;
}
.collection-row__remove-button {
display: inline-block;
width:#{35 / $base-font-size}rem;
height:#{35 / $base-font-size}rem;
@include icon();
@include themify() {
// icon graphic
polygon {
fill: getThemifyVariable('table-button-color');
}
// icon background circle
path {
fill: getThemifyVariable('table-button-background-color');
}
& svg {
width:#{35 / $base-font-size}rem;
height:#{35 / $base-font-size}rem;
}
&:hover,
&:focus {
polygon {
fill: getThemifyVariable('table-button-hover-color');
}
path {
fill: getThemifyVariable('table-button-background-hover-color');
}
}
}
}

View file

@ -0,0 +1,90 @@
.dashboard-header {
padding: #{24 / $base-font-size}rem #{66 / $base-font-size}rem;
position: relative;
flex: 1;
overflow: hidden;
display: flex;
flex-direction:column;
}
.dashboard-header__header {
max-width: #{1012 / $base-font-size}rem;
margin: 0 auto;
width: 100%;
}
.dashboard-header--no-vertical-padding {
padding: 0 66px;
}
.dashboard-header--no-vertical-padding {
padding: 0 66px;
}
.dashboard-header__switcher {
flex: 1;
}
.dashboard-header__tabs {
display: flex;
padding-top: #{24 / $base-font-size}rem;
@include themify() {
border-bottom: 1px solid getThemifyVariable('inactive-text-color');
}
}
.dashboard-header__tab {
@include themify() {
color: getThemifyVariable('inactive-text-color');
border-bottom: #{4 / $base-font-size}rem solid transparent;
padding: 0;
margin-right: #{26 / $base-font-size}rem;
&:hover, &:focus, &.dashboard-header__tab--selected {
color: getThemifyVariable('primary-text-color');
border-bottom-color: getThemifyVariable('nav-hover-color');
cursor: pointer;
}
}
font-size: #{12 / $base-font-size}rem;
}
.dashboard-header__tab--selected {
cursor: auto;
}
.dashboard-header__tab a {
color: inherit;
}
.dashboard-header__tab__title {
margin: 0;
padding: 0;
}
.dashboard-header__tab__title > * {
display: inline-block;
padding: 0 #{5 /$base-font-size}rem #{5 /$base-font-size}rem;
}
.dashboard-header__nav {
}
.dashboard-header__actions {
display: flex;
align-items: center;
padding: #{24 / $base-font-size}rem 0;
justify-content: space-between;
}
.dashboard-header__actions > *:not(:first-child) {
margin-left: #{15 / $base-font-size}rem;
}
.dashboard__action-button {
flex-grow: 0;
@extend %button;
}

View file

@ -0,0 +1,53 @@
.editable-input {
height: 70%;
display: flex;
align-items: center;
}
.editable-input__label {
display: flex;
@include themify() {
color: getThemifyVariable('inactive-text-color');
&:hover {
color: getThemifyVariable('primary-text-color');
& .editable-input__icon path {
fill: getThemifyVariable('primary-text-color');
}
}
}
cursor: pointer;
line-height: #{18 / $base-font-size}rem;
font-size: unset;
font-weight: unset;
}
.editable-input__icon svg {
width: 1.5rem;
height: 1.5rem;
@include themify() {
path {
fill: getThemifyVariable('inactive-text-color');
}
}
}
.editable-input:hover {
@include themify() {
.editable-input__icon path {
fill: getThemifyVariable('primary-text-color');
}
}
}
.editable-input--is-not-editing .editable-input__input,
.editable-input--is-editing .editable-input__label {
display: none;
}
.editable-input--is-editing .editable-input__input,
.editable-input--is-not-editing .editable-input__label {
display: block;
}

View file

@ -6,6 +6,14 @@
align-items: center; align-items: center;
} }
.form-container--align-left {
text-align: left;
}
.form-container--align-top {
height: unset;
}
.form-container__header { .form-container__header {
width: 100%; width: 100%;
padding: #{15 / $base-font-size}rem #{34 / $base-font-size}rem; padding: #{15 / $base-font-size}rem #{34 / $base-font-size}rem;
@ -21,9 +29,21 @@
align-items: center; align-items: center;
} }
.form-container--align-left .form-container__content {
align-items: left;
}
.form-container__title { .form-container__title {
font-weight: normal; font-weight: normal;
color: $form-title-color; @include themify() {
color: getThemifyVariable('form-title-color')
}
}
.form-container__context {
@include themify() {
color: getThemifyVariable('secondary-text-color')
}
} }
.form-container__divider { .form-container__divider {
@ -31,9 +51,9 @@
} }
.form-container__logo-button { .form-container__logo-button {
@extend %none-themify-icon; @include icon();
} }
.form-container__exit-button { .form-container__exit-button {
@extend %none-themify-icon-with-hover; @include icon();
} }

View file

@ -5,10 +5,15 @@
font-size: #{9 / $base-font-size}rem; font-size: #{9 / $base-font-size}rem;
text-align: left; text-align: left;
@include themify() { @include themify() {
color: getThemifyVariable('error-color') color: getThemifyVariable('error-color');
} }
} }
.form--inline {
display: flex;
align-items: center;
}
.form__cancel-button { .form__cancel-button {
margin-top: #{10 / $base-font-size}rem; margin-top: #{10 / $base-font-size}rem;
font-size: #{12 / $base-font-size}rem; font-size: #{12 / $base-font-size}rem;
@ -17,22 +22,48 @@
.form__navigation-options { .form__navigation-options {
margin-top: #{16 / $base-font-size}rem; margin-top: #{16 / $base-font-size}rem;
font-size: #{12 / $base-font-size}rem; font-size: #{12 / $base-font-size}rem;
color: $form-navigation-options-color; @include themify() {
color: getThemifyVariable('form-navigation-options-color');
}
}
.form__legend{
font-size: #{21 / $base-font-size}rem;
font-weight: bold;
} }
.form__label { .form__label {
color: $secondary-form-title-color;
font-size: #{12 / $base-font-size}rem; font-size: #{12 / $base-font-size}rem;
margin-top: #{25 / $base-font-size}rem; margin-top: #{25 / $base-font-size}rem;
margin-bottom: #{7 / $base-font-size}rem; margin-bottom: #{7 / $base-font-size}rem;
display: block; display: block;
@include themify() {
color: getThemifyVariable('form-secondary-title-color');
}
}
.form__label--hidden {
@extend %hidden-element;
} }
.form__input { .form__input {
width: #{360 / $base-font-size}rem; width: 100%;
min-width: #{360 / $base-font-size}rem;
height: #{40 / $base-font-size}rem; height: #{40 / $base-font-size}rem;
color: $icon-hover-color; font-size: #{16 / $base-font-size}rem;
border-color: $secondary-form-title-color; @include themify() {
color: getThemifyVariable('form-input-text-color');
}
}
.form__input-flexible-height {
height: auto;
}
.form__input::placeholder {
@include themify() {
color: getThemifyVariable('form-input-placeholder-text-color');
}
} }
.form__context { .form__context {
@ -41,13 +72,26 @@
} }
.form__status { .form__status {
color: $form-navigation-options-color; @include themify() {
color: getThemifyVariable('form-navigation-options-color');
}
} }
.form input[type="submit"] { .form [type="submit"] {
@extend %forms-button; @extend %button;
padding: #{8 / $base-font-size}rem #{25 / $base-font-size}rem;
margin: #{39 / $base-font-size}rem 0 #{24 / $base-font-size}rem 0;
} }
.form input[type="submit"]:disabled { .form [type="submit"][disabled] {
cursor: not-allowed;
}
.form--inline [type="submit"] {
margin: 0 0 0 #{24 / $base-font-size}rem;
}
.form [type="submit"][disabled],
.form--inline [type="submit"][disabled] {
cursor: not-allowed; cursor: not-allowed;
} }

View file

@ -1,28 +1,4 @@
.github-button { .github-button,
@include themify() {
@extend %button;
& path {
color: getThemifyVariable('primary-text-color');
}
&:hover path, &:active path {
fill: $white;
}
&:hover, &:active {
background-color: getThemifyVariable('secondary-text-color');
border-color: getThemifyVariable('secondary-text-color');
}
}
width: #{300 / $base-font-size}rem;
display: flex;
justify-content: center;
align-items: center;
}
.github-icon {
margin-right: #{10 / $base-font-size}rem;
}
.google-button { .google-button {
@include themify() { @include themify() {
@extend %button; @extend %button;
@ -33,16 +9,23 @@
fill: $white; fill: $white;
} }
&:hover, &:active { &:hover, &:active {
background-color: getThemifyVariable('secondary-text-color'); color: getThemifyVariable('button-hover-color');
border-color: getThemifyVariable('secondary-text-color'); background-color: getThemifyVariable('button-background-hover-color');
border-color: getThemifyVariable('button-background-hover-color');
} }
} }
margin-top: #{4 / $base-font-size}rem; width: #{300 / $base-font-size}rem;
display: flex; display: flex;
flex-direction: row;
justify-content: center; justify-content: center;
align-items: center; align-items: center;
width: #{300 / $base-font-size}rem;
& + & {
margin-top: #{10 / $base-font-size}rem;
}
}
.github-icon {
margin-right: #{10 / $base-font-size}rem;
} }
.google-icon { .google-icon {

View file

@ -1,9 +1,17 @@
.loader-container {
width: 100%;
height: 100%;
display: flex;
justify-content: center;
align-items: center;
}
.loader { .loader {
width: #{80 / $base-font-size }rem; width: #{80 / $base-font-size }rem;
height: #{80 / $base-font-size}rem; height: #{80 / $base-font-size}rem;
position: relative; position: relative;
margin: #{100 / $base-font-size}rem auto;
} }
.loader__circle1, .loader__circle1,

View file

@ -10,7 +10,7 @@
.modal-content { .modal-content {
@extend %modal; @extend %modal;
min-height: #{150 / $base-font-size}rem; min-height: #{150 / $base-font-size}rem;
width: #{400 / $base-font-size}rem; width: #{500 / $base-font-size}rem;
padding: #{20 / $base-font-size}rem; padding: #{20 / $base-font-size}rem;
.modal--reduced & { .modal--reduced & {
//min-height: #{150 / $base-font-size}rem; //min-height: #{150 / $base-font-size}rem;
@ -32,9 +32,8 @@
margin-bottom: #{20 / $base-font-size}rem; margin-bottom: #{20 / $base-font-size}rem;
} }
.new-file-form, .new-file-folder { .new-folder-form__input-wrapper, .new-file-form__input-wrapper {
display: flex; display: flex;
flex-wrap: wrap;
} }
.new-file-form__name-label, .new-folder-form__name-label { .new-file-form__name-label, .new-folder-form__name-label {
@ -43,6 +42,7 @@
.new-file-form__name-input, .new-folder-form__name-input { .new-file-form__name-input, .new-folder-form__name-input {
margin-right: #{10 / $base-font-size}rem; margin-right: #{10 / $base-font-size}rem;
flex: 1;
} }
.modal__divider { .modal__divider {

View file

@ -43,7 +43,8 @@
} }
} }
.nav__item:first-child { .nav__item:first-child,
.nav__item--no-icon {
padding-left: #{15 / $base-font-size}rem; padding-left: #{15 / $base-font-size}rem;
} }
@ -57,6 +58,12 @@
color: getThemifyVariable('nav-hover-color'); color: getThemifyVariable('nav-hover-color');
} }
} }
& g, & path {
@include themify() {
fill: getThemifyVariable('nav-hover-color');
}
}
.nav__item-header-triangle polygon { .nav__item-header-triangle polygon {
@include themify() { @include themify() {
@ -66,6 +73,21 @@
} }
.nav__item-header:hover { .nav__item-header:hover {
@include themify() {
color: getThemifyVariable('nav-hover-color');
}
& g, & path {
@include themify() {
fill: getThemifyVariable('nav-hover-color');
}
}
}
.nav__item-header-triangle {
margin-left: #{5 / $base-font-size}rem;
}
.nav__dropdown {
@include themify() { @include themify() {
color: getThemifyVariable('nav-hover-color'); color: getThemifyVariable('nav-hover-color');
} }
@ -87,7 +109,7 @@
padding-right: #{20 / $base-font-size}rem; padding-right: #{20 / $base-font-size}rem;
& .nav__dropdown { & .nav__dropdown {
width: #{121 / $base-font-size}rem; width: #{122 / $base-font-size}rem;
} }
} }
@ -159,4 +181,18 @@
color: getThemifyVariable('button-hover-color'); color: getThemifyVariable('button-hover-color');
} }
} }
} }
.nav__back-icon {
& g, & path {
opacity: 1;
@include themify() {
fill: getThemifyVariable('inactive-text-color');
}
}
margin-right: #{5 / $base-font-size}rem;
}
.nav__back-link {
display: flex;
}

View file

@ -10,6 +10,8 @@
display: block; display: block;
margin-top: #{40 / $base-font-size}rem; margin-top: #{40 / $base-font-size}rem;
margin-bottom: #{80 / $base-font-size}rem; margin-bottom: #{80 / $base-font-size}rem;
color: $form-navigation-options-color; @include themify() {
color: getThemifyVariable('form-navigation-options-color');
}
} }
} }

Some files were not shown because too many files have changed in this diff Show more