Commit 35dbcb07 authored by Paul Kaplan's avatar Paul Kaplan

Add user projects modal

parent f1fde9e5
......@@ -5,6 +5,8 @@ const curators = InfiniteList('curators');
const managers = InfiniteList('managers');
const activity = InfiniteList('activity');
const userProjects = InfiniteList('user-projects');
export {
projects, curators, managers, activity
projects, curators, managers, activity, userProjects
};
import keyMirror from 'keymirror';
import api from '../../../lib/api';
import {selectUsername} from '../../../redux/session';
import {userProjects} from './redux-modules';
const Errors = keyMirror({
NETWORK: null,
SERVER: null,
PERMISSION: null
});
const Filters = keyMirror({
SHARED: null,
FAVORITED: null,
RECENT: null
});
const Uris = {
[Filters.SHARED]: username => `/users/${username}/projects`,
[Filters.FAVORITED]: username => `/users/${username}/favorites`,
[Filters.RECENT]: username => `/users/${username}/recent`
};
const normalizeError = (err, body, res) => {
if (err) return Errors.NETWORK;
if (res.statusCode === 401 || res.statusCode === 403) return Errors.PERMISSION;
if (res.statusCode !== 200) return Errors.SERVER;
return null;
};
const loadUserProjects = type => ((dispatch, getState) => {
const state = getState();
const username = selectUsername(state);
const projectCount = userProjects.selector(state).items.length;
const projectsPerPage = 20;
dispatch(userProjects.actions.loading());
api({
uri: Uris[type](username),
params: {limit: projectsPerPage, offset: projectCount}
}, (err, body, res) => {
const error = normalizeError(err, body, res);
if (error) return dispatch(userProjects.actions.error(error));
dispatch(userProjects.actions.append(body, body.length === projectsPerPage));
});
});
// Re-export clear so that the consumer can manage filter changes
const clearUserProjects = userProjects.actions.clear;
export {
Filters,
loadUserProjects,
clearUserProjects
};
/* eslint-disable react/jsx-no-bind */
import React, {useEffect, useState} from 'react';
import PropTypes from 'prop-types';
import {connect} from 'react-redux';
import classNames from 'classnames';
import {addProject, removeProject} from '../lib/studio-project-actions';
import {userProjects} from '../lib/redux-modules';
import {Filters, loadUserProjects, clearUserProjects} from '../lib/user-projects-actions';
import Modal from '../../../components/modal/base/modal.jsx';
import ModalTitle from '../../../components/modal/base/modal-title.jsx';
import ModalInnerContent from '../../../components/modal/base/modal-inner-content.jsx';
import SubNavigation from '../../../components/subnavigation/subnavigation.jsx';
import UserProjectsTile from './user-projects-tile.jsx';
import './user-projects-modal.scss';
const UserProjectsModal = ({
items, error, loading, moreToLoad, onLoadMore, onClear,
onAdd, onRemove, onRequestClose
}) => {
const [filter, setFilter] = useState(Filters.SHARED);
useEffect(() => {
onClear();
onLoadMore(filter);
}, [filter]);
return (
<Modal
isOpen
className="user-projects-modal"
onRequestClose={onRequestClose}
>
<ModalTitle
className="user-projects-modal-title modal-header"
title="Add to Studio"
/>
<SubNavigation
align="left"
className="user-projects-modal-nav"
>
<li
className={classNames({active: filter === Filters.SHARED})}
onClick={() => setFilter(Filters.SHARED)}
>
Shared
</li>
<li
className={classNames({active: filter === Filters.FAVORITED})}
onClick={() => setFilter(Filters.FAVORITED)}
>
Favorited
</li>
<li
className={classNames({active: filter === Filters.RECENT})}
onClick={() => setFilter(Filters.RECENT)}
>
Recent
</li>
</SubNavigation>
<ModalInnerContent className="user-projects-modal-content">
{error && <div>Error loading {filter}: {error}</div>}
<div className="user-projects-modal-grid">
{items.map(project => (
<UserProjectsTile
key={project.id}
id={project.id}
title={project.title}
image={project.image}
onAdd={onAdd}
onRemove={onRemove}
/>
))}
<div className="studio-projects-load-more">
{loading ? <small>Loading...</small> : (
moreToLoad ?
<button onClick={() => onLoadMore(filter)}>
Load more
</button> :
<small>No more to load</small>
)}
</div>
</div>
</ModalInnerContent>
</Modal>
);
};
UserProjectsModal.propTypes = {
items: PropTypes.arrayOf(PropTypes.shape({
id: PropTypes.id,
image: PropTypes.string,
title: PropTypes.string
})),
loading: PropTypes.bool,
error: PropTypes.object, // eslint-disable-line react/forbid-prop-types
moreToLoad: PropTypes.bool,
onLoadMore: PropTypes.func,
onClear: PropTypes.func,
onAdd: PropTypes.func,
onRemove: PropTypes.func,
onRequestClose: PropTypes.func
};
const mapStateToProps = state => ({
...userProjects.selector(state)
});
const mapDispatchToProps = ({
onLoadMore: loadUserProjects,
onClear: clearUserProjects,
onAdd: addProject,
onRemove: removeProject
});
export default connect(mapStateToProps, mapDispatchToProps)(UserProjectsModal);
@import "../../../colors";
@import "../../../frameless";
.user-projects-modal {
.user-projects-modal-title {
box-shadow: inset 0 -1px 0 0 $ui-blue-dark;
background-color: $ui-blue;
border-top-left-radius: 12px;
border-top-right-radius: 12px;
padding-top: .75rem;
width: 100%;
height: 3rem;
}
.user-projects-modal-nav {
padding: 6px 12px;
li {
cursor: pointer;
background: rgba(0, 0, 0, 0.15);
&.active { background: $ui-blue; }
}
}
.user-projects-modal-content {
padding: 0 30px 30px;
background: #E9F1FC;
max-height: 80vh;
overflow-y: auto;
overscroll-behavior: contain;
border-bottom-left-radius: 12px;
border-bottom-right-radius: 12px;
}
}
.studio-tile-dynamic-remove,
.studio-tile-dynamic-add {
position: absolute;
top: 10px;
right: 10px;
width: 32px;
height: 32px;
background: rgba(0, 0, 0, 0.25);
border: 3px solid rgba(0, 0, 0, 0.1);
background-clip: padding-box;
color: white;
border-radius: 100%;
font-weight: bold;
margin: 0;
padding: 0;
line-height: 32px;
text-align: center;
}
.studio-tile-dynamic-remove {
background: #0FBD8C;
background-clip: padding-box;
border: 3px solid rgba(15, 189, 140, 0.2);
}
.user-projects-modal-grid {
margin-top: 12px;
display: grid;
grid-template-columns: repeat(3, minmax(0,1fr));
@media #{$medium} { /* Keep 3 columns to narrower width since it is in a modal */
& { grid-template-columns: repeat(2, minmax(0,1fr)); }
}
@media #{$small} {
& { grid-template-columns: repeat(1, minmax(0,1fr)); }
}
column-gap: 14px;
row-gap: 14px;
.studio-projects-load-more {
grid-column: 1 / -1;
}
.studio-project-bottom {
padding: 8px 10px 8px 10px;
}
.studio-project-avatar {
width: 32px;
height: 32px;
}
.studio-project-info {
margin: 0;
}
.studio-project-title {
font-size: 12px;
}
.studio-project-username {
font-size: 12px;
}
}
\ No newline at end of file
/* eslint-disable react/jsx-no-bind */
import React, {useState} from 'react';
import PropTypes from 'prop-types';
import classNames from 'classnames';
const UserProjectsTile = ({id, title, image, onAdd, onRemove}) => {
const [submitting, setSubmitting] = useState(false);
const [added, setAdded] = useState(false);
const [error, setError] = useState(null);
const toggle = () => {
setSubmitting(true);
setError(null);
(added ? onRemove(id) : onAdd(id))
.then(() => {
setAdded(!added);
setSubmitting(false);
})
.catch(e => {
setError(e);
setSubmitting(false);
});
};
return (
<div
role="button"
tabIndex="0"
className={classNames('studio-project-tile', {
'mod-clickable': true,
'mod-mutating': submitting
})}
onClick={toggle}
onKeyDown={e => e.key === 'Enter' && toggle()}
>
<img
className="studio-project-image"
src={image}
/>
<div className="studio-project-bottom">
<div className="studio-project-title">{title}</div>
<div className={`studio-tile-dynamic-${added ? 'remove' : 'add'}`}>
{added ? '' : ''}
</div>
{error && <div>{error}</div>}
</div>
</div>
);
};
UserProjectsTile.propTypes = {
id: PropTypes.number.isRequired,
title: PropTypes.string.isRequired,
image: PropTypes.string.isRequired,
onAdd: PropTypes.func.isRequired,
onRemove: PropTypes.func.isRequired
};
export default UserProjectsTile;
......@@ -5,6 +5,7 @@ import {connect} from 'react-redux';
import classNames from 'classnames';
import {inviteCurator} from './lib/studio-member-actions';
import FlexRow from '../../components/flex-row/flex-row.jsx';
const StudioCuratorInviter = ({onSubmit}) => {
const [value, setValue] = useState('');
......@@ -14,28 +15,30 @@ const StudioCuratorInviter = ({onSubmit}) => {
return (
<div className="studio-adder-section">
<h3>✦ Invite Curators</h3>
<input
disabled={submitting}
type="text"
placeholder="<username>"
value={value}
onChange={e => setValue(e.target.value)}
/>
<button
className={classNames('button', {
'mod-mutating': submitting
})}
disabled={submitting}
onClick={() => {
setSubmitting(true);
setError(null);
onSubmit(value)
.then(() => setValue(''))
.catch(e => setError(e))
.then(() => setSubmitting(false));
}}
>Invite</button>
{error && <div>{error}</div>}
<FlexRow>
<input
disabled={submitting}
type="text"
placeholder="<username>"
value={value}
onChange={e => setValue(e.target.value)}
/>
<button
className={classNames('button', {
'mod-mutating': submitting
})}
disabled={submitting}
onClick={() => {
setSubmitting(true);
setError(null);
onSubmit(value)
.then(() => setValue(''))
.catch(e => setError(e))
.then(() => setSubmitting(false));
}}
>Invite</button>
{error && <div>{error}</div>}
</FlexRow>
</div>
);
};
......
......@@ -5,37 +5,50 @@ import {connect} from 'react-redux';
import classNames from 'classnames';
import {addProject} from './lib/studio-project-actions';
import UserProjectsModal from './modals/user-projects-modal.jsx';
import FlexRow from '../../components/flex-row/flex-row.jsx';
const StudioProjectAdder = ({onSubmit}) => {
const [value, setValue] = useState('');
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState(null);
const [modalOpen, setModalOpen] = useState(false);
return (
<div className="studio-adder-section">
<h3>✦ Add Projects</h3>
<input
disabled={submitting}
type="text"
placeholder="<project id>"
value={value}
onChange={e => setValue(e.target.value)}
/>
<button
className={classNames('button', {
'mod-mutating': submitting
})}
disabled={submitting}
onClick={() => {
setSubmitting(true);
setError(null);
onSubmit(value)
.then(() => setValue(''))
.catch(e => setError(e))
.then(() => setSubmitting(false));
}}
>Add</button>
{error && <div>{error}</div>}
<FlexRow>
<input
disabled={submitting}
type="text"
placeholder="<project id>"
value={value}
onChange={e => setValue(e.target.value)}
/>
<button
className={classNames('button', {
'mod-mutating': submitting
})}
disabled={submitting}
onClick={() => {
setSubmitting(true);
setError(null);
onSubmit(value)
.then(() => setValue(''))
.catch(e => setError(e))
.then(() => setSubmitting(false));
}}
>Add</button>
{error && <div>{error}</div>}
<div className="studio-adder-vertical-divider" />
<button
className="button"
onClick={() => setModalOpen(true)}
>
Browse Projects
</button>
{modalOpen && <UserProjectsModal onRequestClose={() => setModalOpen(false)} />}
</FlexRow>
</div>
);
};
......
......@@ -22,7 +22,8 @@ import {
projects,
curators,
managers,
activity
activity,
userProjects
} from './lib/redux-modules';
const {getInitialState, studioReducer} = require('../../redux/studio');
......@@ -85,6 +86,7 @@ render(
[curators.key]: curators.reducer,
[managers.key]: managers.reducer,
[activity.key]: activity.reducer,
[userProjects.key]: userProjects.reducer,
comments: commentsReducer,
studio: studioReducer,
studioMutations: studioMutationsReducer,
......
......@@ -5,7 +5,7 @@ $radius: 8px;
.studio-page {
background-color: #E9F1FC;
#view {
/* Reset some defaults on width and margin */
background-color: transparent;
......@@ -63,7 +63,7 @@ $radius: 8px;
.studio-tab-nav {
border-bottom: 1px solid $active-dark-gray;
padding-bottom: 8px;
li { background: $active-gray; }
li { background: rgba(0, 0, 0, 0.15); }
.active > li { background: $ui-blue; }
}
......@@ -72,12 +72,12 @@ $radius: 8px;
margin-top: 20px;
display: grid;
grid-template-columns: minmax(0, 1fr);
@media #{$medium} {
grid-template-columns: repeat(3, minmax(0,1fr));
@media #{$medium-and-intermediate} {
& { grid-template-columns: repeat(2, minmax(0,1fr)); }
}
@media #{$big} {
& { grid-template-columns: repeat(3, minmax(0,1fr)); }
@media #{$small} {
& { grid-template-columns: repeat(1, minmax(0,1fr)); }
}
column-gap: 30px;
row-gap: 20px;
......@@ -91,6 +91,9 @@ $radius: 8px;
background: white;
border-radius: 8px;
border: 1px solid $ui-border;
position: relative;
margin: 0;
padding: 0;
.studio-project-image {
max-width: 100%;
......@@ -123,6 +126,7 @@ $radius: 8px;
font-weight: 700;
font-size: 14px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.studio-project-username {
......@@ -130,6 +134,7 @@ $radius: 8px;
font-weight: 700;
font-size: 12px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.studio-project-remove {
......@@ -143,13 +148,12 @@ $radius: 8px;
.studio-members-grid {
margin-top: 20px;
display: grid;
grid-template-columns: minmax(0, 1fr);
@media #{$medium} {
grid-template-columns: repeat(3, minmax(0,1fr));
@media #{$medium-and-intermediate} {
& { grid-template-columns: repeat(2, minmax(0,1fr)); }
}
@media #{$big} {
& { grid-template-columns: repeat(3, minmax(0,1fr)); }
@media #{$small} {
& { grid-template-columns: repeat(1, minmax(0,1fr)); }
}
column-gap: 30px;
row-gap: 20px;
......@@ -187,6 +191,7 @@ $radius: 8px;
font-weight: 700;
font-size: 14px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.studio-member-role {
......@@ -194,6 +199,7 @@ $radius: 8px;
font-weight: 400;
font-size: 12px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.studio-member-remove, .studio-member-promote {
......@@ -209,15 +215,19 @@ $radius: 8px;
.studio-adder-section {
margin-top: 20px;
display: flex;
flex-wrap: wrap;
h3 {
color: #4C97FF;
}
.flex-row {
margin: 0 -6px;
& > * {
margin: 0 6px;
}
}
input {
flex-basis: 80%;
flex-grow: 1;
display: inline-block;
margin: .5em 0;
......@@ -228,11 +238,12 @@ $radius: 8px;
}
button {
flex-grow: 1;
flex-grow: 0;
}
input + button {
margin-inline-start: 12px;
.studio-adder-vertical-divider {
border: 1px solid $ui-border;
align-self: stretch;
}
}
......@@ -264,3 +275,7 @@ $radius: 8px;
cursor: wait !important;
opacity: .5;
}
.mod-clickable {
cursor: pointer;
}
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment