mirror of
https://github.com/scratchfoundation/scratch-www.git
synced 2025-02-17 08:31:23 -05:00
Add user projects modal
This commit is contained in:
parent
f1fde9e5f8
commit
35dbcb07ba
9 changed files with 420 additions and 64 deletions
|
@ -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
|
||||
};
|
||||
|
|
54
src/views/studio/lib/user-projects-actions.js
Normal file
54
src/views/studio/lib/user-projects-actions.js
Normal file
|
@ -0,0 +1,54 @@
|
|||
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
|
||||
};
|
118
src/views/studio/modals/user-projects-modal.jsx
Normal file
118
src/views/studio/modals/user-projects-modal.jsx
Normal file
|
@ -0,0 +1,118 @@
|
|||
/* 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);
|
92
src/views/studio/modals/user-projects-modal.scss
Normal file
92
src/views/studio/modals/user-projects-modal.scss
Normal file
|
@ -0,0 +1,92 @@
|
|||
@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;
|
||||
}
|
||||
}
|
57
src/views/studio/modals/user-projects-tile.jsx
Normal file
57
src/views/studio/modals/user-projects-tile.jsx
Normal file
|
@ -0,0 +1,57 @@
|
|||
/* 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;
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue