Merge branch 'master' into feature/storybook
|
@ -1,4 +1,4 @@
|
|||
API_URL=/api
|
||||
API_URL=/editor
|
||||
AWS_ACCESS_KEY=<your-aws-access-key>
|
||||
AWS_REGION=<your-aws-region>
|
||||
AWS_SECRET_KEY=<your-aws-secret-key>
|
||||
|
@ -23,3 +23,5 @@ PORT=8000
|
|||
S3_BUCKET=<your-s3-bucket>
|
||||
S3_BUCKET_URL_BASE=<alt-for-s3-url>
|
||||
SESSION_SECRET=whatever_you_want_this_to_be_it_only_matters_for_production
|
||||
UI_ACCESS_TOKEN_ENABLED=false
|
||||
UPLOAD_LIMIT=250000000
|
||||
|
|
46
.github/CONTRIBUTING.md
vendored
|
@ -1,16 +1,16 @@
|
|||
# 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**.
|
||||
|
||||
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. -->
|
||||
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**.
|
||||
|
||||
## Table of Contents
|
||||
- [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)
|
||||
- [How Can I Contribute?](#how-can-i-contribute)
|
||||
- [First Timers](#first-timers)
|
||||
- [Milestones](#milestones)
|
||||
- [First Steps](#first-steps)
|
||||
- [Good First Issues](#good-first-issues)
|
||||
- [Good Medium Issues](#good-medium-issues)
|
||||
- [Project Board](#project-board)
|
||||
- [Project Ideas](#project-ideas)
|
||||
- [Issue Search and Tagging](#issue-search-and-tagging)
|
||||
- [Beginning Work](#beginning-work)
|
||||
|
@ -20,15 +20,35 @@ Here are links to all the sections in this document:
|
|||
|
||||
## 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?
|
||||
If you're new to open source, [read about how to contribute to open source](https://opensource.guide/how-to-contribute/).
|
||||
|
||||
### First Timers
|
||||
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.
|
||||
### First Steps
|
||||
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
|
||||
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.
|
||||
### Good First Issues
|
||||
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
|
||||
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
|
||||
|
||||
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.
|
||||
|
||||
|
|
1
.github/FUNDING.yml
vendored
Normal file
|
@ -0,0 +1 @@
|
|||
custom: https://processingfoundation.org/support
|
2
.github/config.yml
vendored
|
@ -10,7 +10,7 @@ newIssueWelcomeComment: >
|
|||
|
||||
# Comment to be posted to on PRs from first time contributors in your repository
|
||||
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
|
||||
|
||||
|
|
1
.nvmrc
Normal file
|
@ -0,0 +1 @@
|
|||
12.16.1
|
|
@ -1,7 +1,7 @@
|
|||
sudo: required
|
||||
language: node_js
|
||||
node_js:
|
||||
- "10.15.0"
|
||||
- "12.16.1"
|
||||
|
||||
cache:
|
||||
directories:
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
FROM node:10.15.0 as base
|
||||
FROM node:12.16.1 as base
|
||||
ENV APP_HOME=/usr/src/app \
|
||||
TERM=xterm
|
||||
RUN mkdir -p $APP_HOME
|
||||
|
|
33
README.md
|
@ -1,29 +1,42 @@
|
|||
# [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 editor’s 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.
|
||||
|
||||
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
|
||||
|
||||
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:
|
||||
|
||||
* 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.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)
|
||||
|
||||
## 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. 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
|
||||
## Acknowledgements
|
||||
|
||||
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/).
|
||||
|
||||
|
|
2
app.json
|
@ -16,7 +16,7 @@
|
|||
],
|
||||
"env": {
|
||||
"API_URL": {
|
||||
"value": "/api"
|
||||
"value": "/editor"
|
||||
},
|
||||
"AWS_ACCESS_KEY": {
|
||||
"description": "AWS Access Key",
|
||||
|
|
24
client/components/AddRemoveButton.jsx
Normal 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;
|
|
@ -12,6 +12,7 @@ import { setAllAccessibleOutput } from '../modules/IDE/actions/preferences';
|
|||
import { logoutUser } from '../modules/User/actions';
|
||||
|
||||
import { metaKeyName, } from '../utils/metaKey';
|
||||
import caretLeft from '../images/left-arrow.svg';
|
||||
|
||||
const triangleUrl = require('../images/down-filled-triangle.svg');
|
||||
const logoUrl = require('../images/p5js-logo-small.svg');
|
||||
|
@ -92,11 +93,12 @@ class Nav extends React.PureComponent {
|
|||
}
|
||||
|
||||
handleNew() {
|
||||
if (!this.props.unsavedChanges) {
|
||||
const { unsavedChanges, warnIfUnsavedChanges } = this.props;
|
||||
if (!unsavedChanges) {
|
||||
this.props.showToast(1500);
|
||||
this.props.setToastText('Opened new sketch.');
|
||||
this.props.newProject();
|
||||
} else if (this.props.warnIfUnsavedChanges()) {
|
||||
} else if (warnIfUnsavedChanges && warnIfUnsavedChanges()) {
|
||||
this.props.showToast(1500);
|
||||
this.props.setToastText('Opened new sketch.');
|
||||
this.props.newProject();
|
||||
|
@ -180,7 +182,8 @@ class Nav extends React.PureComponent {
|
|||
}
|
||||
|
||||
handleShare() {
|
||||
this.props.showShareModal();
|
||||
const { username } = this.props.params;
|
||||
this.props.showShareModal(this.props.project.id, this.props.project.name, username);
|
||||
this.setDropdown('none');
|
||||
}
|
||||
|
||||
|
@ -222,32 +225,27 @@ class Nav extends React.PureComponent {
|
|||
this.timer = setTimeout(this.setDropdown.bind(this, 'none'), 10);
|
||||
}
|
||||
|
||||
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'
|
||||
})
|
||||
};
|
||||
renderDashboardMenu(navDropdownState) {
|
||||
return (
|
||||
<nav className="nav" title="main-navigation" ref={(node) => { this.node = node; }}>
|
||||
<ul className="nav__items-left" title="project-menu">
|
||||
<ul className="nav__items-left">
|
||||
<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">
|
||||
<InlineSVG src={logoUrl} alt="p5.js logo" className="svg__logo" />
|
||||
</li>
|
||||
|
@ -327,6 +325,19 @@ class Nav extends React.PureComponent {
|
|||
Open
|
||||
</Link>
|
||||
</li> }
|
||||
{__process.env.UI_COLLECTIONS_ENABLED &&
|
||||
this.props.user.authenticated &&
|
||||
this.props.project.id &&
|
||||
<li className="nav__dropdown-item">
|
||||
<Link
|
||||
to={`/${this.props.user.username}/sketches/${this.props.project.id}/add-to-collection`}
|
||||
onFocus={this.handleFocusForFile}
|
||||
onBlur={this.handleBlur}
|
||||
onClick={this.setDropdownForNone}
|
||||
>
|
||||
Add to Collection
|
||||
</Link>
|
||||
</li>}
|
||||
{ __process.env.EXAMPLES_ENABLED &&
|
||||
<li className="nav__dropdown-item">
|
||||
<Link
|
||||
|
@ -523,21 +534,29 @@ class Nav extends React.PureComponent {
|
|||
</ul>
|
||||
</li>
|
||||
</ul>
|
||||
{ __process.env.LOGIN_ENABLED && !this.props.user.authenticated &&
|
||||
);
|
||||
}
|
||||
|
||||
renderUnauthenticatedUserMenu(navDropdownState) {
|
||||
return (
|
||||
<ul className="nav__items-right" title="user-menu">
|
||||
<li>
|
||||
<li className="nav__item">
|
||||
<Link to="/login">
|
||||
<span className="nav__item-header">Log in</span>
|
||||
</Link>
|
||||
</li>
|
||||
<span className="nav__item-spacer">or</span>
|
||||
<li>
|
||||
<li className="nav__item">
|
||||
<Link to="/signup">
|
||||
<span className="nav__item-header">Sign up</span>
|
||||
</Link>
|
||||
</li>
|
||||
</ul>}
|
||||
{ __process.env.LOGIN_ENABLED && this.props.user.authenticated &&
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
renderAuthenticatedUserMenu(navDropdownState) {
|
||||
return (
|
||||
<ul className="nav__items-right" title="user-menu">
|
||||
<li className="nav__item">
|
||||
<span>Hello, {this.props.user.username}!</span>
|
||||
|
@ -569,9 +588,21 @@ class Nav extends React.PureComponent {
|
|||
My sketches
|
||||
</Link>
|
||||
</li>
|
||||
{__process.env.UI_COLLECTIONS_ENABLED &&
|
||||
<li className="nav__dropdown-item">
|
||||
<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}
|
||||
onBlur={this.handleBlur}
|
||||
onClick={this.setDropdownForNone}
|
||||
|
@ -600,7 +631,61 @@ class Nav extends React.PureComponent {
|
|||
</li>
|
||||
</ul>
|
||||
</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">
|
||||
This is a preview version of the editor, that has not yet been officially released.
|
||||
|
@ -631,6 +716,7 @@ Nav.propTypes = {
|
|||
}).isRequired,
|
||||
project: PropTypes.shape({
|
||||
id: PropTypes.string,
|
||||
name: PropTypes.string,
|
||||
owner: PropTypes.shape({
|
||||
id: PropTypes.string
|
||||
})
|
||||
|
@ -639,7 +725,7 @@ Nav.propTypes = {
|
|||
showShareModal: PropTypes.func.isRequired,
|
||||
showErrorModal: PropTypes.func.isRequired,
|
||||
unsavedChanges: PropTypes.bool.isRequired,
|
||||
warnIfUnsavedChanges: PropTypes.func.isRequired,
|
||||
warnIfUnsavedChanges: PropTypes.func,
|
||||
showKeyboardShortcutModal: PropTypes.func.isRequired,
|
||||
cmController: PropTypes.shape({
|
||||
tidyCode: PropTypes.func,
|
||||
|
@ -653,9 +739,13 @@ Nav.propTypes = {
|
|||
setAllAccessibleOutput: PropTypes.func.isRequired,
|
||||
newFile: PropTypes.func.isRequired,
|
||||
newFolder: PropTypes.func.isRequired,
|
||||
layout: PropTypes.oneOf(['dashboard', 'project']),
|
||||
rootFile: PropTypes.shape({
|
||||
id: PropTypes.string.isRequired
|
||||
}).isRequired
|
||||
}).isRequired,
|
||||
params: PropTypes.shape({
|
||||
username: PropTypes.string
|
||||
})
|
||||
};
|
||||
|
||||
Nav.defaultProps = {
|
||||
|
@ -663,7 +753,12 @@ Nav.defaultProps = {
|
|||
id: undefined,
|
||||
owner: undefined
|
||||
},
|
||||
cmController: {}
|
||||
cmController: {},
|
||||
layout: 'project',
|
||||
warnIfUnsavedChanges: undefined,
|
||||
params: {
|
||||
username: undefined
|
||||
}
|
||||
};
|
||||
|
||||
function mapStateToProps(state) {
|
||||
|
|
40
client/components/NavBasic.jsx
Normal 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;
|
71
client/components/__test__/FileNode.test.jsx
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
|
@ -7,7 +7,6 @@ exports[`Nav renders correctly 1`] = `
|
|||
>
|
||||
<ul
|
||||
className="nav__items-left"
|
||||
title="project-menu"
|
||||
>
|
||||
<li
|
||||
className="nav__item-logo"
|
||||
|
|
27
client/components/createRedirectWithUsername.jsx
Normal 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;
|
|
@ -20,6 +20,9 @@ export const AUTH_ERROR = 'AUTH_ERROR';
|
|||
|
||||
export const SETTINGS_UPDATED = 'SETTINGS_UPDATED';
|
||||
|
||||
export const API_KEY_CREATED = 'API_KEY_CREATED';
|
||||
export const API_KEY_REMOVED = 'API_KEY_REMOVED';
|
||||
|
||||
export const SET_PROJECT_NAME = 'SET_PROJECT_NAME';
|
||||
export const RENAME_PROJECT = 'RENAME_PROJECT';
|
||||
|
||||
|
@ -33,6 +36,14 @@ export const HIDE_EDIT_PROJECT_NAME = 'HIDE_EDIT_PROJECT_NAME';
|
|||
export const SET_PROJECT = 'SET_PROJECT';
|
||||
export const SET_PROJECTS = 'SET_PROJECTS';
|
||||
|
||||
export const SET_COLLECTIONS = 'SET_COLLECTIONS';
|
||||
export const CREATE_COLLECTION = 'CREATED_COLLECTION';
|
||||
export const DELETE_COLLECTION = 'DELETE_COLLECTION';
|
||||
|
||||
export const ADD_TO_COLLECTION = 'ADD_TO_COLLECTION';
|
||||
export const REMOVE_FROM_COLLECTION = 'REMOVE_FROM_COLLECTION';
|
||||
export const EDIT_COLLECTION = 'EDIT_COLLECTION';
|
||||
|
||||
export const DELETE_PROJECT = 'DELETE_PROJECT';
|
||||
|
||||
export const SET_SELECTED_FILE = 'SET_SELECTED_FILE';
|
||||
|
@ -69,6 +80,8 @@ export const SHOW_NEW_FOLDER_MODAL = 'SHOW_NEW_FOLDER_MODAL';
|
|||
export const CLOSE_NEW_FOLDER_MODAL = 'CLOSE_NEW_FOLDER_MODAL';
|
||||
export const SHOW_FOLDER_CHILDREN = 'SHOW_FOLDER_CHILDREN';
|
||||
export const HIDE_FOLDER_CHILDREN = 'HIDE_FOLDER_CHILDREN';
|
||||
export const OPEN_UPLOAD_FILE_MODAL = 'OPEN_UPLOAD_FILE_MODAL';
|
||||
export const CLOSE_UPLOAD_FILE_MODAL = 'CLOSE_UPLOAD_FILE_MODAL';
|
||||
|
||||
export const SHOW_SHARE_MODAL = 'SHOW_SHARE_MODAL';
|
||||
export const CLOSE_SHARE_MODAL = 'CLOSE_SHARE_MODAL';
|
||||
|
@ -116,6 +129,7 @@ export const CLEAR_PERSISTED_STATE = 'CLEAR_PERSISTED_STATE';
|
|||
export const HIDE_RUNTIME_ERROR_WARNING = 'HIDE_RUNTIME_ERROR_WARNING';
|
||||
export const SHOW_RUNTIME_ERROR_WARNING = 'SHOW_RUNTIME_ERROR_WARNING';
|
||||
export const SET_ASSETS = 'SET_ASSETS';
|
||||
export const DELETE_ASSET = 'DELETE_ASSET';
|
||||
|
||||
export const TOGGLE_DIRECTION = 'TOGGLE_DIRECTION';
|
||||
export const SET_SORTING = 'SET_SORTING';
|
||||
|
|
6
client/images/check.svg
Normal file
|
@ -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 |
11
client/images/check_encircled.svg
Normal 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
|
@ -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 |
|
@ -1,7 +1,6 @@
|
|||
<?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">
|
||||
<!-- Generator: Sketch 39 (31667) - http://www.bohemiancoding.com/sketch -->
|
||||
<title>arrow shape copy 2</title>
|
||||
<!-- <desc>Created with Sketch.</desc> -->
|
||||
<defs></defs>
|
||||
<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 |
|
@ -1,7 +1,6 @@
|
|||
<?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">
|
||||
<!-- Generator: Sketch 3.8.3 (29802) - http://www.bohemiancoding.com/sketch -->
|
||||
<title>arrow shape copy</title>
|
||||
<!-- <desc>Created with Sketch.</desc> -->
|
||||
<defs></defs>
|
||||
<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 |
|
@ -1,7 +1,6 @@
|
|||
<?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">
|
||||
<!-- Generator: Sketch 3.8.3 (29802) - http://www.bohemiancoding.com/sketch -->
|
||||
<title>arrow shape copy</title>
|
||||
<!-- <desc>Created with Sketch.</desc> -->
|
||||
<defs></defs>
|
||||
<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 |
14
client/images/triangle-arrow-left.svg
Normal 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 |
|
@ -1,7 +1,6 @@
|
|||
<?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">
|
||||
<!-- Generator: Sketch 39 (31667) - http://www.bohemiancoding.com/sketch -->
|
||||
<title>arrow shape copy</title>
|
||||
<!-- <desc>Created with Sketch.</desc> -->
|
||||
<defs></defs>
|
||||
<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 |
|
@ -18,7 +18,10 @@ class App extends React.Component {
|
|||
}
|
||||
|
||||
componentWillReceiveProps(nextProps) {
|
||||
if (nextProps.location !== this.props.location) {
|
||||
const locationWillChange = nextProps.location !== this.props.location;
|
||||
const shouldSkipRemembering = nextProps.location.state && nextProps.location.state.skipSavingPath === true;
|
||||
|
||||
if (locationWillChange && !shouldSkipRemembering) {
|
||||
this.props.setPreviousPath(this.props.location.pathname);
|
||||
}
|
||||
}
|
||||
|
@ -42,7 +45,10 @@ class App extends React.Component {
|
|||
App.propTypes = {
|
||||
children: PropTypes.element,
|
||||
location: PropTypes.shape({
|
||||
pathname: PropTypes.string
|
||||
pathname: PropTypes.string,
|
||||
state: PropTypes.shape({
|
||||
skipSavingPath: PropTypes.bool,
|
||||
}),
|
||||
}).isRequired,
|
||||
setPreviousPath: PropTypes.func.isRequired,
|
||||
theme: PropTypes.string,
|
||||
|
|
|
@ -64,10 +64,12 @@ class Overlay extends React.Component {
|
|||
const {
|
||||
ariaLabel,
|
||||
title,
|
||||
children
|
||||
children,
|
||||
actions,
|
||||
isFixedHeight,
|
||||
} = this.props;
|
||||
return (
|
||||
<div className="overlay">
|
||||
<div className={`overlay ${isFixedHeight ? 'overlay--is-fixed-height' : ''}`}>
|
||||
<div className="overlay__content">
|
||||
<section
|
||||
role="main"
|
||||
|
@ -77,9 +79,12 @@ class Overlay extends React.Component {
|
|||
>
|
||||
<header className="overlay__header">
|
||||
<h2 className="overlay__title">{title}</h2>
|
||||
<div className="overlay__actions">
|
||||
{actions}
|
||||
<button className="overlay__close-button" onClick={this.close} >
|
||||
<InlineSVG src={exitUrl} alt="close overlay" />
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
{children}
|
||||
</section>
|
||||
|
@ -91,18 +96,22 @@ class Overlay extends React.Component {
|
|||
|
||||
Overlay.propTypes = {
|
||||
children: PropTypes.element,
|
||||
actions: PropTypes.element,
|
||||
closeOverlay: PropTypes.func,
|
||||
title: PropTypes.string,
|
||||
ariaLabel: PropTypes.string,
|
||||
previousPath: PropTypes.string
|
||||
previousPath: PropTypes.string,
|
||||
isFixedHeight: PropTypes.bool,
|
||||
};
|
||||
|
||||
Overlay.defaultProps = {
|
||||
children: null,
|
||||
actions: null,
|
||||
title: 'Modal',
|
||||
closeOverlay: null,
|
||||
ariaLabel: 'modal',
|
||||
previousPath: '/'
|
||||
previousPath: '/',
|
||||
isFixedHeight: false,
|
||||
};
|
||||
|
||||
export default Overlay;
|
||||
|
|
|
@ -1,9 +1,11 @@
|
|||
import React from 'react';
|
||||
|
||||
const Loader = () => (
|
||||
<div className="loader-container">
|
||||
<div className="loader">
|
||||
<div className="loader__circle1" />
|
||||
<div className="loader__circle2" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
export default Loader;
|
||||
|
|
|
@ -30,8 +30,23 @@ export function getAssets() {
|
|||
};
|
||||
}
|
||||
|
||||
export function deleteAsset(assetKey, userId) {
|
||||
export function deleteAsset(assetKey) {
|
||||
return {
|
||||
type: 'PLACEHOLDER'
|
||||
type: ActionTypes.DELETE_ASSET,
|
||||
key: assetKey
|
||||
};
|
||||
}
|
||||
|
||||
export function deleteAssetRequest(assetKey) {
|
||||
return (dispatch) => {
|
||||
axios.delete(`${ROOT_URL}/S3/${assetKey}`, { withCredentials: true })
|
||||
.then((response) => {
|
||||
dispatch(deleteAsset(assetKey));
|
||||
})
|
||||
.catch(() => {
|
||||
dispatch({
|
||||
type: ActionTypes.ERROR
|
||||
});
|
||||
});
|
||||
};
|
||||
}
|
||||
|
|
176
client/modules/IDE/actions/collections.js
Normal 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;
|
||||
});
|
||||
};
|
||||
}
|
|
@ -3,7 +3,7 @@ import objectID from 'bson-objectid';
|
|||
import blobUtil from 'blob-util';
|
||||
import { reset } from 'redux-form';
|
||||
import * as ActionTypes from '../../../constants';
|
||||
import { setUnsavedChanges } from './ide';
|
||||
import { setUnsavedChanges, closeNewFolderModal, closeNewFileModal } from './ide';
|
||||
import { setProjectSavedTime } from './project';
|
||||
|
||||
const __process = (typeof global !== 'undefined' ? global : window).process;
|
||||
|
@ -58,6 +58,7 @@ export function createFile(formProps) {
|
|||
parentId
|
||||
});
|
||||
dispatch(setProjectSavedTime(response.data.project.updatedAt));
|
||||
dispatch(closeNewFileModal());
|
||||
dispatch(reset('new-file'));
|
||||
// dispatch({
|
||||
// type: ActionTypes.HIDE_MODAL
|
||||
|
@ -85,6 +86,7 @@ export function createFile(formProps) {
|
|||
// type: ActionTypes.HIDE_MODAL
|
||||
// });
|
||||
dispatch(setUnsavedChanges(true));
|
||||
dispatch(closeNewFileModal());
|
||||
}
|
||||
};
|
||||
}
|
||||
|
@ -109,9 +111,7 @@ export function createFolder(formProps) {
|
|||
parentId
|
||||
});
|
||||
dispatch(setProjectSavedTime(response.data.project.updatedAt));
|
||||
dispatch({
|
||||
type: ActionTypes.CLOSE_NEW_FOLDER_MODAL
|
||||
});
|
||||
dispatch(closeNewFolderModal());
|
||||
})
|
||||
.catch(response => dispatch({
|
||||
type: ActionTypes.ERROR,
|
||||
|
@ -130,18 +130,19 @@ export function createFolder(formProps) {
|
|||
fileType: 'folder',
|
||||
children: []
|
||||
});
|
||||
dispatch({
|
||||
type: ActionTypes.CLOSE_NEW_FOLDER_MODAL
|
||||
});
|
||||
dispatch(closeNewFolderModal());
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function updateFileName(id, name) {
|
||||
return {
|
||||
return (dispatch) => {
|
||||
dispatch(setUnsavedChanges(true));
|
||||
dispatch({
|
||||
type: ActionTypes.UPDATE_FILE_NAME,
|
||||
id,
|
||||
name
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
@ -75,6 +75,19 @@ export function closeNewFileModal() {
|
|||
};
|
||||
}
|
||||
|
||||
export function openUploadFileModal(parentId) {
|
||||
return {
|
||||
type: ActionTypes.OPEN_UPLOAD_FILE_MODAL,
|
||||
parentId
|
||||
};
|
||||
}
|
||||
|
||||
export function closeUploadFileModal() {
|
||||
return {
|
||||
type: ActionTypes.CLOSE_UPLOAD_FILE_MODAL
|
||||
};
|
||||
}
|
||||
|
||||
export function expandSidebar() {
|
||||
return {
|
||||
type: ActionTypes.EXPAND_SIDEBAR
|
||||
|
|
|
@ -133,6 +133,7 @@ export function saveProject(selectedFile = null, autosave = false) {
|
|||
}
|
||||
const formParams = Object.assign({}, state.project);
|
||||
formParams.files = [...state.files];
|
||||
|
||||
if (selectedFile) {
|
||||
const fileToUpdate = formParams.files.find(file => file.id === selectedFile.id);
|
||||
fileToUpdate.content = selectedFile.content;
|
||||
|
|
|
@ -26,13 +26,14 @@ export function toggleDirectionForField(field) {
|
|||
};
|
||||
}
|
||||
|
||||
export function setSearchTerm(searchTerm) {
|
||||
export function setSearchTerm(scope, searchTerm) {
|
||||
return {
|
||||
type: ActionTypes.SET_SEARCH_TERM,
|
||||
query: searchTerm
|
||||
query: searchTerm,
|
||||
scope,
|
||||
};
|
||||
}
|
||||
|
||||
export function resetSearchTerm() {
|
||||
return setSearchTerm('');
|
||||
export function resetSearchTerm(scope) {
|
||||
return setSearchTerm(scope, '');
|
||||
}
|
||||
|
|
|
@ -67,10 +67,10 @@ export function dropzoneAcceptCallback(userId, file, done) {
|
|||
})
|
||||
.catch((response) => {
|
||||
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('error preparing the upload');
|
||||
done('Error: Reached upload limit.');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
165
client/modules/IDE/components/AddToCollectionList.jsx
Normal 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);
|
137
client/modules/IDE/components/AddToCollectionSketchList.jsx
Normal 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);
|
|
@ -5,9 +5,146 @@ import { bindActionCreators } from 'redux';
|
|||
import { Link } from 'react-router';
|
||||
import { Helmet } from 'react-helmet';
|
||||
import prettyBytes from 'pretty-bytes';
|
||||
import InlineSVG from 'react-inlinesvg';
|
||||
|
||||
import Loader from '../../App/components/loader';
|
||||
import * as AssetActions from '../actions/assets';
|
||||
import downFilledTriangle from '../../../images/down-filled-triangle.svg';
|
||||
|
||||
class AssetListRowBase extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
isFocused: false,
|
||||
optionsOpen: false
|
||||
};
|
||||
}
|
||||
|
||||
onFocusComponent = () => {
|
||||
this.setState({ isFocused: true });
|
||||
}
|
||||
|
||||
onBlurComponent = () => {
|
||||
this.setState({ isFocused: false });
|
||||
setTimeout(() => {
|
||||
if (!this.state.isFocused) {
|
||||
this.closeOptions();
|
||||
}
|
||||
}, 200);
|
||||
}
|
||||
|
||||
openOptions = () => {
|
||||
this.setState({
|
||||
optionsOpen: true
|
||||
});
|
||||
}
|
||||
|
||||
closeOptions = () => {
|
||||
this.setState({
|
||||
optionsOpen: false
|
||||
});
|
||||
}
|
||||
|
||||
toggleOptions = () => {
|
||||
if (this.state.optionsOpen) {
|
||||
this.closeOptions();
|
||||
} else {
|
||||
this.openOptions();
|
||||
}
|
||||
}
|
||||
|
||||
handleDropdownOpen = () => {
|
||||
this.closeOptions();
|
||||
this.openOptions();
|
||||
}
|
||||
|
||||
handleAssetDelete = () => {
|
||||
const { key, name } = this.props.asset;
|
||||
this.closeOptions();
|
||||
if (window.confirm(`Are you sure you want to delete "${name}"?`)) {
|
||||
this.props.deleteAssetRequest(key);
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
const { asset, username } = this.props;
|
||||
const { optionsOpen } = this.state;
|
||||
return (
|
||||
<tr className="asset-table__row" key={asset.key}>
|
||||
<th scope="row">
|
||||
<Link to={asset.url} target="_blank">
|
||||
{asset.name}
|
||||
</Link>
|
||||
</th>
|
||||
<td>{prettyBytes(asset.size)}</td>
|
||||
<td>
|
||||
{ asset.sketchId && <Link to={`/${username}/sketches/${asset.sketchId}`}>{asset.sketchName}</Link> }
|
||||
</td>
|
||||
<td className="asset-table__dropdown-column">
|
||||
<button
|
||||
className="asset-table__dropdown-button"
|
||||
onClick={this.toggleOptions}
|
||||
onBlur={this.onBlurComponent}
|
||||
onFocus={this.onFocusComponent}
|
||||
>
|
||||
<InlineSVG src={downFilledTriangle} alt="Menu" />
|
||||
</button>
|
||||
{optionsOpen &&
|
||||
<ul
|
||||
className="asset-table__action-dialogue"
|
||||
>
|
||||
<li>
|
||||
<button
|
||||
className="asset-table__action-option"
|
||||
onClick={this.handleAssetDelete}
|
||||
onBlur={this.onBlurComponent}
|
||||
onFocus={this.onFocusComponent}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</li>
|
||||
<li>
|
||||
<Link
|
||||
to={asset.url}
|
||||
target="_blank"
|
||||
onBlur={this.onBlurComponent}
|
||||
onFocus={this.onFocusComponent}
|
||||
className="asset-table__action-option"
|
||||
>
|
||||
Open in New Tab
|
||||
</Link>
|
||||
</li>
|
||||
</ul>}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
AssetListRowBase.propTypes = {
|
||||
asset: PropTypes.shape({
|
||||
key: PropTypes.string.isRequired,
|
||||
url: PropTypes.string.isRequired,
|
||||
sketchId: PropTypes.string,
|
||||
sketchName: PropTypes.string,
|
||||
name: PropTypes.string.isRequired,
|
||||
size: PropTypes.number.isRequired
|
||||
}).isRequired,
|
||||
deleteAssetRequest: PropTypes.func.isRequired,
|
||||
username: PropTypes.string.isRequired
|
||||
};
|
||||
|
||||
function mapStateToPropsAssetListRow(state) {
|
||||
return {
|
||||
username: state.user.username
|
||||
};
|
||||
}
|
||||
|
||||
function mapDispatchToPropsAssetListRow(dispatch) {
|
||||
return bindActionCreators(AssetActions, dispatch);
|
||||
}
|
||||
|
||||
const AssetListRow = connect(mapStateToPropsAssetListRow, mapDispatchToPropsAssetListRow)(AssetListRowBase);
|
||||
|
||||
class AssetList extends React.Component {
|
||||
constructor(props) {
|
||||
|
@ -16,11 +153,8 @@ class AssetList extends React.Component {
|
|||
}
|
||||
|
||||
getAssetsTitle() {
|
||||
if (!this.props.username || this.props.username === this.props.user.username) {
|
||||
return 'p5.js Web Editor | My assets';
|
||||
}
|
||||
return `p5.js Web Editor | ${this.props.username}'s assets`;
|
||||
}
|
||||
|
||||
hasAssets() {
|
||||
return !this.props.loading && this.props.assetList.length > 0;
|
||||
|
@ -39,14 +173,9 @@ class AssetList extends React.Component {
|
|||
}
|
||||
|
||||
render() {
|
||||
const username = this.props.username !== undefined ? this.props.username : this.props.user.username;
|
||||
const { assetList, totalSize } = this.props;
|
||||
const { assetList } = this.props;
|
||||
return (
|
||||
<div className="asset-table-container">
|
||||
{/* Eventually, this copy should be Total / 250 MB Used */}
|
||||
{this.hasAssets() &&
|
||||
<p className="asset-table__total">{`${prettyBytes(totalSize)} Total`}</p>
|
||||
}
|
||||
<Helmet>
|
||||
<title>{this.getAssetsTitle()}</title>
|
||||
</Helmet>
|
||||
|
@ -58,20 +187,12 @@ class AssetList extends React.Component {
|
|||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Size</th>
|
||||
<th>View</th>
|
||||
<th>Sketch</th>
|
||||
<th scope="col"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{assetList.map(asset =>
|
||||
(
|
||||
<tr className="asset-table__row" key={asset.key}>
|
||||
<td>{asset.name}</td>
|
||||
<td>{prettyBytes(asset.size)}</td>
|
||||
<td><Link to={asset.url} target="_blank">View</Link></td>
|
||||
<td><Link to={`/${username}/sketches/${asset.sketchId}`}>{asset.sketchName}</Link></td>
|
||||
</tr>
|
||||
))}
|
||||
{assetList.map(asset => <AssetListRow asset={asset} key={asset.key} />)}
|
||||
</tbody>
|
||||
</table>}
|
||||
</div>
|
||||
|
@ -83,15 +204,13 @@ AssetList.propTypes = {
|
|||
user: PropTypes.shape({
|
||||
username: PropTypes.string
|
||||
}).isRequired,
|
||||
username: PropTypes.string.isRequired,
|
||||
assetList: PropTypes.arrayOf(PropTypes.shape({
|
||||
key: PropTypes.string.isRequired,
|
||||
name: PropTypes.string.isRequired,
|
||||
url: PropTypes.string.isRequired,
|
||||
sketchName: PropTypes.string.isRequired,
|
||||
sketchId: PropTypes.string.isRequired
|
||||
sketchName: PropTypes.string,
|
||||
sketchId: PropTypes.string
|
||||
})).isRequired,
|
||||
totalSize: PropTypes.number.isRequired,
|
||||
getAssets: PropTypes.func.isRequired,
|
||||
loading: PropTypes.bool.isRequired
|
||||
};
|
||||
|
@ -100,7 +219,6 @@ function mapStateToProps(state) {
|
|||
return {
|
||||
user: state.user,
|
||||
assetList: state.assets.list,
|
||||
totalSize: state.assets.totalSize,
|
||||
loading: state.loading
|
||||
};
|
||||
}
|
||||
|
|
51
client/modules/IDE/components/AssetSize.jsx
Normal 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);
|
221
client/modules/IDE/components/CollectionList/CollectionList.jsx
Normal 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);
|
|
@ -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);
|
1
client/modules/IDE/components/CollectionList/index.js
Normal file
|
@ -0,0 +1 @@
|
|||
export { default } from './CollectionList';
|
93
client/modules/IDE/components/EditableInput.jsx
Normal 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;
|
|
@ -269,8 +269,8 @@ class Editor extends React.Component {
|
|||
indent_size: INDENTATION_AMOUNT,
|
||||
indent_with_tabs: IS_TAB_INDENT
|
||||
};
|
||||
|
||||
const mode = this._cm.getOption('mode');
|
||||
const currentPosition = this._cm.doc.getCursor();
|
||||
if (mode === 'javascript') {
|
||||
this._cm.doc.setValue(beautifyJS(this._cm.doc.getValue(), beautifyOptions));
|
||||
} else if (mode === 'css') {
|
||||
|
@ -278,6 +278,10 @@ class Editor extends React.Component {
|
|||
} else if (mode === 'htmlmixed') {
|
||||
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) {
|
||||
|
|
|
@ -6,39 +6,29 @@ import InlineSVG from 'react-inlinesvg';
|
|||
import classNames from 'classnames';
|
||||
import * as IDEActions from '../actions/ide';
|
||||
import * as FileActions from '../actions/files';
|
||||
|
||||
const downArrowUrl = require('../../../images/down-filled-triangle.svg');
|
||||
const folderRightUrl = require('../../../images/triangle-arrow-right.svg');
|
||||
const folderDownUrl = require('../../../images/triangle-arrow-down.svg');
|
||||
const fileUrl = require('../../../images/file.svg');
|
||||
import downArrowUrl from '../../../images/down-filled-triangle.svg';
|
||||
import folderRightUrl from '../../../images/triangle-arrow-right.svg';
|
||||
import folderDownUrl from '../../../images/triangle-arrow-down.svg';
|
||||
import fileUrl from '../../../images/file.svg';
|
||||
|
||||
export class FileNode extends React.Component {
|
||||
constructor(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 = {
|
||||
isOptionsOpen: false,
|
||||
isEditingName: false,
|
||||
isFocused: false,
|
||||
isDeleting: false,
|
||||
updatedName: this.props.name
|
||||
};
|
||||
}
|
||||
|
||||
onFocusComponent() {
|
||||
onFocusComponent = () => {
|
||||
this.setState({ isFocused: true });
|
||||
}
|
||||
|
||||
onBlurComponent() {
|
||||
onBlurComponent = () => {
|
||||
this.setState({ isFocused: false });
|
||||
setTimeout(() => {
|
||||
if (!this.state.isFocused) {
|
||||
|
@ -47,41 +37,96 @@ export class FileNode extends React.Component {
|
|||
}, 200);
|
||||
}
|
||||
|
||||
handleFileClick(e) {
|
||||
e.stopPropagation();
|
||||
if (this.props.name !== 'root' && !this.isDeleting) {
|
||||
this.props.setSelectedFile(this.props.id);
|
||||
|
||||
setUpdatedName = (updatedName) => {
|
||||
this.setState({ updatedName });
|
||||
}
|
||||
|
||||
saveUpdatedFileName = () => {
|
||||
const { updatedName } = this.state;
|
||||
const { name, updateFileName, id } = this.props;
|
||||
|
||||
if (updatedName !== name) {
|
||||
updateFileName(id, updatedName);
|
||||
}
|
||||
}
|
||||
|
||||
handleFileNameChange(event) {
|
||||
this.props.updateFileName(this.props.id, event.target.value);
|
||||
handleFileClick = (event) => {
|
||||
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') {
|
||||
this.hideEditFileName();
|
||||
}
|
||||
}
|
||||
|
||||
validateFileName() {
|
||||
const oldFileExtension = this.originalFileName.match(/\.[0-9a-z]+$/i);
|
||||
const newFileExtension = this.props.name.match(/\.[0-9a-z]+$/i);
|
||||
const hasPeriod = this.props.name.match(/\.+/);
|
||||
const newFileName = this.props.name;
|
||||
validateFileName = () => {
|
||||
const currentName = this.props.name;
|
||||
const { updatedName } = this.state;
|
||||
const oldFileExtension = currentName.match(/\.[0-9a-z]+$/i);
|
||||
const newFileExtension = updatedName.match(/\.[0-9a-z]+$/i);
|
||||
const hasPeriod = updatedName.match(/\.+/);
|
||||
const hasNoExtension = oldFileExtension && !newFileExtension;
|
||||
const hasExtensionIfFolder = this.props.fileType === 'folder' && hasPeriod;
|
||||
const notSameExtension = oldFileExtension && newFileExtension
|
||||
&& oldFileExtension[0].toLowerCase() !== newFileExtension[0].toLowerCase();
|
||||
const hasEmptyFilename = newFileName === '';
|
||||
const hasOnlyExtension = newFileExtension && newFileName === newFileExtension[0];
|
||||
const hasEmptyFilename = updatedName.trim() === '';
|
||||
const hasOnlyExtension = newFileExtension && updatedName.trim() === newFileExtension[0];
|
||||
if (hasEmptyFilename || hasNoExtension || notSameExtension || hasOnlyExtension || hasExtensionIfFolder) {
|
||||
this.props.updateFileName(this.props.id, this.originalFileName);
|
||||
this.setUpdatedName(currentName);
|
||||
} else {
|
||||
this.saveUpdatedFileName();
|
||||
}
|
||||
}
|
||||
|
||||
toggleFileOptions(e) {
|
||||
e.preventDefault();
|
||||
toggleFileOptions = (event) => {
|
||||
event.preventDefault();
|
||||
if (!this.props.canEdit) {
|
||||
return;
|
||||
}
|
||||
|
@ -93,25 +138,31 @@ export class FileNode extends React.Component {
|
|||
}
|
||||
}
|
||||
|
||||
hideFileOptions() {
|
||||
hideFileOptions = () => {
|
||||
this.setState({ isOptionsOpen: false });
|
||||
}
|
||||
|
||||
showEditFileName() {
|
||||
showEditFileName = () => {
|
||||
this.setState({ isEditingName: true });
|
||||
}
|
||||
|
||||
hideEditFileName() {
|
||||
hideEditFileName = () => {
|
||||
this.setState({ isEditingName: false });
|
||||
}
|
||||
|
||||
renderChild(childId) {
|
||||
return (
|
||||
showFolderChildren = () => {
|
||||
this.props.showFolderChildren(this.props.id);
|
||||
}
|
||||
|
||||
hideFolderChildren = () => {
|
||||
this.props.hideFolderChildren(this.props.id);
|
||||
}
|
||||
|
||||
renderChild = childId => (
|
||||
<li key={childId}>
|
||||
<ConnectedFileNode id={childId} parentId={this.props.id} canEdit={this.props.canEdit} />
|
||||
</li>
|
||||
);
|
||||
}
|
||||
)
|
||||
|
||||
render() {
|
||||
const itemClass = classNames({
|
||||
|
@ -123,49 +174,50 @@ export class FileNode extends React.Component {
|
|||
'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 (
|
||||
<div className={itemClass}>
|
||||
{(() => { // eslint-disable-line
|
||||
if (this.props.name !== 'root') {
|
||||
return (
|
||||
{ !isRoot &&
|
||||
<div className="file-item__content" onContextMenu={this.toggleFileOptions}>
|
||||
<span className="file-item__spacer"></span>
|
||||
{(() => { // eslint-disable-line
|
||||
if (this.props.fileType === 'file') {
|
||||
return (
|
||||
{ isFile &&
|
||||
<span className="sidebar__file-item-icon">
|
||||
<InlineSVG src={fileUrl} />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return (
|
||||
{ isFolder &&
|
||||
<div className="sidebar__file-item--folder">
|
||||
<button
|
||||
className="sidebar__file-item-closed"
|
||||
onClick={() => this.props.showFolderChildren(this.props.id)}
|
||||
onClick={this.showFolderChildren}
|
||||
>
|
||||
<InlineSVG className="folder-right" src={folderRightUrl} />
|
||||
</button>
|
||||
<button
|
||||
className="sidebar__file-item-open"
|
||||
onClick={() => this.props.hideFolderChildren(this.props.id)}
|
||||
onClick={this.hideFolderChildren}
|
||||
>
|
||||
<InlineSVG className="folder-down" src={folderDownUrl} />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
<button className="sidebar__file-item-name" onClick={this.handleFileClick}>{this.props.name}</button>
|
||||
}
|
||||
<button
|
||||
className="sidebar__file-item-name"
|
||||
onClick={this.handleFileClick}
|
||||
>
|
||||
{this.state.updatedName}
|
||||
</button>
|
||||
<input
|
||||
type="text"
|
||||
className="sidebar__file-item-input"
|
||||
value={this.props.name}
|
||||
value={this.state.updatedName}
|
||||
maxLength="128"
|
||||
onChange={this.handleFileNameChange}
|
||||
ref={(element) => { this.fileNameInput = element; }}
|
||||
onBlur={() => {
|
||||
this.validateFileName();
|
||||
this.hideEditFileName();
|
||||
}}
|
||||
onBlur={this.handleFileNameBlur}
|
||||
onKeyPress={this.handleKeyPress}
|
||||
/>
|
||||
<button
|
||||
|
@ -181,54 +233,47 @@ export class FileNode extends React.Component {
|
|||
</button>
|
||||
<div className="sidebar__file-item-options">
|
||||
<ul title="file options">
|
||||
{(() => { // eslint-disable-line
|
||||
if (this.props.fileType === 'folder') {
|
||||
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 (
|
||||
{ isFolder &&
|
||||
<React.Fragment>
|
||||
<li>
|
||||
<button
|
||||
aria-label="add folder"
|
||||
onClick={() => {
|
||||
this.props.newFolder(this.props.id);
|
||||
setTimeout(() => this.hideFileOptions(), 0);
|
||||
}}
|
||||
onClick={this.handleClickAddFolder}
|
||||
onBlur={this.onBlurComponent}
|
||||
onFocus={this.onFocusComponent}
|
||||
className="sidebar__file-item-option"
|
||||
>
|
||||
Add Folder
|
||||
Create folder
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
})()}
|
||||
<li>
|
||||
<button
|
||||
onClick={() => {
|
||||
this.originalFileName = this.props.name;
|
||||
this.showEditFileName();
|
||||
setTimeout(() => this.fileNameInput.focus(), 0);
|
||||
setTimeout(() => this.hideFileOptions(), 0);
|
||||
}}
|
||||
aria-label="add file"
|
||||
onClick={this.handleClickAddFile}
|
||||
onBlur={this.onBlurComponent}
|
||||
onFocus={this.onFocusComponent}
|
||||
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}
|
||||
onFocus={this.onFocusComponent}
|
||||
className="sidebar__file-item-option"
|
||||
|
@ -238,13 +283,7 @@ export class FileNode extends React.Component {
|
|||
</li>
|
||||
<li>
|
||||
<button
|
||||
onClick={() => {
|
||||
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);
|
||||
}
|
||||
}}
|
||||
onClick={this.handleClickDelete}
|
||||
onBlur={this.onBlurComponent}
|
||||
onFocus={this.onFocusComponent}
|
||||
className="sidebar__file-item-option"
|
||||
|
@ -255,18 +294,12 @@ export class FileNode extends React.Component {
|
|||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
})()}
|
||||
{(() => { // eslint-disable-line
|
||||
if (this.props.children) {
|
||||
return (
|
||||
{ this.props.children &&
|
||||
<ul className="file-item__children">
|
||||
{this.props.children.map(this.renderChild)}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
})()}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
@ -288,18 +321,21 @@ FileNode.propTypes = {
|
|||
newFolder: PropTypes.func.isRequired,
|
||||
showFolderChildren: 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 = {
|
||||
parentId: '0',
|
||||
isSelectedFile: false,
|
||||
isFolderClosed: false
|
||||
isFolderClosed: false,
|
||||
};
|
||||
|
||||
function mapStateToProps(state, 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) {
|
||||
|
|
|
@ -30,7 +30,7 @@ class FileUploader extends React.Component {
|
|||
thumbnailWidth: 200,
|
||||
thumbnailHeight: 200,
|
||||
acceptedFiles: fileExtensionsAndMimeTypes,
|
||||
dictDefaultMessage: 'Drop files here to upload or click to use the file browser',
|
||||
dictDefaultMessage: 'Drop files here or click to use the file browser',
|
||||
accept: this.props.dropzoneAcceptCallback.bind(this, userId),
|
||||
sending: this.props.dropzoneSendingCallback,
|
||||
complete: this.props.dropzoneCompleteCallback
|
||||
|
|
|
@ -74,6 +74,18 @@ function KeyboardShortcutModal() {
|
|||
</span>
|
||||
<span>Turn off Accessible Output</span>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -22,16 +22,19 @@ class NewFileForm extends React.Component {
|
|||
handleSubmit(this.createFile)(data);
|
||||
}}
|
||||
>
|
||||
<div className="new-file-form__input-wrapper">
|
||||
<label className="new-file-form__name-label" htmlFor="name">Name:</label>
|
||||
<input
|
||||
className="new-file-form__name-input"
|
||||
id="name"
|
||||
type="text"
|
||||
placeholder="Name"
|
||||
maxLength="128"
|
||||
{...domOnlyProps(name)}
|
||||
ref={(element) => { this.fileName = element; }}
|
||||
/>
|
||||
<input type="submit" value="Add File" aria-label="add file" />
|
||||
</div>
|
||||
{name.touched && name.error && <span className="form-error">{name.error}</span>}
|
||||
</form>
|
||||
);
|
||||
|
|
|
@ -1,10 +1,12 @@
|
|||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import { bindActionCreators, compose } from 'redux';
|
||||
import { reduxForm } from 'redux-form';
|
||||
import classNames from 'classnames';
|
||||
import InlineSVG from 'react-inlinesvg';
|
||||
import NewFileForm from './NewFileForm';
|
||||
import FileUploader from './FileUploader';
|
||||
import { closeNewFileModal } from '../actions/ide';
|
||||
import { createFile } from '../actions/files';
|
||||
import { CREATE_FILE_REGEX } from '../../../../server/utils/fileUtils';
|
||||
|
||||
const exitUrl = require('../../../images/exit.svg');
|
||||
|
@ -28,16 +30,12 @@ class NewFileModal extends React.Component {
|
|||
}
|
||||
|
||||
render() {
|
||||
const modalClass = classNames({
|
||||
'modal': true,
|
||||
'modal--reduced': !this.props.canUploadMedia
|
||||
});
|
||||
return (
|
||||
<section className={modalClass} ref={(element) => { this.modal = element; }}>
|
||||
<section className="modal" ref={(element) => { this.modal = element; }}>
|
||||
<div className="modal-content">
|
||||
<div className="modal__header">
|
||||
<h2 className="modal__title">Add File</h2>
|
||||
<button className="modal__exit-button" onClick={this.props.closeModal}>
|
||||
<h2 className="modal__title">Create File</h2>
|
||||
<button className="modal__exit-button" onClick={this.props.closeNewFileModal}>
|
||||
<InlineSVG src={exitUrl} alt="Close New File Modal" />
|
||||
</button>
|
||||
</div>
|
||||
|
@ -45,17 +43,6 @@ class NewFileModal extends React.Component {
|
|||
focusOnModal={this.focusOnModal}
|
||||
{...this.props}
|
||||
/>
|
||||
{(() => {
|
||||
if (this.props.canUploadMedia) {
|
||||
return (
|
||||
<div>
|
||||
<p className="modal__divider">OR</p>
|
||||
<FileUploader />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return '';
|
||||
})()}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
|
@ -63,8 +50,8 @@ class NewFileModal extends React.Component {
|
|||
}
|
||||
|
||||
NewFileModal.propTypes = {
|
||||
closeModal: PropTypes.func.isRequired,
|
||||
canUploadMedia: PropTypes.bool.isRequired
|
||||
createFile: PropTypes.func.isRequired,
|
||||
closeNewFileModal: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
function validate(formProps) {
|
||||
|
@ -79,9 +66,19 @@ function validate(formProps) {
|
|||
return errors;
|
||||
}
|
||||
|
||||
function mapStateToProps() {
|
||||
return {};
|
||||
}
|
||||
|
||||
export default reduxForm({
|
||||
function mapDispatchToProps(dispatch) {
|
||||
return bindActionCreators({ createFile, closeNewFileModal }, dispatch);
|
||||
}
|
||||
|
||||
export default compose(
|
||||
connect(mapStateToProps, mapDispatchToProps),
|
||||
reduxForm({
|
||||
form: 'new-file',
|
||||
fields: ['name'],
|
||||
validate
|
||||
})(NewFileModal);
|
||||
})
|
||||
)(NewFileModal);
|
||||
|
|
|
@ -20,21 +20,22 @@ class NewFolderForm extends React.Component {
|
|||
<form
|
||||
className="new-folder-form"
|
||||
onSubmit={(data) => {
|
||||
if (handleSubmit(this.createFolder)(data)) {
|
||||
this.props.closeModal();
|
||||
}
|
||||
handleSubmit(this.createFolder)(data);
|
||||
}}
|
||||
>
|
||||
<div className="new-folder-form__input-wrapper">
|
||||
<label className="new-folder-form__name-label" htmlFor="name">Name:</label>
|
||||
<input
|
||||
className="new-folder-form__name-input"
|
||||
id="name"
|
||||
type="text"
|
||||
maxLength="128"
|
||||
placeholder="Name"
|
||||
ref={(element) => { this.fileName = element; }}
|
||||
{...domOnlyProps(name)}
|
||||
/>
|
||||
<input type="submit" value="Add Folder" aria-label="add folder" />
|
||||
</div>
|
||||
{name.touched && name.error && <span className="form-error">{name.error}</span>}
|
||||
</form>
|
||||
);
|
||||
|
|
|
@ -16,7 +16,7 @@ class NewFolderModal extends React.Component {
|
|||
<section className="modal" ref={(element) => { this.newFolderModal = element; }} >
|
||||
<div className="modal-content-folder">
|
||||
<div className="modal__header">
|
||||
<h2 className="modal__title">Add Folder</h2>
|
||||
<h2 className="modal__title">Create Folder</h2>
|
||||
<button className="modal__exit-button" onClick={this.props.closeModal}>
|
||||
<InlineSVG src={exitUrl} alt="Close New Folder Modal" />
|
||||
</button>
|
||||
|
|
|
@ -98,9 +98,9 @@ class Preferences extends React.Component {
|
|||
</Helmet>
|
||||
<Tabs>
|
||||
<TabList>
|
||||
<div className="preference__subheadings">
|
||||
<Tab><h4 className="preference__subheading">General Settings</h4></Tab>
|
||||
<Tab><h4 className="preference__subheading">Accessibility</h4></Tab>
|
||||
<div className="tabs__titles">
|
||||
<Tab><h4 className="tabs__title">General Settings</h4></Tab>
|
||||
<Tab><h4 className="tabs__title">Accessibility</h4></Tab>
|
||||
</div>
|
||||
</TabList>
|
||||
<TabPanel>
|
||||
|
|
27
client/modules/IDE/components/QuickAddList/Icons.jsx
Normal 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;
|
72
client/modules/IDE/components/QuickAddList/QuickAddList.jsx
Normal 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;
|
1
client/modules/IDE/components/QuickAddList/index.js
Normal file
|
@ -0,0 +1 @@
|
|||
export { default } from './QuickAddList.jsx';
|
24
client/modules/IDE/components/Searchbar/Collection.jsx
Normal 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);
|
|
@ -1,12 +1,9 @@
|
|||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import InlineSVG from 'react-inlinesvg';
|
||||
import { bindActionCreators } from 'redux';
|
||||
import { connect } from 'react-redux';
|
||||
import { throttle } from 'lodash';
|
||||
import * as SortingActions from '../actions/sorting';
|
||||
|
||||
const searchIcon = require('../../../images/magnifyingglass.svg');
|
||||
const searchIcon = require('../../../../images/magnifyingglass.svg');
|
||||
|
||||
class Searchbar extends React.Component {
|
||||
constructor(props) {
|
||||
|
@ -29,12 +26,13 @@ class Searchbar extends React.Component {
|
|||
|
||||
handleSearchEnter = (e) => {
|
||||
if (e.key === 'Enter') {
|
||||
this.props.setSearchTerm(this.state.searchValue);
|
||||
this.searchChange();
|
||||
}
|
||||
}
|
||||
|
||||
searchChange = (value) => {
|
||||
this.props.setSearchTerm(this.state.searchValue);
|
||||
searchChange = () => {
|
||||
if (this.state.searchValue.trim().length === 0) return;
|
||||
this.props.setSearchTerm(this.state.searchValue.trim());
|
||||
};
|
||||
|
||||
handleSearchChange = (e) => {
|
||||
|
@ -46,19 +44,15 @@ class Searchbar extends React.Component {
|
|||
render() {
|
||||
const { searchValue } = this.state;
|
||||
return (
|
||||
<div className="searchbar">
|
||||
<button
|
||||
type="submit"
|
||||
className="searchbar__button"
|
||||
onClick={this.handleSearchEnter}
|
||||
>
|
||||
<div className={`searchbar ${searchValue === '' ? 'searchbar--is-empty' : ''}`}>
|
||||
<div className="searchbar__button">
|
||||
<InlineSVG className="searchbar__icon" src={searchIcon} />
|
||||
</button>
|
||||
</div>
|
||||
<input
|
||||
className="searchbar__input"
|
||||
type="text"
|
||||
value={searchValue}
|
||||
placeholder="Search files..."
|
||||
placeholder={this.props.searchLabel}
|
||||
onChange={this.handleSearchChange}
|
||||
onKeyUp={this.handleSearchEnter}
|
||||
/>
|
||||
|
@ -75,17 +69,12 @@ class Searchbar extends React.Component {
|
|||
Searchbar.propTypes = {
|
||||
searchTerm: PropTypes.string.isRequired,
|
||||
setSearchTerm: PropTypes.func.isRequired,
|
||||
resetSearchTerm: PropTypes.func.isRequired
|
||||
resetSearchTerm: PropTypes.func.isRequired,
|
||||
searchLabel: PropTypes.string,
|
||||
};
|
||||
|
||||
function mapStateToProps(state) {
|
||||
return {
|
||||
searchTerm: state.search.searchTerm
|
||||
};
|
||||
}
|
||||
Searchbar.defaultProps = {
|
||||
searchLabel: 'Search sketches...',
|
||||
};
|
||||
|
||||
function mapDispatchToProps(dispatch) {
|
||||
return bindActionCreators(Object.assign({}, SortingActions), dispatch);
|
||||
}
|
||||
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(Searchbar);
|
||||
export default Searchbar;
|
23
client/modules/IDE/components/Searchbar/Sketch.jsx
Normal 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);
|
2
client/modules/IDE/components/Searchbar/index.js
Normal file
|
@ -0,0 +1,2 @@
|
|||
export { default as CollectionSearchbar } from './Collection.jsx';
|
||||
export { default as SketchSearchbar } from './Sketch.jsx';
|
|
@ -97,7 +97,7 @@ class Sidebar extends React.Component {
|
|||
onBlur={this.onBlurComponent}
|
||||
onFocus={this.onFocusComponent}
|
||||
>
|
||||
Add folder
|
||||
Create folder
|
||||
</button>
|
||||
</li>
|
||||
<li>
|
||||
|
@ -110,9 +110,25 @@ class Sidebar extends React.Component {
|
|||
onBlur={this.onBlurComponent}
|
||||
onFocus={this.onFocusComponent}
|
||||
>
|
||||
Add file
|
||||
Create file
|
||||
</button>
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -137,6 +153,7 @@ Sidebar.propTypes = {
|
|||
openProjectOptions: PropTypes.func.isRequired,
|
||||
closeProjectOptions: PropTypes.func.isRequired,
|
||||
newFolder: PropTypes.func.isRequired,
|
||||
openUploadFileModal: PropTypes.func.isRequired,
|
||||
owner: PropTypes.shape({
|
||||
id: PropTypes.string
|
||||
}),
|
||||
|
|
|
@ -10,11 +10,14 @@ import classNames from 'classnames';
|
|||
import slugify from 'slugify';
|
||||
import * as ProjectActions from '../actions/project';
|
||||
import * as ProjectsActions from '../actions/projects';
|
||||
import * as CollectionsActions from '../actions/collections';
|
||||
import * as ToastActions from '../actions/toast';
|
||||
import * as SortingActions from '../actions/sorting';
|
||||
import * as IdeActions from '../actions/ide';
|
||||
import getSortedSketches from '../selectors/projects';
|
||||
import Loader from '../../App/components/loader';
|
||||
import Overlay from '../../App/components/Overlay';
|
||||
import AddToCollectionList from './AddToCollectionList';
|
||||
|
||||
const arrowUp = require('../../../images/sort-arrow-up.svg');
|
||||
const arrowDown = require('../../../images/sort-arrow-down.svg');
|
||||
|
@ -27,9 +30,11 @@ class SketchListRowBase extends React.Component {
|
|||
optionsOpen: false,
|
||||
renameOpen: false,
|
||||
renameValue: props.sketch.name,
|
||||
isFocused: false
|
||||
isFocused: false,
|
||||
};
|
||||
this.renameInput = React.createRef();
|
||||
}
|
||||
|
||||
onFocusComponent = () => {
|
||||
this.setState({ isFocused: true });
|
||||
}
|
||||
|
@ -65,8 +70,9 @@ class SketchListRowBase extends React.Component {
|
|||
|
||||
openRename = () => {
|
||||
this.setState({
|
||||
renameOpen: true
|
||||
});
|
||||
renameOpen: true,
|
||||
renameValue: this.props.sketch.name
|
||||
}, () => this.renameInput.current.focus());
|
||||
}
|
||||
|
||||
closeRename = () => {
|
||||
|
@ -90,15 +96,27 @@ class SketchListRowBase extends React.Component {
|
|||
|
||||
handleRenameEnter = (e) => {
|
||||
if (e.key === 'Enter') {
|
||||
// TODO pass this func
|
||||
this.props.changeProjectName(this.props.sketch.id, this.state.renameValue);
|
||||
this.updateName();
|
||||
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 = () => {
|
||||
this.setState({
|
||||
renameValue: this.props.sketch.name
|
||||
renameValue: this.props.sketch.name,
|
||||
renameOpen: false
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -133,36 +151,17 @@ class SketchListRowBase extends React.Component {
|
|||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
const { sketch, username } = this.props;
|
||||
const { renameOpen, optionsOpen, renameValue } = this.state;
|
||||
renderViewButton = sketchURL => (
|
||||
<td className="sketch-list__dropdown-column">
|
||||
<Link to={sketchURL}>View</Link>
|
||||
</td>
|
||||
)
|
||||
|
||||
renderDropdown = () => {
|
||||
const { optionsOpen } = this.state;
|
||||
const userIsOwner = this.props.user.username === this.props.username;
|
||||
let url = `/${username}/sketches/${sketch.id}`;
|
||||
if (username === 'p5') {
|
||||
url = `/${username}/sketches/${slugify(sketch.name, '_')}`;
|
||||
}
|
||||
|
||||
return (
|
||||
<tr
|
||||
className="sketches-table__row"
|
||||
key={sketch.id}
|
||||
>
|
||||
<th scope="row">
|
||||
<Link to={url}>
|
||||
{renameOpen ? '' : sketch.name}
|
||||
</Link>
|
||||
{renameOpen
|
||||
&&
|
||||
<input
|
||||
value={renameValue}
|
||||
onChange={this.handleRenameChange}
|
||||
onKeyUp={this.handleRenameEnter}
|
||||
onBlur={this.resetSketchName}
|
||||
onClick={e => e.stopPropagation()}
|
||||
/>
|
||||
}
|
||||
</th>
|
||||
<td>{format(new Date(sketch.createdAt), 'MMM D, YYYY h:mm A')}</td>
|
||||
<td>{format(new Date(sketch.updatedAt), 'MMM D, YYYY h:mm A')}</td>
|
||||
<td className="sketch-list__dropdown-column">
|
||||
<button
|
||||
className="sketch-list__dropdown-button"
|
||||
|
@ -208,6 +207,20 @@ class SketchListRowBase extends React.Component {
|
|||
Duplicate
|
||||
</button>
|
||||
</li>}
|
||||
{this.props.user.authenticated &&
|
||||
<li>
|
||||
<button
|
||||
className="sketch-list__action-option"
|
||||
onClick={() => {
|
||||
this.props.onAddToCollection();
|
||||
this.closeAll();
|
||||
}}
|
||||
onBlur={this.onBlurComponent}
|
||||
onFocus={this.onFocusComponent}
|
||||
>
|
||||
Add to collection
|
||||
</button>
|
||||
</li>}
|
||||
{ /* <li>
|
||||
<button
|
||||
className="sketch-list__action-option"
|
||||
|
@ -231,14 +244,63 @@ class SketchListRowBase extends React.Component {
|
|||
</li>}
|
||||
</ul>}
|
||||
</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 = {
|
||||
sketch: PropTypes.shape({
|
||||
id: PropTypes.string.isRequired,
|
||||
name: PropTypes.string.isRequired
|
||||
name: PropTypes.string.isRequired,
|
||||
createdAt: PropTypes.string.isRequired,
|
||||
updatedAt: PropTypes.string.isRequired
|
||||
}).isRequired,
|
||||
username: PropTypes.string.isRequired,
|
||||
user: PropTypes.shape({
|
||||
|
@ -249,7 +311,8 @@ SketchListRowBase.propTypes = {
|
|||
showShareModal: PropTypes.func.isRequired,
|
||||
cloneProject: PropTypes.func.isRequired,
|
||||
exportProjectAsZip: PropTypes.func.isRequired,
|
||||
changeProjectName: PropTypes.func.isRequired
|
||||
changeProjectName: PropTypes.func.isRequired,
|
||||
onAddToCollection: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
function mapDispatchToPropsSketchListRow(dispatch) {
|
||||
|
@ -264,6 +327,18 @@ class SketchList extends React.Component {
|
|||
this.props.getProjects(this.props.username);
|
||||
this.props.resetSorting();
|
||||
this._renderFieldHeader = this._renderFieldHeader.bind(this);
|
||||
|
||||
this.state = {
|
||||
isInitialDataLoad: true,
|
||||
};
|
||||
}
|
||||
|
||||
componentWillReceiveProps(nextProps) {
|
||||
if (this.props.sketches !== nextProps.sketches && Array.isArray(nextProps.sketches)) {
|
||||
this.setState({
|
||||
isInitialDataLoad: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
getSketchesTitle() {
|
||||
|
@ -274,16 +349,20 @@ class SketchList extends React.Component {
|
|||
}
|
||||
|
||||
hasSketches() {
|
||||
return !this.props.loading && this.props.sketches.length > 0;
|
||||
return !this.isLoading() && this.props.sketches.length > 0;
|
||||
}
|
||||
|
||||
isLoading() {
|
||||
return this.props.loading && this.state.isInitialDataLoad;
|
||||
}
|
||||
|
||||
_renderLoader() {
|
||||
if (this.props.loading) return <Loader />;
|
||||
if (this.isLoading()) return <Loader />;
|
||||
return null;
|
||||
}
|
||||
|
||||
_renderEmptyTable() {
|
||||
if (!this.props.loading && this.props.sketches.length === 0) {
|
||||
if (!this.isLoading() && this.props.sketches.length === 0) {
|
||||
return (<p className="sketches-table__empty">No sketches.</p>);
|
||||
}
|
||||
return null;
|
||||
|
@ -336,9 +415,26 @@ class SketchList extends React.Component {
|
|||
sketch={sketch}
|
||||
user={this.props.user}
|
||||
username={username}
|
||||
onAddToCollection={() => {
|
||||
this.setState({ sketchToAddToCollection: sketch });
|
||||
}}
|
||||
/>))}
|
||||
</tbody>
|
||||
</table>}
|
||||
{
|
||||
this.state.sketchToAddToCollection &&
|
||||
<Overlay
|
||||
isFixedHeight
|
||||
title="Add to collection"
|
||||
closeOverlay={() => this.setState({ sketchToAddToCollection: null })}
|
||||
>
|
||||
<AddToCollectionList
|
||||
project={this.state.sketchToAddToCollection}
|
||||
username={this.props.username}
|
||||
user={this.props.user}
|
||||
/>
|
||||
</Overlay>
|
||||
}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
@ -364,19 +460,9 @@ SketchList.propTypes = {
|
|||
field: PropTypes.string.isRequired,
|
||||
direction: PropTypes.string.isRequired
|
||||
}).isRequired,
|
||||
project: PropTypes.shape({
|
||||
id: PropTypes.string,
|
||||
owner: PropTypes.shape({
|
||||
id: PropTypes.string
|
||||
})
|
||||
})
|
||||
};
|
||||
|
||||
SketchList.defaultProps = {
|
||||
project: {
|
||||
id: undefined,
|
||||
owner: undefined
|
||||
},
|
||||
username: undefined
|
||||
};
|
||||
|
||||
|
@ -391,7 +477,10 @@ function mapStateToProps(state) {
|
|||
}
|
||||
|
||||
function mapDispatchToProps(dispatch) {
|
||||
return bindActionCreators(Object.assign({}, ProjectsActions, ToastActions, SortingActions), dispatch);
|
||||
return bindActionCreators(
|
||||
Object.assign({}, ProjectsActions, CollectionsActions, ToastActions, SortingActions),
|
||||
dispatch
|
||||
);
|
||||
}
|
||||
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(SketchList);
|
||||
|
|
|
@ -32,7 +32,7 @@ class Toolbar extends React.Component {
|
|||
}
|
||||
|
||||
validateProjectName() {
|
||||
if (this.props.project.name === '') {
|
||||
if ((this.props.project.name.trim()).length === 0) {
|
||||
this.props.setProjectName(this.originalProjectName);
|
||||
}
|
||||
}
|
||||
|
|
68
client/modules/IDE/components/UploadFileModal.jsx
Normal 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);
|
|
@ -12,6 +12,7 @@ import Toolbar from '../components/Toolbar';
|
|||
import Preferences from '../components/Preferences';
|
||||
import NewFileModal from '../components/NewFileModal';
|
||||
import NewFolderModal from '../components/NewFolderModal';
|
||||
import UploadFileModal from '../components/UploadFileModal';
|
||||
import ShareModal from '../components/ShareModal';
|
||||
import KeyboardShortcutModal from '../components/KeyboardShortcutModal';
|
||||
import ErrorModal from '../components/ErrorModal';
|
||||
|
@ -28,11 +29,10 @@ import * as ToastActions from '../actions/toast';
|
|||
import * as ConsoleActions from '../actions/console';
|
||||
import { getHTMLFile } from '../reducers/files';
|
||||
import Overlay from '../../App/components/Overlay';
|
||||
import SketchList from '../components/SketchList';
|
||||
import Searchbar from '../components/Searchbar';
|
||||
import AssetList from '../components/AssetList';
|
||||
import About from '../components/About';
|
||||
import AddToCollectionList from '../components/AddToCollectionList';
|
||||
import Feedback from '../components/Feedback';
|
||||
import { CollectionSearchbar } from '../components/Searchbar';
|
||||
|
||||
class IDEView extends React.Component {
|
||||
constructor(props) {
|
||||
|
@ -156,6 +156,20 @@ class IDEView extends React.Component {
|
|||
} else if (e.keyCode === 49 && ((e.metaKey && this.isMac) || (e.ctrlKey && !this.isMac)) && e.shiftKey) {
|
||||
e.preventDefault();
|
||||
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}
|
||||
user={this.props.user}
|
||||
owner={this.props.project.owner}
|
||||
openUploadFileModal={this.props.openUploadFileModal}
|
||||
closeUploadFileModal={this.props.closeUploadFileModal}
|
||||
/>
|
||||
<SplitPane
|
||||
split="vertical"
|
||||
|
@ -351,42 +367,18 @@ class IDEView extends React.Component {
|
|||
</SplitPane>
|
||||
</div>
|
||||
{ this.props.ide.modalIsVisible &&
|
||||
<NewFileModal
|
||||
canUploadMedia={this.props.user.authenticated}
|
||||
closeModal={this.props.closeNewFileModal}
|
||||
createFile={this.props.createFile}
|
||||
/>
|
||||
<NewFileModal />
|
||||
}
|
||||
{ this.props.ide.newFolderModalVisible &&
|
||||
{this.props.ide.newFolderModalVisible &&
|
||||
<NewFolderModal
|
||||
closeModal={this.props.closeNewFolderModal}
|
||||
createFolder={this.props.createFolder}
|
||||
/>
|
||||
}
|
||||
{ this.props.location.pathname.match(/sketches$/) &&
|
||||
<Overlay
|
||||
ariaLabel="project list"
|
||||
title="Open a Sketch"
|
||||
previousPath={this.props.ide.previousPath}
|
||||
>
|
||||
<Searchbar />
|
||||
<SketchList
|
||||
username={this.props.params.username}
|
||||
user={this.props.user}
|
||||
{this.props.ide.uploadFileModalVisible &&
|
||||
<UploadFileModal
|
||||
closeModal={this.props.closeUploadFileModal}
|
||||
/>
|
||||
</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' &&
|
||||
<Overlay
|
||||
|
@ -397,7 +389,7 @@ class IDEView extends React.Component {
|
|||
<About previousPath={this.props.ide.previousPath} />
|
||||
</Overlay>
|
||||
}
|
||||
{ this.props.location.pathname === '/feedback' &&
|
||||
{this.props.location.pathname === '/feedback' &&
|
||||
<Overlay
|
||||
title="Submit Feedback"
|
||||
previousPath={this.props.ide.previousPath}
|
||||
|
@ -406,7 +398,22 @@ class IDEView extends React.Component {
|
|||
<Feedback previousPath={this.props.ide.previousPath} />
|
||||
</Overlay>
|
||||
}
|
||||
{ this.props.ide.shareModalVisible &&
|
||||
{this.props.location.pathname.match(/add-to-collection$/) &&
|
||||
<Overlay
|
||||
ariaLabel="add to collection"
|
||||
title="Add to collection"
|
||||
previousPath={this.props.ide.previousPath}
|
||||
actions={<CollectionSearchbar />}
|
||||
isFixedHeight
|
||||
>
|
||||
<AddToCollectionList
|
||||
projectId={this.props.params.project_id}
|
||||
username={this.props.params.username}
|
||||
user={this.props.user}
|
||||
/>
|
||||
</Overlay>
|
||||
}
|
||||
{this.props.ide.shareModalVisible &&
|
||||
<Overlay
|
||||
title="Share"
|
||||
ariaLabel="share"
|
||||
|
@ -419,7 +426,7 @@ class IDEView extends React.Component {
|
|||
/>
|
||||
</Overlay>
|
||||
}
|
||||
{ this.props.ide.keyboardShortcutVisible &&
|
||||
{this.props.ide.keyboardShortcutVisible &&
|
||||
<Overlay
|
||||
title="Keyboard Shortcuts"
|
||||
ariaLabel="keyboard shortcuts"
|
||||
|
@ -428,7 +435,7 @@ class IDEView extends React.Component {
|
|||
<KeyboardShortcutModal />
|
||||
</Overlay>
|
||||
}
|
||||
{ this.props.ide.errorType &&
|
||||
{this.props.ide.errorType &&
|
||||
<Overlay
|
||||
title="Error"
|
||||
ariaLabel="error"
|
||||
|
@ -486,6 +493,7 @@ IDEView.propTypes = {
|
|||
justOpenedProject: PropTypes.bool.isRequired,
|
||||
errorType: PropTypes.string,
|
||||
runtimeErrorWarningVisible: PropTypes.bool.isRequired,
|
||||
uploadFileModalVisible: PropTypes.bool.isRequired
|
||||
}).isRequired,
|
||||
stopSketch: PropTypes.func.isRequired,
|
||||
project: PropTypes.shape({
|
||||
|
@ -543,7 +551,6 @@ IDEView.propTypes = {
|
|||
}).isRequired,
|
||||
dispatchConsoleEvent: PropTypes.func.isRequired,
|
||||
newFile: PropTypes.func.isRequired,
|
||||
closeNewFileModal: PropTypes.func.isRequired,
|
||||
expandSidebar: PropTypes.func.isRequired,
|
||||
collapseSidebar: PropTypes.func.isRequired,
|
||||
cloneProject: PropTypes.func.isRequired,
|
||||
|
@ -556,7 +563,6 @@ IDEView.propTypes = {
|
|||
newFolder: PropTypes.func.isRequired,
|
||||
closeNewFolderModal: PropTypes.func.isRequired,
|
||||
createFolder: PropTypes.func.isRequired,
|
||||
createFile: PropTypes.func.isRequired,
|
||||
closeShareModal: PropTypes.func.isRequired,
|
||||
showEditorOptions: PropTypes.func.isRequired,
|
||||
closeEditorOptions: PropTypes.func.isRequired,
|
||||
|
@ -588,6 +594,8 @@ IDEView.propTypes = {
|
|||
showRuntimeErrorWarning: PropTypes.func.isRequired,
|
||||
hideRuntimeErrorWarning: PropTypes.func.isRequired,
|
||||
startSketch: PropTypes.func.isRequired,
|
||||
openUploadFileModal: PropTypes.func.isRequired,
|
||||
closeUploadFileModal: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
function mapStateToProps(state) {
|
||||
|
|
|
@ -10,6 +10,8 @@ const assets = (state = initialState, action) => {
|
|||
switch (action.type) {
|
||||
case ActionTypes.SET_ASSETS:
|
||||
return { list: action.assets, totalSize: action.totalSize };
|
||||
case ActionTypes.DELETE_ASSET:
|
||||
return { list: state.list.filter(asset => asset.key !== action.key) };
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
|
|
28
client/modules/IDE/reducers/collections.js
Normal 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;
|
|
@ -11,7 +11,7 @@ function draw() {
|
|||
|
||||
const defaultHTML =
|
||||
`<!DOCTYPE html>
|
||||
<html>
|
||||
<html lang="en">
|
||||
<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/addons/p5.sound.min.js"></script>
|
||||
|
|
|
@ -9,11 +9,11 @@ const initialState = {
|
|||
preferencesIsVisible: false,
|
||||
projectOptionsVisible: false,
|
||||
newFolderModalVisible: false,
|
||||
uploadFileModalVisible: false,
|
||||
shareModalVisible: false,
|
||||
shareModalProjectId: 'abcd',
|
||||
shareModalProjectName: 'My Cute Sketch',
|
||||
shareModalProjectUsername: 'p5_user',
|
||||
sketchlistModalVisible: false,
|
||||
editorOptionsVisible: false,
|
||||
keyboardShortcutVisible: false,
|
||||
unsavedChanges: false,
|
||||
|
@ -106,6 +106,10 @@ const ide = (state = initialState, action) => {
|
|||
return Object.assign({}, state, { runtimeErrorWarningVisible: false });
|
||||
case ActionTypes.SHOW_RUNTIME_ERROR_WARNING:
|
||||
return Object.assign({}, state, { runtimeErrorWarningVisible: true });
|
||||
case ActionTypes.OPEN_UPLOAD_FILE_MODAL:
|
||||
return Object.assign({}, state, { uploadFileModalVisible: true, parentId: action.parentId });
|
||||
case ActionTypes.CLOSE_UPLOAD_FILE_MODAL:
|
||||
return Object.assign({}, state, { uploadFileModalVisible: false });
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
|
|
|
@ -1,14 +1,8 @@
|
|||
import friendlyWords from 'friendly-words';
|
||||
import * as ActionTypes from '../../../constants';
|
||||
|
||||
const generateRandomName = () => {
|
||||
const adj = friendlyWords.predicates[Math.floor(Math.random() * friendlyWords.predicates.length)];
|
||||
const obj = friendlyWords.objects[Math.floor(Math.random() * friendlyWords.objects.length)];
|
||||
return `${adj} ${obj}`;
|
||||
};
|
||||
import { generateProjectName } from '../../../utils/generateRandomName';
|
||||
|
||||
const initialState = () => {
|
||||
const generatedString = generateRandomName();
|
||||
const generatedString = generateProjectName();
|
||||
const generatedName = generatedString.charAt(0).toUpperCase() + generatedString.slice(1);
|
||||
return {
|
||||
name: generatedName,
|
||||
|
|
|
@ -1,13 +1,14 @@
|
|||
import * as ActionTypes from '../../../constants';
|
||||
|
||||
const initialState = {
|
||||
searchTerm: ''
|
||||
collectionSearchTerm: '',
|
||||
sketchSearchTerm: ''
|
||||
};
|
||||
|
||||
export default (state = initialState, action) => {
|
||||
switch (action.type) {
|
||||
case ActionTypes.SET_SEARCH_TERM:
|
||||
return { ...state, searchTerm: action.query };
|
||||
return { ...state, [`${action.scope}SearchTerm`]: action.query };
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
|
|
56
client/modules/IDE/selectors/collections.js
Normal 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;
|
|
@ -6,7 +6,7 @@ import { DIRECTION } from '../actions/sorting';
|
|||
const getSketches = state => state.sketches;
|
||||
const getField = state => state.sorting.field;
|
||||
const getDirection = state => state.sorting.direction;
|
||||
const getSearchTerm = state => state.search.searchTerm;
|
||||
const getSearchTerm = state => state.search.sketchSearchTerm;
|
||||
|
||||
const getFilteredSketches = createSelector(
|
||||
getSketches,
|
||||
|
|
30
client/modules/IDE/selectors/users.js
Normal 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;
|
||||
}
|
||||
);
|
|
@ -218,3 +218,31 @@ export function updateSettings(formValues) {
|
|||
})
|
||||
.catch(response => Promise.reject(new Error(response.data.error)));
|
||||
}
|
||||
|
||||
export function createApiKeySuccess(user) {
|
||||
return {
|
||||
type: ActionTypes.API_KEY_CREATED,
|
||||
user
|
||||
};
|
||||
}
|
||||
|
||||
export function createApiKey(label) {
|
||||
return dispatch =>
|
||||
axios.post(`${ROOT_URL}/account/api-keys`, { label }, { withCredentials: true })
|
||||
.then((response) => {
|
||||
dispatch(createApiKeySuccess(response.data));
|
||||
})
|
||||
.catch(response => Promise.reject(new Error(response.data.error)));
|
||||
}
|
||||
|
||||
export function removeApiKey(keyId) {
|
||||
return dispatch =>
|
||||
axios.delete(`${ROOT_URL}/account/api-keys/${keyId}`, { withCredentials: true })
|
||||
.then((response) => {
|
||||
dispatch({
|
||||
type: ActionTypes.API_KEY_REMOVED,
|
||||
user: response.data
|
||||
});
|
||||
})
|
||||
.catch(response => Promise.reject(new Error(response.data.error)));
|
||||
}
|
||||
|
|
123
client/modules/User/components/APIKeyForm.jsx
Normal 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 won’t 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;
|
52
client/modules/User/components/APIKeyList.jsx
Normal 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;
|
431
client/modules/User/components/Collection.jsx
Normal 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);
|
110
client/modules/User/components/CollectionCreate.jsx
Normal 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'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);
|
47
client/modules/User/components/DashboardTabSwitcher.jsx
Normal 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 };
|
|
@ -2,54 +2,72 @@ import PropTypes from 'prop-types';
|
|||
import React from 'react';
|
||||
import { reduxForm } from 'redux-form';
|
||||
import { bindActionCreators } from 'redux';
|
||||
import { browserHistory } from 'react-router';
|
||||
import InlineSVG from 'react-inlinesvg';
|
||||
import { Tab, Tabs, TabList, TabPanel } from 'react-tabs';
|
||||
import axios from 'axios';
|
||||
import { Helmet } from 'react-helmet';
|
||||
import { updateSettings, initiateVerification } from '../actions';
|
||||
import { updateSettings, initiateVerification, createApiKey, removeApiKey } from '../actions';
|
||||
import AccountForm from '../components/AccountForm';
|
||||
import { validateSettings } from '../../../utils/reduxFormUtils';
|
||||
import GithubButton from '../components/GithubButton';
|
||||
import GoogleButton from '../components/GoogleButton';
|
||||
import APIKeyForm from '../components/APIKeyForm';
|
||||
import Nav from '../../../components/Nav';
|
||||
|
||||
const exitUrl = require('../../../images/exit.svg');
|
||||
const logoUrl = require('../../../images/p5js-logo.svg');
|
||||
const __process = (typeof global !== 'undefined' ? global : window).process;
|
||||
const ROOT_URL = __process.env.API_URL;
|
||||
|
||||
function SocialLoginPanel(props) {
|
||||
return (
|
||||
<React.Fragment>
|
||||
<AccountForm {...props} />
|
||||
<h2 className="form-container__divider">Social Login</h2>
|
||||
<p className="account__social-text">
|
||||
Use your GitHub or Google account to log into the p5.js Web Editor.
|
||||
</p>
|
||||
<GithubButton buttonText="Login with GitHub" />
|
||||
<GoogleButton buttonText="Login with Google" />
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
class AccountView extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.closeAccountPage = this.closeAccountPage.bind(this);
|
||||
this.gotoHomePage = this.gotoHomePage.bind(this);
|
||||
}
|
||||
|
||||
closeAccountPage() {
|
||||
browserHistory.push(this.props.previousPath);
|
||||
}
|
||||
|
||||
gotoHomePage() {
|
||||
browserHistory.push('/');
|
||||
componentDidMount() {
|
||||
document.body.className = this.props.theme;
|
||||
}
|
||||
|
||||
render() {
|
||||
const accessTokensUIEnabled = window.process.env.UI_ACCESS_TOKEN_ENABLED;
|
||||
|
||||
return (
|
||||
<div className="form-container">
|
||||
<div className="account-settings__container">
|
||||
<Helmet>
|
||||
<title>p5.js Web Editor | Account</title>
|
||||
<title>p5.js Web Editor | Account Settings</title>
|
||||
</Helmet>
|
||||
<div className="form-container__header">
|
||||
<button className="form-container__logo-button" onClick={this.gotoHomePage}>
|
||||
<InlineSVG src={logoUrl} alt="p5js Logo" />
|
||||
</button>
|
||||
<button className="form-container__exit-button" onClick={this.closeAccountPage}>
|
||||
<InlineSVG src={exitUrl} alt="Close Account Page" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="form-container__content">
|
||||
<h2 className="form-container__title">My Account</h2>
|
||||
<AccountForm {...this.props} />
|
||||
<h2 className="form-container__divider">Or</h2>
|
||||
<GithubButton buttonText="Login with Github" />
|
||||
|
||||
<Nav layout="dashboard" />
|
||||
|
||||
<section className="account-settings">
|
||||
<header className="account-settings__header">
|
||||
<h1 className="account-settings__title">Account Settings</h1>
|
||||
</header>
|
||||
{accessTokensUIEnabled &&
|
||||
<Tabs className="account__tabs">
|
||||
<TabList>
|
||||
<div className="tabs__titles">
|
||||
<Tab><h4 className="tabs__title">Account</h4></Tab>
|
||||
{accessTokensUIEnabled && <Tab><h4 className="tabs__title">Access Tokens</h4></Tab>}
|
||||
</div>
|
||||
</TabList>
|
||||
<TabPanel>
|
||||
<SocialLoginPanel {...this.props} />
|
||||
</TabPanel>
|
||||
<TabPanel>
|
||||
<APIKeyForm {...this.props} />
|
||||
</TabPanel>
|
||||
</Tabs>
|
||||
}
|
||||
{ !accessTokensUIEnabled && <SocialLoginPanel {...this.props} /> }
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
@ -58,13 +76,17 @@ class AccountView extends React.Component {
|
|||
function mapStateToProps(state) {
|
||||
return {
|
||||
initialValues: state.user, // <- initialValues for reduxForm
|
||||
previousPath: state.ide.previousPath,
|
||||
user: state.user,
|
||||
previousPath: state.ide.previousPath
|
||||
apiKeys: state.user.apiKeys,
|
||||
theme: state.preferences.theme
|
||||
};
|
||||
}
|
||||
|
||||
function mapDispatchToProps(dispatch) {
|
||||
return bindActionCreators({ updateSettings, initiateVerification }, dispatch);
|
||||
return bindActionCreators({
|
||||
updateSettings, initiateVerification, createApiKey, removeApiKey
|
||||
}, dispatch);
|
||||
}
|
||||
|
||||
function asyncValidate(formProps, dispatch, props) {
|
||||
|
@ -73,7 +95,7 @@ function asyncValidate(formProps, dispatch, props) {
|
|||
const queryParams = {};
|
||||
queryParams[fieldToValidate] = formProps[fieldToValidate];
|
||||
queryParams.check_type = fieldToValidate;
|
||||
return axios.get('/api/signup/duplicate_check', { params: queryParams })
|
||||
return axios.get(`${ROOT_URL}/signup/duplicate_check`, { params: queryParams })
|
||||
.then((response) => {
|
||||
if (response.data.exists) {
|
||||
const error = {};
|
||||
|
@ -87,6 +109,7 @@ function asyncValidate(formProps, dispatch, props) {
|
|||
|
||||
AccountView.propTypes = {
|
||||
previousPath: PropTypes.string.isRequired,
|
||||
theme: PropTypes.string.isRequired
|
||||
};
|
||||
|
||||
export default reduxForm({
|
||||
|
|
92
client/modules/User/pages/CollectionView.jsx
Normal 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);
|
178
client/modules/User/pages/DashboardView.jsx
Normal 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);
|
|
@ -3,13 +3,10 @@ import React from 'react';
|
|||
import { connect } from 'react-redux';
|
||||
import { bindActionCreators } from 'redux';
|
||||
import { browserHistory } from 'react-router';
|
||||
import InlineSVG from 'react-inlinesvg';
|
||||
import get from 'lodash/get';
|
||||
import { Helmet } from 'react-helmet';
|
||||
import { verifyEmailConfirmation } from '../actions';
|
||||
|
||||
const exitUrl = require('../../../images/exit.svg');
|
||||
const logoUrl = require('../../../images/p5js-logo.svg');
|
||||
import Nav from '../../../components/Nav';
|
||||
|
||||
|
||||
class EmailVerificationView extends React.Component {
|
||||
|
@ -17,12 +14,6 @@ class EmailVerificationView extends React.Component {
|
|||
emailVerificationTokenState: null,
|
||||
}
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.closeLoginPage = this.closeLoginPage.bind(this);
|
||||
this.gotoHomePage = this.gotoHomePage.bind(this);
|
||||
}
|
||||
|
||||
componentWillMount() {
|
||||
const verificationToken = this.verificationToken();
|
||||
if (verificationToken != null) {
|
||||
|
@ -32,14 +23,6 @@ class EmailVerificationView extends React.Component {
|
|||
|
||||
verificationToken = () => get(this.props, 'location.query.t', null);
|
||||
|
||||
closeLoginPage() {
|
||||
browserHistory.push(this.props.previousPath);
|
||||
}
|
||||
|
||||
gotoHomePage() {
|
||||
browserHistory.push('/');
|
||||
}
|
||||
|
||||
render() {
|
||||
let status = null;
|
||||
const {
|
||||
|
@ -48,7 +31,7 @@ class EmailVerificationView extends React.Component {
|
|||
|
||||
if (this.verificationToken() == null) {
|
||||
status = (
|
||||
<p>That link is invalid</p>
|
||||
<p>That link is invalid.</p>
|
||||
);
|
||||
} else if (emailVerificationTokenState === 'checking') {
|
||||
status = (
|
||||
|
@ -58,6 +41,7 @@ class EmailVerificationView extends React.Component {
|
|||
status = (
|
||||
<p>All done, your email address has been verified.</p>
|
||||
);
|
||||
setTimeout(() => browserHistory.push('/'), 1000);
|
||||
} else if (emailVerificationTokenState === 'invalid') {
|
||||
status = (
|
||||
<p>Something went wrong.</p>
|
||||
|
@ -65,23 +49,18 @@ class EmailVerificationView extends React.Component {
|
|||
}
|
||||
|
||||
return (
|
||||
<div className="email-verification">
|
||||
<Nav layout="dashboard" />
|
||||
<div className="form-container">
|
||||
<Helmet>
|
||||
<title>p5.js Web Editor | Email Verification</title>
|
||||
</Helmet>
|
||||
<div className="form-container__header">
|
||||
<button className="form-container__logo-button" onClick={this.gotoHomePage}>
|
||||
<InlineSVG src={logoUrl} alt="p5js Logo" />
|
||||
</button>
|
||||
<button className="form-container__exit-button" onClick={this.closeLoginPage}>
|
||||
<InlineSVG src={exitUrl} alt="Close Login Page" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="form-container__content">
|
||||
<h2 className="form-container__title">Verify your email</h2>
|
||||
{status}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -89,7 +68,6 @@ class EmailVerificationView extends React.Component {
|
|||
function mapStateToProps(state) {
|
||||
return {
|
||||
emailVerificationTokenState: state.user.emailVerificationTokenState,
|
||||
previousPath: state.ide.previousPath
|
||||
};
|
||||
}
|
||||
|
||||
|
@ -101,7 +79,6 @@ function mapDispatchToProps(dispatch) {
|
|||
|
||||
|
||||
EmailVerificationView.propTypes = {
|
||||
previousPath: PropTypes.string.isRequired,
|
||||
emailVerificationTokenState: PropTypes.oneOf([
|
||||
'checking', 'verified', 'invalid'
|
||||
]),
|
||||
|
|
|
@ -2,16 +2,13 @@ import PropTypes from 'prop-types';
|
|||
import React from 'react';
|
||||
import { reduxForm } from 'redux-form';
|
||||
import { Link, browserHistory } from 'react-router';
|
||||
import InlineSVG from 'react-inlinesvg';
|
||||
import { Helmet } from 'react-helmet';
|
||||
import { validateAndLoginUser } from '../actions';
|
||||
import LoginForm from '../components/LoginForm';
|
||||
import { validateLogin } from '../../../utils/reduxFormUtils';
|
||||
import GithubButton from '../components/GithubButton';
|
||||
import GoogleButton from '../components/GoogleButton';
|
||||
|
||||
const exitUrl = require('../../../images/exit.svg');
|
||||
const logoUrl = require('../../../images/p5js-logo.svg');
|
||||
import Nav from '../../../components/Nav';
|
||||
|
||||
class LoginView extends React.Component {
|
||||
constructor(props) {
|
||||
|
@ -34,18 +31,12 @@ class LoginView extends React.Component {
|
|||
return null;
|
||||
}
|
||||
return (
|
||||
<div className="login">
|
||||
<Nav layout="dashboard" />
|
||||
<div className="form-container">
|
||||
<Helmet>
|
||||
<title>p5.js Web Editor | Login</title>
|
||||
</Helmet>
|
||||
<div className="form-container__header">
|
||||
<button className="form-container__logo-button" onClick={this.gotoHomePage}>
|
||||
<InlineSVG src={logoUrl} alt="p5js Logo" />
|
||||
</button>
|
||||
<button className="form-container__exit-button" onClick={this.closeLoginPage}>
|
||||
<InlineSVG src={exitUrl} alt="Close Login Page" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="form-container__content">
|
||||
<h2 className="form-container__title">Log In</h2>
|
||||
<LoginForm {...this.props} />
|
||||
|
@ -62,6 +53,7 @@ class LoginView extends React.Component {
|
|||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -2,60 +2,36 @@ import PropTypes from 'prop-types';
|
|||
import React from 'react';
|
||||
import { reduxForm } from 'redux-form';
|
||||
import classNames from 'classnames';
|
||||
import { browserHistory } from 'react-router';
|
||||
import InlineSVG from 'react-inlinesvg';
|
||||
import { bindActionCreators } from 'redux';
|
||||
import { Helmet } from 'react-helmet';
|
||||
import NewPasswordForm from '../components/NewPasswordForm';
|
||||
import * as UserActions from '../actions';
|
||||
import Nav from '../../../components/Nav';
|
||||
|
||||
const exitUrl = require('../../../images/exit.svg');
|
||||
const logoUrl = require('../../../images/p5js-logo.svg');
|
||||
|
||||
class NewPasswordView extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.gotoHomePage = this.gotoHomePage.bind(this);
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
// need to check if this is a valid token
|
||||
this.props.validateResetPasswordToken(this.props.params.reset_password_token);
|
||||
}
|
||||
|
||||
gotoHomePage() {
|
||||
browserHistory.push('/');
|
||||
}
|
||||
|
||||
render() {
|
||||
function NewPasswordView(props) {
|
||||
const newPasswordClass = classNames({
|
||||
'new-password': true,
|
||||
'new-password--invalid': this.props.user.resetPasswordInvalid,
|
||||
'form-container': true
|
||||
'new-password--invalid': props.user.resetPasswordInvalid,
|
||||
'form-container': true,
|
||||
'user': true
|
||||
});
|
||||
return (
|
||||
<div className="new-password-container">
|
||||
<Nav layout="dashboard" />
|
||||
<div className={newPasswordClass}>
|
||||
<Helmet>
|
||||
<title>p5.js Web Editor | New Password</title>
|
||||
</Helmet>
|
||||
<div className="form-container__header">
|
||||
<button className="form-container__logo-button" onClick={this.gotoHomePage}>
|
||||
<InlineSVG src={logoUrl} alt="p5js Logo" />
|
||||
</button>
|
||||
<button className="form-container__exit-button" onClick={this.gotoHomePage}>
|
||||
<InlineSVG src={exitUrl} alt="Close NewPassword Page" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="form-container__content">
|
||||
<h2 className="form-container__title">Set a New Password</h2>
|
||||
<NewPasswordForm {...this.props} />
|
||||
<NewPasswordForm {...props} />
|
||||
<p className="new-password__invalid">
|
||||
The password reset token is invalid or has expired.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
NewPasswordView.propTypes = {
|
||||
|
|
|
@ -1,55 +1,33 @@
|
|||
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import { Link, browserHistory } from 'react-router';
|
||||
import { Link } from 'react-router';
|
||||
import classNames from 'classnames';
|
||||
import InlineSVG from 'react-inlinesvg';
|
||||
import { bindActionCreators } from 'redux';
|
||||
import { reduxForm } from 'redux-form';
|
||||
import { Helmet } from 'react-helmet';
|
||||
import * as UserActions from '../actions';
|
||||
import ResetPasswordForm from '../components/ResetPasswordForm';
|
||||
import { validateResetPassword } from '../../../utils/reduxFormUtils';
|
||||
import Nav from '../../../components/Nav';
|
||||
|
||||
const exitUrl = require('../../../images/exit.svg');
|
||||
const logoUrl = require('../../../images/p5js-logo.svg');
|
||||
|
||||
class ResetPasswordView extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.gotoHomePage = this.gotoHomePage.bind(this);
|
||||
}
|
||||
|
||||
componentWillMount() {
|
||||
this.props.resetPasswordReset();
|
||||
}
|
||||
|
||||
gotoHomePage() {
|
||||
browserHistory.push('/');
|
||||
}
|
||||
|
||||
render() {
|
||||
function ResetPasswordView(props) {
|
||||
const resetPasswordClass = classNames({
|
||||
'reset-password': true,
|
||||
'reset-password--submitted': this.props.user.resetPasswordInitiate,
|
||||
'form-container': true
|
||||
'reset-password--submitted': props.user.resetPasswordInitiate,
|
||||
'form-container': true,
|
||||
'user': true
|
||||
});
|
||||
return (
|
||||
<div className="reset-password-container">
|
||||
<Nav layout="dashboard" />
|
||||
<div className={resetPasswordClass}>
|
||||
<Helmet>
|
||||
<title>p5.js Web Editor | Reset Password</title>
|
||||
</Helmet>
|
||||
<div className="form-container__header">
|
||||
<button className="form-container__logo-button" onClick={this.gotoHomePage}>
|
||||
<InlineSVG src={logoUrl} alt="p5js Logo" />
|
||||
</button>
|
||||
<button className="form-container__exit-button" onClick={this.gotoHomePage}>
|
||||
<InlineSVG src={exitUrl} alt="Close ResetPassword Page" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="form-container__content">
|
||||
<h2 className="form-container__title">Reset Your Password</h2>
|
||||
<ResetPasswordForm {...this.props} />
|
||||
<ResetPasswordForm {...props} />
|
||||
<p className="reset-password__submitted">
|
||||
Your password reset email should arrive shortly. If you don't see it, check
|
||||
in your spam folder as sometimes it can end up there.
|
||||
|
@ -61,8 +39,8 @@ class ResetPasswordView extends React.Component {
|
|||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
ResetPasswordView.propTypes = {
|
||||
|
|
|
@ -4,27 +4,17 @@ import { bindActionCreators } from 'redux';
|
|||
import axios from 'axios';
|
||||
import { Link, browserHistory } from 'react-router';
|
||||
import { Helmet } from 'react-helmet';
|
||||
import InlineSVG from 'react-inlinesvg';
|
||||
import { reduxForm } from 'redux-form';
|
||||
import * as UserActions from '../actions';
|
||||
import SignupForm from '../components/SignupForm';
|
||||
import { validateSignup } from '../../../utils/reduxFormUtils';
|
||||
import Nav from '../../../components/Nav';
|
||||
|
||||
const exitUrl = require('../../../images/exit.svg');
|
||||
const logoUrl = require('../../../images/p5js-logo.svg');
|
||||
const __process = (typeof global !== 'undefined' ? global : window).process;
|
||||
const ROOT_URL = __process.env.API_URL;
|
||||
|
||||
class SignupView extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.closeSignupPage = this.closeSignupPage.bind(this);
|
||||
this.gotoHomePage = this.gotoHomePage.bind(this);
|
||||
}
|
||||
|
||||
closeSignupPage() {
|
||||
browserHistory.push(this.props.previousPath);
|
||||
}
|
||||
|
||||
gotoHomePage() {
|
||||
gotoHomePage = () => {
|
||||
browserHistory.push('/');
|
||||
}
|
||||
|
||||
|
@ -34,18 +24,12 @@ class SignupView extends React.Component {
|
|||
return null;
|
||||
}
|
||||
return (
|
||||
<div className="signup">
|
||||
<Nav layout="dashboard" />
|
||||
<div className="form-container">
|
||||
<Helmet>
|
||||
<title>p5.js Web Editor | Signup</title>
|
||||
</Helmet>
|
||||
<div className="form-container__header">
|
||||
<button className="form-container__logo-button" onClick={this.gotoHomePage}>
|
||||
<InlineSVG src={logoUrl} alt="p5js Logo" />
|
||||
</button>
|
||||
<button className="form-container__exit-button" onClick={this.closeSignupPage}>
|
||||
<InlineSVG src={exitUrl} alt="Close Signup Page" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="form-container__content">
|
||||
<h2 className="form-container__title">Sign Up</h2>
|
||||
<SignupForm {...this.props} />
|
||||
|
@ -55,6 +39,7 @@ class SignupView extends React.Component {
|
|||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -95,7 +80,7 @@ function asyncValidate(formProps, dispatch, props) {
|
|||
const queryParams = {};
|
||||
queryParams[fieldToValidate] = formProps[fieldToValidate];
|
||||
queryParams.check_type = fieldToValidate;
|
||||
return axios.get('/api/signup/duplicate_check', { params: queryParams })
|
||||
return axios.get(`${ROOT_URL}/signup/duplicate_check`, { params: queryParams })
|
||||
.then((response) => {
|
||||
if (response.data.exists) {
|
||||
errors[fieldToValidate] = response.data.message;
|
||||
|
@ -118,9 +103,9 @@ function onSubmitFail(errors) {
|
|||
|
||||
SignupView.propTypes = {
|
||||
previousPath: PropTypes.string.isRequired,
|
||||
user: {
|
||||
user: PropTypes.shape({
|
||||
authenticated: PropTypes.bool
|
||||
}
|
||||
})
|
||||
};
|
||||
|
||||
SignupView.defaultProps = {
|
||||
|
|
|
@ -31,6 +31,10 @@ const user = (state = { authenticated: false }, action) => {
|
|||
return Object.assign({}, state, { emailVerificationTokenState: 'invalid' });
|
||||
case ActionTypes.SETTINGS_UPDATED:
|
||||
return { ...state, ...action.user };
|
||||
case ActionTypes.API_KEY_REMOVED:
|
||||
return { ...state, ...action.user };
|
||||
case ActionTypes.API_KEY_CREATED:
|
||||
return { ...state, ...action.user };
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
|
|
|
@ -13,6 +13,7 @@ import assets from './modules/IDE/reducers/assets';
|
|||
import search from './modules/IDE/reducers/search';
|
||||
import sorting from './modules/IDE/reducers/sorting';
|
||||
import loading from './modules/IDE/reducers/loading';
|
||||
import collections from './modules/IDE/reducers/collections';
|
||||
|
||||
const rootReducer = combineReducers({
|
||||
form,
|
||||
|
@ -28,7 +29,8 @@ const rootReducer = combineReducers({
|
|||
toast,
|
||||
console,
|
||||
assets,
|
||||
loading
|
||||
loading,
|
||||
collections
|
||||
});
|
||||
|
||||
export default rootReducer;
|
||||
|
|
|
@ -9,9 +9,12 @@ import ResetPasswordView from './modules/User/pages/ResetPasswordView';
|
|||
import EmailVerificationView from './modules/User/pages/EmailVerificationView';
|
||||
import NewPasswordView from './modules/User/pages/NewPasswordView';
|
||||
import AccountView from './modules/User/pages/AccountView';
|
||||
// import SketchListView from './modules/Sketch/pages/SketchListView';
|
||||
import CollectionView from './modules/User/pages/CollectionView';
|
||||
import DashboardView from './modules/User/pages/DashboardView';
|
||||
import createRedirectWithUsername from './components/createRedirectWithUsername';
|
||||
import { getUser } from './modules/User/actions';
|
||||
import { stopSketch } from './modules/IDE/actions/ide';
|
||||
import { userIsAuthenticated, userIsNotAuthenticated, userIsAuthorized } from './utils/auth';
|
||||
|
||||
const checkAuth = (store) => {
|
||||
store.dispatch(getUser());
|
||||
|
@ -24,9 +27,9 @@ const onRouteChange = (store) => {
|
|||
const routes = store => (
|
||||
<Route path="/" component={App} onChange={() => { onRouteChange(store); }}>
|
||||
<IndexRoute component={IDEView} onEnter={checkAuth(store)} />
|
||||
<Route path="/login" component={LoginView} />
|
||||
<Route path="/signup" component={SignupView} />
|
||||
<Route path="/reset-password" component={ResetPasswordView} />
|
||||
<Route path="/login" component={userIsNotAuthenticated(LoginView)} />
|
||||
<Route path="/signup" component={userIsNotAuthenticated(SignupView)} />
|
||||
<Route path="/reset-password" component={userIsNotAuthenticated(ResetPasswordView)} />
|
||||
<Route path="/verify" component={EmailVerificationView} />
|
||||
<Route
|
||||
path="/reset-password/:reset_password_token"
|
||||
|
@ -35,11 +38,16 @@ const routes = store => (
|
|||
<Route path="/projects/:project_id" component={IDEView} />
|
||||
<Route path="/:username/full/:project_id" component={FullView} />
|
||||
<Route path="/full/:project_id" component={FullView} />
|
||||
<Route path="/sketches" component={IDEView} />
|
||||
<Route path="/assets" component={IDEView} />
|
||||
<Route path="/account" component={AccountView} />
|
||||
<Route path="/sketches" component={createRedirectWithUsername('/:username/sketches')} />
|
||||
<Route path="/:username/assets" component={userIsAuthenticated(userIsAuthorized(DashboardView))} />
|
||||
<Route path="/assets" component={createRedirectWithUsername('/:username/assets')} />
|
||||
<Route path="/account" component={userIsAuthenticated(AccountView)} />
|
||||
<Route path="/:username/sketches/:project_id" component={IDEView} />
|
||||
<Route path="/:username/sketches" component={IDEView} />
|
||||
<Route path="/:username/sketches/:project_id/add-to-collection" component={IDEView} />
|
||||
<Route path="/:username/sketches" component={DashboardView} />
|
||||
<Route path="/:username/collections" component={DashboardView} />
|
||||
<Route path="/:username/collections/create" component={DashboardView} />
|
||||
<Route path="/:username/collections/:collection_id" component={CollectionView} />
|
||||
<Route path="/about" component={IDEView} />
|
||||
</Route>
|
||||
);
|
||||
|
|
|
@ -83,7 +83,11 @@
|
|||
border: 2px solid getThemifyVariable('button-border-color');
|
||||
border-radius: 2px;
|
||||
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');
|
||||
background-color: getThemifyVariable('button-background-hover-color');
|
||||
color: getThemifyVariable('button-hover-color');
|
||||
|
@ -91,7 +95,7 @@
|
|||
fill: getThemifyVariable('button-hover-color');
|
||||
}
|
||||
}
|
||||
&:enabled:active {
|
||||
&:not(disabled):active {
|
||||
border-color: getThemifyVariable('button-background-active-color');
|
||||
background-color: getThemifyVariable('button-background-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 {
|
||||
@extend %toolbar-button;
|
||||
@include themify() {
|
||||
|
|
|
@ -9,15 +9,22 @@ $orange: #ffa500;
|
|||
$red: #ff0000;
|
||||
$lightsteelblue: #B0C4DE;
|
||||
$dodgerblue: #1E90FF;
|
||||
$primary-text-color: #333;
|
||||
$icon-color: #8b8b8b;
|
||||
$icon-hover-color: #333;
|
||||
$p5-contrast-pink: #FFA9D9;
|
||||
|
||||
// Grays
|
||||
$dark: #333;
|
||||
$middleGray: #7d7d7d;
|
||||
$middleLight: #a6a6a6;
|
||||
|
||||
// Abstracts
|
||||
$primary-text-color: $dark;
|
||||
|
||||
$themes: (
|
||||
light: (
|
||||
logo-color: $p5js-pink,
|
||||
primary-text-color: #333,
|
||||
primary-text-color: $primary-text-color,
|
||||
dropzone-text-color: #333,
|
||||
modal-button-color: #333,
|
||||
heading-text-color: #333,
|
||||
|
@ -65,8 +72,35 @@ $themes: (
|
|||
keyboard-shortcut-color: #757575,
|
||||
nav-hover-color: $p5js-pink,
|
||||
error-color: $p5js-pink,
|
||||
table-row-stripe-color: #d6d6d6,
|
||||
codefold-icon-open: url(../images/triangle-arrow-down.svg),
|
||||
codefold-icon-closed: url(../images/triangle-arrow-right.svg)
|
||||
codefold-icon-closed: url(../images/triangle-arrow-right.svg),
|
||||
|
||||
primary-button-color: #fff,
|
||||
primary-button-background-color: $p5js-pink,
|
||||
|
||||
table-button-color: $white,
|
||||
table-button-background-color: #979797,
|
||||
table-button-active-color: $white,
|
||||
table-button-background-active-color: #00A1D3,
|
||||
table-button-hover-color: $white,
|
||||
table-button-background-hover-color: $p5js-pink,
|
||||
|
||||
progress-bar-background-color: #979797,
|
||||
progress-bar-active-color: #f10046,
|
||||
|
||||
form-title-color: rgba(51, 51, 51, 0.87),
|
||||
form-secondary-title-color: $middleGray,
|
||||
form-input-text-color: $dark,
|
||||
form-input-placeholder-text-color: $middleLight,
|
||||
form-border-color: #b5b5b5,
|
||||
form-button-background-color: $white,
|
||||
form-button-color: #f10046,
|
||||
form-button-background-hover-color: $p5js-pink,
|
||||
form-button-background-active-color: #f10046,
|
||||
form-button-hover-color: $white,
|
||||
form-button-active-color: $white,
|
||||
form-navigation-options-color: #999999
|
||||
),
|
||||
dark: (
|
||||
logo-color: $p5js-pink,
|
||||
|
@ -117,8 +151,33 @@ $themes: (
|
|||
keyboard-shortcut-color: #B5B5B5,
|
||||
nav-hover-color: $p5js-pink,
|
||||
error-color: $p5js-pink,
|
||||
table-row-stripe-color: #3f3f3f,
|
||||
codefold-icon-open: url(../images/triangle-arrow-down-white.svg),
|
||||
codefold-icon-closed: url(../images/triangle-arrow-right-white.svg)
|
||||
codefold-icon-closed: url(../images/triangle-arrow-right-white.svg),
|
||||
|
||||
primary-button-color: #fff,
|
||||
primary-button-background-color: $p5js-pink,
|
||||
|
||||
table-button-color: $white,
|
||||
table-button-background-color: #979797,
|
||||
table-button-active-color: $white,
|
||||
table-button-background-active-color: #00A1D3,
|
||||
table-button-hover-color: $white,
|
||||
table-button-background-hover-color: $p5js-pink,
|
||||
|
||||
progress-bar-background-color: #979797,
|
||||
progress-bar-active-color: #f10046,
|
||||
|
||||
form-title-color: $white,
|
||||
form-secondary-title-color: #b5b5b5,
|
||||
form-border-color: #b5b5b5,
|
||||
form-button-background-color: $black,
|
||||
form-button-color: #f10046,
|
||||
form-button-background-hover-color: $p5js-pink,
|
||||
form-button-background-active-color: #f10046,
|
||||
form-button-hover-color: $white,
|
||||
form-button-active-color: $white,
|
||||
form-navigation-options-color: #999999
|
||||
),
|
||||
contrast: (
|
||||
logo-color: $yellow,
|
||||
|
@ -127,7 +186,7 @@ $themes: (
|
|||
modal-button-color: #333,
|
||||
heading-text-color: #e1e1e1,
|
||||
secondary-text-color: #e1e1e1,
|
||||
inactive-text-color: #c1c1c1,
|
||||
inactive-text-color: #f2f2f2,
|
||||
background-color: #333,
|
||||
button-background-color: $white,
|
||||
button-color: $black,
|
||||
|
@ -135,21 +194,21 @@ $themes: (
|
|||
toolbar-button-color: #333333,
|
||||
toolbar-button-background-color: #C1C1C1,
|
||||
button-background-hover-color: $yellow,
|
||||
button-background-active-color: #f10046,
|
||||
button-background-active-color: $yellow,
|
||||
button-nav-inactive-color: #a0a0a0,
|
||||
button-hover-color: #333333,
|
||||
button-active-color: #333333,
|
||||
modal-background-color: #444,
|
||||
modal-button-background-color: #C1C1C1,
|
||||
modal-border-color: #949494,
|
||||
icon-color: #a9a9a9,
|
||||
icon-color: #d9d9d9,
|
||||
icon-hover-color: $yellow,
|
||||
icon-toast-hover-color: $yellow,
|
||||
shadow-color: rgba(0, 0, 0, 0.16),
|
||||
console-background-color: #4f4f4f,
|
||||
console-color: $black,
|
||||
console-header-background-color: #3f3f3f,
|
||||
console-header-color: #b5b5b5,
|
||||
console-header-color: #d9d9d9,
|
||||
console-info-background-color: $lightsteelblue,
|
||||
console-warn-background-color: $orange,
|
||||
console-debug-background-color: $dodgerblue,
|
||||
|
@ -168,8 +227,33 @@ $themes: (
|
|||
keyboard-shortcut-color: #e1e1e1,
|
||||
nav-hover-color: $yellow,
|
||||
error-color: $p5-contrast-pink,
|
||||
table-row-stripe-color: #3f3f3f,
|
||||
codefold-icon-open: url(../images/triangle-arrow-down-white.svg),
|
||||
codefold-icon-closed: url(../images/triangle-arrow-right-white.svg)
|
||||
codefold-icon-closed: url(../images/triangle-arrow-right-white.svg),
|
||||
|
||||
primary-button-color: #fff,
|
||||
primary-button-background-color: $p5js-pink,
|
||||
|
||||
table-button-color: #333,
|
||||
table-button-background-color: #C1C1C1,
|
||||
table-button-active-color: #333,
|
||||
table-button-background-active-color: #00FFFF,
|
||||
table-button-hover-color: #333,
|
||||
table-button-background-hover-color: $yellow,
|
||||
|
||||
progress-bar-background-color: #979797,
|
||||
progress-bar-active-color: #f10046,
|
||||
|
||||
form-title-color: $white,
|
||||
form-secondary-title-color: #b5b5b5,
|
||||
form-border-color: #b5b5b5,
|
||||
form-button-background-color: $black,
|
||||
form-button-color: #f10046,
|
||||
form-button-background-hover-color: $p5-contrast-pink,
|
||||
form-button-background-active-color: #f10046,
|
||||
form-button-hover-color: $white,
|
||||
form-button-active-color: $white,
|
||||
form-navigation-options-color: #999999
|
||||
)
|
||||
);
|
||||
|
||||
|
@ -179,15 +263,5 @@ $console-error-color: #ff5f52;
|
|||
$toast-background-color: #4A4A4A;
|
||||
$toast-text-color: $white;
|
||||
|
||||
$form-title-color: rgba(51, 51, 51, 0.87);
|
||||
$secondary-form-title-color: #b5b5b5;
|
||||
$form-button-background-color: $white;
|
||||
$form-button-color: #f10046;
|
||||
$form-button-background-hover-color: $p5js-pink;
|
||||
$form-button-background-active-color: #f10046;
|
||||
$form-button-hover-color: $white;
|
||||
$form-button-active-color: $white;
|
||||
$form-navigation-options-color: #999999;
|
||||
|
||||
$about-play-background-color: rgba(255, 255, 255, 0.7);
|
||||
$about-button-border-color: rgba(151, 151, 151, 0.7);
|
||||
|
|
|
@ -6,13 +6,13 @@ html, body {
|
|||
font-size: #{$base-font-size}px;
|
||||
}
|
||||
|
||||
body, input {
|
||||
body, input, textarea {
|
||||
@include themify() {
|
||||
color: getThemifyVariable('primary-text-color');
|
||||
}
|
||||
}
|
||||
|
||||
body, input, button {
|
||||
body, input, textarea, button {
|
||||
font-family: Montserrat, sans-serif;
|
||||
}
|
||||
|
||||
|
@ -31,7 +31,8 @@ input, button {
|
|||
font-size: 1rem;
|
||||
}
|
||||
|
||||
input {
|
||||
input,
|
||||
textarea {
|
||||
padding: #{5 / $base-font-size}rem;
|
||||
border: 1px solid ;
|
||||
border-radius: 2px;
|
||||
|
@ -42,12 +43,18 @@ input {
|
|||
}
|
||||
}
|
||||
|
||||
button[type="submit"],
|
||||
input[type="submit"] {
|
||||
@include themify() {
|
||||
@extend %button;
|
||||
}
|
||||
}
|
||||
|
||||
button[type="submit"]:disabled,
|
||||
input[type="submit"]:disabled {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
button {
|
||||
@include themify() {
|
||||
@extend %link;
|
||||
|
@ -56,6 +63,10 @@ button {
|
|||
border: none;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: #{21 / $base-font-size}em;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: #{21 / $base-font-size}em;
|
||||
}
|
||||
|
@ -68,6 +79,7 @@ h4 {
|
|||
}
|
||||
h6 {
|
||||
font-weight: normal;
|
||||
font-size: #{12 / $base-font-size}rem;
|
||||
}
|
||||
thead {
|
||||
text-align: left;
|
||||
|
|
|
@ -46,7 +46,9 @@
|
|||
padding-left: #{20 / $base-font-size}rem;
|
||||
width: #{720 / $base-font-size}rem;
|
||||
& a {
|
||||
color: $form-navigation-options-color;
|
||||
@include themify() {
|
||||
color: getThemifyVariable('form-navigation-options-color');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
22
client/styles/components/_account.scss
Normal 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;
|
||||
}
|
109
client/styles/components/_api-key.scss
Normal 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;
|
||||
}
|
|
@ -1,60 +1,99 @@
|
|||
.asset-table-container {
|
||||
// flex: 1 1 0%;
|
||||
overflow-y: auto;
|
||||
max-width: 100%;
|
||||
width: #{1000 / $base-font-size}rem;
|
||||
min-height: #{400 / $base-font-size}rem;
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
.asset-table {
|
||||
width: 100%;
|
||||
padding: #{10 / $base-font-size}rem #{20 / $base-font-size}rem;
|
||||
|
||||
max-height: 100%;
|
||||
border-spacing: 0;
|
||||
& .asset-list__delete-column {
|
||||
width: #{23 / $base-font-size}rem;
|
||||
position: relative;
|
||||
& .asset-table__dropdown-column {
|
||||
width: #{60 / $base-font-size}rem;
|
||||
position: relative;
|
||||
}
|
||||
}
|
||||
|
||||
& thead {
|
||||
font-size: #{12 / $base-font-size}rem;
|
||||
@include themify() {
|
||||
color: getThemifyVariable('inactive-text-color')
|
||||
}
|
||||
}
|
||||
|
||||
& th {
|
||||
.asset-table thead th {
|
||||
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 {
|
||||
margin: #{10 / $base-font-size}rem;
|
||||
height: #{72 / $base-font-size}rem;
|
||||
font-size: #{16 / $base-font-size}rem;
|
||||
}
|
||||
|
||||
&:nth-child(odd) {
|
||||
.asset-table__row:nth-child(odd) {
|
||||
@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() {
|
||||
color: getThemifyVariable('primary-text-color');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
& td:first-child {
|
||||
padding-left: #{10 / $base-font-size}rem;
|
||||
.asset-table thead {
|
||||
font-size: #{12 / $base-font-size}rem;
|
||||
@include themify() {
|
||||
color: getThemifyVariable('inactive-text-color')
|
||||
}
|
||||
}
|
||||
|
||||
.asset-table th {
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
.asset-table__empty {
|
||||
text-align: center;
|
||||
font-size: #{16 / $base-font-size}rem;
|
||||
padding: #{42 / $base-font-size}rem 0;
|
||||
}
|
||||
|
||||
.asset-table__total {
|
||||
padding: 0 #{20 / $base-font-size}rem;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
@include themify() {
|
||||
background-color: getThemifyVariable('background-color');
|
||||
}
|
||||
}
|
||||
|
||||
.asset-table__dropdown-button {
|
||||
width:#{25 / $base-font-size}rem;
|
||||
height:#{25 / $base-font-size}rem;
|
||||
|
||||
@include themify() {
|
||||
& polygon {
|
||||
fill: getThemifyVariable('dropdown-color');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.asset-table__action-dialogue {
|
||||
@extend %dropdown-open-right;
|
||||
top: 63%;
|
||||
right: calc(100% - 26px);
|
||||
}
|
||||
|
||||
.asset-table__action-option {
|
||||
font-size: #{12 / $base-font-size}rem;
|
||||
}
|
||||
|
|
48
client/styles/components/_asset-size.scss
Normal 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;
|
||||
}
|
3
client/styles/components/_collection-create.scss
Normal file
|
@ -0,0 +1,3 @@
|
|||
.collection-create {
|
||||
padding: #{24 / $base-font-size}rem;
|
||||
}
|
95
client/styles/components/_collection-popover.scss
Normal 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%;
|
||||
}
|
190
client/styles/components/_collection.scss
Normal 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');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|