Merge pull request #1340 from processing/feature/public-api

Merge Collections to Master
This commit is contained in:
Cassie Tarakajian 2020-03-30 16:07:53 -04:00 committed by GitHub
commit f1dd9f44ef
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
149 changed files with 8425 additions and 1579 deletions

View File

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

View File

@ -16,7 +16,7 @@
],
"env": {
"API_URL": {
"value": "/api"
"value": "/editor"
},
"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 { connect } from 'react-redux';
import { withRouter } from 'react-router';
import { Link } from 'react-router';
import { Link, browserHistory } from 'react-router';
import InlineSVG from 'react-inlinesvg';
import classNames from 'classnames';
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 { metaKeyName, } from '../utils/metaKey';
import caretLeft from '../images/left-arrow.svg';
const triangleUrl = require('../images/down-filled-triangle.svg');
const logoUrl = require('../images/p5js-logo-small.svg');
@ -92,11 +93,12 @@ class Nav extends React.PureComponent {
}
handleNew() {
if (!this.props.unsavedChanges) {
const { unsavedChanges, warnIfUnsavedChanges } = this.props;
if (!unsavedChanges) {
this.props.showToast(1500);
this.props.setToastText('Opened new sketch.');
this.props.newProject();
} else if (this.props.warnIfUnsavedChanges()) {
} else if (warnIfUnsavedChanges && warnIfUnsavedChanges()) {
this.props.showToast(1500);
this.props.setToastText('Opened new sketch.');
this.props.newProject();
@ -165,6 +167,8 @@ class Nav extends React.PureComponent {
handleLogout() {
this.props.logoutUser();
// if you're on the settings page, probably.
browserHistory.push('/');
this.setDropdown('none');
}
@ -222,6 +226,439 @@ class Nav extends React.PureComponent {
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() {
const navDropdownState = {
file: classNames({
@ -245,362 +682,11 @@ class Nav extends React.PureComponent {
'nav__item--open': this.state.dropdownOpen === 'account'
})
};
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>
<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> }
{this.renderLeftLayout(navDropdownState)}
{this.renderUserMenu(navDropdownState)}
{/*
<div className="nav__announce">
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,
showErrorModal: PropTypes.func.isRequired,
unsavedChanges: PropTypes.bool.isRequired,
warnIfUnsavedChanges: PropTypes.func.isRequired,
warnIfUnsavedChanges: PropTypes.func,
showKeyboardShortcutModal: PropTypes.func.isRequired,
cmController: PropTypes.shape({
tidyCode: PropTypes.func,
@ -653,6 +739,7 @@ Nav.propTypes = {
setAllAccessibleOutput: PropTypes.func.isRequired,
newFile: PropTypes.func.isRequired,
newFolder: PropTypes.func.isRequired,
layout: PropTypes.oneOf(['dashboard', 'project']),
rootFile: PropTypes.shape({
id: PropTypes.string.isRequired
}).isRequired
@ -663,7 +750,9 @@ Nav.defaultProps = {
id: undefined,
owner: undefined
},
cmController: {}
cmController: {},
layout: 'project',
warnIfUnsavedChanges: undefined
};
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 API_KEY_CREATED = 'API_KEY_CREATED';
export const API_KEY_REMOVED = 'API_KEY_REMOVED';
export const SET_PROJECT_NAME = 'SET_PROJECT_NAME';
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_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 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 SHOW_FOLDER_CHILDREN = 'SHOW_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 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 SHOW_RUNTIME_ERROR_WARNING = 'SHOW_RUNTIME_ERROR_WARNING';
export const SET_ASSETS = 'SET_ASSETS';
export const DELETE_ASSET = 'DELETE_ASSET';
export const TOGGLE_DIRECTION = 'TOGGLE_DIRECTION';
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

@ -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

@ -18,7 +18,10 @@ class App extends React.Component {
}
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);
}
}
@ -42,7 +45,10 @@ class App extends React.Component {
App.propTypes = {
children: PropTypes.element,
location: PropTypes.shape({
pathname: PropTypes.string
pathname: PropTypes.string,
state: PropTypes.shape({
skipSavingPath: PropTypes.bool,
}),
}).isRequired,
setPreviousPath: PropTypes.func.isRequired,
theme: PropTypes.string,

View File

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

View File

@ -30,8 +30,23 @@ export function getAssets() {
};
}
export function deleteAsset(assetKey, userId) {
export function deleteAsset(assetKey) {
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 { reset } from 'redux-form';
import * as ActionTypes from '../../../constants';
import { setUnsavedChanges } from './ide';
import { setUnsavedChanges, closeNewFolderModal, closeNewFileModal } from './ide';
import { setProjectSavedTime } from './project';
const __process = (typeof global !== 'undefined' ? global : window).process;
@ -58,6 +58,7 @@ export function createFile(formProps) {
parentId
});
dispatch(setProjectSavedTime(response.data.project.updatedAt));
dispatch(closeNewFileModal());
dispatch(reset('new-file'));
// dispatch({
// type: ActionTypes.HIDE_MODAL
@ -85,6 +86,7 @@ export function createFile(formProps) {
// type: ActionTypes.HIDE_MODAL
// });
dispatch(setUnsavedChanges(true));
dispatch(closeNewFileModal());
}
};
}
@ -109,9 +111,7 @@ export function createFolder(formProps) {
parentId
});
dispatch(setProjectSavedTime(response.data.project.updatedAt));
dispatch({
type: ActionTypes.CLOSE_NEW_FOLDER_MODAL
});
dispatch(closeNewFolderModal());
})
.catch(response => dispatch({
type: ActionTypes.ERROR,
@ -130,9 +130,7 @@ export function createFolder(formProps) {
fileType: 'folder',
children: []
});
dispatch({
type: ActionTypes.CLOSE_NEW_FOLDER_MODAL
});
dispatch(closeNewFolderModal());
}
};
}

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() {
return {
type: ActionTypes.EXPAND_SIDEBAR

View File

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

View File

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

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 { Helmet } from 'react-helmet';
import prettyBytes from 'pretty-bytes';
import InlineSVG from 'react-inlinesvg';
import Loader from '../../App/components/loader';
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 {
constructor(props) {
@ -16,10 +153,7 @@ class AssetList extends React.Component {
}
getAssetsTitle() {
if (!this.props.username || this.props.username === this.props.user.username) {
return 'p5.js Web Editor | My assets';
}
return `p5.js Web Editor | ${this.props.username}'s assets`;
return 'p5.js Web Editor | My assets';
}
hasAssets() {
@ -39,14 +173,9 @@ class AssetList extends React.Component {
}
render() {
const username = this.props.username !== undefined ? this.props.username : this.props.user.username;
const { assetList, totalSize } = this.props;
const { assetList } = this.props;
return (
<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>
<title>{this.getAssetsTitle()}</title>
</Helmet>
@ -58,20 +187,12 @@ class AssetList extends React.Component {
<tr>
<th>Name</th>
<th>Size</th>
<th>View</th>
<th>Sketch</th>
<th scope="col"></th>
</tr>
</thead>
<tbody>
{assetList.map(asset =>
(
<tr className="asset-table__row" key={asset.key}>
<td>{asset.name}</td>
<td>{prettyBytes(asset.size)}</td>
<td><Link to={asset.url} target="_blank">View</Link></td>
<td><Link to={`/${username}/sketches/${asset.sketchId}`}>{asset.sketchName}</Link></td>
</tr>
))}
{assetList.map(asset => <AssetListRow asset={asset} key={asset.key} />)}
</tbody>
</table>}
</div>
@ -83,15 +204,13 @@ AssetList.propTypes = {
user: PropTypes.shape({
username: PropTypes.string
}).isRequired,
username: PropTypes.string.isRequired,
assetList: PropTypes.arrayOf(PropTypes.shape({
key: PropTypes.string.isRequired,
name: PropTypes.string.isRequired,
url: PropTypes.string.isRequired,
sketchName: PropTypes.string.isRequired,
sketchId: PropTypes.string.isRequired
sketchName: PropTypes.string,
sketchId: PropTypes.string
})).isRequired,
totalSize: PropTypes.number.isRequired,
getAssets: PropTypes.func.isRequired,
loading: PropTypes.bool.isRequired
};
@ -100,7 +219,6 @@ function mapStateToProps(state) {
return {
user: state.user,
assetList: state.assets.list,
totalSize: state.assets.totalSize,
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,
thumbnailHeight: 200,
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),
sending: this.props.dropzoneSendingCallback,
complete: this.props.dropzoneCompleteCallback

View File

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

View File

@ -1,10 +1,12 @@
import PropTypes from 'prop-types';
import React from 'react';
import { connect } from 'react-redux';
import { bindActionCreators, compose } from 'redux';
import { reduxForm } from 'redux-form';
import classNames from 'classnames';
import InlineSVG from 'react-inlinesvg';
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';
const exitUrl = require('../../../images/exit.svg');
@ -28,16 +30,12 @@ class NewFileModal extends React.Component {
}
render() {
const modalClass = classNames({
'modal': true,
'modal--reduced': !this.props.canUploadMedia
});
return (
<section className={modalClass} ref={(element) => { this.modal = element; }}>
<section className="modal" ref={(element) => { this.modal = element; }}>
<div className="modal-content">
<div className="modal__header">
<h2 className="modal__title">Add File</h2>
<button className="modal__exit-button" onClick={this.props.closeModal}>
<h2 className="modal__title">Create File</h2>
<button className="modal__exit-button" onClick={this.props.closeNewFileModal}>
<InlineSVG src={exitUrl} alt="Close New File Modal" />
</button>
</div>
@ -45,17 +43,6 @@ class NewFileModal extends React.Component {
focusOnModal={this.focusOnModal}
{...this.props}
/>
{(() => {
if (this.props.canUploadMedia) {
return (
<div>
<p className="modal__divider">OR</p>
<FileUploader />
</div>
);
}
return '';
})()}
</div>
</section>
);
@ -63,8 +50,8 @@ class NewFileModal extends React.Component {
}
NewFileModal.propTypes = {
closeModal: PropTypes.func.isRequired,
canUploadMedia: PropTypes.bool.isRequired
createFile: PropTypes.func.isRequired,
closeNewFileModal: PropTypes.func.isRequired
};
function validate(formProps) {
@ -79,9 +66,19 @@ function validate(formProps) {
return errors;
}
function mapStateToProps() {
return {};
}
export default reduxForm({
form: 'new-file',
fields: ['name'],
validate
})(NewFileModal);
function mapDispatchToProps(dispatch) {
return bindActionCreators({ createFile, closeNewFileModal }, dispatch);
}
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
className="new-folder-form"
onSubmit={(data) => {
if (handleSubmit(this.createFolder)(data)) {
this.props.closeModal();
}
handleSubmit(this.createFolder)(data);
}}
>
<label className="new-folder-form__name-label" htmlFor="name">Name:</label>
<input
className="new-folder-form__name-input"
id="name"
type="text"
placeholder="Name"
ref={(element) => { this.fileName = element; }}
{...domOnlyProps(name)}
/>
<input type="submit" value="Add Folder" aria-label="add folder" />
<div className="new-folder-form__input-wrapper">
<label className="new-folder-form__name-label" htmlFor="name">Name:</label>
<input
className="new-folder-form__name-input"
id="name"
type="text"
placeholder="Name"
ref={(element) => { this.fileName = element; }}
{...domOnlyProps(name)}
/>
<input type="submit" value="Add Folder" aria-label="add folder" />
</div>
{name.touched && name.error && <span className="form-error">{name.error}</span>}
</form>
);

View File

@ -16,7 +16,7 @@ class NewFolderModal extends React.Component {
<section className="modal" ref={(element) => { this.newFolderModal = element; }} >
<div className="modal-content-folder">
<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}>
<InlineSVG src={exitUrl} alt="Close New Folder Modal" />
</button>

View File

@ -98,9 +98,9 @@ class Preferences extends React.Component {
</Helmet>
<Tabs>
<TabList>
<div className="preference__subheadings">
<Tab><h4 className="preference__subheading">General Settings</h4></Tab>
<Tab><h4 className="preference__subheading">Accessibility</h4></Tab>
<div className="tabs__titles">
<Tab><h4 className="tabs__title">General Settings</h4></Tab>
<Tab><h4 className="tabs__title">Accessibility</h4></Tab>
</div>
</TabList>
<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 React from 'react';
import InlineSVG from 'react-inlinesvg';
import { bindActionCreators } from 'redux';
import { connect } from 'react-redux';
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 {
constructor(props) {
@ -46,7 +43,7 @@ class Searchbar extends React.Component {
render() {
const { searchValue } = this.state;
return (
<div className="searchbar">
<div className={`searchbar ${searchValue === '' ? 'searchbar--is-empty' : ''}`}>
<button
type="submit"
className="searchbar__button"
@ -58,7 +55,7 @@ class Searchbar extends React.Component {
className="searchbar__input"
type="text"
value={searchValue}
placeholder="Search files..."
placeholder={this.props.searchLabel}
onChange={this.handleSearchChange}
onKeyUp={this.handleSearchEnter}
/>
@ -75,17 +72,12 @@ class Searchbar extends React.Component {
Searchbar.propTypes = {
searchTerm: PropTypes.string.isRequired,
setSearchTerm: PropTypes.func.isRequired,
resetSearchTerm: PropTypes.func.isRequired
resetSearchTerm: PropTypes.func.isRequired,
searchLabel: PropTypes.string,
};
function mapStateToProps(state) {
return {
searchTerm: state.search.searchTerm
};
}
Searchbar.defaultProps = {
searchLabel: 'Search sketches...',
};
function mapDispatchToProps(dispatch) {
return bindActionCreators(Object.assign({}, SortingActions), dispatch);
}
export default connect(mapStateToProps, mapDispatchToProps)(Searchbar);
export default 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}
onFocus={this.onFocusComponent}
>
Add folder
Create folder
</button>
</li>
<li>
@ -110,7 +110,20 @@ class Sidebar extends React.Component {
onBlur={this.onBlurComponent}
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>
</li>
</ul>
@ -137,6 +150,7 @@ Sidebar.propTypes = {
openProjectOptions: PropTypes.func.isRequired,
closeProjectOptions: PropTypes.func.isRequired,
newFolder: PropTypes.func.isRequired,
openUploadFileModal: PropTypes.func.isRequired,
owner: PropTypes.shape({
id: PropTypes.string
}),

View File

@ -10,11 +10,14 @@ import classNames from 'classnames';
import slugify from 'slugify';
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 * as IdeActions from '../actions/ide';
import getSortedSketches from '../selectors/projects';
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 arrowDown = require('../../../images/sort-arrow-down.svg');
@ -27,9 +30,10 @@ class SketchListRowBase extends React.Component {
optionsOpen: false,
renameOpen: false,
renameValue: props.sketch.name,
isFocused: false
isFocused: false,
};
}
onFocusComponent = () => {
this.setState({ isFocused: true });
}
@ -133,105 +137,146 @@ class SketchListRowBase extends React.Component {
}
}
render() {
const { sketch, username } = this.props;
const { renameOpen, optionsOpen, renameValue } = this.state;
renderViewButton = sketchURL => (
<td className="sketch-list__dropdown-column">
<Link to={sketchURL}>View</Link>
</td>
)
renderDropdown = () => {
const { optionsOpen } = this.state;
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}`;
if (username === 'p5') {
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 (
<tr
className="sketches-table__row"
key={sketch.id}
>
<th scope="row">
<Link to={url}>
{renameOpen ? '' : sketch.name}
</Link>
{renameOpen
&&
<input
value={renameValue}
onChange={this.handleRenameChange}
onKeyUp={this.handleRenameEnter}
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>);
<React.Fragment>
<tr
className="sketches-table__row"
key={sketch.id}
onClick={this.handleRowClick}
>
<th scope="row">
{name}
</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>
{this.renderDropdown()}
</tr>
</React.Fragment>);
}
}
@ -251,7 +296,8 @@ SketchListRowBase.propTypes = {
showShareModal: PropTypes.func.isRequired,
cloneProject: PropTypes.func.isRequired,
exportProjectAsZip: PropTypes.func.isRequired,
changeProjectName: PropTypes.func.isRequired
changeProjectName: PropTypes.func.isRequired,
onAddToCollection: PropTypes.func.isRequired,
};
function mapDispatchToPropsSketchListRow(dispatch) {
@ -266,6 +312,18 @@ class SketchList extends React.Component {
this.props.getProjects(this.props.username);
this.props.resetSorting();
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() {
@ -276,16 +334,20 @@ class SketchList extends React.Component {
}
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() {
if (this.props.loading) return <Loader />;
if (this.isLoading()) return <Loader />;
return null;
}
_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 null;
@ -338,9 +400,26 @@ class SketchList extends React.Component {
sketch={sketch}
user={this.props.user}
username={username}
onAddToCollection={() => {
this.setState({ sketchToAddToCollection: sketch });
}}
/>))}
</tbody>
</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>
);
}
@ -366,19 +445,9 @@ SketchList.propTypes = {
field: PropTypes.string.isRequired,
direction: PropTypes.string.isRequired
}).isRequired,
project: PropTypes.shape({
id: PropTypes.string,
owner: PropTypes.shape({
id: PropTypes.string
})
})
};
SketchList.defaultProps = {
project: {
id: undefined,
owner: undefined
},
username: undefined
};
@ -393,7 +462,10 @@ function mapStateToProps(state) {
}
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);

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

View File

@ -10,6 +10,8 @@ const assets = (state = initialState, action) => {
switch (action.type) {
case ActionTypes.SET_ASSETS:
return { list: action.assets, totalSize: action.totalSize };
case ActionTypes.DELETE_ASSET:
return { list: state.list.filter(asset => asset.key !== action.key) };
default:
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,
projectOptionsVisible: false,
newFolderModalVisible: false,
uploadFileModalVisible: false,
shareModalVisible: false,
shareModalProjectId: 'abcd',
shareModalProjectName: 'My Cute Sketch',
shareModalProjectUsername: 'p5_user',
sketchlistModalVisible: false,
editorOptionsVisible: false,
keyboardShortcutVisible: false,
unsavedChanges: false,
@ -106,6 +106,10 @@ const ide = (state = initialState, action) => {
return Object.assign({}, state, { runtimeErrorWarningVisible: false });
case ActionTypes.SHOW_RUNTIME_ERROR_WARNING:
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:
return state;
}

View File

@ -1,14 +1,8 @@
import friendlyWords from 'friendly-words';
import * as ActionTypes from '../../../constants';
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}`;
};
import { generateProjectName } from '../../../utils/generateRandomName';
const initialState = () => {
const generatedString = generateRandomName();
const generatedString = generateProjectName();
const generatedName = generatedString.charAt(0).toUpperCase() + generatedString.slice(1);
return {
name: generatedName,

View File

@ -1,13 +1,14 @@
import * as ActionTypes from '../../../constants';
const initialState = {
searchTerm: ''
collectionSearchTerm: '',
sketchSearchTerm: ''
};
export default (state = initialState, action) => {
switch (action.type) {
case ActionTypes.SET_SEARCH_TERM:
return { ...state, searchTerm: action.query };
return { ...state, [`${action.scope}SearchTerm`]: action.query };
default:
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 getField = state => state.sorting.field;
const getDirection = state => state.sorting.direction;
const getSearchTerm = state => state.search.searchTerm;
const getSearchTerm = state => state.search.sketchSearchTerm;
const getFilteredSketches = createSelector(
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)));
}
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 { reduxForm } from 'redux-form';
import { bindActionCreators } from 'redux';
import { browserHistory } from 'react-router';
import InlineSVG from 'react-inlinesvg';
import { Tab, Tabs, TabList, TabPanel } from 'react-tabs';
import axios from 'axios';
import { Helmet } from 'react-helmet';
import { updateSettings, initiateVerification } from '../actions';
import { updateSettings, initiateVerification, createApiKey, removeApiKey } from '../actions';
import AccountForm from '../components/AccountForm';
import { validateSettings } from '../../../utils/reduxFormUtils';
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 logoUrl = require('../../../images/p5js-logo.svg');
const __process = (typeof global !== 'undefined' ? global : window).process;
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 {
constructor(props) {
super(props);
this.closeAccountPage = this.closeAccountPage.bind(this);
this.gotoHomePage = this.gotoHomePage.bind(this);
}
closeAccountPage() {
browserHistory.push(this.props.previousPath);
}
gotoHomePage() {
browserHistory.push('/');
componentDidMount() {
document.body.className = this.props.theme;
}
render() {
const accessTokensUIEnabled = window.process.env.UI_ACCESS_TOKEN_ENABLED;
return (
<div className="form-container">
<div className="account-settings__container">
<Helmet>
<title>p5.js Web Editor | Account</title>
<title>p5.js Web Editor | Account Settings</title>
</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.closeAccountPage}>
<InlineSVG src={exitUrl} alt="Close Account Page" />
</button>
</div>
<div className="form-container__content">
<h2 className="form-container__title">My Account</h2>
<AccountForm {...this.props} />
<h2 className="form-container__divider">Or</h2>
<GithubButton buttonText="Login with Github" />
</div>
<Nav layout="dashboard" />
<section className="account-settings">
<header className="account-settings__header">
<h1 className="account-settings__title">Account Settings</h1>
</header>
{accessTokensUIEnabled &&
<Tabs className="account__tabs">
<TabList>
<div className="tabs__titles">
<Tab><h4 className="tabs__title">Account</h4></Tab>
{accessTokensUIEnabled && <Tab><h4 className="tabs__title">Access Tokens</h4></Tab>}
</div>
</TabList>
<TabPanel>
<SocialLoginPanel {...this.props} />
</TabPanel>
<TabPanel>
<APIKeyForm {...this.props} />
</TabPanel>
</Tabs>
}
{ !accessTokensUIEnabled && <SocialLoginPanel {...this.props} /> }
</section>
</div>
);
}
@ -58,13 +76,17 @@ class AccountView extends React.Component {
function mapStateToProps(state) {
return {
initialValues: state.user, // <- initialValues for reduxForm
previousPath: state.ide.previousPath,
user: state.user,
previousPath: state.ide.previousPath
apiKeys: state.user.apiKeys,
theme: state.preferences.theme
};
}
function mapDispatchToProps(dispatch) {
return bindActionCreators({ updateSettings, initiateVerification }, dispatch);
return bindActionCreators({
updateSettings, initiateVerification, createApiKey, removeApiKey
}, dispatch);
}
function asyncValidate(formProps, dispatch, props) {
@ -73,7 +95,7 @@ function asyncValidate(formProps, dispatch, props) {
const queryParams = {};
queryParams[fieldToValidate] = formProps[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) => {
if (response.data.exists) {
const error = {};
@ -87,6 +109,7 @@ function asyncValidate(formProps, dispatch, props) {
AccountView.propTypes = {
previousPath: PropTypes.string.isRequired,
theme: PropTypes.string.isRequired
};
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 { bindActionCreators } from 'redux';
import { browserHistory } from 'react-router';
import InlineSVG from 'react-inlinesvg';
import get from 'lodash/get';
import { Helmet } from 'react-helmet';
import { verifyEmailConfirmation } from '../actions';
const exitUrl = require('../../../images/exit.svg');
const logoUrl = require('../../../images/p5js-logo.svg');
import Nav from '../../../components/Nav';
class EmailVerificationView extends React.Component {
@ -17,12 +14,6 @@ class EmailVerificationView extends React.Component {
emailVerificationTokenState: null,
}
constructor(props) {
super(props);
this.closeLoginPage = this.closeLoginPage.bind(this);
this.gotoHomePage = this.gotoHomePage.bind(this);
}
componentWillMount() {
const verificationToken = this.verificationToken();
if (verificationToken != null) {
@ -32,14 +23,6 @@ class EmailVerificationView extends React.Component {
verificationToken = () => get(this.props, 'location.query.t', null);
closeLoginPage() {
browserHistory.push(this.props.previousPath);
}
gotoHomePage() {
browserHistory.push('/');
}
render() {
let status = null;
const {
@ -48,7 +31,7 @@ class EmailVerificationView extends React.Component {
if (this.verificationToken() == null) {
status = (
<p>That link is invalid</p>
<p>That link is invalid.</p>
);
} else if (emailVerificationTokenState === 'checking') {
status = (
@ -58,6 +41,7 @@ class EmailVerificationView extends React.Component {
status = (
<p>All done, your email address has been verified.</p>
);
setTimeout(() => browserHistory.push('/'), 1000);
} else if (emailVerificationTokenState === 'invalid') {
status = (
<p>Something went wrong.</p>
@ -65,21 +49,16 @@ class EmailVerificationView extends React.Component {
}
return (
<div className="form-container">
<Helmet>
<title>p5.js Web Editor | Email Verification</title>
</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.closeLoginPage}>
<InlineSVG src={exitUrl} alt="Close Login Page" />
</button>
</div>
<div className="form-container__content">
<h2 className="form-container__title">Verify your email</h2>
{status}
<div className="email-verification">
<Nav layout="dashboard" />
<div className="form-container">
<Helmet>
<title>p5.js Web Editor | Email Verification</title>
</Helmet>
<div className="form-container__content">
<h2 className="form-container__title">Verify your email</h2>
{status}
</div>
</div>
</div>
);
@ -89,7 +68,6 @@ class EmailVerificationView extends React.Component {
function mapStateToProps(state) {
return {
emailVerificationTokenState: state.user.emailVerificationTokenState,
previousPath: state.ide.previousPath
};
}
@ -101,7 +79,6 @@ function mapDispatchToProps(dispatch) {
EmailVerificationView.propTypes = {
previousPath: PropTypes.string.isRequired,
emailVerificationTokenState: PropTypes.oneOf([
'checking', 'verified', 'invalid'
]),

View File

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

View File

@ -2,60 +2,36 @@ import PropTypes from 'prop-types';
import React from 'react';
import { reduxForm } from 'redux-form';
import classNames from 'classnames';
import { browserHistory } from 'react-router';
import InlineSVG from 'react-inlinesvg';
import { bindActionCreators } from 'redux';
import { Helmet } from 'react-helmet';
import NewPasswordForm from '../components/NewPasswordForm';
import * as UserActions from '../actions';
import Nav from '../../../components/Nav';
const exitUrl = require('../../../images/exit.svg');
const logoUrl = require('../../../images/p5js-logo.svg');
class NewPasswordView extends React.Component {
constructor(props) {
super(props);
this.gotoHomePage = this.gotoHomePage.bind(this);
}
componentDidMount() {
// 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 (
function NewPasswordView(props) {
const newPasswordClass = classNames({
'new-password': true,
'new-password--invalid': props.user.resetPasswordInvalid,
'form-container': true,
'user': true
});
return (
<div className="new-password-container">
<Nav layout="dashboard" />
<div className={newPasswordClass}>
<Helmet>
<title>p5.js Web Editor | New Password</title>
</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">
<h2 className="form-container__title">Set a New Password</h2>
<NewPasswordForm {...this.props} />
<NewPasswordForm {...props} />
<p className="new-password__invalid">
The password reset token is invalid or has expired.
</p>
</div>
</div>
);
}
</div>
);
}
NewPasswordView.propTypes = {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -83,6 +83,10 @@
border: 2px solid getThemifyVariable('button-border-color');
border-radius: 2px;
padding: #{10 / $base-font-size}rem #{30 / $base-font-size}rem;
& g {
fill: getThemifyVariable('button-color');
opacity: 1;
}
&:enabled:hover {
border-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 {
@extend %toolbar-button;
@include themify() {

View File

@ -9,15 +9,22 @@ $orange: #ffa500;
$red: #ff0000;
$lightsteelblue: #B0C4DE;
$dodgerblue: #1E90FF;
$primary-text-color: #333;
$icon-color: #8b8b8b;
$icon-hover-color: #333;
$p5-contrast-pink: #FFA9D9;
// Grays
$dark: #333;
$middleGray: #7d7d7d;
$middleLight: #a6a6a6;
// Abstracts
$primary-text-color: $dark;
$themes: (
light: (
logo-color: $p5js-pink,
primary-text-color: #333,
primary-text-color: $primary-text-color,
dropzone-text-color: #333,
modal-button-color: #333,
heading-text-color: #333,
@ -65,8 +72,35 @@ $themes: (
keyboard-shortcut-color: #757575,
nav-hover-color: $p5js-pink,
error-color: $p5js-pink,
table-row-stripe-color: #d6d6d6,
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: (
logo-color: $p5js-pink,
@ -117,8 +151,33 @@ $themes: (
keyboard-shortcut-color: #B5B5B5,
nav-hover-color: $p5js-pink,
error-color: $p5js-pink,
table-row-stripe-color: #3f3f3f,
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: (
logo-color: $yellow,
@ -168,8 +227,33 @@ $themes: (
keyboard-shortcut-color: #e1e1e1,
nav-hover-color: $yellow,
error-color: $p5-contrast-pink,
table-row-stripe-color: #3f3f3f,
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-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-button-border-color: rgba(151, 151, 151, 0.7);

View File

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

View File

@ -46,7 +46,9 @@
padding-left: #{20 / $base-font-size}rem;
width: #{720 / $base-font-size}rem;
& 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 {
// flex: 1 1 0%;
overflow-y: auto;
max-width: 100%;
width: #{1000 / $base-font-size}rem;
min-height: #{400 / $base-font-size}rem;
}
.asset-table {
width: 100%;
padding: #{10 / $base-font-size}rem #{20 / $base-font-size}rem;
max-height: 100%;
border-spacing: 0;
& .asset-list__delete-column {
width: #{23 / $base-font-size}rem;
position: relative;
& .asset-table__dropdown-column {
width: #{60 / $base-font-size}rem;
position: relative;
}
}
& thead {
font-size: #{12 / $base-font-size}rem;
@include themify() {
color: getThemifyVariable('inactive-text-color')
}
.asset-table thead th {
height: #{32 / $base-font-size}rem;
position: sticky;
top: 0;
@include themify() {
background-color: getThemifyVariable('background-color');
}
}
& th {
height: #{32 / $base-font-size}rem;
font-weight: normal;
}
.asset-table thead th:nth-child(1){
padding-left: #{12 / $base-font-size}rem;
}
.asset-table__row {
margin: #{10 / $base-font-size}rem;
height: #{72 / $base-font-size}rem;
font-size: #{16 / $base-font-size}rem;
}
&:nth-child(odd) {
@include themify() {
background: getThemifyVariable('console-header-background-color');
}
.asset-table__row:nth-child(odd) {
@include themify() {
background: getThemifyVariable('table-row-stripe-color');
}
}
& a {
@include themify() {
color: getThemifyVariable('primary-text-color');
}
}
.asset-table__row > th:nth-child(1) {
padding-left: #{12 / $base-font-size}rem;
}
& td:first-child {
padding-left: #{10 / $base-font-size}rem;
.asset-table__row a {
@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 {
text-align: center;
font-size: #{16 / $base-font-size}rem;
padding: #{42 / $base-font-size}rem 0;
}
.asset-table__total {
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;
}
.form-container--align-left {
text-align: left;
}
.form-container--align-top {
height: unset;
}
.form-container__header {
width: 100%;
padding: #{15 / $base-font-size}rem #{34 / $base-font-size}rem;
@ -21,9 +29,21 @@
align-items: center;
}
.form-container--align-left .form-container__content {
align-items: left;
}
.form-container__title {
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 {
@ -31,9 +51,9 @@
}
.form-container__logo-button {
@extend %none-themify-icon;
@include icon();
}
.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;
text-align: left;
@include themify() {
color: getThemifyVariable('error-color')
color: getThemifyVariable('error-color');
}
}
.form--inline {
display: flex;
align-items: center;
}
.form__cancel-button {
margin-top: #{10 / $base-font-size}rem;
font-size: #{12 / $base-font-size}rem;
@ -17,22 +22,48 @@
.form__navigation-options {
margin-top: #{16 / $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 {
color: $secondary-form-title-color;
font-size: #{12 / $base-font-size}rem;
margin-top: #{25 / $base-font-size}rem;
margin-bottom: #{7 / $base-font-size}rem;
display: block;
@include themify() {
color: getThemifyVariable('form-secondary-title-color');
}
}
.form__label--hidden {
@extend %hidden-element;
}
.form__input {
width: #{360 / $base-font-size}rem;
width: 100%;
min-width: #{360 / $base-font-size}rem;
height: #{40 / $base-font-size}rem;
color: $icon-hover-color;
border-color: $secondary-form-title-color;
font-size: #{16 / $base-font-size}rem;
@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 {
@ -41,13 +72,26 @@
}
.form__status {
color: $form-navigation-options-color;
@include themify() {
color: getThemifyVariable('form-navigation-options-color');
}
}
.form input[type="submit"] {
@extend %forms-button;
.form [type="submit"] {
@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;
}

View File

@ -1,28 +1,4 @@
.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;
}
.github-button,
.google-button {
@include themify() {
@extend %button;
@ -33,16 +9,23 @@
fill: $white;
}
&:hover, &:active {
background-color: getThemifyVariable('secondary-text-color');
border-color: getThemifyVariable('secondary-text-color');
color: getThemifyVariable('button-hover-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;
flex-direction: row;
justify-content: 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 {

View File

@ -10,7 +10,7 @@
.modal-content {
@extend %modal;
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;
.modal--reduced & {
//min-height: #{150 / $base-font-size}rem;
@ -32,9 +32,8 @@
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;
flex-wrap: wrap;
}
.new-file-form__name-label, .new-folder-form__name-label {
@ -43,6 +42,7 @@
.new-file-form__name-input, .new-folder-form__name-input {
margin-right: #{10 / $base-font-size}rem;
flex: 1;
}
.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;
}
@ -57,6 +58,12 @@
color: getThemifyVariable('nav-hover-color');
}
}
& g, & path {
@include themify() {
fill: getThemifyVariable('nav-hover-color');
}
}
.nav__item-header-triangle polygon {
@include themify() {
@ -66,6 +73,21 @@
}
.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() {
color: getThemifyVariable('nav-hover-color');
}
@ -87,7 +109,7 @@
padding-right: #{20 / $base-font-size}rem;
& .nav__dropdown {
width: #{121 / $base-font-size}rem;
width: #{122 / $base-font-size}rem;
}
}
@ -159,4 +181,18 @@
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;
margin-top: #{40 / $base-font-size}rem;
margin-bottom: #{80 / $base-font-size}rem;
color: $form-navigation-options-color;
@include themify() {
color: getThemifyVariable('form-navigation-options-color');
}
}
}
}

View File

@ -1,5 +1,5 @@
.overlay {
position: absolute;
position: fixed;
top: 0;
left: 0;
right: 0;
@ -34,11 +34,25 @@
flex: 1 0 auto;
}
.overlay__actions {
display: flex;
}
.overlay__title {
font-size: #{21 / $base-font-size}rem;
}
.overlay__close-button {
@include icon();
padding: #{3 / $base-font-size}rem 0;
padding: #{3 / $base-font-size}rem 0 #{3 / $base-font-size}rem #{10 / $base-font-size}rem;
}
/* Fixed height overlay */
.overlay--is-fixed-height .overlay__body {
height: 80vh;
}
.overlay--is-fixed-height .overlay__header {
flex: 0;
}

View File

@ -0,0 +1,127 @@
.quick-add-wrapper {
min-width: #{600 / $base-font-size}rem;
}
.quick-add {
width: auto;
padding: #{24 / $base-font-size}rem;
}
.quick-add__item {
display: flex;
align-items: center;
height: #{64 / $base-font-size}rem;
padding-right: #{24 / $base-font-size}rem;
button, a {
@include themify() {
color: getThemifyVariable('primary-text-color');
}
}
}
.quick-add__item:nth-child(odd) {
@include themify() {
background: getThemifyVariable('table-row-stripe-color');
}
}
.quick-add__item-toggle {
display: flex;
align-items: center;
}
.quick-add__item-name {
flex: 1;
}
.quick-add__icon {
display: inline-block;
margin-right:#{15 / $base-font-size}rem;
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;
}
}
}
.quick-add__icon > * {
display: none;
}
.quick-add__in-icon {
display: inline-block;
}
.quick-add__icon--in-collection .quick-add__in-icon svg {
@include themify() {
// icon graphic
polygon {
fill: getThemifyVariable('table-button-active-color');
}
// icon background circle
path {
fill: getThemifyVariable('table-button-background-active-color');
}
}
}
.quick-add__add-icon {
transform: rotate(45deg);
}
.quick-add__item:hover,
.quick-add__item-toggle:hover,
.quick-add__item-toggle:focus {
cursor: pointer;
@include themify() {
.quick-add__icon {
polygon {
fill: getThemifyVariable('table-button-hover-color');
}
path {
fill: getThemifyVariable('table-button-background-hover-color');
}
}
}
& .quick-add__in-icon {
display: none;
}
& .quick-add__icon--in-collection {
.quick-add__remove-icon {
display: inline-block;
}
.quick-add__add-icon {
display: none;
}
}
& .quick-add__icon--not-in-collection {
.quick-add__add-icon {
display: inline-block;
}
.quick-add__remove-icon {
display: none;
}
}
}

View File

@ -4,7 +4,9 @@
text-align: left;
font-size: #{12 / $base-font-size}rem;
margin-top: #{10 / $base-font-size}rem;
color: $form-navigation-options-color;
@include themify() {
color: getThemifyVariable('form-navigation-options-color');
}
padding-right: #{30 / $base-font-size}rem;
padding-left: #{39 / $base-font-size}rem;
.reset-password--submitted & {

View File

@ -1,9 +1,6 @@
.searchbar {
position: absolute;
position: relative;
display: flex;
padding-left: #{17 / $base-font-size}rem;
right: #{50 / $base-font-size}rem;
top: #{14 / $base-font-size}rem;
}
.searchbar__input {
@ -18,15 +15,26 @@
}
}
.searchbar__button {
button[type="submit"].searchbar__button {
background-color: unset;
width: #{31 / $base-font-size}rem;
height: #{36 / $base-font-size}rem;
position: absolute;
padding: 0;
border: 0;
border-right: solid 1px;
@include themify() {
border-right-color: getThemifyVariable('input-border-color');
}
&:enabled:hover {
background-color: unset;
@include themify() {
border-right-color: getThemifyVariable('input-border-color');
}
& g {
fill: unset;
}
}
}
.searchbar__icon {
@ -49,7 +57,7 @@
align-self: center;
position: absolute;
padding: #{3 / $base-font-size}rem #{4 / $base-font-size}rem;
left: #{216 / $base-font-size}rem;;
right: #{7 / $base-font-size}rem;
@include themify() {
color: getThemifyVariable('primary-text-color');
background-color: getThemifyVariable('search-clear-background-color');
@ -59,3 +67,7 @@
}
}
}
.searchbar--is-empty .searchbar__clear-button {
display: none;
}

View File

@ -1,14 +1,12 @@
.sketches-table-container {
overflow-y: auto;
max-width: 100%;
width: #{1000 / $base-font-size}rem;
height: #{628 / $base-font-size}rem;
// min-height: #{400 / $base-font-size}rem;
min-height: #{400 / $base-font-size}rem;
}
.sketches-table {
width: 100%;
padding: #{10 / $base-font-size}rem #{20 / $base-font-size}rem;
max-height: 100%;
border-spacing: 0;
& .sketch-list__dropdown-column {
@ -19,6 +17,11 @@
.sketches-table thead th {
height: #{32 / $base-font-size}rem;
position: sticky;
top: 0;
@include themify() {
background-color: getThemifyVariable('background-color');
}
}
.sketch-list__sort-button {
@ -58,7 +61,7 @@
.sketches-table__row:nth-child(odd) {
@include themify() {
background: getThemifyVariable('console-header-background-color');
background: getThemifyVariable('table-row-stripe-color');
}
}
@ -66,6 +69,10 @@
padding-left: #{12 / $base-font-size}rem;
}
.sketches-table__row > td {
padding-left: #{8 / $base-font-size}rem;
}
.sketches-table__row a {
@include themify() {
color: getThemifyVariable('primary-text-color');
@ -83,6 +90,7 @@
font-weight: normal;
}
.sketch-list__dropdown-button {
width:#{25 / $base-font-size}rem;
height:#{25 / $base-font-size}rem;
@ -93,17 +101,23 @@
}
}
.sketches-table__name {
display: flex;
align-items: center;
}
.sketches-table__icon-cell {
width: #{35 / $base-font-size}rem;
}
.sketch-list__action-dialogue {
@extend %dropdown-open-right;
top: 63%;
right: calc(100% - 26px);
}
.sketch-list__action-option {
}
.sketches-table__empty {
text-align: center;
font-size: #{16 / $base-font-size}rem;
padding: #{42 / $base-font-size}rem 0;
}

View File

@ -0,0 +1,69 @@
.tabs__titles {
display: flex;
flex-wrap: wrap;
padding-bottom: #{0.1 / $base-font-size}rem;
@include themify() {
border-bottom: 1px solid getThemifyVariable('button-border-color');
}
}
.tabs__title {
@include themify() {
color: getThemifyVariable('inactive-text-color');
&:hover, &:focus{
color: getThemifyVariable('primary-text-color');
cursor: pointer;
}
&:focus {
color: getThemifyVariable('primary-text-color');
cursor: pointer;
}
}
font-size: #{12 / $base-font-size}rem;
height: #{20 / $base-font-size}rem;
width: 100%;
margin: 0;
padding: 0 #{5 /$base-font-size}rem;
}
.react-tabs__tab--selected {
@include themify() {
border-bottom: #{4 / $base-font-size}rem solid getThemifyVariable('button-background-hover-color');
}
}
.react-tabs__tab--selected .tabs__title {
@include themify() {
color: getThemifyVariable('primary-text-color');
}
}
.react-tabs__tab {
text-align: center;
color: black;
display: flex;
align-items: center;
border-bottom: #{4 / $base-font-size}rem solid transparent;
& + & {
margin-left: #{45 / $base-font-size}rem;
}
}
.tabs__title {
@include themify() {
color: getThemifyVariable('inactive-text-color');
&:hover, &:focus{
color: getThemifyVariable('primary-text-color');
cursor: pointer;
}
&:focus {
color: getThemifyVariable('primary-text-color');
cursor: pointer;
}
}
font-size: #{12 / $base-font-size}rem;
height: #{20 / $base-font-size}rem;
width: 100%;
margin: 0;
padding: 0 #{5 /$base-font-size}rem;
}

View File

@ -0,0 +1,24 @@
.dashboard {
display: flex;
flex-direction: column;
flex-wrap: wrap;
@include themify() {
color: getThemifyVariable('primary-text-color');
background-color: getThemifyVariable('background-color');
}
height: 100%;
}
.dashboard-content {
display: flex;
flex-direction: column;
flex: 1;
overflow: hidden;
max-width: #{1012 / $base-font-size}rem;
margin: 0 auto;
width: 100%;
@include themify() {
border: 1px solid getThemifyVariable('modal-border-color');
}
}

View File

@ -0,0 +1,22 @@
.user {
display: flex;
flex-direction: column;
height: 100%;
flex-wrap: wrap;
@include themify() {
color: getThemifyVariable('primary-text-color');
background-color: getThemifyVariable('background-color');
}
}
.login,
.signup,
.reset-password-container,
.new-password-container,
.email-verification {
height: 100%;
@include themify() {
color: getThemifyVariable('primary-text-color');
background-color: getThemifyVariable('background-color');
}
}

View File

@ -14,6 +14,8 @@
@import 'components/p5-light-codemirror-theme';
@import 'components/p5-dark-codemirror-theme';
@import 'components/p5-contrast-codemirror-theme';
@import 'components/account';
@import 'components/api-key';
@import 'components/editor';
@import 'components/nav';
@import 'components/preview-nav';
@ -39,11 +41,21 @@
@import 'components/help-modal';
@import 'components/share';
@import 'components/asset-list';
@import 'components/asset-size';
@import 'components/keyboard-shortcuts';
@import 'components/copyable-input';
@import 'components/feedback';
@import 'components/loader';
@import 'components/uploader';
@import 'components/tabs';
@import 'components/dashboard-header';
@import 'components/editable-input';
@import 'components/collection';
@import 'components/collection-create';
@import 'components/collection-popover';
@import 'components/quick-add';
@import 'layout/dashboard';
@import 'layout/ide';
@import 'layout/fullscreen';
@import 'layout/user';

29
client/utils/auth.js Normal file
View File

@ -0,0 +1,29 @@
import { connectedRouterRedirect } from 'redux-auth-wrapper/history3/redirect';
import locationHelperBuilder from 'redux-auth-wrapper/history3/locationHelper';
const locationHelper = locationHelperBuilder({});
export const userIsAuthenticated = connectedRouterRedirect({
// The url to redirect user to if they fail
redirectPath: '/login',
// Determine if the user is authenticated or not
authenticatedSelector: state => state.user.authenticated === true,
// A nice display name for this check
wrapperDisplayName: 'UserIsAuthenticated'
});
export const userIsNotAuthenticated = connectedRouterRedirect({
redirectPath: (state, ownProps) => locationHelper.getRedirectQueryParam(ownProps) || '/',
allowRedirectBack: false,
authenticatedSelector: state => state.user.authenticated === false,
wrapperDisplayName: 'UserIsNotAuthenticated'
});
export const userIsAuthorized = connectedRouterRedirect({
redirectPath: '/',
allowRedirectBack: false,
authenticatedSelector: (state, ownProps) => {
const { username } = ownProps.params;
return state.user.username === username;
},
});

View File

@ -0,0 +1,12 @@
import friendlyWords from 'friendly-words';
export function generateProjectName() {
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}`;
}
export function generateCollectionName() {
const adj = friendlyWords.predicates[Math.floor(Math.random() * friendlyWords.predicates.length)];
return `My ${adj} collection`;
}

View File

@ -0,0 +1,219 @@
# Public API
This API provides a way to programmatically import data into the p5.js Web Editor.
# Authentication
Access to the API is available via a Personal Access Token, linked to an existing editor user account. Tokens can be created and deleted via logged-in users Settings page.
When contacting the API, the username and token must be sent with every request using basic auth.
This involved sending the base64 encoded `${username}:${personalAccessToken}` in the `Authorization` header. For example:
`Authorization: Basic cDU6YWJjMTIzYWJj`
# API Access
- All requests send and receive `Content-Type: application/json` unless otherwise stated
# Versioning
The API is versioned and this version is indicated in the root URL path e.g. version 1 of the API can be found at `http://editor.p5js.org/api/v1`.
You must provide the version number when accessing the API.
| Version | Release date |
| ------- | ------------ |
| v1 | Unreleased |
# Models
The API accepts and returns the following model objects, as JSON.
## Sketch
| Name | Type | Description |
| ----- | ----------------- | ------------------------------------------------------------------------------------ |
| name | String | The sketchs title |
| files | DirectoryContents | The files and directories in this sketch. See `DirectoryContents` for the structure. |
| slug | String | A path that can be used to access the sketch |
{
"id": String, // opaque ID
"name: String,
"files": DirectoryContents,
"slug": String // optional
}
### Validations
- `files` must have exactly one top-level file with the `.html` extension. If none is provided, then a default `index.html` and associated `style.css` will be automatically created.
- `slug` must be an URL-safe string
- `slug` must be unique across all user's sketches
## DirectoryContents
A map of filenames to `File` or `Directory`. The key of each item is used as the filename. Using a map ensures that filenames are unique in the directory.
{
[String]: File | Directory
}
{
"sketch.js": { "content": "var answer = 42;" },
"index.html" { "content": "..." }
}
## DirectFile
This file is editable in the Editor UI and stored in the Editor's database.
| Name | Type | Description |
| ------- | ------------ | ------------------------------------------ |
| content | UTF-8 String | The contents of the file as a UTF-8 string |
{
"content": String
}
## ReferencedFile
This file is hosted elsewhere on the Internet. It appears in the Editor's listing and can be referenced using a proxy URL in the Editor.
| Name | Type | Description |
| ---- | ---- | ----------------------------------------------- |
| url | URL | A valid URL pointing to a file hosted elsewhere |
{
"url": URL
}
## File
A `File` is either a `DirectFile` or `ReferencedFile`. The API supports both everywhere.
## Directory
| Name | Type | Description |
| ----- | ----------------- | ------------------------------- |
| files | DirectoryContents | A map of the directory contents |
{
"files": DirectoryContents
}
# API endpoints
## Sketches
## `GET /:user/sketches`
List a users sketches.
This will not return the files within the sketch, just the sketch metadata.
### Request format
No body.
### Response format
{
"sketches": Array<Sketch>
}
### Example
GET /p5/sketches
{
"sketches": [
{ "id": "H1PLJg8_", "name": "My Lovely Sketch" },
{ "id": "Bkhf0APpg", "name": "My Lovely Sketch 2" }
]
}
## `POST /:user/sketches`
Create a new sketch.
A sketch must contain at least one file with the `.html` extension. If none if provided in the payload, a default `index.html` and linked `style.css` file will be created automatically.
### Request format
See `Sketch` in Models above.
### Response format
{
"id": String
}
### Example
POST /p5/sketches
{
"name": "My Lovely Sketch",
"files": {
"index.html": { "content": "<DOCTYPE html!><body>Hello!</body></html>" },
"sketch.js": { "content": "var useless = true;" }
}
}
`files` can be nested to represent a folder structure. For example, this will create an empty “data” directory in the sketch:
POST /p5/sketches
{
"name": "My Lovely Sketch 2",
"files": [
{
"name": "assets",
"type": "",
"files": {
"index.html": { "content": "<DOCTYPE html!><body>Hello!</body></html>" },
"data": {
"files": {}
}
}
}
}
### Responses
| HTTP code | Body |
| ------------------------ | ----------------------------------------------------------------- |
| 201 Created | id of sketch |
| 422 Unprocessable Entity | file validation failed, unsupported filetype, slug already exists |
### Examples
201 CREATED
{
"id": "Ckhf0APpg"
}
## `DELETE /:user/sketches/:id`
Delete a sketch and all its associated files.
### Request format
No body
### Response format
No body
### Example
DELETE /p5/sketches/Ckhf0APpg
### Responses
| HTTP code | Description |
| ------------- | ----------------------- |
| 200 OK | Sketch has been deleted |
| 404 Not Found | Sketch does not exist |

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