Merge pull request #1164 from andrewn/feature/sketch-collections

Sketch collections
This commit is contained in:
Cassie Tarakajian 2020-01-28 16:56:09 -05:00 committed by GitHub
commit 1a3a1aa960
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
77 changed files with 3554 additions and 299 deletions

View file

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

View file

@ -326,6 +326,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
@ -576,6 +589,18 @@ class Nav extends React.PureComponent {
My sketches
</Link>
</li>
{__process.env.UI_COLLECTIONS_ENABLED &&
<li className="nav__dropdown-item">
<Link
to={`/${this.props.user.username}/collections`}
onFocus={this.handleFocusForAccount}
onBlur={this.handleBlur}
onClick={this.setDropdownForNone}
>
My collections
</Link>
</li>
}
<li className="nav__dropdown-item">
<Link
to={`/${this.props.user.username}/assets`}

View file

@ -7,7 +7,7 @@ const logoUrl = require('../images/p5js-logo-small.svg');
const editorUrl = require('../images/code.svg');
const PreviewNav = ({ owner, project }) => (
<nav className="nav">
<nav className="nav preview-nav">
<div className="nav__items-left">
<div className="nav__item-logo">
<InlineSVG src={logoUrl} alt="p5.js logo" />

View file

@ -36,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';

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

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

After

Width:  |  Height:  |  Size: 583 B

View file

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

After

Width:  |  Height:  |  Size: 727 B

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

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

After

Width:  |  Height:  |  Size: 801 B

View file

@ -14,7 +14,7 @@ class App extends React.Component {
componentDidMount() {
this.setState({ isMounted: true }); // eslint-disable-line react/no-did-mount-set-state
document.body.className = 'light';
document.body.className = this.props.theme;
}
componentWillReceiveProps(nextProps) {
@ -26,6 +26,12 @@ class App extends React.Component {
}
}
componentDidUpdate(prevProps) {
if (this.props.theme !== prevProps.theme) {
document.body.className = this.props.theme;
}
}
render() {
return (
<div className="app">
@ -45,10 +51,18 @@ App.propTypes = {
}),
}).isRequired,
setPreviousPath: PropTypes.func.isRequired,
theme: PropTypes.string,
};
App.defaultProps = {
children: null
children: null,
theme: 'light'
};
export default connect(() => ({}), { setPreviousPath })(App);
const mapStateToProps = state => ({
theme: state.preferences.theme,
});
const mapDispatchToProps = { setPreviousPath };
export default connect(mapStateToProps, mapDispatchToProps)(App);

View file

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

View file

@ -0,0 +1,173 @@
import axios from 'axios';
import * as ActionTypes from '../../../constants';
import { startLoader, stopLoader } from './loader';
import { setToastText, showToast } from './toast';
const __process = (typeof global !== 'undefined' ? global : window).process;
const ROOT_URL = __process.env.API_URL;
const TOAST_DISPLAY_TIME_MS = 1500;
// eslint-disable-next-line
export function getCollections(username) {
return (dispatch) => {
dispatch(startLoader());
let url;
if (username) {
url = `${ROOT_URL}/${username}/collections`;
} else {
url = `${ROOT_URL}/collections`;
}
axios.get(url, { withCredentials: true })
.then((response) => {
dispatch({
type: ActionTypes.SET_COLLECTIONS,
collections: response.data
});
dispatch(stopLoader());
})
.catch((response) => {
dispatch({
type: ActionTypes.ERROR,
error: response.data
});
dispatch(stopLoader());
});
};
}
export function createCollection(collection) {
return (dispatch) => {
dispatch(startLoader());
const url = `${ROOT_URL}/collections`;
return axios.post(url, collection, { withCredentials: true })
.then((response) => {
dispatch({
type: ActionTypes.CREATE_COLLECTION
});
dispatch(stopLoader());
const collectionName = response.data.name;
dispatch(setToastText(`Created "${collectionName}"`));
dispatch(showToast(TOAST_DISPLAY_TIME_MS));
return response.data;
})
.catch((response) => {
dispatch({
type: ActionTypes.ERROR,
error: response.data
});
dispatch(stopLoader());
return response.data;
});
};
}
export function addToCollection(collectionId, projectId) {
return (dispatch) => {
dispatch(startLoader());
const url = `${ROOT_URL}/collections/${collectionId}/${projectId}`;
return axios.post(url, { withCredentials: true })
.then((response) => {
dispatch({
type: ActionTypes.ADD_TO_COLLECTION,
payload: response.data
});
dispatch(stopLoader());
const collectionName = response.data.name;
dispatch(setToastText(`Added to "${collectionName}`));
dispatch(showToast(TOAST_DISPLAY_TIME_MS));
return response.data;
})
.catch((response) => {
dispatch({
type: ActionTypes.ERROR,
error: response.data
});
dispatch(stopLoader());
return response.data;
});
};
}
export function removeFromCollection(collectionId, projectId) {
return (dispatch) => {
dispatch(startLoader());
const url = `${ROOT_URL}/collections/${collectionId}/${projectId}`;
return axios.delete(url, { withCredentials: true })
.then((response) => {
dispatch({
type: ActionTypes.REMOVE_FROM_COLLECTION,
payload: response.data
});
dispatch(stopLoader());
const collectionName = response.data.name;
dispatch(setToastText(`Removed from "${collectionName}`));
dispatch(showToast(TOAST_DISPLAY_TIME_MS));
return response.data;
})
.catch((response) => {
dispatch({
type: ActionTypes.ERROR,
error: response.data
});
dispatch(stopLoader());
return response.data;
});
};
}
export function editCollection(collectionId, { name, description }) {
return (dispatch) => {
const url = `${ROOT_URL}/collections/${collectionId}`;
return axios.patch(url, { name, description }, { withCredentials: true })
.then((response) => {
dispatch({
type: ActionTypes.EDIT_COLLECTION,
payload: response.data
});
return response.data;
})
.catch((response) => {
dispatch({
type: ActionTypes.ERROR,
error: response.data
});
return response.data;
});
};
}
export function deleteCollection(collectionId) {
return (dispatch) => {
const url = `${ROOT_URL}/collections/${collectionId}`;
return axios.delete(url, { withCredentials: true })
.then((response) => {
dispatch({
type: ActionTypes.DELETE_COLLECTION,
payload: response.data,
collectionId,
});
return response.data;
})
.catch((response) => {
dispatch({
type: ActionTypes.ERROR,
error: response.data
});
return response.data;
});
};
}

View file

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

View file

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

View file

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

View file

@ -40,13 +40,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>
@ -93,7 +89,6 @@ AssetList.propTypes = {
sketchName: PropTypes.string.isRequired,
sketchId: PropTypes.string.isRequired
})).isRequired,
totalSize: PropTypes.number.isRequired,
getAssets: PropTypes.func.isRequired,
loading: PropTypes.bool.isRequired
};

View file

@ -0,0 +1,49 @@
import PropTypes from 'prop-types';
import React from 'react';
import { connect } from 'react-redux';
import prettyBytes from 'pretty-bytes';
const MB_TO_B = 1000 * 1000;
const MAX_SIZE_B = 250 * MB_TO_B;
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) {
return null;
}
const currentSize = prettyBytes(totalSize);
const sizeLimit = prettyBytes(MAX_SIZE_B);
const percentValue = totalSize / MAX_SIZE_B;
const percent = formatPercent(percentValue);
return (
<div className="asset-size" style={{ '--percent': percentValue }}>
<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.assets.totalSize,
};
}
export default connect(mapStateToProps)(AssetSize);

View file

@ -0,0 +1,238 @@
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 = () => {
console.log('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>
);
}
}
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,
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,
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 = {
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.prams.project_id : null,
};
}
function mapDispatchToProps(dispatch) {
return bindActionCreators(
Object.assign({}, CollectionsActions, ProjectsActions, ProjectActions, ToastActions, SortingActions),
dispatch
);
}
export default connect(mapStateToProps, mapDispatchToProps)(CollectionList);

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,59 @@
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">
<button className="quick-add__item-toggle" onClick={onSelect}>
<Icons isAdded={isAdded} />
{name}
</button>
<Link className="quick-add__item-view" to={url}>View</Link>
</li>
);
const ItemType = PropTypes.shape({
name: PropTypes.string.isRequired,
url: PropTypes.string.isRequired,
isAdded: PropTypes.bool.isRequired,
});
Item.propTypes = {
...ItemType,
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={
() => handleAction(item)
}
/>))}
</ul>
);
};
QuickAddList.propTypes = {
items: PropTypes.arrayOf(ItemType).isRequired,
onAdd: PropTypes.func.isRequired,
onRemove: PropTypes.func.isRequired,
};
export default QuickAddList;

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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,7 +30,7 @@ class SketchListRowBase extends React.Component {
optionsOpen: false,
renameOpen: false,
renameValue: props.sketch.name,
isFocused: false
isFocused: false,
};
}
@ -134,105 +137,146 @@ class SketchListRowBase extends React.Component {
}
}
render() {
const { sketch, username } = this.props;
const { renameOpen, optionsOpen, renameValue } = this.state;
renderViewButton = sketchURL => (
<td className="sketch-list__dropdown-column">
<Link to={sketchURL}>View</Link>
</td>
)
renderDropdown = () => {
const { optionsOpen } = this.state;
const userIsOwner = this.props.user.username === this.props.username;
return (
<td className="sketch-list__dropdown-column">
<button
className="sketch-list__dropdown-button"
onClick={this.toggleOptions}
onBlur={this.onBlurComponent}
onFocus={this.onFocusComponent}
>
<InlineSVG src={downFilledTriangle} alt="Menu" />
</button>
{optionsOpen &&
<ul
className="sketch-list__action-dialogue"
>
{userIsOwner &&
<li>
<button
className="sketch-list__action-option"
onClick={this.handleRenameOpen}
onBlur={this.onBlurComponent}
onFocus={this.onFocusComponent}
>
Rename
</button>
</li>}
<li>
<button
className="sketch-list__action-option"
onClick={this.handleSketchDownload}
onBlur={this.onBlurComponent}
onFocus={this.onFocusComponent}
>
Download
</button>
</li>
{this.props.user.authenticated &&
<li>
<button
className="sketch-list__action-option"
onClick={this.handleSketchDuplicate}
onBlur={this.onBlurComponent}
onFocus={this.onFocusComponent}
>
Duplicate
</button>
</li>}
{this.props.user.authenticated &&
<li>
<button
className="sketch-list__action-option"
onClick={() => {
this.props.onAddToCollection();
this.closeAll();
}}
onBlur={this.onBlurComponent}
onFocus={this.onFocusComponent}
>
Add to collection
</button>
</li>}
{ /* <li>
<button
className="sketch-list__action-option"
onClick={this.handleSketchShare}
onBlur={this.onBlurComponent}
onFocus={this.onFocusComponent}
>
Share
</button>
</li> */ }
{userIsOwner &&
<li>
<button
className="sketch-list__action-option"
onClick={this.handleSketchDelete}
onBlur={this.onBlurComponent}
onFocus={this.onFocusComponent}
>
Delete
</button>
</li>}
</ul>}
</td>
);
}
render() {
const {
sketch,
username,
} = this.props;
const { renameOpen, renameValue } = this.state;
let url = `/${username}/sketches/${sketch.id}`;
if (username === 'p5') {
url = `/${username}/sketches/${slugify(sketch.name, '_')}`;
}
const name = (
<React.Fragment>
<Link to={url}>
{renameOpen ? '' : sketch.name}
</Link>
{renameOpen
&&
<input
value={renameValue}
onChange={this.handleRenameChange}
onKeyUp={this.handleRenameEnter}
onBlur={this.resetSketchName}
onClick={e => e.stopPropagation()}
/>
}
</React.Fragment>
);
return (
<tr
className="sketches-table__row"
key={sketch.id}
>
<th scope="row">
<Link to={url}>
{renameOpen ? '' : sketch.name}
</Link>
{renameOpen
&&
<input
value={renameValue}
onChange={this.handleRenameChange}
onKeyUp={this.handleRenameEnter}
onBlur={this.resetSketchName}
onClick={e => e.stopPropagation()}
/>
}
</th>
<td>{format(new Date(sketch.createdAt), 'MMM D, YYYY h:mm A')}</td>
<td>{format(new Date(sketch.updatedAt), 'MMM D, YYYY h:mm A')}</td>
<td className="sketch-list__dropdown-column">
<button
className="sketch-list__dropdown-button"
onClick={this.toggleOptions}
onBlur={this.onBlurComponent}
onFocus={this.onFocusComponent}
>
<InlineSVG src={downFilledTriangle} alt="Menu" />
</button>
{optionsOpen &&
<ul
className="sketch-list__action-dialogue"
>
{userIsOwner &&
<li>
<button
className="sketch-list__action-option"
onClick={this.handleRenameOpen}
onBlur={this.onBlurComponent}
onFocus={this.onFocusComponent}
>
Rename
</button>
</li>}
<li>
<button
className="sketch-list__action-option"
onClick={this.handleSketchDownload}
onBlur={this.onBlurComponent}
onFocus={this.onFocusComponent}
>
Download
</button>
</li>
{this.props.user.authenticated &&
<li>
<button
className="sketch-list__action-option"
onClick={this.handleSketchDuplicate}
onBlur={this.onBlurComponent}
onFocus={this.onFocusComponent}
>
Duplicate
</button>
</li>}
{ /* <li>
<button
className="sketch-list__action-option"
onClick={this.handleSketchShare}
onBlur={this.onBlurComponent}
onFocus={this.onFocusComponent}
>
Share
</button>
</li> */ }
{userIsOwner &&
<li>
<button
className="sketch-list__action-option"
onClick={this.handleSketchDelete}
onBlur={this.onBlurComponent}
onFocus={this.onFocusComponent}
>
Delete
</button>
</li>}
</ul>}
</td>
</tr>);
<React.Fragment>
<tr
className="sketches-table__row"
key={sketch.id}
onClick={this.handleRowClick}
>
<th scope="row">
{name}
</th>
<td>{format(new Date(sketch.createdAt), 'MMM D, YYYY h:mm A')}</td>
<td>{format(new Date(sketch.updatedAt), 'MMM D, YYYY h:mm A')}</td>
{this.renderDropdown()}
</tr>
</React.Fragment>);
}
}
@ -250,7 +294,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) {
@ -265,6 +310,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() {
@ -275,16 +332,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;
@ -337,9 +398,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>
);
}
@ -357,6 +435,15 @@ SketchList.propTypes = {
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,
toggleDirectionForField: PropTypes.func.isRequired,
@ -365,19 +452,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
};
@ -392,7 +469,10 @@ function mapStateToProps(state) {
}
function mapDispatchToProps(dispatch) {
return bindActionCreators(Object.assign({}, ProjectsActions, ToastActions, SortingActions), dispatch);
return bindActionCreators(
Object.assign({}, ProjectsActions, CollectionsActions, ToastActions, SortingActions),
dispatch
);
}
export default connect(mapStateToProps, mapDispatchToProps)(SketchList);

View file

@ -11,13 +11,6 @@ import * as ProjectActions from '../actions/project';
class FullView extends React.Component {
componentDidMount() {
this.props.getProject(this.props.params.project_id);
document.body.className = this.props.theme;
}
componentWillUpdate(nextProps) {
if (nextProps.theme !== this.props.theme) {
document.body.className = nextProps.theme;
}
}
ident = () => {}
@ -62,7 +55,6 @@ class FullView extends React.Component {
}
FullView.propTypes = {
theme: PropTypes.string.isRequired,
params: PropTypes.shape({
project_id: PropTypes.string
}).isRequired,
@ -98,7 +90,6 @@ FullView.propTypes = {
function mapStateToProps(state) {
return {
user: state.user,
theme: state.preferences.theme,
htmlFile: getHTMLFile(state.files),
jsFiles: getJSFiles(state.files),
cssFiles: getCSSFiles(state.files),

View file

@ -28,11 +28,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) {
@ -66,7 +65,6 @@ class IDEView extends React.Component {
window.onbeforeunload = () => this.warnIfUnsavedChanges();
document.body.className = this.props.preferences.theme;
this.autosaveInterval = null;
}
@ -90,10 +88,6 @@ class IDEView extends React.Component {
this.props.getProject(nextProps.params.project_id);
}
}
if (nextProps.preferences.theme !== this.props.preferences.theme) {
document.body.className = nextProps.preferences.theme;
}
}
componentDidUpdate(prevProps) {
@ -318,12 +312,12 @@ class IDEView extends React.Component {
{(
(
(this.props.preferences.textOutput ||
this.props.preferences.gridOutput ||
this.props.preferences.soundOutput
this.props.preferences.gridOutput ||
this.props.preferences.soundOutput
) &&
this.props.ide.isPlaying
this.props.ide.isPlaying
) ||
this.props.ide.isAccessibleOutputPlaying
this.props.ide.isAccessibleOutputPlaying
)
}
</div>
@ -354,44 +348,19 @@ class IDEView extends React.Component {
</SplitPane>
</SplitPane>
</div>
{ this.props.ide.modalIsVisible &&
{this.props.ide.modalIsVisible &&
<NewFileModal
canUploadMedia={this.props.user.authenticated}
closeModal={this.props.closeNewFileModal}
createFile={this.props.createFile}
/>
}
{ this.props.ide.newFolderModalVisible &&
{this.props.ide.newFolderModalVisible &&
<NewFolderModal
closeModal={this.props.closeNewFolderModal}
createFolder={this.props.createFolder}
/>
}
{ this.props.location.pathname.match(/sketches$/) &&
<Overlay
ariaLabel="project list"
title="Open a Sketch"
previousPath={this.props.ide.previousPath}
>
<Searchbar />
<SketchList
username={this.props.params.username}
user={this.props.user}
/>
</Overlay>
}
{ this.props.location.pathname.match(/assets$/) &&
<Overlay
title="Assets"
ariaLabel="asset list"
previousPath={this.props.ide.previousPath}
>
<AssetList
username={this.props.params.username}
user={this.props.user}
/>
</Overlay>
}
{ this.props.location.pathname === '/about' &&
<Overlay
previousPath={this.props.ide.previousPath}
@ -400,7 +369,7 @@ class IDEView extends React.Component {
<About previousPath={this.props.ide.previousPath} />
</Overlay>
}
{ this.props.location.pathname === '/feedback' &&
{this.props.location.pathname === '/feedback' &&
<Overlay
previousPath={this.props.ide.previousPath}
ariaLabel="submit-feedback"
@ -408,7 +377,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
ariaLabel="share"
closeOverlay={this.props.closeShareModal}
@ -420,7 +404,7 @@ class IDEView extends React.Component {
/>
</Overlay>
}
{ this.props.ide.keyboardShortcutVisible &&
{this.props.ide.keyboardShortcutVisible &&
<Overlay
ariaLabel="keyboard shortcuts"
closeOverlay={this.props.closeKeyboardShortcutModal}
@ -428,7 +412,7 @@ class IDEView extends React.Component {
<KeyboardShortcutModal />
</Overlay>
}
{ this.props.ide.errorType &&
{this.props.ide.errorType &&
<Overlay
ariaLabel="error"
closeOverlay={this.props.hideErrorModal}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,393 @@
import format from 'date-fns/format';
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 { 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] = React.useState(false);
return (
<div className="collection-share">
<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({
project: PropTypes.shape({
id: PropTypes.string.isRequired,
name: PropTypes.string.isRequired,
}).isRequired,
}).isRequired,
user: PropTypes.shape({
username: PropTypes.string,
authenticated: PropTypes.bool.isRequired
}).isRequired,
removeFromCollection: PropTypes.func.isRequired
};
function mapDispatchToPropsSketchListRow(dispatch) {
return bindActionCreators(Object.assign({}, CollectionsActions, ProjectActions, IdeActions), dispatch);
}
const CollectionItemRow = connect(null, mapDispatchToPropsSketchListRow)(CollectionItemRowBase);
class Collection extends React.Component {
constructor(props) {
super(props);
this.props.getCollections(this.props.username);
this.props.resetSorting();
this._renderFieldHeader = this._renderFieldHeader.bind(this);
this.showAddSketches = this.showAddSketches.bind(this);
this.hideAddSketches = this.hideAddSketches.bind(this);
this.state = {
isAddingSketches: false,
};
}
getTitle() {
if (this.props.username === this.props.user.username) {
return 'p5.js Web Editor | My collections';
}
return `p5.js Web Editor | ${this.props.username}'s collections`;
}
getUsername() {
return this.props.username !== undefined ? this.props.username : this.props.user.username;
}
getCollectionName() {
return this.props.collection.name;
}
isOwner() {
let isOwner = false;
if (this.props.user != null &&
this.props.user.username &&
this.props.collection.owner.username === this.props.user.username) {
isOwner = true;
}
return isOwner;
}
hasCollection() {
return !this.props.loading && this.props.collection != null;
}
hasCollectionItems() {
return this.hasCollection() && this.props.collection.items.length > 0;
}
_renderLoader() {
if (this.props.loading) return <Loader />;
return null;
}
_renderCollectionMetadata() {
const {
id, name, description, items, owner
} = this.props.collection;
const hostname = window.location.origin;
const { username } = this.props;
const baseURL = `${hostname}/${username}/collections/`;
const handleEditCollectionName = (value) => {
if (value === name) {
return;
}
this.props.editCollection(id, { name: value });
};
const handleEditCollectionDescription = (value) => {
if (value === description) {
return;
}
this.props.editCollection(id, { description: value });
};
//
// TODO: Implement UI for editing slug
//
// const handleEditCollectionSlug = (value) => {
// if (value === slug) {
// return;
// }
//
// this.props.editCollection(id, { slug: value });
// };
return (
<div className={`collection-metadata ${this.isOwner() ? 'collection-metadata--is-owner' : ''}`}>
<div className="collection-metadata__columns">
<div className="collection-metadata__column--left">
<h2 className="collection-metadata__name">
{
this.isOwner() ? <EditableInput value={name} onChange={handleEditCollectionName} validate={value => value !== ''} /> : name
}
</h2>
<p className="collection-metadata__description">
{
this.isOwner() ?
<EditableInput
InputComponent="textarea"
value={description}
onChange={handleEditCollectionDescription}
emptyPlaceholder="Add description"
/> :
description
}
</p>
<p className="collection-metadata__user">Collection by{' '}
<Link to={`${hostname}/${username}/sketches`}>{owner.username}</Link>
</p>
<p className="collection-metadata__user">{items.length} sketch{items.length === 1 ? '' : 'es'}</p>
</div>
<div className="collection-metadata__column--right">
<p className="collection-metadata__share">
<ShareURL value={`${baseURL}${id}`} />
</p>
{
this.isOwner() &&
<button className="collection-metadata__add-button" onClick={this.showAddSketches}>
Add Sketch
</button>
}
</div>
</div>
</div>
);
}
showAddSketches() {
this.setState({
isAddingSketches: true,
});
}
hideAddSketches() {
this.setState({
isAddingSketches: false,
});
}
_renderEmptyTable() {
const isLoading = this.props.loading;
const hasCollectionItems = this.props.collection != null &&
this.props.collection.items.length > 0;
if (!isLoading && !hasCollectionItems) {
return (<p className="collection-empty-message">No sketches in collection</p>);
}
return null;
}
_renderFieldHeader(fieldName, displayName) {
const { field, direction } = this.props.sorting;
const headerClass = classNames({
'sketches-table__header': true,
'sketches-table__header--selected': field === fieldName
});
return (
<th scope="col">
<button className="sketch-list__sort-button" onClick={() => this.props.toggleDirectionForField(fieldName)}>
<span className={headerClass}>{displayName}</span>
{field === fieldName && direction === SortingActions.DIRECTION.ASC &&
<InlineSVG src={arrowUp} />
}
{field === fieldName && direction === SortingActions.DIRECTION.DESC &&
<InlineSVG src={arrowDown} />
}
</button>
</th>
);
}
render() {
const title = this.hasCollection() ? this.getCollectionName() : null;
return (
<section className="collection-container" data-has-items={this.hasCollectionItems() ? 'true' : 'false'}>
<Helmet>
<title>{this.getTitle()}</title>
</Helmet>
{this._renderLoader()}
{this.hasCollection() && this._renderCollectionMetadata()}
<div className="collection-table-wrapper">
{this._renderEmptyTable()}
{this.hasCollectionItems() &&
<table className="sketches-table" summary="table containing all collections">
<thead>
<tr>
{this._renderFieldHeader('name', 'Name')}
{this._renderFieldHeader('createdAt', 'Date Added')}
{this._renderFieldHeader('user', 'Owner')}
<th scope="col"></th>
</tr>
</thead>
<tbody>
{this.props.collection.items.map(item =>
(<CollectionItemRow
key={item.id}
item={item}
user={this.props.user}
username={this.getUsername()}
collection={this.props.collection}
/>))}
</tbody>
</table>
}
{
this.state.isAddingSketches && (
<Overlay
title="Add sketch"
actions={<SketchSearchbar />}
closeOverlay={this.hideAddSketches}
isFixedHeight
>
<div className="collection-add-sketch">
<AddToCollectionSketchList username={this.props.username} collection={this.props.collection} />
</div>
</Overlay>
)
}
</div>
</section>
);
}
}
Collection.propTypes = {
user: PropTypes.shape({
username: PropTypes.string,
authenticated: PropTypes.bool.isRequired
}).isRequired,
getCollections: PropTypes.func.isRequired,
collection: PropTypes.shape({
id: PropTypes.string.isRequired,
name: PropTypes.string.isRequired,
slug: PropTypes.string,
description: PropTypes.string,
owner: PropTypes.shape({
username: PropTypes.string.isRequired,
}).isRequired,
items: PropTypes.arrayOf(PropTypes.shape({})),
}).isRequired,
username: PropTypes.string,
loading: PropTypes.bool.isRequired,
toggleDirectionForField: PropTypes.func.isRequired,
editCollection: PropTypes.func.isRequired,
resetSorting: PropTypes.func.isRequired,
sorting: PropTypes.shape({
field: PropTypes.string.isRequired,
direction: PropTypes.string.isRequired
}).isRequired
};
Collection.defaultProps = {
username: undefined
};
function mapStateToProps(state, ownProps) {
return {
user: state.user,
collection: getCollection(state, ownProps.collectionId),
sorting: state.sorting,
loading: state.loading,
project: state.project
};
}
function mapDispatchToProps(dispatch) {
return bindActionCreators(
Object.assign({}, CollectionsActions, ProjectsActions, ToastActions, SortingActions),
dispatch
);
}
export default connect(mapStateToProps, mapDispatchToProps)(Collection);

View file

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

View file

@ -4,6 +4,7 @@ import { Link } from 'react-router';
const TabKey = {
assets: 'assets',
collections: 'collections',
sketches: 'sketches',
};
@ -11,7 +12,7 @@ const Tab = ({ children, isSelected, to }) => {
const selectedClassName = 'dashboard-header__tab--selected';
const location = { pathname: to, state: { skipSavingPath: true } };
const content = isSelected ? children : <Link to={location}>{children}</Link>;
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">
@ -30,8 +31,9 @@ Tab.propTypes = {
const DashboardTabSwitcher = ({ currentTab, isOwner, username }) => (
<ul className="dashboard-header__switcher">
<div className="dashboard-header__tabs">
<Tab to={`/${username}/sketches`} isSelected={currentTab === 'sketches'}>Sketches</Tab>
{isOwner && <Tab to={`/${username}/assets`} isSelected={currentTab === 'assets'}>Assets</Tab>}
<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>
);

View file

@ -40,7 +40,7 @@ class AccountView extends React.Component {
<TabList>
<div className="tabs__titles">
<Tab><h4 className="tabs__title">Account</h4></Tab>
<Tab><h4 className="tabs__title">Access Tokens</h4></Tab>
{accessTokensUIEnabled && <Tab><h4 className="tabs__title">Access Tokens</h4></Tab>}
</div>
</TabList>
<TabPanel>

View file

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

View file

@ -2,15 +2,18 @@ import PropTypes from 'prop-types';
import React from 'react';
import { connect } from 'react-redux';
import { bindActionCreators } from 'redux';
import { browserHistory } from 'react-router';
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 Searchbar from '../../IDE/components/Searchbar';
import { CollectionSearchbar, SketchSearchbar } from '../../IDE/components/Searchbar';
import CollectionCreate from '../components/CollectionCreate';
import DashboardTabSwitcher, { TabKey } from '../components/DashboardTabSwitcher';
class DashboardView extends React.Component {
@ -36,11 +39,13 @@ class DashboardView extends React.Component {
browserHistory.push('/');
}
selectedTabName() {
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;
@ -58,14 +63,55 @@ class DashboardView extends React.Component {
return this.props.user.username === this.props.params.username;
}
navigationItem() {
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.selectedTabName();
const currentTab = this.selectedTabKey();
const isOwner = this.isOwner();
const { username } = this.props.params;
const actions = this.renderActionButton(currentTab, username);
return (
<div className="dashboard">
@ -74,17 +120,28 @@ class DashboardView extends React.Component {
<section className="dashboard-header">
<div className="dashboard-header__header">
<h2 className="dashboard-header__header__title">{this.ownerName()}</h2>
<DashboardTabSwitcher currentTab={currentTab} isOwner={isOwner} username={username} />
{ currentTab === TabKey.sketches && <Searchbar /> }
<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">
{
currentTab === TabKey.sketches ? <SketchList username={username} /> : <AssetList username={username} />
}
{this.renderContent(currentTab, username)}
</div>
</section>
{this.isCollectionCreate() &&
<Overlay
title="Create collection"
closeOverlay={this.returnToDashboard}
>
<CollectionCreate />
</Overlay>
}
</div>
);
}
@ -94,7 +151,7 @@ function mapStateToProps(state) {
return {
previousPath: state.ide.previousPath,
user: state.user,
theme: state.preferences.theme
theme: state.preferences.theme,
};
}

View file

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

View file

@ -9,6 +9,7 @@ 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 CollectionView from './modules/User/pages/CollectionView';
import DashboardView from './modules/User/pages/DashboardView';
import createRedirectWithUsername from './components/createRedirectWithUsername';
import { getUser } from './modules/User/actions';
@ -42,7 +43,11 @@ const routes = store => (
<Route path="/assets" component={createRedirectWithUsername('/:username/assets')} />
<Route path="/account" component={userIsAuthenticated(AccountView)} />
<Route path="/:username/sketches/:project_id" component={IDEView} />
<Route path="/:username/sketches/:project_id/add-to-collection" component={IDEView} />
<Route path="/:username/sketches" component={DashboardView} />
<Route path="/:username/collections" component={DashboardView} />
<Route path="/:username/collections/create" component={DashboardView} />
<Route path="/:username/collections/:collection_id" component={CollectionView} />
<Route path="/about" component={IDEView} />
</Route>
);

View file

@ -83,6 +83,10 @@
border: 2px solid getThemifyVariable('button-border-color');
border-radius: 2px;
padding: #{10 / $base-font-size}rem #{30 / $base-font-size}rem;
& g {
fill: getThemifyVariable('button-color');
opacity: 1;
}
&:enabled:hover {
border-color: getThemifyVariable('button-background-hover-color');
background-color: getThemifyVariable('button-background-hover-color');

View file

@ -76,6 +76,19 @@ $themes: (
codefold-icon-open: url(../images/triangle-arrow-down.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,
@ -141,6 +154,20 @@ $themes: (
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),
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,
@ -203,6 +230,20 @@ $themes: (
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),
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,

View file

@ -6,13 +6,13 @@ html, body {
font-size: #{$base-font-size}px;
}
body, input {
body, input, textarea {
@include themify() {
color: getThemifyVariable('primary-text-color');
}
}
body, input, button {
body, input, textarea, button {
font-family: Montserrat, sans-serif;
}
@ -31,7 +31,8 @@ input, button {
font-size: 1rem;
}
input {
input,
textarea {
padding: #{5 / $base-font-size}rem;
border: 1px solid ;
border-radius: 2px;

View file

@ -1,5 +1,4 @@
.asset-table-container {
// flex: 1 1 0%;
overflow-y: auto;
max-width: 100%;
min-height: #{400 / $base-font-size}rem;
@ -7,74 +6,63 @@
.asset-table {
width: 100%;
padding-bottom: #{10 / $base-font-size}rem;
max-height: 100%;
border-spacing: 0;
& .asset-list__delete-column {
width: #{23 / $base-font-size}rem;
& .sketch-list__dropdown-column {
width: #{60 / $base-font-size}rem;
position: relative;
}
}
& thead {
font-size: #{12 / $base-font-size}rem;
@include themify() {
color: getThemifyVariable('inactive-text-color')
}
.asset-table thead th {
height: #{32 / $base-font-size}rem;
position: sticky;
top: 0;
@include themify() {
background-color: getThemifyVariable('background-color');
}
}
.asset-table__row>th:nth-child(1) {
padding-left: #{12 / $base-font-size}rem;
}
& thead th {
padding-top: #{10 / $base-font-size}rem;
padding-bottom: #{15 / $base-font-size}rem;
height: #{32 / $base-font-size}rem;
font-weight: normal;
position: sticky;
top: #{15 / $base-font-size}rem;
@include themify() {
background-color: getThemifyVariable('background-color');
}
}
& th {
font-weight: normal;
}
.asset-table thead th:nth-child(1){
padding-left: #{12 / $base-font-size}rem;
}
.asset-table__row {
margin: #{10 / $base-font-size}rem;
height: #{72 / $base-font-size}rem;
font-size: #{16 / $base-font-size}rem;
}
&:nth-child(odd) {
@include themify() {
background: getThemifyVariable('table-row-stripe-color');
}
.asset-table__row:nth-child(odd) {
@include themify() {
background: getThemifyVariable('table-row-stripe-color');
}
}
& a {
@include themify() {
color: getThemifyVariable('primary-text-color');
}
}
.asset-table__row > th:nth-child(1) {
padding-left: #{12 / $base-font-size}rem;
}
& td:first-child {
padding-left: #{10 / $base-font-size}rem;
.asset-table__row a {
@include themify() {
color: getThemifyVariable('primary-text-color');
}
}
.asset-table thead {
font-size: #{12 / $base-font-size}rem;
@include themify() {
color: getThemifyVariable('inactive-text-color')
}
}
.asset-table th {
font-weight: normal;
}
.asset-table__empty {
text-align: center;
font-size: #{16 / $base-font-size}rem;
padding: #{42 / $base-font-size}rem 0;
}
.asset-table__total {
padding: 0 #{20 / $base-font-size}rem;
position: sticky;
top: 0;
@include themify() {
background-color: getThemifyVariable('background-color');
}
}

View file

@ -0,0 +1,51 @@
.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: 28px;
left: calc(200px * var(--percent));
margin-left: -8px;
}
.asset-max {
position: absolute;
top: 0;
left: 0;
transform: translate(210%); // align max label to right of asset-size-bar
padding-left: #{8 / $base-font-size}rem;
}

View file

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

View file

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

View file

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

View file

@ -7,10 +7,28 @@
flex-direction:column;
}
.dashboard-header__header {
max-width: #{1012 / $base-font-size}rem;
margin: 0 auto;
width: 100%;
}
.dashboard-header--no-vertical-padding {
padding: 0 66px;
}
.dashboard-header--no-vertical-padding {
padding: 0 66px;
}
.dashboard-header__switcher {
flex: 1;
}
.dashboard-header__tabs {
display: flex;
padding-top: #{24 / $base-font-size}rem;
margin-bottom: #{24 / $base-font-size}rem;
@include themify() {
border-bottom: 1px solid getThemifyVariable('inactive-text-color');
}
@ -21,7 +39,7 @@
color: getThemifyVariable('inactive-text-color');
border-bottom: #{4 / $base-font-size}rem solid transparent;
padding: 0 0 #{8 / $base-font-size}rem 0;
padding: 0;
margin-right: #{26 / $base-font-size}rem;
&:hover, &:focus, &.dashboard-header__tab--selected {
@ -31,7 +49,7 @@
}
}
font-size: #{21 / $base-font-size}rem;
font-size: #{12 / $base-font-size}rem;
}
.dashboard-header__tab--selected {
@ -43,6 +61,30 @@
}
.dashboard-header__tab__title {
font-weight: bold;
margin: 0;
padding: 0;
}
.dashboard-header__tab__title > * {
display: inline-block;
padding: 0 #{5 /$base-font-size}rem #{5 /$base-font-size}rem;
}
.dashboard-header__nav {
}
.dashboard-header__actions {
display: flex;
align-items: center;
padding: #{24 / $base-font-size}rem 0;
justify-content: space-between;
}
.dashboard-header__actions > *:not(:first-child) {
margin-left: #{15 / $base-font-size}rem;
}
.dashboard__action-button {
flex-grow: 0;
@extend %button;
}

View file

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

View file

@ -56,6 +56,10 @@
}
}
.form__input-flexible-height {
height: auto;
}
.form__input::placeholder {
@include themify() {
color: getThemifyVariable('form-input-placeholder-text-color');
@ -79,6 +83,15 @@
margin: #{39 / $base-font-size}rem 0 #{24 / $base-font-size}rem 0;
}
.form [type="submit"][disabled] {
cursor: not-allowed;
}
.form--inline [type="submit"] {
margin: 0 0 0 #{24 / $base-font-size}rem;
}
.form [type="submit"][disabled],
.form--inline [type="submit"][disabled] {
cursor: not-allowed;
}

View file

@ -109,7 +109,7 @@
padding-right: #{20 / $base-font-size}rem;
& .nav__dropdown {
width: #{121 / $base-font-size}rem;
width: #{122 / $base-font-size}rem;
}
}

View file

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

View file

@ -12,3 +12,9 @@
}
}
}
.preview-nav {
.nav__item {
margin-left: #{5 / $base-font-size}rem;
}
}

View file

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

View file

@ -1,9 +1,6 @@
.searchbar {
position: absolute;
position: relative;
display: flex;
padding-left: #{17 / $base-font-size}rem;
right: #{66 / $base-font-size}rem;
top: #{65 / $base-font-size}rem;
}
.searchbar__input {
@ -60,7 +57,7 @@ button[type="submit"].searchbar__button {
align-self: center;
position: absolute;
padding: #{3 / $base-font-size}rem #{4 / $base-font-size}rem;
left: #{216 / $base-font-size}rem;;
right: #{7 / $base-font-size}rem;
@include themify() {
color: getThemifyVariable('primary-text-color');
background-color: getThemifyVariable('search-clear-background-color');
@ -70,3 +67,7 @@ button[type="submit"].searchbar__button {
}
}
}
.searchbar--is-empty .searchbar__clear-button {
display: none;
}

View file

@ -69,6 +69,10 @@
padding-left: #{12 / $base-font-size}rem;
}
.sketches-table__row > td {
padding-left: #{8 / $base-font-size}rem;
}
.sketches-table__row a {
@include themify() {
color: getThemifyVariable('primary-text-color');
@ -86,6 +90,7 @@
font-weight: normal;
}
.sketch-list__dropdown-button {
width:#{25 / $base-font-size}rem;
height:#{25 / $base-font-size}rem;
@ -96,16 +101,21 @@
}
}
.sketches-table__name {
display: flex;
align-items: center;
}
.sketches-table__icon-cell {
width: #{35 / $base-font-size}rem;
}
.sketch-list__action-dialogue {
@extend %dropdown-open-right;
top: 63%;
right: calc(100% - 26px);
}
.sketch-list__action-option {
}
.sketches-table__empty {
text-align: center;
font-size: #{16 / $base-font-size}rem;

View file

@ -14,4 +14,11 @@
flex-direction: column;
flex: 1;
overflow: hidden;
max-width: #{1012 / $base-font-size}rem;
margin: 0 auto;
width: 100%;
@include themify() {
border: 1px solid getThemifyVariable('modal-border-color');
}
}

View file

@ -41,6 +41,7 @@
@import 'components/help-modal';
@import 'components/share';
@import 'components/asset-list';
@import 'components/asset-size';
@import 'components/keyboard-shortcuts';
@import 'components/copyable-input';
@import 'components/feedback';
@ -48,6 +49,11 @@
@import 'components/uploader';
@import 'components/tabs';
@import 'components/dashboard-header';
@import 'components/editable-input';
@import 'components/collection';
@import 'components/collection-create';
@import 'components/collection-popover';
@import 'components/quick-add';
@import 'layout/dashboard';
@import 'layout/ide';

View file

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

View file

@ -0,0 +1,74 @@
import Collection from '../../models/collection';
import Project from '../../models/project';
export default function addProjectToCollection(req, res) {
const owner = req.user._id;
const { id: collectionId, projectId } = req.params;
const collectionPromise = Collection.findById(collectionId).populate('items.project', '_id');
const projectPromise = Project.findById(projectId);
function sendFailure(code, message) {
res.status(code).json({ success: false, message });
}
function sendSuccess(collection) {
res.status(200).json(collection);
}
function updateCollection([collection, project]) {
if (collection == null) {
sendFailure(404, 'Collection not found');
return null;
}
if (project == null) {
sendFailure(404, 'Project not found');
return null;
}
if (!collection.owner.equals(owner)) {
sendFailure(403, 'User does not own this collection');
return null;
}
const projectInCollection = collection.items.find(p => p.project._id === project._id);
if (projectInCollection) {
sendFailure(404, 'Project already in collection');
return null;
}
try {
collection.items.push({ project });
return collection.save();
} catch (error) {
console.error(error);
sendFailure(500, error.message);
return null;
}
}
function populateReferences(collection) {
return Collection.populate(
collection,
[
{ path: 'owner', select: ['id', 'username'] },
{
path: 'items.project',
select: ['id', 'name', 'slug'],
populate: {
path: 'user', select: ['username']
}
}
]
);
}
return Promise.all([collectionPromise, projectPromise])
.then(updateCollection)
.then(populateReferences)
.then(sendSuccess)
.catch(sendFailure);
}

View file

@ -0,0 +1,29 @@
import Collection from '../../models/collection';
import User from '../../models/user';
export default function collectionForUserExists(username, collectionId, callback) {
function sendFailure() {
callback(false);
}
function sendSuccess(collection) {
callback(collection != null);
}
function findUser() {
return User.findOne({ username });
}
function findCollection(owner) {
if (owner == null) {
throw new Error('User not found');
}
return Collection.findOne({ _id: collectionId, owner });
}
return findUser()
.then(findCollection)
.then(sendSuccess)
.catch(sendFailure);
}

View file

@ -0,0 +1,47 @@
import Collection from '../../models/collection';
export default function createCollection(req, res) {
const owner = req.user._id;
const { name, description, slug } = req.body;
const values = {
owner,
name,
description,
slug
};
function sendFailure({ code = 500, message = 'Something went wrong' }) {
res.status(code).json({ success: false, message });
}
function sendSuccess(newCollection) {
res.json(newCollection);
}
function populateReferences(newCollection) {
return Collection.populate(
newCollection,
[
{ path: 'owner', select: ['id', 'username'] },
{
path: 'items.project',
select: ['id', 'name', 'slug'],
populate: {
path: 'user', select: ['username']
}
}
]
);
}
if (owner == null) {
sendFailure({ code: 404, message: 'No user specified' });
return null;
}
return Collection.create(values)
.then(populateReferences)
.then(sendSuccess)
.catch(sendFailure);
}

View file

@ -0,0 +1,7 @@
export { default as addProjectToCollection } from './addProjectToCollection';
export { default as collectionForUserExists } from './collectionForUserExists';
export { default as createCollection } from './createCollection';
export { default as listCollections } from './listCollections';
export { default as removeCollection } from './removeCollection';
export { default as removeProjectFromCollection } from './removeProjectFromCollection';
export { default as updateCollection } from './updateCollection';

View file

@ -0,0 +1,48 @@
import Collection from '../../models/collection';
import User from '../../models/user';
async function getOwnerUserId(req) {
if (req.params.username) {
const user = await User.findOne({ username: req.params.username });
if (user && user._id) {
return user._id;
}
} else if (req.user._id) {
return req.user._id;
}
return null;
}
export default function listCollections(req, res) {
function sendFailure({ code = 500, message = 'Something went wrong' }) {
res.status(code).json({ success: false, message });
}
function sendSuccess(collections) {
res.status(200).json(collections);
}
function findCollections(owner) {
if (owner == null) {
sendFailure({ code: 404, message: 'User not found' });
}
return Collection.find({ owner })
.populate([
{ path: 'owner', select: ['id', 'username'] },
{
path: 'items.project',
select: ['id', 'name', 'slug'],
populate: {
path: 'user', select: ['username']
}
}
]);
}
return getOwnerUserId(req)
.then(findCollections)
.then(sendSuccess)
.catch(sendFailure);
}

View file

@ -0,0 +1,34 @@
import Collection from '../../models/collection';
export default function createCollection(req, res) {
const { id: collectionId } = req.params;
const owner = req.user._id;
function sendFailure({ code = 500, message = 'Something went wrong' }) {
res.status(code).json({ success: false, message });
}
function sendSuccess() {
res.status(200).json({ success: true });
}
function removeCollection(collection) {
if (collection == null) {
sendFailure({ code: 404, message: 'Not found, or you user does not own this collection' });
return null;
}
return collection.remove();
}
function findCollection() {
// Only returned if owner matches current user
return Collection.findOne({ _id: collectionId, owner });
}
return findCollection()
.then(removeCollection)
.then(sendSuccess)
.catch(sendFailure);
}

View file

@ -0,0 +1,61 @@
import Collection from '../../models/collection';
export default function addProjectToCollection(req, res) {
const owner = req.user._id;
const { id: collectionId, projectId } = req.params;
function sendFailure({ code = 500, message = 'Something went wrong' }) {
res.status(code).json({ success: false, message });
}
function sendSuccess(collection) {
res.status(200).json(collection);
}
function updateCollection(collection) {
if (collection == null) {
sendFailure({ code: 404, message: 'Collection not found' });
return null;
}
if (!collection.owner.equals(owner)) {
sendFailure({ code: 403, message: 'User does not own this collection' });
return null;
}
const project = collection.items.find(p => p.project._id === projectId);
if (project != null) {
project.remove();
return collection.save();
}
const error = new Error('not found');
error.code = 404;
throw error;
}
function populateReferences(collection) {
return Collection.populate(
collection,
[
{ path: 'owner', select: ['id', 'username'] },
{
path: 'items.project',
select: ['id', 'name', 'slug'],
populate: {
path: 'user', select: ['username']
}
}
]
);
}
return Collection.findById(collectionId)
.populate('items.project', '_id')
.then(updateCollection)
.then(populateReferences)
.then(sendSuccess)
.catch(sendFailure);
}

View file

@ -0,0 +1,54 @@
import omitBy from 'lodash/omitBy';
import isUndefined from 'lodash/isUndefined';
import Collection from '../../models/collection';
function removeUndefined(obj) {
return omitBy(obj, isUndefined);
}
export default function createCollection(req, res) {
const { id: collectionId } = req.params;
const owner = req.user._id;
const { name, description, slug } = req.body;
const values = removeUndefined({
name,
description,
slug
});
function sendFailure({ code = 500, message = 'Something went wrong' }) {
res.status(code).json({ success: false, message });
}
function sendSuccess(collection) {
if (collection == null) {
sendFailure({ code: 404, message: 'Not found, or you user does not own this collection' });
return;
}
res.json(collection);
}
async function findAndUpdateCollection() {
// Only update if owner matches current user
return Collection.findOneAndUpdate(
{ _id: collectionId, owner },
values,
{ new: true, runValidators: true, setDefaultsOnInsert: true }
).populate([
{ path: 'owner', select: ['id', 'username'] },
{
path: 'items.project',
select: ['id', 'name', 'slug'],
populate: {
path: 'user', select: ['username']
}
}
]).exec();
}
return findAndUpdateCollection()
.then(sendSuccess)
.catch(sendFailure);
}

View file

@ -0,0 +1,48 @@
import mongoose from 'mongoose';
import shortid from 'shortid';
import slugify from 'slugify';
const { Schema } = mongoose;
const collectedProjectSchema = new Schema(
{
project: { type: Schema.Types.ObjectId, ref: 'Project' },
},
{ timestamps: true, _id: true, usePushEach: true }
);
collectedProjectSchema.virtual('id').get(function getId() {
return this._id.toHexString();
});
collectedProjectSchema.set('toJSON', {
virtuals: true
});
const collectionSchema = new Schema(
{
_id: { type: String, default: shortid.generate },
name: { type: String, default: 'My collection' },
description: { type: String },
slug: { type: String },
owner: { type: Schema.Types.ObjectId, ref: 'User' },
items: { type: [collectedProjectSchema] }
},
{ timestamps: true, usePushEach: true }
);
collectionSchema.virtual('id').get(function getId() {
return this._id;
});
collectionSchema.set('toJSON', {
virtuals: true
});
collectionSchema.pre('save', function generateSlug(next) {
const collection = this;
collection.slug = slugify(collection.name, '_');
return next();
});
export default mongoose.model('Collection', collectionSchema);

View file

@ -0,0 +1,20 @@
import { Router } from 'express';
import * as CollectionController from '../controllers/collection.controller';
import isAuthenticated from '../utils/isAuthenticated';
const router = new Router();
// List collections
router.get('/collections', isAuthenticated, CollectionController.listCollections);
router.get('/:username/collections', CollectionController.listCollections);
// Create, modify, delete collection
router.post('/collections', isAuthenticated, CollectionController.createCollection);
router.patch('/collections/:id', isAuthenticated, CollectionController.updateCollection);
router.delete('/collections/:id', isAuthenticated, CollectionController.removeCollection);
// Add and remove projects to collection
router.post('/collections/:id/:projectId', isAuthenticated, CollectionController.addProjectToCollection);
router.delete('/collections/:id/:projectId', isAuthenticated, CollectionController.removeProjectFromCollection);
export default router;

View file

@ -3,6 +3,7 @@ import { renderIndex } from '../views/index';
import { get404Sketch } from '../views/404Page';
import { userExists } from '../controllers/user.controller';
import { projectExists, projectForUserExists } from '../controllers/project.controller';
import { collectionForUserExists } from '../controllers/collection.controller';
const router = new Router();
@ -26,12 +27,24 @@ router.get('/projects/:project_id', (req, res) => {
));
});
router.get('/:username/sketches/:project_id/add-to-collection', (req, res) => {
projectForUserExists(req.params.username, req.params.project_id, exists => (
exists ? res.send(renderIndex()) : get404Sketch(html => res.send(html))
));
});
router.get('/:username/sketches/:project_id', (req, res) => {
projectForUserExists(req.params.username, req.params.project_id, exists => (
exists ? res.send(renderIndex()) : get404Sketch(html => res.send(html))
));
});
router.get('/:username/sketches', (req, res) => {
userExists(req.params.username, exists => (
exists ? res.send(renderIndex()) : get404Sketch(html => res.send(html))
));
});
router.get('/:username/full/:project_id', (req, res) => {
projectForUserExists(req.params.username, req.params.project_id, exists => (
exists ? res.send(renderIndex()) : get404Sketch(html => res.send(html))
@ -105,7 +118,29 @@ router.get('/feedback', (req, res) => {
res.send(renderIndex());
});
router.get('/:username/sketches', (req, res) => {
router.get('/:username/collections/create', (req, res) => {
userExists(req.params.username, (exists) => {
const isLoggedInUser = req.user && req.user.username === req.params.username;
const canAccess = exists && isLoggedInUser;
return canAccess ?
res.send(renderIndex()) :
get404Sketch(html => res.send(html));
});
});
router.get('/:username/collections/create', (req, res) => {
userExists(req.params.username, exists => (
exists ? res.send(renderIndex()) : get404Sketch(html => res.send(html))
));
});
router.get('/:username/collections/:id', (req, res) => {
collectionForUserExists(req.params.username, req.params.id, exists => (
exists ? res.send(renderIndex()) : get404Sketch(html => res.send(html))
));
});
router.get('/:username/collections', (req, res) => {
userExists(req.params.username, exists => (
exists ? res.send(renderIndex()) : get404Sketch(html => res.send(html))
));

View file

@ -66,7 +66,8 @@ const headers = { 'User-Agent': 'p5js-web-editor/0.0.1' };
const mongoConnectionString = process.env.MONGO_URL;
mongoose.connect(mongoConnectionString, { useMongoClient: true });
mongoose.connect(mongoConnectionString, { useNewUrlParser: true, useUnifiedTopology: true });
mongoose.set('useCreateIndex', true);
mongoose.connection.on('error', () => {
console.error('MongoDB Connection Error. Please make sure that MongoDB is running.');
process.exit(1);

View file

@ -40,7 +40,8 @@ const headers = { 'User-Agent': 'p5js-web-editor/0.0.1' };
const mongoConnectionString = process.env.MONGO_URL;
mongoose.connect(mongoConnectionString, { useMongoClient: true });
mongoose.connect(mongoConnectionString, { useNewUrlParser: true, useUnifiedTopology: true });
mongoose.set('useCreateIndex', true);
mongoose.connection.on('error', () => {
console.error('MongoDB Connection Error. Please make sure that MongoDB is running.');
process.exit(1);

View file

@ -21,6 +21,7 @@ import users from './routes/user.routes';
import sessions from './routes/session.routes';
import projects from './routes/project.routes';
import files from './routes/file.routes';
import collections from './routes/collection.routes';
import aws from './routes/aws.routes';
import serverRoutes from './routes/server.routes';
import embedRoutes from './routes/embed.routes';
@ -102,6 +103,7 @@ app.use('/editor', requestsOfTypeJSON(), sessions);
app.use('/editor', requestsOfTypeJSON(), files);
app.use('/editor', requestsOfTypeJSON(), projects);
app.use('/editor', requestsOfTypeJSON(), aws);
app.use('/editor', requestsOfTypeJSON(), collections);
// This is a temporary way to test access via Personal Access Tokens
// Sending a valid username:<personal-access-token> combination will
@ -111,11 +113,12 @@ app.get(
passport.authenticate('basic', { session: false }), (req, res) => res.json(req.user)
);
app.use(assetRoutes);
// this is supposed to be TEMPORARY -- until i figure out
// isomorphic rendering
app.use('/', serverRoutes);
app.use(assetRoutes);
app.use('/', embedRoutes);
app.get('/auth/github', passport.authenticate('github'));
app.get('/auth/github/callback', passport.authenticate('github', { failureRedirect: '/login' }), (req, res) => {

View file

@ -29,8 +29,8 @@ export function renderIndex() {
window.process.env.CLIENT = true;
window.process.env.LOGIN_ENABLED = ${process.env.LOGIN_ENABLED === 'false' ? false : true};
window.process.env.EXAMPLES_ENABLED = ${process.env.EXAMPLES_ENABLED === 'false' ? false : true};
window.process.env.EXAMPLES_ENABLED = ${process.env.EXAMPLES_ENABLED === 'false' ? false : true};
window.process.env.UI_ACCESS_TOKEN_ENABLED = ${process.env.UI_ACCESS_TOKEN_ENABLED === 'false' ? false : true};
window.process.env.UI_COLLECTIONS_ENABLED = ${process.env.UI_COLLECTIONS_ENABLED === 'false' ? false : true};
</script>
</head>
<body>