Merge branch 'master' into feature/storybook

This commit is contained in:
Andrew Nicolaou 2020-04-19 12:19:37 +02:00
commit 499c17ec86
193 changed files with 17025 additions and 7220 deletions

View file

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

View file

@ -1,16 +1,16 @@
# Contributing to the p5.js Web Editor # Contributing to the p5.js Web Editor
Hello! We welcome community contributions to the p5.js Web Editor. Contributing takes many forms and doesn't have to be **writing code**, it can be **report bugs**, **proposing new features**, **creating UI/UX designs**, and **updating documentation**. Hello! We welcome community contributions to the p5.js Web Editor. Contributing takes many forms and doesn't have to be **writing code**, it can be **reporting bugs**, **proposing new features**, **creating UI/UX designs**, and **updating documentation**.
Here are links to all the sections in this document:
<!-- If you change any of the headings in this document, remember to update the table of contents. -->
## Table of Contents
- [Contributing to the p5.js Web Editor](#contributing-to-the-p5js-web-editor) - [Contributing to the p5.js Web Editor](#contributing-to-the-p5js-web-editor)
- [Table of Contents](#table-of-contents)
- [Code of Conduct](#code-of-conduct) - [Code of Conduct](#code-of-conduct)
- [How Can I Contribute?](#how-can-i-contribute) - [How Can I Contribute?](#how-can-i-contribute)
- [First Timers](#first-timers) - [First Steps](#first-steps)
- [Milestones](#milestones) - [Good First Issues](#good-first-issues)
- [Good Medium Issues](#good-medium-issues)
- [Project Board](#project-board)
- [Project Ideas](#project-ideas) - [Project Ideas](#project-ideas)
- [Issue Search and Tagging](#issue-search-and-tagging) - [Issue Search and Tagging](#issue-search-and-tagging)
- [Beginning Work](#beginning-work) - [Beginning Work](#beginning-work)
@ -20,15 +20,35 @@ Here are links to all the sections in this document:
## Code of Conduct ## Code of Conduct
Please follow the guidelines mentioned at [CODE OF CONDUCT.md](https://github.com/processing/p5.js-web-editor/blob/master/.github/CODE_OF_CONDUCT.md). Please follow the guidelines in the [Code of Conduct](https://github.com/processing/p5.js-web-editor/blob/master/.github/CODE_OF_CONDUCT.md).
## How Can I Contribute? ## How Can I Contribute?
If you're new to open source, [read about how to contribute to open source](https://opensource.guide/how-to-contribute/).
### First Timers ### First Steps
For first-time contributors or those who want to start with a small task: [check out our list of good first bugs](https://github.com/processing/p5.js-web-editor/labels/good%20first%20issue). First read the github discussion on that issue and find out if there's currently a person working on that or not. If no one is working on it or if there has was one claimed to but has not been active for a while, ask if it is up for grabs. It's okay to not know how to fix an issue and feel free to ask questions about to approach the problem! We are all just here to learn and make something awesome. Someone from the community would help you out and these are great issues for learning about the web editor, its file structure and its development process. Don't know where to begin? Here are some suggestions to get started:
* Think about what you're hoping to learn by working on open source. The web editor is a full-stack web application, therefore there's tons of different areas to focus on:
- UI/UX design
- Project management: Organizing tickets, pull requests, tasks
- Front end: React/Redux, CSS/Sass, CodeMirror
- Back end: Node, Express, MongoDB, Jest, AWS
- DevOps: Travis CI, Jest, Docker, Kubernetes, AWS
- Documentation
- Translations: Application and documentation
* Use the [p5.js Web Editor](https://editor.p5js.org)! Find a bug? Think of something you think would add to the project? Open an issue.
* Expand an existing issue. Sometimes issues are missing steps to reproduce, or need suggestions for potential solutions. Sometimes they need another voice saying, "this is really important!"
* Try getting the project running locally on your computer by following the [installation steps](./../developer_docs/installation.md).
* Look through the documentation in the [developer docs](../developer_docs/). Is there anything that could be expanded? Is there anything missing?
* Look at the [development guide](./../developer_docs/development.md).
### Milestones ### Good First Issues
A good place to check for tickets to work on is [milestones](https://github.com/processing/p5.js-web-editor/milestones), as miletones have a due date, and will give you a sense of tickets the tickets that maintainers would like to be completed sooner rather than later. For first-time contributors or those who want to start with a small task, [check out the list of good first issues](https://github.com/processing/p5.js-web-editor/labels/good%20first%20issue), or [issues that need documentation of steps to reproduce](https://github.com/processing/p5.js-web-editor/issues?q=is%3Aissue+is%3Aopen+label%3A%22needs+steps+to+reproduce%22). If the issue has not been assigned to anyone, then you can work on it! It's okay to not know how to fix an issue, and feel free to ask questions about to approach the problem! We are all here to learn and make something awesome. Someone from the community would help you out and these are great issues for learning about the web editor, its file structure and its development process.
### Good Medium Issues
If you're looking for a bigger project to take on, look through the issues tagged [good medium issue](https://github.com/processing/p5.js-web-editor/labels/good%20medium%20issue). These issues are self-contained projects that may take longer to work on, but are great if you're looking to get more deeply involved in contributing!
### Project Board
Many issues are related to each other and fall under bigger projects. To get a bigger picture, look at the [All Projects](https://github.com/processing/p5.js-web-editor/projects/4) board.
### Project Ideas ### Project Ideas
If you're looking for inspiration for Google Summer of Code or a bigger project, there's a [project list](https://github.com/processing/processing/wiki/Project-List#p5js-web-editor) maintained on the Processing wiki. If you're looking for inspiration for Google Summer of Code or a bigger project, there's a [project list](https://github.com/processing/processing/wiki/Project-List#p5js-web-editor) maintained on the Processing wiki.
@ -40,7 +60,7 @@ If you feel like an issue is tagged incorrectly (e.g. it's low priority and you
### Beginning Work ### Beginning Work
If you'd like to work on an issue, please comment on it to let the maintainers know. If someone else has already commented and taken up that issue, please refrain from working on it and submitting a PR without asking the maintainers as it leads to unnecessary duplication of effort. If you'd like to work on an issue, please comment on it to let the maintainers know, so that they can assign it to you. If someone else has already commented and taken up that issue, please refrain from working on it and submitting a PR without asking the maintainers as it leads to unnecessary duplication of effort.
Then, follow the [installation guide](https://github.com/processing/p5.js-web-editor/blob/master/developer_docs/installation.md) to get the project building and working on your computer. Then, follow the [installation guide](https://github.com/processing/p5.js-web-editor/blob/master/developer_docs/installation.md) to get the project building and working on your computer.

1
.github/FUNDING.yml vendored Normal file
View file

@ -0,0 +1 @@
custom: https://processingfoundation.org/support

2
.github/config.yml vendored
View file

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

1
.nvmrc Normal file
View file

@ -0,0 +1 @@
12.16.1

View file

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

View file

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

View file

@ -1,29 +1,42 @@
# [p5.js Web Editor](https://editor.p5js.org) # [p5.js Web Editor](https://editor.p5js.org)
Documentation is also available in the following languages:
[한국어](https://github.com/processing/p5.js-web-editor/blob/master/translations/ko)
## Welcome! 👋👋🏿👋🏽👋🏻👋🏾👋🏼
The p5.js Web Editor is a platform for creative coding, with a focus on making coding accessible for as many people as possible, including artists, designers, educators, beginners, and anyone who wants to learn. Simply by opening the website you can get started writing p5.js sketches without downloading or configuring anything. The editor is designed with simplicity in mind by limiting features and frills. We strive to listen to the community to drive the editors development, and to be intentional with every change. The editor is free and open-source. The p5.js Web Editor is a platform for creative coding, with a focus on making coding accessible for as many people as possible, including artists, designers, educators, beginners, and anyone who wants to learn. Simply by opening the website you can get started writing p5.js sketches without downloading or configuring anything. The editor is designed with simplicity in mind by limiting features and frills. We strive to listen to the community to drive the editors development, and to be intentional with every change. The editor is free and open-source.
We also strive to give the community as much ownership and control as possible. You can download your sketches so that you can edit them locally or host them elsewhere. You can also host your own version of the editor, giving you control over its data. We also strive to give the community as much ownership and control as possible. You can download your sketches so that you can edit them locally or host them elsewhere. You can also host your own version of the editor, giving you control over its data.
The p5.js Web Editor is currently in active development, and looking for contributions of any type! Please check out the [contribution guide](https://github.com/processing/p5.js-web-editor/blob/master/.github/CONTRIBUTING.md) for more details. ## Community
If you have found a bug in the p5.js Web Editor, you can file it under the ["issues" tab](https://github.com/processing/p5.js-web-editor/issues). New to the p5.js community? Read our [community statement](https://p5js.org/community/).
## Code of Conduct
All contributors to the p5.js Web Editor are expected to follow the [Code of Conduct](./.github/CODE_OF_CONDUCT.md). We strive to create a friendly and safe community!
## Get Involved
The p5.js Web Editor is a collaborative project created by many individuals, and you are invited to help. All types of involvement are welcome! Please check out the [contribution guide](./.github/CONTRIBUTING.md) for more details.
Developers, check the [developer docs](https://github.com/processing/p5.js-web-editor/blob/master/developer_docs/) details about contributing code, bug fixes, and documentation. To start writing code, a great place to start is the [development guide](https://github.com/processing/p5.js-web-editor/blob/master/developer_docs/development.md).
## Issues ## Issues
If you have found a bug in the p5.js Web Editor, you can file it under the ["issues" tab](https://github.com/processing/p5.js-web-editor/issues).
Please post bugs and feature requests in the correct repository: Please post bugs and feature requests in the correct repository:
* p5.js general and p5.dom: [https://github.com/processing/p5.js/issues](https://github.com/processing/p5.js/issues) * p5.js library and p5.dom: [https://github.com/processing/p5.js/issues](https://github.com/processing/p5.js/issues)
* p5.accessibility: [https://github.com/processing/p5.accessibility/issues](https://github.com/processing/p5.accessibility/issues) * p5.accessibility: [https://github.com/processing/p5.accessibility/issues](https://github.com/processing/p5.accessibility/issues)
* p5.sound: [https://github.com/processing/p5.js-sound/issues](https://github.com/processing/p5.js-sound/issues) * p5.sound: [https://github.com/processing/p5.js-sound/issues](https://github.com/processing/p5.js-sound/issues)
* p5.js website: [https://github.com/processing/p5.js-website/issues](https://github.com/processing/p5.js-website/issues) * p5.js website: [https://github.com/processing/p5.js-website/issues](https://github.com/processing/p5.js-website/issues)
## Get Involved ## Acknowledgements
The p5.js Web Editor is a collaborative project created by many individuals, and you are invited to help. All types of involvement are welcome. You can start with the [p5.js community section](https://p5js.org/community), which also applies to this project.
Developers, check the [developer docs](https://github.com/processing/p5.js-web-editor/blob/master/developer_docs/) details about contributing code, bug fixes, and documentation. To start writing code, a great place to start is the [development guide](https://github.com/processing/p5.js-web-editor/blob/master/developer_docs/development.md).
## Support
Support for this project has come from [Processing Foundation](https://processingfoundation.org/), [NYU ITP](https://tisch.nyu.edu/itp), and [CS4All, NYC DOE](http://cs4all.nyc/). Support for this project has come from [Processing Foundation](https://processingfoundation.org/), [NYU ITP](https://tisch.nyu.edu/itp), and [CS4All, NYC DOE](http://cs4all.nyc/).

View file

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

View file

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

View file

@ -12,6 +12,7 @@ import { setAllAccessibleOutput } from '../modules/IDE/actions/preferences';
import { logoutUser } from '../modules/User/actions'; import { logoutUser } from '../modules/User/actions';
import { metaKeyName, } from '../utils/metaKey'; import { metaKeyName, } from '../utils/metaKey';
import caretLeft from '../images/left-arrow.svg';
const triangleUrl = require('../images/down-filled-triangle.svg'); const triangleUrl = require('../images/down-filled-triangle.svg');
const logoUrl = require('../images/p5js-logo-small.svg'); const logoUrl = require('../images/p5js-logo-small.svg');
@ -92,11 +93,12 @@ class Nav extends React.PureComponent {
} }
handleNew() { handleNew() {
if (!this.props.unsavedChanges) { const { unsavedChanges, warnIfUnsavedChanges } = this.props;
if (!unsavedChanges) {
this.props.showToast(1500); this.props.showToast(1500);
this.props.setToastText('Opened new sketch.'); this.props.setToastText('Opened new sketch.');
this.props.newProject(); this.props.newProject();
} else if (this.props.warnIfUnsavedChanges()) { } else if (warnIfUnsavedChanges && warnIfUnsavedChanges()) {
this.props.showToast(1500); this.props.showToast(1500);
this.props.setToastText('Opened new sketch.'); this.props.setToastText('Opened new sketch.');
this.props.newProject(); this.props.newProject();
@ -180,7 +182,8 @@ class Nav extends React.PureComponent {
} }
handleShare() { handleShare() {
this.props.showShareModal(); const { username } = this.props.params;
this.props.showShareModal(this.props.project.id, this.props.project.name, username);
this.setDropdown('none'); this.setDropdown('none');
} }
@ -222,32 +225,27 @@ class Nav extends React.PureComponent {
this.timer = setTimeout(this.setDropdown.bind(this, 'none'), 10); this.timer = setTimeout(this.setDropdown.bind(this, 'none'), 10);
} }
render() { renderDashboardMenu(navDropdownState) {
const navDropdownState = {
file: classNames({
'nav__item': true,
'nav__item--open': this.state.dropdownOpen === 'file'
}),
edit: classNames({
'nav__item': true,
'nav__item--open': this.state.dropdownOpen === 'edit'
}),
sketch: classNames({
'nav__item': true,
'nav__item--open': this.state.dropdownOpen === 'sketch'
}),
help: classNames({
'nav__item': true,
'nav__item--open': this.state.dropdownOpen === 'help'
}),
account: classNames({
'nav__item': true,
'nav__item--open': this.state.dropdownOpen === 'account'
})
};
return ( return (
<nav className="nav" title="main-navigation" ref={(node) => { this.node = node; }}> <ul className="nav__items-left">
<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">
<li className="nav__item-logo"> <li className="nav__item-logo">
<InlineSVG src={logoUrl} alt="p5.js logo" className="svg__logo" /> <InlineSVG src={logoUrl} alt="p5.js logo" className="svg__logo" />
</li> </li>
@ -327,6 +325,19 @@ class Nav extends React.PureComponent {
Open Open
</Link> </Link>
</li> } </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 && { __process.env.EXAMPLES_ENABLED &&
<li className="nav__dropdown-item"> <li className="nav__dropdown-item">
<Link <Link
@ -523,21 +534,29 @@ class Nav extends React.PureComponent {
</ul> </ul>
</li> </li>
</ul> </ul>
{ __process.env.LOGIN_ENABLED && !this.props.user.authenticated && );
}
renderUnauthenticatedUserMenu(navDropdownState) {
return (
<ul className="nav__items-right" title="user-menu"> <ul className="nav__items-right" title="user-menu">
<li> <li className="nav__item">
<Link to="/login"> <Link to="/login">
<span className="nav__item-header">Log in</span> <span className="nav__item-header">Log in</span>
</Link> </Link>
</li> </li>
<span className="nav__item-spacer">or</span> <span className="nav__item-spacer">or</span>
<li> <li className="nav__item">
<Link to="/signup"> <Link to="/signup">
<span className="nav__item-header">Sign up</span> <span className="nav__item-header">Sign up</span>
</Link> </Link>
</li> </li>
</ul>} </ul>
{ __process.env.LOGIN_ENABLED && this.props.user.authenticated && );
}
renderAuthenticatedUserMenu(navDropdownState) {
return (
<ul className="nav__items-right" title="user-menu"> <ul className="nav__items-right" title="user-menu">
<li className="nav__item"> <li className="nav__item">
<span>Hello, {this.props.user.username}!</span> <span>Hello, {this.props.user.username}!</span>
@ -569,9 +588,21 @@ class Nav extends React.PureComponent {
My sketches My sketches
</Link> </Link>
</li> </li>
{__process.env.UI_COLLECTIONS_ENABLED &&
<li className="nav__dropdown-item"> <li className="nav__dropdown-item">
<Link <Link
to="/assets" 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} onFocus={this.handleFocusForAccount}
onBlur={this.handleBlur} onBlur={this.handleBlur}
onClick={this.setDropdownForNone} onClick={this.setDropdownForNone}
@ -600,7 +631,61 @@ class Nav extends React.PureComponent {
</li> </li>
</ul> </ul>
</li> </li>
</ul> } </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({
'nav__item': true,
'nav__item--open': this.state.dropdownOpen === 'file'
}),
edit: classNames({
'nav__item': true,
'nav__item--open': this.state.dropdownOpen === 'edit'
}),
sketch: classNames({
'nav__item': true,
'nav__item--open': this.state.dropdownOpen === 'sketch'
}),
help: classNames({
'nav__item': true,
'nav__item--open': this.state.dropdownOpen === 'help'
}),
account: classNames({
'nav__item': true,
'nav__item--open': this.state.dropdownOpen === 'account'
})
};
return (
<nav className="nav" title="main-navigation" ref={(node) => { this.node = node; }}>
{this.renderLeftLayout(navDropdownState)}
{this.renderUserMenu(navDropdownState)}
{/* {/*
<div className="nav__announce"> <div className="nav__announce">
This is a preview version of the editor, that has not yet been officially released. This is a preview version of the editor, that has not yet been officially released.
@ -631,6 +716,7 @@ Nav.propTypes = {
}).isRequired, }).isRequired,
project: PropTypes.shape({ project: PropTypes.shape({
id: PropTypes.string, id: PropTypes.string,
name: PropTypes.string,
owner: PropTypes.shape({ owner: PropTypes.shape({
id: PropTypes.string id: PropTypes.string
}) })
@ -639,7 +725,7 @@ Nav.propTypes = {
showShareModal: PropTypes.func.isRequired, showShareModal: PropTypes.func.isRequired,
showErrorModal: PropTypes.func.isRequired, showErrorModal: PropTypes.func.isRequired,
unsavedChanges: PropTypes.bool.isRequired, unsavedChanges: PropTypes.bool.isRequired,
warnIfUnsavedChanges: PropTypes.func.isRequired, warnIfUnsavedChanges: PropTypes.func,
showKeyboardShortcutModal: PropTypes.func.isRequired, showKeyboardShortcutModal: PropTypes.func.isRequired,
cmController: PropTypes.shape({ cmController: PropTypes.shape({
tidyCode: PropTypes.func, tidyCode: PropTypes.func,
@ -653,9 +739,13 @@ Nav.propTypes = {
setAllAccessibleOutput: PropTypes.func.isRequired, setAllAccessibleOutput: PropTypes.func.isRequired,
newFile: PropTypes.func.isRequired, newFile: PropTypes.func.isRequired,
newFolder: PropTypes.func.isRequired, newFolder: PropTypes.func.isRequired,
layout: PropTypes.oneOf(['dashboard', 'project']),
rootFile: PropTypes.shape({ rootFile: PropTypes.shape({
id: PropTypes.string.isRequired id: PropTypes.string.isRequired
}).isRequired }).isRequired,
params: PropTypes.shape({
username: PropTypes.string
})
}; };
Nav.defaultProps = { Nav.defaultProps = {
@ -663,7 +753,12 @@ Nav.defaultProps = {
id: undefined, id: undefined,
owner: undefined owner: undefined
}, },
cmController: {} cmController: {},
layout: 'project',
warnIfUnsavedChanges: undefined,
params: {
username: undefined
}
}; };
function mapStateToProps(state) { function mapStateToProps(state) {

View file

@ -0,0 +1,40 @@
import PropTypes from 'prop-types';
import React from 'react';
import InlineSVG from 'react-inlinesvg';
const logoUrl = require('../images/p5js-logo-small.svg');
const arrowUrl = require('../images/triangle-arrow-left.svg');
class NavBasic extends React.PureComponent {
static defaultProps = {
onBack: null
}
render() {
return (
<nav className="nav" title="main-navigation" ref={(node) => { this.node = node; }}>
<ul className="nav__items-left">
<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,71 @@
import React from 'react';
import { shallow } from 'enzyme';
import { FileNode } from '../../modules/IDE/components/FileNode';
beforeAll(() => {});
describe('<FileNode />', () => {
let component;
let props = {};
describe('with valid props', () => {
beforeEach(() => {
props = {
...props,
id: '0',
children: [],
name: 'test.jsx',
fileType: 'dunno',
setSelectedFile: jest.fn(),
deleteFile: jest.fn(),
updateFileName: jest.fn(),
resetSelectedFile: jest.fn(),
newFile: jest.fn(),
newFolder: jest.fn(),
showFolderChildren: jest.fn(),
hideFolderChildren: jest.fn(),
canEdit: true,
authenticated: false
};
component = shallow(<FileNode {...props} />);
});
describe('when changing name', () => {
let input;
let renameTriggerButton;
const changeName = (newFileName) => {
renameTriggerButton.simulate('click');
input.simulate('change', { target: { value: newFileName } });
input.simulate('blur');
};
beforeEach(() => {
input = component.find('.sidebar__file-item-input');
renameTriggerButton = component
.find('.sidebar__file-item-option')
.first();
component.setState({ isEditing: true });
});
it('should render', () => expect(component).toBeDefined());
// it('should debug', () => console.log(component.debug()));
describe('to a valid filename', () => {
const newName = 'newname.jsx';
beforeEach(() => changeName(newName));
it('should save the name', () => {
expect(props.updateFileName).toBeCalledWith(props.id, newName);
});
});
describe('to an empty filename', () => {
const newName = '';
beforeEach(() => changeName(newName));
it('should not save the name', () => {
expect(props.updateFileName).not.toHaveBeenCalled();
});
});
});
});
});

View file

@ -7,7 +7,6 @@ exports[`Nav renders correctly 1`] = `
> >
<ul <ul
className="nav__items-left" className="nav__items-left"
title="project-menu"
> >
<li <li
className="nav__item-logo" className="nav__item-logo"

View file

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

View file

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

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

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

After

Width:  |  Height:  |  Size: 583 B

View file

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

After

Width:  |  Height:  |  Size: 727 B

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

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

After

Width:  |  Height:  |  Size: 801 B

View file

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

Before

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

After

Width:  |  Height:  |  Size: 979 B

View file

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

Before

Width:  |  Height:  |  Size: 925 B

After

Width:  |  Height:  |  Size: 889 B

View file

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

Before

Width:  |  Height:  |  Size: 924 B

After

Width:  |  Height:  |  Size: 888 B

View file

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

After

Width:  |  Height:  |  Size: 665 B

View file

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

Before

Width:  |  Height:  |  Size: 909 B

After

Width:  |  Height:  |  Size: 873 B

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,176 @@
import axios from 'axios';
import { browserHistory } from 'react-router';
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 newCollection = response.data;
dispatch(setToastText(`Created "${newCollection.name}"`));
dispatch(showToast(TOAST_DISPLAY_TIME_MS));
const pathname = `/${newCollection.owner.username}/collections/${newCollection.id}`;
const location = { pathname, state: { skipSavingPath: true } };
browserHistory.push(location);
})
.catch((response) => {
console.error('Error creating collection', response.data);
dispatch({
type: ActionTypes.ERROR,
error: response.data
});
dispatch(stopLoader());
});
};
}
export function addToCollection(collectionId, projectId) {
return (dispatch) => {
dispatch(startLoader());
const url = `${ROOT_URL}/collections/${collectionId}/${projectId}`;
return axios.post(url, { withCredentials: true })
.then((response) => {
dispatch({
type: ActionTypes.ADD_TO_COLLECTION,
payload: response.data
});
dispatch(stopLoader());
const collectionName = response.data.name;
dispatch(setToastText(`Added to "${collectionName}`));
dispatch(showToast(TOAST_DISPLAY_TIME_MS));
return response.data;
})
.catch((response) => {
dispatch({
type: ActionTypes.ERROR,
error: response.data
});
dispatch(stopLoader());
return response.data;
});
};
}
export function removeFromCollection(collectionId, projectId) {
return (dispatch) => {
dispatch(startLoader());
const url = `${ROOT_URL}/collections/${collectionId}/${projectId}`;
return axios.delete(url, { withCredentials: true })
.then((response) => {
dispatch({
type: ActionTypes.REMOVE_FROM_COLLECTION,
payload: response.data
});
dispatch(stopLoader());
const collectionName = response.data.name;
dispatch(setToastText(`Removed from "${collectionName}`));
dispatch(showToast(TOAST_DISPLAY_TIME_MS));
return response.data;
})
.catch((response) => {
dispatch({
type: ActionTypes.ERROR,
error: response.data
});
dispatch(stopLoader());
return response.data;
});
};
}
export function editCollection(collectionId, { name, description }) {
return (dispatch) => {
const url = `${ROOT_URL}/collections/${collectionId}`;
return axios.patch(url, { name, description }, { withCredentials: true })
.then((response) => {
dispatch({
type: ActionTypes.EDIT_COLLECTION,
payload: response.data
});
return response.data;
})
.catch((response) => {
dispatch({
type: ActionTypes.ERROR,
error: response.data
});
return response.data;
});
};
}
export function deleteCollection(collectionId) {
return (dispatch) => {
const url = `${ROOT_URL}/collections/${collectionId}`;
return axios.delete(url, { withCredentials: true })
.then((response) => {
dispatch({
type: ActionTypes.DELETE_COLLECTION,
payload: response.data,
collectionId,
});
return response.data;
})
.catch((response) => {
dispatch({
type: ActionTypes.ERROR,
error: response.data
});
return response.data;
});
};
}

View file

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

View file

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

View file

@ -133,6 +133,7 @@ export function saveProject(selectedFile = null, autosave = false) {
} }
const formParams = Object.assign({}, state.project); const formParams = Object.assign({}, state.project);
formParams.files = [...state.files]; formParams.files = [...state.files];
if (selectedFile) { if (selectedFile) {
const fileToUpdate = formParams.files.find(file => file.id === selectedFile.id); const fileToUpdate = formParams.files.find(file => file.id === selectedFile.id);
fileToUpdate.content = selectedFile.content; fileToUpdate.content = selectedFile.content;

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,254 @@
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: '',
};
this.renameInput = React.createRef();
}
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,
}, () => this.renameInput.current.focus());
}
handleRenameChange = (e) => {
this.setState({
renameValue: e.target.value
});
}
handleRenameEnter = (e) => {
if (e.key === 'Enter') {
this.updateName();
this.closeAll();
}
}
handleRenameBlur = () => {
this.updateName();
this.closeAll();
}
updateName = () => {
const isValid = this.state.renameValue.trim().length !== 0;
if (isValid) {
this.props.editCollection(this.props.collection.id, { name: this.state.renameValue.trim() });
}
}
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.handleRenameBlur}
onClick={e => e.stopPropagation()}
ref={this.renameInput}
/>
}
</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

@ -269,8 +269,8 @@ class Editor extends React.Component {
indent_size: INDENTATION_AMOUNT, indent_size: INDENTATION_AMOUNT,
indent_with_tabs: IS_TAB_INDENT indent_with_tabs: IS_TAB_INDENT
}; };
const mode = this._cm.getOption('mode'); const mode = this._cm.getOption('mode');
const currentPosition = this._cm.doc.getCursor();
if (mode === 'javascript') { if (mode === 'javascript') {
this._cm.doc.setValue(beautifyJS(this._cm.doc.getValue(), beautifyOptions)); this._cm.doc.setValue(beautifyJS(this._cm.doc.getValue(), beautifyOptions));
} else if (mode === 'css') { } else if (mode === 'css') {
@ -278,6 +278,10 @@ class Editor extends React.Component {
} else if (mode === 'htmlmixed') { } else if (mode === 'htmlmixed') {
this._cm.doc.setValue(beautifyHTML(this._cm.doc.getValue(), beautifyOptions)); this._cm.doc.setValue(beautifyHTML(this._cm.doc.getValue(), beautifyOptions));
} }
setTimeout(() => {
this._cm.focus();
this._cm.doc.setCursor({ line: currentPosition.line, ch: currentPosition.ch + INDENTATION_AMOUNT });
}, 0);
} }
initializeDocuments(files) { initializeDocuments(files) {

View file

@ -6,39 +6,29 @@ import InlineSVG from 'react-inlinesvg';
import classNames from 'classnames'; import classNames from 'classnames';
import * as IDEActions from '../actions/ide'; import * as IDEActions from '../actions/ide';
import * as FileActions from '../actions/files'; import * as FileActions from '../actions/files';
import downArrowUrl from '../../../images/down-filled-triangle.svg';
const downArrowUrl = require('../../../images/down-filled-triangle.svg'); import folderRightUrl from '../../../images/triangle-arrow-right.svg';
const folderRightUrl = require('../../../images/triangle-arrow-right.svg'); import folderDownUrl from '../../../images/triangle-arrow-down.svg';
const folderDownUrl = require('../../../images/triangle-arrow-down.svg'); import fileUrl from '../../../images/file.svg';
const fileUrl = require('../../../images/file.svg');
export class FileNode extends React.Component { export class FileNode extends React.Component {
constructor(props) { constructor(props) {
super(props); super(props);
this.renderChild = this.renderChild.bind(this);
this.handleKeyPress = this.handleKeyPress.bind(this);
this.handleFileNameChange = this.handleFileNameChange.bind(this);
this.validateFileName = this.validateFileName.bind(this);
this.handleFileClick = this.handleFileClick.bind(this);
this.toggleFileOptions = this.toggleFileOptions.bind(this);
this.hideFileOptions = this.hideFileOptions.bind(this);
this.showEditFileName = this.showEditFileName.bind(this);
this.hideEditFileName = this.hideEditFileName.bind(this);
this.onBlurComponent = this.onBlurComponent.bind(this);
this.onFocusComponent = this.onFocusComponent.bind(this);
this.state = { this.state = {
isOptionsOpen: false, isOptionsOpen: false,
isEditingName: false, isEditingName: false,
isFocused: false, isFocused: false,
isDeleting: false,
updatedName: this.props.name
}; };
} }
onFocusComponent() { onFocusComponent = () => {
this.setState({ isFocused: true }); this.setState({ isFocused: true });
} }
onBlurComponent() { onBlurComponent = () => {
this.setState({ isFocused: false }); this.setState({ isFocused: false });
setTimeout(() => { setTimeout(() => {
if (!this.state.isFocused) { if (!this.state.isFocused) {
@ -47,41 +37,96 @@ export class FileNode extends React.Component {
}, 200); }, 200);
} }
handleFileClick(e) {
e.stopPropagation(); setUpdatedName = (updatedName) => {
if (this.props.name !== 'root' && !this.isDeleting) { this.setState({ updatedName });
this.props.setSelectedFile(this.props.id); }
saveUpdatedFileName = () => {
const { updatedName } = this.state;
const { name, updateFileName, id } = this.props;
if (updatedName !== name) {
updateFileName(id, updatedName);
} }
} }
handleFileNameChange(event) { handleFileClick = (event) => {
this.props.updateFileName(this.props.id, event.target.value); event.stopPropagation();
const { isDeleting } = this.state;
const { id, setSelectedFile, name } = this.props;
if (name !== 'root' && !isDeleting) {
setSelectedFile(id);
}
} }
handleKeyPress(event) { handleFileNameChange = (event) => {
const newName = event.target.value;
this.setUpdatedName(newName);
}
handleFileNameBlur = () => {
this.validateFileName();
this.hideEditFileName();
}
handleClickRename = () => {
this.setUpdatedName(this.props.name);
this.showEditFileName();
setTimeout(() => this.fileNameInput.focus(), 0);
setTimeout(() => this.hideFileOptions(), 0);
}
handleClickAddFile = () => {
this.props.newFile(this.props.id);
setTimeout(() => this.hideFileOptions(), 0);
}
handleClickAddFolder = () => {
this.props.newFolder(this.props.id);
setTimeout(() => this.hideFileOptions(), 0);
}
handleClickUploadFile = () => {
this.props.openUploadFileModal(this.props.id);
setTimeout(this.hideFileOptions, 0);
}
handleClickDelete = () => {
if (window.confirm(`Are you sure you want to delete ${this.props.name}?`)) {
this.setState({ isDeleting: true });
this.props.resetSelectedFile(this.props.id);
setTimeout(() => this.props.deleteFile(this.props.id, this.props.parentId), 100);
}
}
handleKeyPress = (event) => {
if (event.key === 'Enter') { if (event.key === 'Enter') {
this.hideEditFileName(); this.hideEditFileName();
} }
} }
validateFileName() { validateFileName = () => {
const oldFileExtension = this.originalFileName.match(/\.[0-9a-z]+$/i); const currentName = this.props.name;
const newFileExtension = this.props.name.match(/\.[0-9a-z]+$/i); const { updatedName } = this.state;
const hasPeriod = this.props.name.match(/\.+/); const oldFileExtension = currentName.match(/\.[0-9a-z]+$/i);
const newFileName = this.props.name; const newFileExtension = updatedName.match(/\.[0-9a-z]+$/i);
const hasPeriod = updatedName.match(/\.+/);
const hasNoExtension = oldFileExtension && !newFileExtension; const hasNoExtension = oldFileExtension && !newFileExtension;
const hasExtensionIfFolder = this.props.fileType === 'folder' && hasPeriod; const hasExtensionIfFolder = this.props.fileType === 'folder' && hasPeriod;
const notSameExtension = oldFileExtension && newFileExtension const notSameExtension = oldFileExtension && newFileExtension
&& oldFileExtension[0].toLowerCase() !== newFileExtension[0].toLowerCase(); && oldFileExtension[0].toLowerCase() !== newFileExtension[0].toLowerCase();
const hasEmptyFilename = newFileName === ''; const hasEmptyFilename = updatedName.trim() === '';
const hasOnlyExtension = newFileExtension && newFileName === newFileExtension[0]; const hasOnlyExtension = newFileExtension && updatedName.trim() === newFileExtension[0];
if (hasEmptyFilename || hasNoExtension || notSameExtension || hasOnlyExtension || hasExtensionIfFolder) { if (hasEmptyFilename || hasNoExtension || notSameExtension || hasOnlyExtension || hasExtensionIfFolder) {
this.props.updateFileName(this.props.id, this.originalFileName); this.setUpdatedName(currentName);
} else {
this.saveUpdatedFileName();
} }
} }
toggleFileOptions(e) { toggleFileOptions = (event) => {
e.preventDefault(); event.preventDefault();
if (!this.props.canEdit) { if (!this.props.canEdit) {
return; return;
} }
@ -93,25 +138,31 @@ export class FileNode extends React.Component {
} }
} }
hideFileOptions() { hideFileOptions = () => {
this.setState({ isOptionsOpen: false }); this.setState({ isOptionsOpen: false });
} }
showEditFileName() { showEditFileName = () => {
this.setState({ isEditingName: true }); this.setState({ isEditingName: true });
} }
hideEditFileName() { hideEditFileName = () => {
this.setState({ isEditingName: false }); this.setState({ isEditingName: false });
} }
renderChild(childId) { showFolderChildren = () => {
return ( this.props.showFolderChildren(this.props.id);
}
hideFolderChildren = () => {
this.props.hideFolderChildren(this.props.id);
}
renderChild = childId => (
<li key={childId}> <li key={childId}>
<ConnectedFileNode id={childId} parentId={this.props.id} canEdit={this.props.canEdit} /> <ConnectedFileNode id={childId} parentId={this.props.id} canEdit={this.props.canEdit} />
</li> </li>
); )
}
render() { render() {
const itemClass = classNames({ const itemClass = classNames({
@ -123,49 +174,50 @@ export class FileNode extends React.Component {
'sidebar__file-item--closed': this.props.isFolderClosed 'sidebar__file-item--closed': this.props.isFolderClosed
}); });
const isFile = this.props.fileType === 'file';
const isFolder = this.props.fileType === 'folder';
const isRoot = this.props.name === 'root';
return ( return (
<div className={itemClass}> <div className={itemClass}>
{(() => { // eslint-disable-line { !isRoot &&
if (this.props.name !== 'root') {
return (
<div className="file-item__content" onContextMenu={this.toggleFileOptions}> <div className="file-item__content" onContextMenu={this.toggleFileOptions}>
<span className="file-item__spacer"></span> <span className="file-item__spacer"></span>
{(() => { // eslint-disable-line { isFile &&
if (this.props.fileType === 'file') {
return (
<span className="sidebar__file-item-icon"> <span className="sidebar__file-item-icon">
<InlineSVG src={fileUrl} /> <InlineSVG src={fileUrl} />
</span> </span>
);
} }
return ( { isFolder &&
<div className="sidebar__file-item--folder"> <div className="sidebar__file-item--folder">
<button <button
className="sidebar__file-item-closed" className="sidebar__file-item-closed"
onClick={() => this.props.showFolderChildren(this.props.id)} onClick={this.showFolderChildren}
> >
<InlineSVG className="folder-right" src={folderRightUrl} /> <InlineSVG className="folder-right" src={folderRightUrl} />
</button> </button>
<button <button
className="sidebar__file-item-open" className="sidebar__file-item-open"
onClick={() => this.props.hideFolderChildren(this.props.id)} onClick={this.hideFolderChildren}
> >
<InlineSVG className="folder-down" src={folderDownUrl} /> <InlineSVG className="folder-down" src={folderDownUrl} />
</button> </button>
</div> </div>
); }
})()} <button
<button className="sidebar__file-item-name" onClick={this.handleFileClick}>{this.props.name}</button> className="sidebar__file-item-name"
onClick={this.handleFileClick}
>
{this.state.updatedName}
</button>
<input <input
type="text" type="text"
className="sidebar__file-item-input" className="sidebar__file-item-input"
value={this.props.name} value={this.state.updatedName}
maxLength="128"
onChange={this.handleFileNameChange} onChange={this.handleFileNameChange}
ref={(element) => { this.fileNameInput = element; }} ref={(element) => { this.fileNameInput = element; }}
onBlur={() => { onBlur={this.handleFileNameBlur}
this.validateFileName();
this.hideEditFileName();
}}
onKeyPress={this.handleKeyPress} onKeyPress={this.handleKeyPress}
/> />
<button <button
@ -181,54 +233,47 @@ export class FileNode extends React.Component {
</button> </button>
<div className="sidebar__file-item-options"> <div className="sidebar__file-item-options">
<ul title="file options"> <ul title="file options">
{(() => { // eslint-disable-line { isFolder &&
if (this.props.fileType === 'folder') { <React.Fragment>
return (
<li>
<button
aria-label="add file"
onClick={() => {
this.props.newFile(this.props.id);
setTimeout(() => this.hideFileOptions(), 0);
}}
onBlur={this.onBlurComponent}
onFocus={this.onFocusComponent}
className="sidebar__file-item-option"
>
Add File
</button>
</li>
);
}
})()}
{(() => { // eslint-disable-line
if (this.props.fileType === 'folder') {
return (
<li> <li>
<button <button
aria-label="add folder" aria-label="add folder"
onClick={() => { onClick={this.handleClickAddFolder}
this.props.newFolder(this.props.id);
setTimeout(() => this.hideFileOptions(), 0);
}}
onBlur={this.onBlurComponent} onBlur={this.onBlurComponent}
onFocus={this.onFocusComponent} onFocus={this.onFocusComponent}
className="sidebar__file-item-option" className="sidebar__file-item-option"
> >
Add Folder Create folder
</button> </button>
</li> </li>
);
}
})()}
<li> <li>
<button <button
onClick={() => { aria-label="add file"
this.originalFileName = this.props.name; onClick={this.handleClickAddFile}
this.showEditFileName(); onBlur={this.onBlurComponent}
setTimeout(() => this.fileNameInput.focus(), 0); onFocus={this.onFocusComponent}
setTimeout(() => this.hideFileOptions(), 0); className="sidebar__file-item-option"
}} >
Create file
</button>
</li>
{ this.props.authenticated &&
<li>
<button
aria-label="upload file"
onClick={this.handleClickUploadFile}
onBlur={this.onBlurComponent}
onFocus={this.onFocusComponent}
>
Upload file
</button>
</li>
}
</React.Fragment>
}
<li>
<button
onClick={this.handleClickRename}
onBlur={this.onBlurComponent} onBlur={this.onBlurComponent}
onFocus={this.onFocusComponent} onFocus={this.onFocusComponent}
className="sidebar__file-item-option" className="sidebar__file-item-option"
@ -238,13 +283,7 @@ export class FileNode extends React.Component {
</li> </li>
<li> <li>
<button <button
onClick={() => { onClick={this.handleClickDelete}
if (window.confirm(`Are you sure you want to delete ${this.props.name}?`)) {
this.isDeleting = true;
this.props.resetSelectedFile(this.props.id);
setTimeout(() => this.props.deleteFile(this.props.id, this.props.parentId), 100);
}
}}
onBlur={this.onBlurComponent} onBlur={this.onBlurComponent}
onFocus={this.onFocusComponent} onFocus={this.onFocusComponent}
className="sidebar__file-item-option" className="sidebar__file-item-option"
@ -255,18 +294,12 @@ export class FileNode extends React.Component {
</ul> </ul>
</div> </div>
</div> </div>
);
} }
})()} { this.props.children &&
{(() => { // eslint-disable-line
if (this.props.children) {
return (
<ul className="file-item__children"> <ul className="file-item__children">
{this.props.children.map(this.renderChild)} {this.props.children.map(this.renderChild)}
</ul> </ul>
);
} }
})()}
</div> </div>
); );
} }
@ -288,18 +321,21 @@ FileNode.propTypes = {
newFolder: PropTypes.func.isRequired, newFolder: PropTypes.func.isRequired,
showFolderChildren: PropTypes.func.isRequired, showFolderChildren: PropTypes.func.isRequired,
hideFolderChildren: PropTypes.func.isRequired, hideFolderChildren: PropTypes.func.isRequired,
canEdit: PropTypes.bool.isRequired canEdit: PropTypes.bool.isRequired,
openUploadFileModal: PropTypes.func.isRequired,
authenticated: PropTypes.bool.isRequired
}; };
FileNode.defaultProps = { FileNode.defaultProps = {
parentId: '0', parentId: '0',
isSelectedFile: false, isSelectedFile: false,
isFolderClosed: false isFolderClosed: false,
}; };
function mapStateToProps(state, ownProps) { function mapStateToProps(state, ownProps) {
// this is a hack, state is updated before ownProps // this is a hack, state is updated before ownProps
return state.files.find(file => file.id === ownProps.id) || { name: 'test', fileType: 'file' }; const fileNode = state.files.find(file => file.id === ownProps.id) || { name: 'test', fileType: 'file' };
return Object.assign({}, fileNode, { authenticated: state.user.authenticated });
} }
function mapDispatchToProps(dispatch) { function mapDispatchToProps(dispatch) {

View file

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

View file

@ -74,6 +74,18 @@ function KeyboardShortcutModal() {
</span> </span>
<span>Turn off Accessible Output</span> <span>Turn off Accessible Output</span>
</li> </li>
<li className="keyboard-shortcut-item">
<span className="keyboard-shortcut__command">
{metaKeyName} + B
</span>
<span>Toggle Sidebar</span>
</li>
<li className="keyboard-shortcut-item">
<span className="keyboard-shortcut__command">
Ctrl + `
</span>
<span>Toggle Console</span>
</li>
</ul> </ul>
); );
} }

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,72 @@
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.stopPropagation();
event.currentTarget.blur();
handleAction(item);
}
}
/>))}
</ul>
);
};
QuickAddList.propTypes = {
items: PropTypes.arrayOf(ItemType).isRequired,
onAdd: PropTypes.func.isRequired,
onRemove: PropTypes.func.isRequired,
};
export default QuickAddList;

View file

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

View file

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

View file

@ -1,12 +1,9 @@
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import React from 'react'; import React from 'react';
import InlineSVG from 'react-inlinesvg'; import InlineSVG from 'react-inlinesvg';
import { bindActionCreators } from 'redux';
import { connect } from 'react-redux';
import { throttle } from 'lodash'; import { throttle } from 'lodash';
import * as SortingActions from '../actions/sorting';
const searchIcon = require('../../../images/magnifyingglass.svg'); const searchIcon = require('../../../../images/magnifyingglass.svg');
class Searchbar extends React.Component { class Searchbar extends React.Component {
constructor(props) { constructor(props) {
@ -29,12 +26,13 @@ class Searchbar extends React.Component {
handleSearchEnter = (e) => { handleSearchEnter = (e) => {
if (e.key === 'Enter') { if (e.key === 'Enter') {
this.props.setSearchTerm(this.state.searchValue); this.searchChange();
} }
} }
searchChange = (value) => { searchChange = () => {
this.props.setSearchTerm(this.state.searchValue); if (this.state.searchValue.trim().length === 0) return;
this.props.setSearchTerm(this.state.searchValue.trim());
}; };
handleSearchChange = (e) => { handleSearchChange = (e) => {
@ -46,19 +44,15 @@ class Searchbar extends React.Component {
render() { render() {
const { searchValue } = this.state; const { searchValue } = this.state;
return ( return (
<div className="searchbar"> <div className={`searchbar ${searchValue === '' ? 'searchbar--is-empty' : ''}`}>
<button <div className="searchbar__button">
type="submit"
className="searchbar__button"
onClick={this.handleSearchEnter}
>
<InlineSVG className="searchbar__icon" src={searchIcon} /> <InlineSVG className="searchbar__icon" src={searchIcon} />
</button> </div>
<input <input
className="searchbar__input" className="searchbar__input"
type="text" type="text"
value={searchValue} value={searchValue}
placeholder="Search files..." placeholder={this.props.searchLabel}
onChange={this.handleSearchChange} onChange={this.handleSearchChange}
onKeyUp={this.handleSearchEnter} onKeyUp={this.handleSearchEnter}
/> />
@ -75,17 +69,12 @@ class Searchbar extends React.Component {
Searchbar.propTypes = { Searchbar.propTypes = {
searchTerm: PropTypes.string.isRequired, searchTerm: PropTypes.string.isRequired,
setSearchTerm: PropTypes.func.isRequired, setSearchTerm: PropTypes.func.isRequired,
resetSearchTerm: PropTypes.func.isRequired resetSearchTerm: PropTypes.func.isRequired,
searchLabel: PropTypes.string,
}; };
function mapStateToProps(state) { Searchbar.defaultProps = {
return { searchLabel: 'Search sketches...',
searchTerm: state.search.searchTerm
}; };
}
function mapDispatchToProps(dispatch) { export default Searchbar;
return bindActionCreators(Object.assign({}, SortingActions), dispatch);
}
export default connect(mapStateToProps, mapDispatchToProps)(Searchbar);

View file

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

View file

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

View file

@ -97,7 +97,7 @@ class Sidebar extends React.Component {
onBlur={this.onBlurComponent} onBlur={this.onBlurComponent}
onFocus={this.onFocusComponent} onFocus={this.onFocusComponent}
> >
Add folder Create folder
</button> </button>
</li> </li>
<li> <li>
@ -110,9 +110,25 @@ class Sidebar extends React.Component {
onBlur={this.onBlurComponent} onBlur={this.onBlurComponent}
onFocus={this.onFocusComponent} onFocus={this.onFocusComponent}
> >
Add file Create file
</button> </button>
</li> </li>
{
this.props.user.authenticated &&
<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> </ul>
</div> </div>
</div> </div>
@ -137,6 +153,7 @@ Sidebar.propTypes = {
openProjectOptions: PropTypes.func.isRequired, openProjectOptions: PropTypes.func.isRequired,
closeProjectOptions: PropTypes.func.isRequired, closeProjectOptions: PropTypes.func.isRequired,
newFolder: PropTypes.func.isRequired, newFolder: PropTypes.func.isRequired,
openUploadFileModal: PropTypes.func.isRequired,
owner: PropTypes.shape({ owner: PropTypes.shape({
id: PropTypes.string id: PropTypes.string
}), }),

View file

@ -10,11 +10,14 @@ import classNames from 'classnames';
import slugify from 'slugify'; import slugify from 'slugify';
import * as ProjectActions from '../actions/project'; import * as ProjectActions from '../actions/project';
import * as ProjectsActions from '../actions/projects'; import * as ProjectsActions from '../actions/projects';
import * as CollectionsActions from '../actions/collections';
import * as ToastActions from '../actions/toast'; import * as ToastActions from '../actions/toast';
import * as SortingActions from '../actions/sorting'; import * as SortingActions from '../actions/sorting';
import * as IdeActions from '../actions/ide'; import * as IdeActions from '../actions/ide';
import getSortedSketches from '../selectors/projects'; import getSortedSketches from '../selectors/projects';
import Loader from '../../App/components/loader'; import Loader from '../../App/components/loader';
import Overlay from '../../App/components/Overlay';
import AddToCollectionList from './AddToCollectionList';
const arrowUp = require('../../../images/sort-arrow-up.svg'); const arrowUp = require('../../../images/sort-arrow-up.svg');
const arrowDown = require('../../../images/sort-arrow-down.svg'); const arrowDown = require('../../../images/sort-arrow-down.svg');
@ -27,9 +30,11 @@ class SketchListRowBase extends React.Component {
optionsOpen: false, optionsOpen: false,
renameOpen: false, renameOpen: false,
renameValue: props.sketch.name, renameValue: props.sketch.name,
isFocused: false isFocused: false,
}; };
this.renameInput = React.createRef();
} }
onFocusComponent = () => { onFocusComponent = () => {
this.setState({ isFocused: true }); this.setState({ isFocused: true });
} }
@ -65,8 +70,9 @@ class SketchListRowBase extends React.Component {
openRename = () => { openRename = () => {
this.setState({ this.setState({
renameOpen: true renameOpen: true,
}); renameValue: this.props.sketch.name
}, () => this.renameInput.current.focus());
} }
closeRename = () => { closeRename = () => {
@ -90,15 +96,27 @@ class SketchListRowBase extends React.Component {
handleRenameEnter = (e) => { handleRenameEnter = (e) => {
if (e.key === 'Enter') { if (e.key === 'Enter') {
// TODO pass this func this.updateName();
this.props.changeProjectName(this.props.sketch.id, this.state.renameValue);
this.closeAll(); this.closeAll();
} }
} }
handleRenameBlur = () => {
this.updateName();
this.closeAll();
}
updateName = () => {
const isValid = this.state.renameValue.trim().length !== 0;
if (isValid) {
this.props.changeProjectName(this.props.sketch.id, this.state.renameValue.trim());
}
}
resetSketchName = () => { resetSketchName = () => {
this.setState({ this.setState({
renameValue: this.props.sketch.name renameValue: this.props.sketch.name,
renameOpen: false
}); });
} }
@ -133,36 +151,17 @@ class SketchListRowBase extends React.Component {
} }
} }
render() { renderViewButton = sketchURL => (
const { sketch, username } = this.props; <td className="sketch-list__dropdown-column">
const { renameOpen, optionsOpen, renameValue } = this.state; <Link to={sketchURL}>View</Link>
</td>
)
renderDropdown = () => {
const { optionsOpen } = this.state;
const userIsOwner = this.props.user.username === this.props.username; const userIsOwner = this.props.user.username === this.props.username;
let url = `/${username}/sketches/${sketch.id}`;
if (username === 'p5') {
url = `/${username}/sketches/${slugify(sketch.name, '_')}`;
}
return ( 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"> <td className="sketch-list__dropdown-column">
<button <button
className="sketch-list__dropdown-button" className="sketch-list__dropdown-button"
@ -208,6 +207,20 @@ class SketchListRowBase extends React.Component {
Duplicate Duplicate
</button> </button>
</li>} </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> { /* <li>
<button <button
className="sketch-list__action-option" className="sketch-list__action-option"
@ -231,14 +244,63 @@ class SketchListRowBase extends React.Component {
</li>} </li>}
</ul>} </ul>}
</td> </td>
</tr>); );
}
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.handleRenameBlur}
onClick={e => e.stopPropagation()}
ref={this.renameInput}
/>
}
</React.Fragment>
);
return (
<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>);
} }
} }
SketchListRowBase.propTypes = { SketchListRowBase.propTypes = {
sketch: PropTypes.shape({ sketch: PropTypes.shape({
id: PropTypes.string.isRequired, id: PropTypes.string.isRequired,
name: PropTypes.string.isRequired name: PropTypes.string.isRequired,
createdAt: PropTypes.string.isRequired,
updatedAt: PropTypes.string.isRequired
}).isRequired, }).isRequired,
username: PropTypes.string.isRequired, username: PropTypes.string.isRequired,
user: PropTypes.shape({ user: PropTypes.shape({
@ -249,7 +311,8 @@ SketchListRowBase.propTypes = {
showShareModal: PropTypes.func.isRequired, showShareModal: PropTypes.func.isRequired,
cloneProject: PropTypes.func.isRequired, cloneProject: PropTypes.func.isRequired,
exportProjectAsZip: PropTypes.func.isRequired, exportProjectAsZip: PropTypes.func.isRequired,
changeProjectName: PropTypes.func.isRequired changeProjectName: PropTypes.func.isRequired,
onAddToCollection: PropTypes.func.isRequired
}; };
function mapDispatchToPropsSketchListRow(dispatch) { function mapDispatchToPropsSketchListRow(dispatch) {
@ -264,6 +327,18 @@ class SketchList extends React.Component {
this.props.getProjects(this.props.username); this.props.getProjects(this.props.username);
this.props.resetSorting(); this.props.resetSorting();
this._renderFieldHeader = this._renderFieldHeader.bind(this); this._renderFieldHeader = this._renderFieldHeader.bind(this);
this.state = {
isInitialDataLoad: true,
};
}
componentWillReceiveProps(nextProps) {
if (this.props.sketches !== nextProps.sketches && Array.isArray(nextProps.sketches)) {
this.setState({
isInitialDataLoad: false,
});
}
} }
getSketchesTitle() { getSketchesTitle() {
@ -274,16 +349,20 @@ class SketchList extends React.Component {
} }
hasSketches() { hasSketches() {
return !this.props.loading && this.props.sketches.length > 0; return !this.isLoading() && this.props.sketches.length > 0;
}
isLoading() {
return this.props.loading && this.state.isInitialDataLoad;
} }
_renderLoader() { _renderLoader() {
if (this.props.loading) return <Loader />; if (this.isLoading()) return <Loader />;
return null; return null;
} }
_renderEmptyTable() { _renderEmptyTable() {
if (!this.props.loading && this.props.sketches.length === 0) { if (!this.isLoading() && this.props.sketches.length === 0) {
return (<p className="sketches-table__empty">No sketches.</p>); return (<p className="sketches-table__empty">No sketches.</p>);
} }
return null; return null;
@ -336,9 +415,26 @@ class SketchList extends React.Component {
sketch={sketch} sketch={sketch}
user={this.props.user} user={this.props.user}
username={username} username={username}
onAddToCollection={() => {
this.setState({ sketchToAddToCollection: sketch });
}}
/>))} />))}
</tbody> </tbody>
</table>} </table>}
{
this.state.sketchToAddToCollection &&
<Overlay
isFixedHeight
title="Add to collection"
closeOverlay={() => this.setState({ sketchToAddToCollection: null })}
>
<AddToCollectionList
project={this.state.sketchToAddToCollection}
username={this.props.username}
user={this.props.user}
/>
</Overlay>
}
</div> </div>
); );
} }
@ -364,19 +460,9 @@ SketchList.propTypes = {
field: PropTypes.string.isRequired, field: PropTypes.string.isRequired,
direction: PropTypes.string.isRequired direction: PropTypes.string.isRequired
}).isRequired, }).isRequired,
project: PropTypes.shape({
id: PropTypes.string,
owner: PropTypes.shape({
id: PropTypes.string
})
})
}; };
SketchList.defaultProps = { SketchList.defaultProps = {
project: {
id: undefined,
owner: undefined
},
username: undefined username: undefined
}; };
@ -391,7 +477,10 @@ function mapStateToProps(state) {
} }
function mapDispatchToProps(dispatch) { function mapDispatchToProps(dispatch) {
return bindActionCreators(Object.assign({}, ProjectsActions, ToastActions, SortingActions), dispatch); return bindActionCreators(
Object.assign({}, ProjectsActions, CollectionsActions, ToastActions, SortingActions),
dispatch
);
} }
export default connect(mapStateToProps, mapDispatchToProps)(SketchList); export default connect(mapStateToProps, mapDispatchToProps)(SketchList);

View file

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

View file

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

View file

@ -12,6 +12,7 @@ import Toolbar from '../components/Toolbar';
import Preferences from '../components/Preferences'; import Preferences from '../components/Preferences';
import NewFileModal from '../components/NewFileModal'; import NewFileModal from '../components/NewFileModal';
import NewFolderModal from '../components/NewFolderModal'; import NewFolderModal from '../components/NewFolderModal';
import UploadFileModal from '../components/UploadFileModal';
import ShareModal from '../components/ShareModal'; import ShareModal from '../components/ShareModal';
import KeyboardShortcutModal from '../components/KeyboardShortcutModal'; import KeyboardShortcutModal from '../components/KeyboardShortcutModal';
import ErrorModal from '../components/ErrorModal'; import ErrorModal from '../components/ErrorModal';
@ -28,11 +29,10 @@ import * as ToastActions from '../actions/toast';
import * as ConsoleActions from '../actions/console'; import * as ConsoleActions from '../actions/console';
import { getHTMLFile } from '../reducers/files'; import { getHTMLFile } from '../reducers/files';
import Overlay from '../../App/components/Overlay'; import Overlay from '../../App/components/Overlay';
import SketchList from '../components/SketchList';
import Searchbar from '../components/Searchbar';
import AssetList from '../components/AssetList';
import About from '../components/About'; import About from '../components/About';
import AddToCollectionList from '../components/AddToCollectionList';
import Feedback from '../components/Feedback'; import Feedback from '../components/Feedback';
import { CollectionSearchbar } from '../components/Searchbar';
class IDEView extends React.Component { class IDEView extends React.Component {
constructor(props) { constructor(props) {
@ -156,6 +156,20 @@ class IDEView extends React.Component {
} else if (e.keyCode === 49 && ((e.metaKey && this.isMac) || (e.ctrlKey && !this.isMac)) && e.shiftKey) { } else if (e.keyCode === 49 && ((e.metaKey && this.isMac) || (e.ctrlKey && !this.isMac)) && e.shiftKey) {
e.preventDefault(); e.preventDefault();
this.props.setAllAccessibleOutput(true); this.props.setAllAccessibleOutput(true);
} else if (e.keyCode === 66 && ((e.metaKey && this.isMac) || (e.ctrlKey && !this.isMac))) {
e.preventDefault();
if (!this.props.ide.sidebarIsExpanded) {
this.props.expandSidebar();
} else {
this.props.collapseSidebar();
}
} else if (e.keyCode === 192 && e.ctrlKey) {
e.preventDefault();
if (this.props.ide.consoleIsExpanded) {
this.props.collapseConsole();
} else {
this.props.expandConsole();
}
} }
} }
@ -239,6 +253,8 @@ class IDEView extends React.Component {
newFolder={this.props.newFolder} newFolder={this.props.newFolder}
user={this.props.user} user={this.props.user}
owner={this.props.project.owner} owner={this.props.project.owner}
openUploadFileModal={this.props.openUploadFileModal}
closeUploadFileModal={this.props.closeUploadFileModal}
/> />
<SplitPane <SplitPane
split="vertical" split="vertical"
@ -351,11 +367,7 @@ class IDEView extends React.Component {
</SplitPane> </SplitPane>
</div> </div>
{ this.props.ide.modalIsVisible && { this.props.ide.modalIsVisible &&
<NewFileModal <NewFileModal />
canUploadMedia={this.props.user.authenticated}
closeModal={this.props.closeNewFileModal}
createFile={this.props.createFile}
/>
} }
{this.props.ide.newFolderModalVisible && {this.props.ide.newFolderModalVisible &&
<NewFolderModal <NewFolderModal
@ -363,30 +375,10 @@ class IDEView extends React.Component {
createFolder={this.props.createFolder} createFolder={this.props.createFolder}
/> />
} }
{ this.props.location.pathname.match(/sketches$/) && {this.props.ide.uploadFileModalVisible &&
<Overlay <UploadFileModal
ariaLabel="project list" closeModal={this.props.closeUploadFileModal}
title="Open a Sketch"
previousPath={this.props.ide.previousPath}
>
<Searchbar />
<SketchList
username={this.props.params.username}
user={this.props.user}
/> />
</Overlay>
}
{ this.props.location.pathname.match(/assets$/) &&
<Overlay
title="Assets"
ariaLabel="asset list"
previousPath={this.props.ide.previousPath}
>
<AssetList
username={this.props.params.username}
user={this.props.user}
/>
</Overlay>
} }
{ this.props.location.pathname === '/about' && { this.props.location.pathname === '/about' &&
<Overlay <Overlay
@ -406,6 +398,21 @@ class IDEView extends React.Component {
<Feedback previousPath={this.props.ide.previousPath} /> <Feedback previousPath={this.props.ide.previousPath} />
</Overlay> </Overlay>
} }
{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 && {this.props.ide.shareModalVisible &&
<Overlay <Overlay
title="Share" title="Share"
@ -486,6 +493,7 @@ IDEView.propTypes = {
justOpenedProject: PropTypes.bool.isRequired, justOpenedProject: PropTypes.bool.isRequired,
errorType: PropTypes.string, errorType: PropTypes.string,
runtimeErrorWarningVisible: PropTypes.bool.isRequired, runtimeErrorWarningVisible: PropTypes.bool.isRequired,
uploadFileModalVisible: PropTypes.bool.isRequired
}).isRequired, }).isRequired,
stopSketch: PropTypes.func.isRequired, stopSketch: PropTypes.func.isRequired,
project: PropTypes.shape({ project: PropTypes.shape({
@ -543,7 +551,6 @@ IDEView.propTypes = {
}).isRequired, }).isRequired,
dispatchConsoleEvent: PropTypes.func.isRequired, dispatchConsoleEvent: PropTypes.func.isRequired,
newFile: PropTypes.func.isRequired, newFile: PropTypes.func.isRequired,
closeNewFileModal: PropTypes.func.isRequired,
expandSidebar: PropTypes.func.isRequired, expandSidebar: PropTypes.func.isRequired,
collapseSidebar: PropTypes.func.isRequired, collapseSidebar: PropTypes.func.isRequired,
cloneProject: PropTypes.func.isRequired, cloneProject: PropTypes.func.isRequired,
@ -556,7 +563,6 @@ IDEView.propTypes = {
newFolder: PropTypes.func.isRequired, newFolder: PropTypes.func.isRequired,
closeNewFolderModal: PropTypes.func.isRequired, closeNewFolderModal: PropTypes.func.isRequired,
createFolder: PropTypes.func.isRequired, createFolder: PropTypes.func.isRequired,
createFile: PropTypes.func.isRequired,
closeShareModal: PropTypes.func.isRequired, closeShareModal: PropTypes.func.isRequired,
showEditorOptions: PropTypes.func.isRequired, showEditorOptions: PropTypes.func.isRequired,
closeEditorOptions: PropTypes.func.isRequired, closeEditorOptions: PropTypes.func.isRequired,
@ -588,6 +594,8 @@ IDEView.propTypes = {
showRuntimeErrorWarning: PropTypes.func.isRequired, showRuntimeErrorWarning: PropTypes.func.isRequired,
hideRuntimeErrorWarning: PropTypes.func.isRequired, hideRuntimeErrorWarning: PropTypes.func.isRequired,
startSketch: PropTypes.func.isRequired, startSketch: PropTypes.func.isRequired,
openUploadFileModal: PropTypes.func.isRequired,
closeUploadFileModal: PropTypes.func.isRequired
}; };
function mapStateToProps(state) { function mapStateToProps(state) {

View file

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

View file

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

View file

@ -11,7 +11,7 @@ function draw() {
const defaultHTML = const defaultHTML =
`<!DOCTYPE html> `<!DOCTYPE html>
<html> <html lang="en">
<head> <head>
<script src="https://cdnjs.cloudflare.com/ajax/libs/p5.js/0.10.2/p5.js"></script> <script src="https://cdnjs.cloudflare.com/ajax/libs/p5.js/0.10.2/p5.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/p5.js/0.10.2/addons/p5.sound.min.js"></script> <script src="https://cdnjs.cloudflare.com/ajax/libs/p5.js/0.10.2/addons/p5.sound.min.js"></script>

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,30 @@
import { createSelector } from 'reselect';
const __process = (typeof global !== 'undefined' ? global : window).process;
const getAuthenticated = state => state.user.authenticated;
const getTotalSize = state => state.user.totalSize;
const getAssetsTotalSize = state => state.assets.totalSize;
const limit = __process.env.UPLOAD_LIMIT || 250000000;
export const getCanUploadMedia = createSelector(
getAuthenticated,
getTotalSize,
(authenticated, totalSize) => {
if (!authenticated) return false;
// eventually do the same thing for verified when
// email verification actually works
if (totalSize > limit) return false;
return true;
}
);
export const getreachedTotalSizeLimit = createSelector(
getTotalSize,
getAssetsTotalSize,
(totalSize, assetsTotalSize) => {
const currentSize = totalSize || assetsTotalSize;
if (currentSize && currentSize > limit) return true;
// if (totalSize > 1000) return true;
return false;
}
);

View file

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

View file

@ -0,0 +1,123 @@
import PropTypes from 'prop-types';
import React from 'react';
import InlineSVG from 'react-inlinesvg';
import CopyableInput from '../../IDE/components/CopyableInput';
import APIKeyList from './APIKeyList';
const plusIcon = require('../../../images/plus-icon.svg');
export const APIKeyPropType = PropTypes.shape({
id: PropTypes.object.isRequired,
token: PropTypes.object,
label: PropTypes.string.isRequired,
createdAt: PropTypes.string.isRequired,
lastUsedAt: PropTypes.string,
});
class APIKeyForm extends React.Component {
constructor(props) {
super(props);
this.state = { keyLabel: '' };
this.addKey = this.addKey.bind(this);
this.removeKey = this.removeKey.bind(this);
this.renderApiKeys = this.renderApiKeys.bind(this);
}
addKey(event) {
event.preventDefault();
const { keyLabel } = this.state;
this.setState({
keyLabel: ''
});
this.props.createApiKey(keyLabel);
return false;
}
removeKey(key) {
const message = `Are you sure you want to delete "${key.label}"?`;
if (window.confirm(message)) {
this.props.removeApiKey(key.id);
}
}
renderApiKeys() {
const hasApiKeys = this.props.apiKeys && this.props.apiKeys.length > 0;
if (hasApiKeys) {
return (
<APIKeyList apiKeys={this.props.apiKeys} onRemove={this.removeKey} />
);
}
return <p>You have no exsiting tokens.</p>;
}
render() {
const keyWithToken = this.props.apiKeys.find(k => !!k.token);
return (
<div className="api-key-form">
<p className="api-key-form__summary">
Personal Access Tokens act like your password to allow automated
scripts to access the Editor API. Create a token for each script
that needs access.
</p>
<div className="api-key-form__section">
<h3 className="api-key-form__title">Create new token</h3>
<form className="form form--inline" onSubmit={this.addKey}>
<label htmlFor="keyLabel" className="form__label form__label--hidden ">What is this token for?</label>
<input
className="form__input"
id="keyLabel"
onChange={(event) => { this.setState({ keyLabel: event.target.value }); }}
placeholder="What is this token for? e.g. Example import script"
type="text"
value={this.state.keyLabel}
/>
<button
className="api-key-form__create-button"
disabled={this.state.keyLabel === ''}
type="submit"
>
<InlineSVG src={plusIcon} className="api-key-form__create-icon" />
Create
</button>
</form>
{
keyWithToken && (
<div className="api-key-form__new-token">
<h4 className="api-key-form__new-token__title">Your new access token</h4>
<p className="api-key-form__new-token__info">
Make sure to copy your new personal access token now.
You wont be able to see it again!
</p>
<CopyableInput label={keyWithToken.label} value={keyWithToken.token} />
</div>
)
}
</div>
<div className="api-key-form__section">
<h3 className="api-key-form__title">Existing tokens</h3>
{this.renderApiKeys()}
</div>
</div>
);
}
}
APIKeyForm.propTypes = {
apiKeys: PropTypes.arrayOf(PropTypes.shape(APIKeyPropType)).isRequired,
createApiKey: PropTypes.func.isRequired,
removeApiKey: PropTypes.func.isRequired,
};
export default APIKeyForm;

View file

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

View file

@ -0,0 +1,431 @@
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-content">
<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>
</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,
name: PropTypes.string,
slug: PropTypes.string,
description: PropTypes.string,
owner: PropTypes.shape({
username: PropTypes.string,
}).isRequired,
items: PropTypes.arrayOf(PropTypes.shape({})),
}),
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,
collection: {
id: undefined,
items: [],
owner: {
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,110 @@
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 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);
}
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
};
function mapStateToProps(state, ownProps) {
return {
user: state.user,
};
}
function mapDispatchToProps(dispatch) {
return bindActionCreators(Object.assign({}, CollectionsActions), dispatch);
}
export default connect(mapStateToProps, mapDispatchToProps)(CollectionCreate);

View file

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

View file

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

View file

@ -0,0 +1,92 @@
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) {
return {};
}
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,
}),
};
export default connect(mapStateToProps, mapDispatchToProps)(CollectionView);

View file

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

View file

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

View file

@ -2,16 +2,13 @@ import PropTypes from 'prop-types';
import React from 'react'; import React from 'react';
import { reduxForm } from 'redux-form'; import { reduxForm } from 'redux-form';
import { Link, browserHistory } from 'react-router'; import { Link, browserHistory } from 'react-router';
import InlineSVG from 'react-inlinesvg';
import { Helmet } from 'react-helmet'; import { Helmet } from 'react-helmet';
import { validateAndLoginUser } from '../actions'; import { validateAndLoginUser } from '../actions';
import LoginForm from '../components/LoginForm'; import LoginForm from '../components/LoginForm';
import { validateLogin } from '../../../utils/reduxFormUtils'; import { validateLogin } from '../../../utils/reduxFormUtils';
import GithubButton from '../components/GithubButton'; import GithubButton from '../components/GithubButton';
import GoogleButton from '../components/GoogleButton'; import GoogleButton from '../components/GoogleButton';
import Nav from '../../../components/Nav';
const exitUrl = require('../../../images/exit.svg');
const logoUrl = require('../../../images/p5js-logo.svg');
class LoginView extends React.Component { class LoginView extends React.Component {
constructor(props) { constructor(props) {
@ -34,18 +31,12 @@ class LoginView extends React.Component {
return null; return null;
} }
return ( return (
<div className="login">
<Nav layout="dashboard" />
<div className="form-container"> <div className="form-container">
<Helmet> <Helmet>
<title>p5.js Web Editor | Login</title> <title>p5.js Web Editor | Login</title>
</Helmet> </Helmet>
<div className="form-container__header">
<button className="form-container__logo-button" onClick={this.gotoHomePage}>
<InlineSVG src={logoUrl} alt="p5js Logo" />
</button>
<button className="form-container__exit-button" onClick={this.closeLoginPage}>
<InlineSVG src={exitUrl} alt="Close Login Page" />
</button>
</div>
<div className="form-container__content"> <div className="form-container__content">
<h2 className="form-container__title">Log In</h2> <h2 className="form-container__title">Log In</h2>
<LoginForm {...this.props} /> <LoginForm {...this.props} />
@ -62,6 +53,7 @@ class LoginView extends React.Component {
</p> </p>
</div> </div>
</div> </div>
</div>
); );
} }
} }

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,190 @@
.collection-container {
padding: #{24 / $base-font-size}rem #{66 / $base-font-size}rem;
position: relative;
flex: 1;
overflow: hidden;
display: flex;
flex-direction:column;
}
.collection-metadata {
max-width: #{1012 / $base-font-size}rem;
width: 100%;
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-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');
}
}
.collection-table-wrapper {
overflow-y: auto;
max-width: 100%;
min-height: 100%;
}
// maybe don't need this?
[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');
}
}
}
}

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