Merge pull request #1164 from andrewn/feature/sketch-collections
Sketch collections
This commit is contained in:
commit
1a3a1aa960
77 changed files with 3554 additions and 299 deletions
24
client/components/AddRemoveButton.jsx
Normal file
24
client/components/AddRemoveButton.jsx
Normal file
|
@ -0,0 +1,24 @@
|
|||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import InlineSVG from 'react-inlinesvg';
|
||||
|
||||
const addIcon = require('../images/plus.svg');
|
||||
const removeIcon = require('../images/minus.svg');
|
||||
|
||||
const AddRemoveButton = ({ type, onClick }) => {
|
||||
const alt = type === 'add' ? 'add to collection' : 'remove from collection';
|
||||
const icon = type === 'add' ? addIcon : removeIcon;
|
||||
|
||||
return (
|
||||
<button className="overlay__close-button" onClick={onClick}>
|
||||
<InlineSVG src={icon} alt={alt} />
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
AddRemoveButton.propTypes = {
|
||||
type: PropTypes.oneOf(['add', 'remove']).isRequired,
|
||||
onClick: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
export default AddRemoveButton;
|
|
@ -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`}
|
||||
|
|
|
@ -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" />
|
||||
|
|
|
@ -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
6
client/images/check.svg
Normal file
|
@ -0,0 +1,6 @@
|
|||
|
||||
<svg width="81px" height="65px" viewBox="0 0 81 65" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||
<g id="Page-1" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
|
||||
<path d="M45.437888,42.4740871 L45.437888,-12.5259129 L62.437888,-12.5259129 L62.437888,42.4740871 L62.437888,59.4740871 L18.437888,59.4740871 L18.437888,42.4740871 L45.437888,42.4740871 Z" fill="#D8D8D8" fill-rule="nonzero" transform="translate(40.437888, 23.474087) rotate(42.000000) translate(-40.437888, -23.474087) "></path>
|
||||
</g>
|
||||
</svg>
|
After Width: | Height: | Size: 583 B |
11
client/images/check_encircled.svg
Normal file
11
client/images/check_encircled.svg
Normal file
|
@ -0,0 +1,11 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Generator: Adobe Illustrator 22.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
|
||||
<svg version="1.1" id="check-encircled" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px"
|
||||
y="0px" viewBox="0 0 100 100" style="enable-background:new 0 0 100 100;" xml:space="preserve">
|
||||
<style type="text/css">
|
||||
.st0{fill:#FFFFFF;}
|
||||
</style>
|
||||
<path id="circle" d="M50,26.5c-14.4,0-26,11.5-25.9,25.9c0,14.5,11.6,26,26,26c14.5,0,25.9-11.5,25.9-26C76,38,64.5,26.5,50,26.5z
|
||||
M47.6,66.6L34,53.4l5.6-5.5l7.1,6.8l14-15.4l6.1,5.5L47.6,66.6z"/>
|
||||
<polygon id="check" class="st0 counter-form" points="46.7,54.7 39.6,47.9 34,53.4 47.6,66.6 66.8,44.8 60.7,39.3 "/>
|
||||
</svg>
|
After Width: | Height: | Size: 727 B |
12
client/images/close.svg
Normal file
12
client/images/close.svg
Normal file
|
@ -0,0 +1,12 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Generator: Adobe Illustrator 22.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
|
||||
<svg version="1.1" id="close" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
|
||||
viewBox="0 0 100 100" style="enable-background:new 0 0 100 100;" xml:space="preserve">
|
||||
<style type="text/css">
|
||||
.st0{fill:#FFFFFF;}
|
||||
</style>
|
||||
<path id="circle" d="M50,26.5c-14.4,0-26,11.5-25.9,25.9c0,14.5,11.6,26,26,26c14.5,0,25.9-11.5,25.9-26C76,38,64.5,26.5,50,26.5z
|
||||
M63.4,60.2L58,65.6l-7.9-8L42,65.7l-5.4-5.4l8.1-8l-8.1-8l5.4-5.4l8,8.1l8-8l5.4,5.4l-8,7.8L63.4,60.2z"/>
|
||||
<polygon id="x" class="st0 counter-form" points="58,39 50,47 42,38.9 36.6,44.3 44.7,52.3 36.6,60.3 42,65.7 50.1,57.6 58,65.6 63.4,60.2
|
||||
55.4,52.2 63.4,44.4 "/>
|
||||
</svg>
|
After Width: | Height: | Size: 801 B |
|
@ -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);
|
||||
|
|
|
@ -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;
|
||||
|
|
173
client/modules/IDE/actions/collections.js
Normal file
173
client/modules/IDE/actions/collections.js
Normal 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;
|
||||
});
|
||||
};
|
||||
}
|
|
@ -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, '');
|
||||
}
|
||||
|
|
165
client/modules/IDE/components/AddToCollectionList.jsx
Normal file
165
client/modules/IDE/components/AddToCollectionList.jsx
Normal file
|
@ -0,0 +1,165 @@
|
|||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import { Helmet } from 'react-helmet';
|
||||
import { connect } from 'react-redux';
|
||||
import { bindActionCreators } from 'redux';
|
||||
|
||||
import * as ProjectActions from '../actions/project';
|
||||
import * as ProjectsActions from '../actions/projects';
|
||||
import * as CollectionsActions from '../actions/collections';
|
||||
import * as ToastActions from '../actions/toast';
|
||||
import * as SortingActions from '../actions/sorting';
|
||||
import getSortedCollections from '../selectors/collections';
|
||||
import Loader from '../../App/components/loader';
|
||||
import QuickAddList from './QuickAddList';
|
||||
|
||||
const projectInCollection = (project, collection) =>
|
||||
collection.items.find(item => item.project.id === project.id) != null;
|
||||
|
||||
class CollectionList extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
if (props.projectId) {
|
||||
props.getProject(props.projectId);
|
||||
}
|
||||
|
||||
this.props.getCollections(this.props.username);
|
||||
|
||||
this.state = {
|
||||
hasLoadedData: false,
|
||||
};
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
if (prevProps.loading === true && this.props.loading === false) {
|
||||
// eslint-disable-next-line react/no-did-update-set-state
|
||||
this.setState({
|
||||
hasLoadedData: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
getTitle() {
|
||||
if (this.props.username === this.props.user.username) {
|
||||
return 'p5.js Web Editor | My collections';
|
||||
}
|
||||
return `p5.js Web Editor | ${this.props.username}'s collections`;
|
||||
}
|
||||
|
||||
handleCollectionAdd = (collection) => {
|
||||
this.props.addToCollection(collection.id, this.props.project.id);
|
||||
}
|
||||
|
||||
handleCollectionRemove = (collection) => {
|
||||
this.props.removeFromCollection(collection.id, this.props.project.id);
|
||||
}
|
||||
|
||||
render() {
|
||||
const { collections, project } = this.props;
|
||||
const hasCollections = collections.length > 0;
|
||||
const collectionWithSketchStatus = collections.map(collection => ({
|
||||
...collection,
|
||||
url: `/${collection.owner.username}/collections/${collection.id}`,
|
||||
isAdded: projectInCollection(project, collection),
|
||||
}));
|
||||
|
||||
let content = null;
|
||||
|
||||
if (this.props.loading && !this.state.hasLoadedData) {
|
||||
content = <Loader />;
|
||||
} else if (hasCollections) {
|
||||
content = (
|
||||
<QuickAddList
|
||||
items={collectionWithSketchStatus}
|
||||
onAdd={this.handleCollectionAdd}
|
||||
onRemove={this.handleCollectionRemove}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
content = 'No collections';
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="quick-add-wrapper">
|
||||
<Helmet>
|
||||
<title>{this.getTitle()}</title>
|
||||
</Helmet>
|
||||
|
||||
{content}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const ProjectShape = PropTypes.shape({
|
||||
id: PropTypes.string.isRequired,
|
||||
name: PropTypes.string.isRequired,
|
||||
createdAt: PropTypes.string.isRequired,
|
||||
updatedAt: PropTypes.string.isRequired,
|
||||
user: PropTypes.shape({
|
||||
username: PropTypes.string.isRequired
|
||||
}).isRequired,
|
||||
});
|
||||
|
||||
const ItemsShape = PropTypes.shape({
|
||||
createdAt: PropTypes.string.isRequired,
|
||||
updatedAt: PropTypes.string.isRequired,
|
||||
project: ProjectShape
|
||||
});
|
||||
|
||||
CollectionList.propTypes = {
|
||||
user: PropTypes.shape({
|
||||
username: PropTypes.string,
|
||||
authenticated: PropTypes.bool.isRequired
|
||||
}).isRequired,
|
||||
projectId: PropTypes.string.isRequired,
|
||||
getCollections: PropTypes.func.isRequired,
|
||||
getProject: PropTypes.func.isRequired,
|
||||
addToCollection: PropTypes.func.isRequired,
|
||||
removeFromCollection: PropTypes.func.isRequired,
|
||||
collections: PropTypes.arrayOf(PropTypes.shape({
|
||||
id: PropTypes.string.isRequired,
|
||||
name: PropTypes.string.isRequired,
|
||||
description: PropTypes.string,
|
||||
createdAt: PropTypes.string.isRequired,
|
||||
updatedAt: PropTypes.string.isRequired,
|
||||
items: PropTypes.arrayOf(ItemsShape),
|
||||
})).isRequired,
|
||||
username: PropTypes.string,
|
||||
loading: PropTypes.bool.isRequired,
|
||||
project: PropTypes.shape({
|
||||
id: PropTypes.string,
|
||||
owner: PropTypes.shape({
|
||||
id: PropTypes.string
|
||||
})
|
||||
})
|
||||
};
|
||||
|
||||
CollectionList.defaultProps = {
|
||||
project: {
|
||||
id: undefined,
|
||||
owner: undefined
|
||||
},
|
||||
username: undefined
|
||||
};
|
||||
|
||||
function mapStateToProps(state, ownProps) {
|
||||
return {
|
||||
user: state.user,
|
||||
collections: getSortedCollections(state),
|
||||
sorting: state.sorting,
|
||||
loading: state.loading,
|
||||
project: ownProps.project || state.project,
|
||||
projectId: ownProps && ownProps.params ? ownProps.prams.project_id : null,
|
||||
};
|
||||
}
|
||||
|
||||
function mapDispatchToProps(dispatch) {
|
||||
return bindActionCreators(
|
||||
Object.assign({}, CollectionsActions, ProjectsActions, ProjectActions, ToastActions, SortingActions),
|
||||
dispatch
|
||||
);
|
||||
}
|
||||
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(CollectionList);
|
137
client/modules/IDE/components/AddToCollectionSketchList.jsx
Normal file
137
client/modules/IDE/components/AddToCollectionSketchList.jsx
Normal file
|
@ -0,0 +1,137 @@
|
|||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import { Helmet } from 'react-helmet';
|
||||
import { connect } from 'react-redux';
|
||||
import { bindActionCreators } from 'redux';
|
||||
// import find from 'lodash/find';
|
||||
import * as ProjectsActions from '../actions/projects';
|
||||
import * as CollectionsActions from '../actions/collections';
|
||||
import * as ToastActions from '../actions/toast';
|
||||
import * as SortingActions from '../actions/sorting';
|
||||
import getSortedSketches from '../selectors/projects';
|
||||
import Loader from '../../App/components/loader';
|
||||
import QuickAddList from './QuickAddList';
|
||||
|
||||
class SketchList extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.props.getProjects(this.props.username);
|
||||
|
||||
this.state = {
|
||||
isInitialDataLoad: true,
|
||||
};
|
||||
}
|
||||
|
||||
componentWillReceiveProps(nextProps) {
|
||||
if (this.props.sketches !== nextProps.sketches && Array.isArray(nextProps.sketches)) {
|
||||
this.setState({
|
||||
isInitialDataLoad: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
getSketchesTitle() {
|
||||
if (this.props.username === this.props.user.username) {
|
||||
return 'p5.js Web Editor | My sketches';
|
||||
}
|
||||
return `p5.js Web Editor | ${this.props.username}'s sketches`;
|
||||
}
|
||||
|
||||
handleCollectionAdd = (sketch) => {
|
||||
this.props.addToCollection(this.props.collection.id, sketch.id);
|
||||
}
|
||||
|
||||
handleCollectionRemove = (sketch) => {
|
||||
this.props.removeFromCollection(this.props.collection.id, sketch.id);
|
||||
}
|
||||
|
||||
inCollection = sketch => this.props.collection.items.find(item => item.project.id === sketch.id) != null
|
||||
|
||||
render() {
|
||||
const hasSketches = this.props.sketches.length > 0;
|
||||
const sketchesWithAddedStatus = this.props.sketches.map(sketch => ({
|
||||
...sketch,
|
||||
isAdded: this.inCollection(sketch),
|
||||
url: `/${this.props.username}/sketches/${sketch.id}`,
|
||||
}));
|
||||
|
||||
let content = null;
|
||||
|
||||
if (this.props.loading && this.state.isInitialDataLoad) {
|
||||
content = <Loader />;
|
||||
} else if (hasSketches) {
|
||||
content = (
|
||||
<QuickAddList
|
||||
items={sketchesWithAddedStatus}
|
||||
onAdd={this.handleCollectionAdd}
|
||||
onRemove={this.handleCollectionRemove}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
content = 'No collections';
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="quick-add-wrapper">
|
||||
<Helmet>
|
||||
<title>{this.getSketchesTitle()}</title>
|
||||
</Helmet>
|
||||
{content}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
SketchList.propTypes = {
|
||||
user: PropTypes.shape({
|
||||
username: PropTypes.string,
|
||||
authenticated: PropTypes.bool.isRequired
|
||||
}).isRequired,
|
||||
getProjects: PropTypes.func.isRequired,
|
||||
sketches: PropTypes.arrayOf(PropTypes.shape({
|
||||
id: PropTypes.string.isRequired,
|
||||
name: PropTypes.string.isRequired,
|
||||
createdAt: PropTypes.string.isRequired,
|
||||
updatedAt: PropTypes.string.isRequired
|
||||
})).isRequired,
|
||||
collection: PropTypes.shape({
|
||||
id: PropTypes.string.isRequired,
|
||||
name: PropTypes.string.isRequired,
|
||||
items: PropTypes.arrayOf(PropTypes.shape({
|
||||
project: PropTypes.shape({
|
||||
id: PropTypes.string.isRequired,
|
||||
}),
|
||||
})),
|
||||
}).isRequired,
|
||||
username: PropTypes.string,
|
||||
loading: PropTypes.bool.isRequired,
|
||||
sorting: PropTypes.shape({
|
||||
field: PropTypes.string.isRequired,
|
||||
direction: PropTypes.string.isRequired
|
||||
}).isRequired,
|
||||
addToCollection: PropTypes.func.isRequired,
|
||||
removeFromCollection: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
SketchList.defaultProps = {
|
||||
username: undefined
|
||||
};
|
||||
|
||||
function mapStateToProps(state) {
|
||||
return {
|
||||
user: state.user,
|
||||
sketches: getSortedSketches(state),
|
||||
sorting: state.sorting,
|
||||
loading: state.loading,
|
||||
project: state.project
|
||||
};
|
||||
}
|
||||
|
||||
function mapDispatchToProps(dispatch) {
|
||||
return bindActionCreators(
|
||||
Object.assign({}, ProjectsActions, CollectionsActions, ToastActions, SortingActions),
|
||||
dispatch
|
||||
);
|
||||
}
|
||||
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(SketchList);
|
|
@ -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
|
||||
};
|
||||
|
|
49
client/modules/IDE/components/AssetSize.jsx
Normal file
49
client/modules/IDE/components/AssetSize.jsx
Normal 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);
|
238
client/modules/IDE/components/CollectionList/CollectionList.jsx
Normal file
238
client/modules/IDE/components/CollectionList/CollectionList.jsx
Normal 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);
|
|
@ -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);
|
1
client/modules/IDE/components/CollectionList/index.js
Normal file
1
client/modules/IDE/components/CollectionList/index.js
Normal file
|
@ -0,0 +1 @@
|
|||
export { default } from './CollectionList';
|
93
client/modules/IDE/components/EditableInput.jsx
Normal file
93
client/modules/IDE/components/EditableInput.jsx
Normal file
|
@ -0,0 +1,93 @@
|
|||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import InlineSVG from 'react-inlinesvg';
|
||||
|
||||
const editIconUrl = require('../../../images/pencil.svg');
|
||||
|
||||
function EditIcon() {
|
||||
return <InlineSVG className="editable-input__icon" src={editIconUrl} alt="Edit" />;
|
||||
}
|
||||
|
||||
function EditableInput({
|
||||
validate, value, emptyPlaceholder, InputComponent, inputProps, onChange
|
||||
}) {
|
||||
const [isEditing, setIsEditing] = React.useState(false);
|
||||
const [currentValue, setCurrentValue] = React.useState(value || '');
|
||||
const displayValue = currentValue || emptyPlaceholder;
|
||||
const hasValue = currentValue !== '';
|
||||
const classes = `editable-input editable-input--${isEditing ? 'is-editing' : 'is-not-editing'} editable-input--${hasValue ? 'has-value' : 'has-placeholder'}`;
|
||||
const inputRef = React.createRef();
|
||||
|
||||
React.useEffect(() => {
|
||||
if (isEditing) {
|
||||
inputRef.current.focus();
|
||||
}
|
||||
}, [isEditing]);
|
||||
|
||||
function beginEditing() {
|
||||
setIsEditing(true);
|
||||
}
|
||||
|
||||
function doneEditing() {
|
||||
setIsEditing(false);
|
||||
|
||||
const isValid = typeof validate === 'function' && validate(currentValue);
|
||||
|
||||
if (isValid) {
|
||||
onChange(currentValue);
|
||||
} else {
|
||||
setCurrentValue(value);
|
||||
}
|
||||
}
|
||||
|
||||
function updateValue(event) {
|
||||
setCurrentValue(event.target.value);
|
||||
}
|
||||
|
||||
function checkForKeyAction(event) {
|
||||
if (event.key === 'Enter') {
|
||||
doneEditing();
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<span className={classes}>
|
||||
<button className="editable-input__label" onClick={beginEditing}>
|
||||
<span>{displayValue}</span>
|
||||
<EditIcon />
|
||||
</button>
|
||||
|
||||
<InputComponent
|
||||
className="editable-input__input"
|
||||
type="text"
|
||||
{...inputProps}
|
||||
disabled={!isEditing}
|
||||
onBlur={doneEditing}
|
||||
onChange={updateValue}
|
||||
onKeyPress={checkForKeyAction}
|
||||
ref={inputRef}
|
||||
value={currentValue}
|
||||
/>
|
||||
</span >
|
||||
);
|
||||
}
|
||||
|
||||
EditableInput.defaultProps = {
|
||||
emptyPlaceholder: 'No value',
|
||||
InputComponent: 'input',
|
||||
inputProps: {},
|
||||
validate: () => true,
|
||||
value: '',
|
||||
};
|
||||
|
||||
EditableInput.propTypes = {
|
||||
emptyPlaceholder: PropTypes.string,
|
||||
InputComponent: PropTypes.elementType,
|
||||
// eslint-disable-next-line react/forbid-prop-types
|
||||
inputProps: PropTypes.object,
|
||||
onChange: PropTypes.func.isRequired,
|
||||
validate: PropTypes.func,
|
||||
value: PropTypes.string,
|
||||
};
|
||||
|
||||
export default EditableInput;
|
27
client/modules/IDE/components/QuickAddList/Icons.jsx
Normal file
27
client/modules/IDE/components/QuickAddList/Icons.jsx
Normal file
|
@ -0,0 +1,27 @@
|
|||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import InlineSVG from 'react-inlinesvg';
|
||||
|
||||
const check = require('../../../../images/check_encircled.svg');
|
||||
const close = require('../../../../images/close.svg');
|
||||
|
||||
const Icons = ({ isAdded }) => {
|
||||
const classes = [
|
||||
'quick-add__icon',
|
||||
isAdded ? 'quick-add__icon--in-collection' : 'quick-add__icon--not-in-collection'
|
||||
].join(' ');
|
||||
|
||||
return (
|
||||
<div className={classes}>
|
||||
<InlineSVG className="quick-add__remove-icon" src={close} alt="Remove from collection" />
|
||||
<InlineSVG className="quick-add__in-icon" src={check} alt="In collection" />
|
||||
<InlineSVG className="quick-add__add-icon" src={close} alt="Add to collection" />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
Icons.propTypes = {
|
||||
isAdded: PropTypes.bool.isRequired,
|
||||
};
|
||||
|
||||
export default Icons;
|
59
client/modules/IDE/components/QuickAddList/QuickAddList.jsx
Normal file
59
client/modules/IDE/components/QuickAddList/QuickAddList.jsx
Normal 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;
|
1
client/modules/IDE/components/QuickAddList/index.js
Normal file
1
client/modules/IDE/components/QuickAddList/index.js
Normal file
|
@ -0,0 +1 @@
|
|||
export { default } from './QuickAddList.jsx';
|
24
client/modules/IDE/components/Searchbar/Collection.jsx
Normal file
24
client/modules/IDE/components/Searchbar/Collection.jsx
Normal file
|
@ -0,0 +1,24 @@
|
|||
import { bindActionCreators } from 'redux';
|
||||
import { connect } from 'react-redux';
|
||||
import * as SortingActions from '../../actions/sorting';
|
||||
|
||||
import Searchbar from './Searchbar';
|
||||
|
||||
const scope = 'collection';
|
||||
|
||||
function mapStateToProps(state) {
|
||||
return {
|
||||
searchLabel: 'Search collections...',
|
||||
searchTerm: state.search[`${scope}SearchTerm`],
|
||||
};
|
||||
}
|
||||
|
||||
function mapDispatchToProps(dispatch) {
|
||||
const actions = {
|
||||
setSearchTerm: term => SortingActions.setSearchTerm(scope, term),
|
||||
resetSearchTerm: () => SortingActions.resetSearchTerm(scope),
|
||||
};
|
||||
return bindActionCreators(Object.assign({}, actions), dispatch);
|
||||
}
|
||||
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(Searchbar);
|
|
@ -1,12 +1,9 @@
|
|||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import InlineSVG from 'react-inlinesvg';
|
||||
import { bindActionCreators } from 'redux';
|
||||
import { connect } from 'react-redux';
|
||||
import { throttle } from 'lodash';
|
||||
import * as SortingActions from '../actions/sorting';
|
||||
|
||||
const searchIcon = require('../../../images/magnifyingglass.svg');
|
||||
const searchIcon = require('../../../../images/magnifyingglass.svg');
|
||||
|
||||
class Searchbar extends React.Component {
|
||||
constructor(props) {
|
||||
|
@ -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;
|
23
client/modules/IDE/components/Searchbar/Sketch.jsx
Normal file
23
client/modules/IDE/components/Searchbar/Sketch.jsx
Normal file
|
@ -0,0 +1,23 @@
|
|||
import { bindActionCreators } from 'redux';
|
||||
import { connect } from 'react-redux';
|
||||
import * as SortingActions from '../../actions/sorting';
|
||||
|
||||
import Searchbar from './Searchbar';
|
||||
|
||||
const scope = 'sketch';
|
||||
|
||||
function mapStateToProps(state) {
|
||||
return {
|
||||
searchTerm: state.search[`${scope}SearchTerm`],
|
||||
};
|
||||
}
|
||||
|
||||
function mapDispatchToProps(dispatch) {
|
||||
const actions = {
|
||||
setSearchTerm: term => SortingActions.setSearchTerm(scope, term),
|
||||
resetSearchTerm: () => SortingActions.resetSearchTerm(scope),
|
||||
};
|
||||
return bindActionCreators(Object.assign({}, actions), dispatch);
|
||||
}
|
||||
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(Searchbar);
|
2
client/modules/IDE/components/Searchbar/index.js
Normal file
2
client/modules/IDE/components/Searchbar/index.js
Normal file
|
@ -0,0 +1,2 @@
|
|||
export { default as CollectionSearchbar } from './Collection.jsx';
|
||||
export { default as SketchSearchbar } from './Sketch.jsx';
|
|
@ -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);
|
||||
|
|
|
@ -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),
|
||||
|
|
|
@ -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}
|
||||
|
|
28
client/modules/IDE/reducers/collections.js
Normal file
28
client/modules/IDE/reducers/collections.js
Normal file
|
@ -0,0 +1,28 @@
|
|||
import * as ActionTypes from '../../../constants';
|
||||
|
||||
const sketches = (state = [], action) => {
|
||||
switch (action.type) {
|
||||
case ActionTypes.SET_COLLECTIONS:
|
||||
return action.collections;
|
||||
|
||||
case ActionTypes.DELETE_COLLECTION:
|
||||
return state.filter(({ id }) => action.collectionId !== id);
|
||||
|
||||
// The API returns the complete new edited collection
|
||||
// with any items added or removed
|
||||
case ActionTypes.EDIT_COLLECTION:
|
||||
case ActionTypes.ADD_TO_COLLECTION:
|
||||
case ActionTypes.REMOVE_FROM_COLLECTION:
|
||||
return state.map((collection) => {
|
||||
if (collection.id === action.payload.id) {
|
||||
return action.payload;
|
||||
}
|
||||
|
||||
return collection;
|
||||
});
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
};
|
||||
|
||||
export default sketches;
|
|
@ -1,14 +1,8 @@
|
|||
import friendlyWords from 'friendly-words';
|
||||
import * as ActionTypes from '../../../constants';
|
||||
|
||||
const generateRandomName = () => {
|
||||
const adj = friendlyWords.predicates[Math.floor(Math.random() * friendlyWords.predicates.length)];
|
||||
const obj = friendlyWords.objects[Math.floor(Math.random() * friendlyWords.objects.length)];
|
||||
return `${adj} ${obj}`;
|
||||
};
|
||||
import { generateProjectName } from '../../../utils/generateRandomName';
|
||||
|
||||
const initialState = () => {
|
||||
const generatedString = generateRandomName();
|
||||
const generatedString = generateProjectName();
|
||||
const generatedName = generatedString.charAt(0).toUpperCase() + generatedString.slice(1);
|
||||
return {
|
||||
name: generatedName,
|
||||
|
|
|
@ -1,13 +1,14 @@
|
|||
import * as ActionTypes from '../../../constants';
|
||||
|
||||
const initialState = {
|
||||
searchTerm: ''
|
||||
collectionSearchTerm: '',
|
||||
sketchSearchTerm: ''
|
||||
};
|
||||
|
||||
export default (state = initialState, action) => {
|
||||
switch (action.type) {
|
||||
case ActionTypes.SET_SEARCH_TERM:
|
||||
return { ...state, searchTerm: action.query };
|
||||
return { ...state, [`${action.scope}SearchTerm`]: action.query };
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
|
|
56
client/modules/IDE/selectors/collections.js
Normal file
56
client/modules/IDE/selectors/collections.js
Normal file
|
@ -0,0 +1,56 @@
|
|||
import { createSelector } from 'reselect';
|
||||
import differenceInMilliseconds from 'date-fns/difference_in_milliseconds';
|
||||
import find from 'lodash/find';
|
||||
import orderBy from 'lodash/orderBy';
|
||||
import { DIRECTION } from '../actions/sorting';
|
||||
|
||||
const getCollections = state => state.collections;
|
||||
const getField = state => state.sorting.field;
|
||||
const getDirection = state => state.sorting.direction;
|
||||
const getSearchTerm = state => state.search.collectionSearchTerm;
|
||||
|
||||
const getFilteredCollections = createSelector(
|
||||
getCollections,
|
||||
getSearchTerm,
|
||||
(collections, search) => {
|
||||
if (search) {
|
||||
const searchStrings = collections.map((collection) => {
|
||||
const smallCollection = {
|
||||
name: collection.name
|
||||
};
|
||||
return { ...collection, searchString: Object.values(smallCollection).join(' ').toLowerCase() };
|
||||
});
|
||||
return searchStrings.filter(collection => collection.searchString.includes(search.toLowerCase()));
|
||||
}
|
||||
return collections;
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
const getSortedCollections = createSelector(
|
||||
getFilteredCollections,
|
||||
getField,
|
||||
getDirection,
|
||||
(collections, field, direction) => {
|
||||
if (field === 'name') {
|
||||
if (direction === DIRECTION.DESC) {
|
||||
return orderBy(collections, 'name', 'desc');
|
||||
}
|
||||
return orderBy(collections, 'name', 'asc');
|
||||
}
|
||||
const sortedCollections = [...collections].sort((a, b) => {
|
||||
const result =
|
||||
direction === DIRECTION.ASC
|
||||
? differenceInMilliseconds(new Date(a[field]), new Date(b[field]))
|
||||
: differenceInMilliseconds(new Date(b[field]), new Date(a[field]));
|
||||
return result;
|
||||
});
|
||||
return sortedCollections;
|
||||
}
|
||||
);
|
||||
|
||||
export function getCollection(state, id) {
|
||||
return find(getCollections(state), { id });
|
||||
}
|
||||
|
||||
export default getSortedCollections;
|
|
@ -6,7 +6,7 @@ import { DIRECTION } from '../actions/sorting';
|
|||
const getSketches = state => state.sketches;
|
||||
const getField = state => state.sorting.field;
|
||||
const getDirection = state => state.sorting.direction;
|
||||
const getSearchTerm = state => state.search.searchTerm;
|
||||
const getSearchTerm = state => state.search.sketchSearchTerm;
|
||||
|
||||
const getFilteredSketches = createSelector(
|
||||
getSketches,
|
||||
|
|
393
client/modules/User/components/Collection.jsx
Normal file
393
client/modules/User/components/Collection.jsx
Normal 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);
|
128
client/modules/User/components/CollectionCreate.jsx
Normal file
128
client/modules/User/components/CollectionCreate.jsx
Normal 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'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);
|
|
@ -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>
|
||||
);
|
||||
|
|
|
@ -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>
|
||||
|
|
91
client/modules/User/pages/CollectionView.jsx
Normal file
91
client/modules/User/pages/CollectionView.jsx
Normal 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);
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
@ -13,6 +13,7 @@ import assets from './modules/IDE/reducers/assets';
|
|||
import search from './modules/IDE/reducers/search';
|
||||
import sorting from './modules/IDE/reducers/sorting';
|
||||
import loading from './modules/IDE/reducers/loading';
|
||||
import collections from './modules/IDE/reducers/collections';
|
||||
|
||||
const rootReducer = combineReducers({
|
||||
form,
|
||||
|
@ -28,7 +29,8 @@ const rootReducer = combineReducers({
|
|||
toast,
|
||||
console,
|
||||
assets,
|
||||
loading
|
||||
loading,
|
||||
collections
|
||||
});
|
||||
|
||||
export default rootReducer;
|
||||
|
|
|
@ -9,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>
|
||||
);
|
||||
|
|
|
@ -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');
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
|
51
client/styles/components/_asset-size.scss
Normal file
51
client/styles/components/_asset-size.scss
Normal 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;
|
||||
}
|
3
client/styles/components/_collection-create.scss
Normal file
3
client/styles/components/_collection-create.scss
Normal file
|
@ -0,0 +1,3 @@
|
|||
.collection-create {
|
||||
padding: #{24 / $base-font-size}rem;
|
||||
}
|
95
client/styles/components/_collection-popover.scss
Normal file
95
client/styles/components/_collection-popover.scss
Normal file
|
@ -0,0 +1,95 @@
|
|||
.collection-popover {
|
||||
position: absolute;
|
||||
height: auto;
|
||||
width: #{400 / $base-font-size}rem;
|
||||
top: 63%;
|
||||
right: calc(100% - 26px);
|
||||
|
||||
z-index: 9999;
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
border-radius: #{6 / $base-font-size}rem;
|
||||
|
||||
@include themify() {
|
||||
background-color: map-get($theme-map, 'modal-background-color');
|
||||
border: 1px solid map-get($theme-map, 'modal-border-color');
|
||||
box-shadow: 0 0 18px 0 getThemifyVariable('shadow-color');
|
||||
color: getThemifyVariable('dropdown-color');
|
||||
}
|
||||
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.collection-popover__header {
|
||||
display: flex;
|
||||
margin-left: #{17 / $base-font-size}rem;
|
||||
margin-right: #{17 / $base-font-size}rem;
|
||||
}
|
||||
|
||||
.collection-popover__filter {
|
||||
display: flex;
|
||||
margin-bottom: #{8 / $base-font-size}rem;
|
||||
}
|
||||
|
||||
.collection-popover__exit-button {
|
||||
@include icon();
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.collection-popover__items {
|
||||
height: #{70 * 4 / $base-font-size}rem;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
|
||||
.collection-popover__item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
|
||||
height: #{60 / $base-font-size}rem;
|
||||
margin: 5px;
|
||||
padding: 5px;
|
||||
}
|
||||
|
||||
.collection-popover__item:nth-child(odd) {
|
||||
@include themify() {
|
||||
background: getThemifyVariable('table-row-stripe-color');
|
||||
}
|
||||
}
|
||||
|
||||
.collection-popover__item__info {
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.collection-popover__item__info button,
|
||||
.collection-popover__item__info button:hover {
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
text-align: left;
|
||||
color: black;
|
||||
}
|
||||
|
||||
.collection-popover__item__view {
|
||||
}
|
||||
|
||||
|
||||
.collection-popover__item__view-button {
|
||||
@extend %button;
|
||||
}
|
||||
|
||||
.collection-popover__empty {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 100%;
|
||||
}
|
174
client/styles/components/_collection.scss
Normal file
174
client/styles/components/_collection.scss
Normal 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');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
|
|
53
client/styles/components/_editable-input.scss
Normal file
53
client/styles/components/_editable-input.scss
Normal 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;
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
@ -12,3 +12,9 @@
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
.preview-nav {
|
||||
.nav__item {
|
||||
margin-left: #{5 / $base-font-size}rem;
|
||||
}
|
||||
}
|
||||
|
|
123
client/styles/components/_quick-add.scss
Normal file
123
client/styles/components/_quick-add.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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';
|
||||
|
|
12
client/utils/generateRandomName.js
Normal file
12
client/utils/generateRandomName.js
Normal 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`;
|
||||
}
|
|
@ -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);
|
||||
}
|
|
@ -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);
|
||||
}
|
47
server/controllers/collection.controller/createCollection.js
Normal file
47
server/controllers/collection.controller/createCollection.js
Normal 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);
|
||||
}
|
7
server/controllers/collection.controller/index.js
Normal file
7
server/controllers/collection.controller/index.js
Normal 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';
|
48
server/controllers/collection.controller/listCollections.js
Normal file
48
server/controllers/collection.controller/listCollections.js
Normal 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);
|
||||
}
|
34
server/controllers/collection.controller/removeCollection.js
Normal file
34
server/controllers/collection.controller/removeCollection.js
Normal 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);
|
||||
}
|
|
@ -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);
|
||||
}
|
54
server/controllers/collection.controller/updateCollection.js
Normal file
54
server/controllers/collection.controller/updateCollection.js
Normal 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);
|
||||
}
|
48
server/models/collection.js
Normal file
48
server/models/collection.js
Normal 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);
|
20
server/routes/collection.routes.js
Normal file
20
server/routes/collection.routes.js
Normal 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;
|
|
@ -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))
|
||||
));
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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) => {
|
||||
|
|
|
@ -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>
|
||||
|
|
Loading…
Reference in a new issue