Merge pull request #1985 from benjiwheeler/studio-modal-benwheeler

Add To Studio modal on project page. Sorry for merge commit, merges from develop made these un-squashable :(
This commit is contained in:
Benjamin Wheeler 2018-08-02 17:53:53 -04:00 committed by GitHub
commit f76ca8b891
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
25 changed files with 864 additions and 112 deletions

View file

@ -70,7 +70,7 @@ integration:
smoke: smoke:
$(TAP) ./test/integration/smoke-testing/*.js --timeout=3600 $(TAP) ./test/integration/smoke-testing/*.js --timeout=3600
smoke-verbose: smoke-verbose:
$(TAP) ./test/integration/smoke-testing/*.js --timeout=3600 -R spec $(TAP) ./test/integration/smoke-testing/*.js --timeout=3600 -R spec

View file

@ -2,7 +2,6 @@ version: '3.4'
volumes: volumes:
npm_data: npm_data:
runtime_data: runtime_data:
intl_data:
networks: networks:
scratch-api_scratch_network: scratch-api_scratch_network:
@ -16,7 +15,7 @@ services:
- API_HOST=http://localhost:8491 - API_HOST=http://localhost:8491
- FALLBACK=http://localhost:8080 - FALLBACK=http://localhost:8080
- USE_DOCKER_WATCHOPTIONS=true - USE_DOCKER_WATCHOPTIONS=true
build: build:
context: ./ context: ./
dockerfile: Dockerfile dockerfile: Dockerfile
image: scratch-www:latest image: scratch-www:latest
@ -34,7 +33,6 @@ services:
nocopy: true nocopy: true
- npm_data:/var/app/current/node_modules - npm_data:/var/app/current/node_modules
- runtime_data:/runtime - runtime_data:/runtime
- intl_data:/var/app/current/intl
ports: ports:
- "8333:8333" - "8333:8333"
networks: networks:

View file

@ -77,6 +77,7 @@
"lodash.merge": "3.3.2", "lodash.merge": "3.3.2",
"lodash.omit": "3.1.0", "lodash.omit": "3.1.0",
"lodash.range": "3.0.1", "lodash.range": "3.0.1",
"lodash.truncate": "4.4.2",
"minilog": "2.0.8", "minilog": "2.0.8",
"node-dir": "0.1.16", "node-dir": "0.1.16",
"node-sass": "4.6.1", "node-sass": "4.6.1",

View file

@ -14,7 +14,6 @@ $ui-dark-gray: hsla(0, 0, 70, 1); //#B3B3B3
$background-color: hsla(0, 0, 99, 1); //#FDFDFD $background-color: hsla(0, 0, 99, 1); //#FDFDFD
/* UI Secondary Colors */ /* UI Secondary Colors */
/* 3.0 colors */ /* 3.0 colors */
/* Using www naming convention for now, should be consistent with gui */ /* Using www naming convention for now, should be consistent with gui */
@ -28,14 +27,20 @@ $ui-coral-dark: hsla(350, 100, 60, 1); // #FF3355 More Blocks tertiary
$ui-white: hsla(0, 100%, 100%, 1); //#FFF $ui-white: hsla(0, 100%, 100%, 1); //#FFF
$ui-white-15percent: hsla(0, 100%, 100%, .15); //#FFF $ui-white-15percent: hsla(0, 100%, 100%, .15); //#FFF
$ui-light-primary: hsl(215, 100, 95);
$ui-border: hsla(0, 0, 85, 1); //#D9D9D9 $ui-border: hsla(0, 0, 85, 1); //#D9D9D9
/* modals */
$ui-mint-green: hsl(163, 69, 44);
$ui-light-mint: hsl(163, 53, 67);
/* Overlay UI Gray Colors */ /* Overlay UI Gray Colors */
$active-gray: hsla(0, 0, 0, .1); $active-gray: hsla(0, 0, 0, .1);
$active-dark-gray: hsla(0, 0, 0, .2); $active-dark-gray: hsla(0, 0, 0, .2);
$box-shadow-gray: hsla(0, 0, 0, .25); $box-shadow-gray: hsla(0, 0, 0, .25);
$overlay-gray: hsla(0, 0, 0, .75); $overlay-gray: hsla(0, 0, 0, .75);
$transparent-light-blue: rgba(229, 240, 254, 0);
/* Typography Colors */ /* Typography Colors */
$header-gray: hsla(225, 15, 40, 1); //#575E75 $header-gray: hsla(225, 15, 40, 1); //#575E75

View file

@ -0,0 +1,72 @@
const bindAll = require('lodash.bindall');
const PropTypes = require('prop-types');
const React = require('react');
const AddToStudioModalPresentation = require('./presentation.jsx');
class AddToStudioModal extends React.Component {
constructor (props) {
super(props);
bindAll(this, [
'handleRequestClose',
'handleSubmit'
]);
this.state = {
waitingToClose: false
};
}
componentWillUpdate () {
this.closeIfFinishedUpdating();
}
hasOutstandingUpdates () {
return (this.props.studios.some(studio => (studio.hasRequestOutstanding === true)));
}
closeIfFinishedUpdating () {
if (this.state.waitingToClose === true && this.hasOutstandingUpdates() === false) {
this.closeAndStopWaiting();
}
}
// before closing, set waitingToClose to false. That way, if user reopens
// modal, it won't unexpectedly close.
closeAndStopWaiting () {
this.setState({waitingToClose: false}, () => {
this.props.onRequestClose();
});
}
handleRequestClose () {
this.closeAndStopWaiting();
}
handleSubmit () {
this.setState({waitingToClose: true}, () => {
this.closeIfFinishedUpdating();
});
}
render () {
return (
<AddToStudioModalPresentation
isOpen={this.props.isOpen}
studios={this.props.studios}
waitingToClose={this.state.waitingToClose}
onRequestClose={this.handleRequestClose}
onSubmit={this.handleSubmit}
onToggleStudio={this.props.onToggleStudio}
/>
);
}
}
AddToStudioModal.propTypes = {
isOpen: PropTypes.bool,
onRequestClose: PropTypes.func,
onToggleStudio: PropTypes.func,
studios: PropTypes.arrayOf(PropTypes.object)
};
module.exports = AddToStudioModal;

View file

@ -0,0 +1,190 @@
@import "../../../colors";
@import "../../../frameless";
.mod-addToStudio * {
box-sizing: border-box;
}
.mod-addToStudio {
margin: 100px auto;
outline: none;
padding: 0;
width: 36.25rem; /* 580px; */
height: 388px; /* 24.25rem; */
overflow: hidden;
user-select: none;
}
.addToStudio-modal-header {
box-shadow: inset 0 -1px 0 0 $ui-blue-dark;
background-color: $ui-blue;
padding-top: .75rem;
width: 100%;
height: 3rem;
box-sizing: border-box;
}
.addToStudio-content-label {
text-align: center;
color: $type-white;
font-family: "Helvetica Neue", Helvetica, Arial, sans-serif;
font-size: 1rem;
font-weight: bold;
}
.addToStudio-modal-content {
margin: 0 auto;
width: 100%;
line-height: 1.5rem;
font-size: .875rem;
}
.studio-list-outer-scrollbox {
position: relative;
background-color: $ui-blue-10percent;
}
.studio-list-inner-scrollbox {
margin-right: .5rem;
padding-right: .5rem;
height: 16.9375rem;
overflow: scroll;
overflow-x: hidden;
&::-webkit-scrollbar {
width: 8px;
}
&::-webkit-scrollbar-thumb {
border-radius: 4px;
background-color: $active-dark-gray;
height: 92px;
}
&::-webkit-scrollbar-track {
margin-top: 8px;
margin-bottom: 10px;
}
}
.studio-list-container {
display: flex;
padding: .40625rem 0 0 1.46875rem;
justify-content: flex-start;
flex-flow: row wrap;
}
/* NOTE: force scrolling: add to above:
min-height: 30rem;
*/
.studio-list-bottom-gradient {
position: absolute;
right: 1rem;
bottom: 0;
left: 0;
background: linear-gradient(
$transparent-light-blue,
$ui-light-primary
);
height: 32px;
pointer-events: none; /* pass clicks through to buttons underneath */
}
.studio-selector-button {
display: flex;
position: relative;
margin: .21875rem .21875rem;
border-radius: .5rem;
background-color: $ui-white;
padding: 0;
width: 16.1875rem; /* 259px */
height: 2.5rem;
box-sizing: border-box;
justify-content: space-between;
}
.studio-selector-button-text {
position: absolute;
/* per spec, should be:
margin: .375rem 2.18375rem .375rem .6875rem
but in practice, our css seems to vertically align text to top, where
invision spec aligned to middle.
*/
margin: .575rem 2.18375rem .175rem .6875rem;
width: 13.3125rem;
height: 1rem; /* diff from spec, in case we ever do valign to middle */
line-height: 1.25rem;
font-family: "Helvetica Neue";
font-size: .875rem;
font-weight: regular;
}
.studio-selector-button-selected {
background-color: $ui-mint-green;
color: $ui-white;
}
.studio-selector-button-waiting {
background-color: $ui-light-mint;
color: $ui-white;
}
.studio-selector-button-text-selected {
color: $ui-white;
}
.studio-selector-button-text-unselected {
color: $type-gray;
}
.studio-status-icon {
position: absolute;
margin: .5rem .625rem .5rem 14.0625rem;
border-radius: .75rem;
padding: .0625rem .075rem;
width: 1.5rem;
height: 1.5rem;
color: $ui-white;
box-sizing: border-box;
}
.studio-status-icon-unselected {
background-color: $ui-blue;
}
.submit-button {
background-color: $ui-blue;
}
.submit-button-waiting {
background-color: $ui-blue;
}
.studio-status-icon-plus-img {
width: 1.4rem;
height: 1.4rem;
}
.studio-status-icon--img {
width: 1.4rem;
height: 1.4rem;
}
.action-button-text .spinner-smooth {
margin: .2125rem auto;
width: 1.875rem;
height: 1rem;
}
.studio-status-icon .spinner-smooth {
position: unset; /* don't understand why neither relative nor absolute work */
}
.studio-status-icon .spinner-smooth .circle {
/* overlay spinner on circle */
position: absolute;
margin: .1875rem; /* stay within boundaries of circle */
width: 75%; /* stay within boundaries of circle */
height: 75%; /* stay within boundaries of circle */
}

View file

@ -0,0 +1,119 @@
const PropTypes = require('prop-types');
const React = require('react');
const FormattedMessage = require('react-intl').FormattedMessage;
const injectIntl = require('react-intl').injectIntl;
const intlShape = require('react-intl').intlShape;
const Modal = require('../base/modal.jsx');
const Form = require('../../forms/form.jsx');
const Button = require('../../forms/button.jsx');
const Spinner = require('../../spinner/spinner.jsx');
const FlexRow = require('../../flex-row/flex-row.jsx');
const StudioButton = require('./studio-button.jsx');
require('../../forms/button.scss');
require('./modal.scss');
const AddToStudioModalPresentation = ({
intl,
isOpen,
studios,
waitingToClose,
onToggleStudio,
onRequestClose,
onSubmit
}) => {
const contentLabel = intl.formatMessage({id: 'addToStudio.title'});
const studioButtons = studios.map(studio => (
<StudioButton
hasRequestOutstanding={studio.hasRequestOutstanding}
id={studio.id}
includesProject={studio.includesProject}
key={studio.id}
title={studio.title}
onToggleStudio={onToggleStudio}
/>
));
return (
<Modal
className="mod-addToStudio"
contentLabel={contentLabel}
isOpen={isOpen}
onRequestClose={onRequestClose}
>
<div>
<div className="addToStudio-modal-header">
<div className="addToStudio-content-label">
{contentLabel}
</div>
</div>
<div className="addToStudio-modal-content">
<div className="studio-list-outer-scrollbox">
<div className="studio-list-inner-scrollbox">
<div className="studio-list-container">
{studioButtons}
</div>
<div className="studio-list-bottom-gradient" />
</div>
</div>
<Form
className="add-to-studio"
onSubmit={onSubmit}
>
<FlexRow className="action-buttons">
<Button
className="action-button close-button white"
key="closeButton"
name="closeButton"
type="button"
onClick={onRequestClose}
>
<div className="action-button-text">
<FormattedMessage id="general.close" />
</div>
</Button>
{waitingToClose ? [
<Button
className="action-button submit-button submit-button-waiting"
disabled="disabled"
key="submitButton"
type="submit"
>
<div className="action-button-text">
<Spinner mode="smooth" />
<FormattedMessage id="addToStudio.finishing" />
</div>
</Button>
] : [
<Button
className="action-button submit-button"
key="submitButton"
type="submit"
>
<div className="action-button-text">
<FormattedMessage id="general.okay" />
</div>
</Button>
]}
</FlexRow>
</Form>
</div>
</div>
</Modal>
);
};
AddToStudioModalPresentation.propTypes = {
intl: intlShape,
isOpen: PropTypes.bool,
onRequestClose: PropTypes.func,
onSubmit: PropTypes.func,
onToggleStudio: PropTypes.func,
studios: PropTypes.arrayOf(PropTypes.object),
waitingToClose: PropTypes.bool
};
module.exports = injectIntl(AddToStudioModalPresentation);

View file

@ -0,0 +1,72 @@
const truncateAtWordBoundary = require('../../../lib/truncate').truncateAtWordBoundary;
const PropTypes = require('prop-types');
const React = require('react');
const classNames = require('classnames');
const Spinner = require('../../spinner/spinner.jsx');
require('./modal.scss');
const StudioButton = ({
hasRequestOutstanding,
id,
includesProject,
title,
onToggleStudio
}) => {
const checkmark = (
<img
alt="checkmark-icon"
className="studio-status-icon-checkmark-img"
src="/svgs/modal/confirm.svg"
/>
);
const plus = (
<img
alt="plus-icon"
className="studio-status-icon-plus-img"
src="/svgs/modal/add.svg"
/>
);
return (
<div
className={classNames(
'studio-selector-button',
{'studio-selector-button-waiting': hasRequestOutstanding},
{'studio-selector-button-selected':
includesProject && !hasRequestOutstanding}
)}
data-id={id}
onClick={onToggleStudio}
>
<div
className={classNames(
'studio-selector-button-text',
{'studio-selector-button-text-selected': includesProject || hasRequestOutstanding},
{'studio-selector-button-text-unselected': !includesProject && !hasRequestOutstanding}
)}
>
{truncateAtWordBoundary(title, 25)}
</div>
<div
className={classNames(
'studio-status-icon',
{'studio-status-icon-unselected': !includesProject}
)}
>
{(hasRequestOutstanding ?
(<Spinner mode="smooth" />) :
(includesProject ? checkmark : plus))}
</div>
</div>
);
};
StudioButton.propTypes = {
hasRequestOutstanding: PropTypes.bool,
id: PropTypes.number,
includesProject: PropTypes.bool,
onToggleStudio: PropTypes.func,
title: PropTypes.string
};
module.exports = StudioButton;

View file

@ -5,10 +5,14 @@
position: relative; position: relative;
margin: 3.75rem auto; margin: 3.75rem auto;
border-radius: 1rem; border-radius: 1rem;
box-shadow: 0 0 0 1px $active-gray; box-shadow: 0 0 0 4px $ui-white-15percent;
background-color: $ui-white; background-color: $ui-white;
padding: 0; padding: 0;
width: 48.75rem; width: 48.75rem;
&:focus {
outline: none;
}
} }
.modal-overlay { .modal-overlay {
@ -21,10 +25,6 @@
background-color: transparentize($ui-blue, .3); background-color: transparentize($ui-blue, .3);
} }
.modal-content:focus {
outline: none;
}
$modal-close-size: 1rem; $modal-close-size: 1rem;
.modal-content-close { .modal-content-close {
position: absolute; position: absolute;
@ -59,3 +59,27 @@ $modal-close-size: 1rem;
position: fixed; position: fixed;
} }
} }
/* Close button, Submit button, etc. */
.action-buttons {
display: flex;
margin: 1.125rem .8275rem .9375rem .8275rem;
justify-content: flex-end !important;
align-items: flex-start;
flex-wrap: nowrap;
}
.action-button {
margin: 0 0 0 .54625rem;
border-radius: .25rem;
padding: 6px 1.25rem 14px 1.25rem;
height: 36px;
}
.action-button.close-button {
border: 1px solid $active-gray;
}
.action-button-text {
display: flex;
}

View file

@ -11,6 +11,7 @@ const Button = require('../../forms/button.jsx');
const Select = require('../../forms/select.jsx'); const Select = require('../../forms/select.jsx');
const Spinner = require('../../spinner/spinner.jsx'); const Spinner = require('../../spinner/spinner.jsx');
const TextArea = require('../../forms/textarea.jsx'); const TextArea = require('../../forms/textarea.jsx');
const FlexRow = require('../../flex-row/flex-row.jsx');
require('../../forms/button.scss'); require('../../forms/button.scss');
require('./modal.scss'); require('./modal.scss');
@ -101,21 +102,21 @@ class ReportModal extends React.Component {
</div> </div>
</div> </div>
<div className="report-modal-content"> <Form
<FormattedMessage className="report"
id={`report.${type}Instructions`} onSubmit={onReport}
values={{ >
CommunityGuidelinesLink: ( <div className="report-modal-content">
<a href="/community_guidelines"> <FormattedMessage
<FormattedMessage id="report.CommunityGuidelinesLinkText" /> id={`report.${type}Instructions`}
</a> values={{
) CommunityGuidelinesLink: (
}} <a href="/community_guidelines">
/> <FormattedMessage id="report.CommunityGuidelinesLinkText" />
<Form </a>
className="report" )
onSubmit={onReport} }}
> />
<Select <Select
required required
elementWrapperClassName="report-modal-field" elementWrapperClassName="report-modal-field"
@ -145,9 +146,11 @@ class ReportModal extends React.Component {
}} }}
value={report.notes} value={report.notes}
/> />
</div>
<FlexRow className="action-buttons">
{report.waiting ? [ {report.waiting ? [
<Button <Button
className="submit-button white" className="submit-button"
disabled="disabled" disabled="disabled"
key="submitButton" key="submitButton"
type="submit" type="submit"
@ -156,17 +159,16 @@ class ReportModal extends React.Component {
</Button> </Button>
] : [ ] : [
<Button <Button
className="submit-button white" className="submit-button"
key="submitButton" key="submitButton"
type="submit" type="submit"
> >
<FormattedMessage id="report.send" /> <FormattedMessage id="report.send" />
</Button> </Button>
]} ]}
</Form> </FlexRow>
</div> </Form>
</div> </div>
</Modal> </Modal>
); );
} }

View file

@ -36,7 +36,7 @@
width: 80%; width: 80%;
line-height: 1.5rem; line-height: 1.5rem;
font-size: .875rem; font-size: .875rem;
.validation-message { .validation-message {
$arrow-border-width: 1rem; $arrow-border-width: 1rem;
display: block; display: block;

View file

@ -1,18 +1,29 @@
const range = require('lodash.range'); const range = require('lodash.range');
const PropTypes = require('prop-types');
const React = require('react'); const React = require('react');
require('./spinner.scss'); require('./spinner.scss');
// Adapted from http://tobiasahlin.com/spinkit/ // Adapted from http://tobiasahlin.com/spinkit/
const Spinner = () => ( const Spinner = ({
<div className="spinner"> mode
{range(1, 13).map(id => ( }) => {
<div const spinnerClassName = (mode === 'smooth' ? 'spinner-smooth' : 'spinner');
className={`circle${id} circle`} const spinnerDivCount = (mode === 'smooth' ? 24 : 12);
key={`circle${id}`} return (
/> <div className={spinnerClassName}>
))} {range(1, spinnerDivCount + 1).map(id => (
</div> <div
); className={`circle${id} circle`}
key={`circle${id}`}
/>
))}
</div>
);
};
Spinner.propTypes = {
mode: PropTypes.string
};
module.exports = Spinner; module.exports = Spinner;

View file

@ -56,3 +56,63 @@
opacity: 1; opacity: 1;
} }
} }
/*********************/
/* type === "smooth" */
/*********************/
.spinner-smooth {
position: relative;
margin: 0 auto;
width: 20px;
height: 20px;
.circle {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
&:before {
display: block;
animation: circleFadeDelaySmooth 1.8s infinite ease-in-out both;
margin: 0 auto;
border-radius: 100%;
background-color: $ui-white;
width: 30%;
height: 20%;
content: "";
.white & {
background-color: darken($ui-blue, 8%);
}
}
}
@for $i from 1 through 24 {
$rotation: 15deg * ($i - 1);
$delay: -1.9s + $i * .075;
.circle#{$i} {
transform: rotate($rotation);
&:before {
animation-delay: $delay;
}
}
}
}
@keyframes circleFadeDelaySmooth {
0%,
35% {
opacity: 0;
},
40% {
opacity: 1;
}
}

View file

@ -6,6 +6,7 @@
"general.birthMonth": "Birth Month", "general.birthMonth": "Birth Month",
"general.birthYear": "Birth Year", "general.birthYear": "Birth Year",
"general.donate": "Donate", "general.donate": "Donate",
"general.close": "Close",
"general.collaborators": "Collaborators", "general.collaborators": "Collaborators",
"general.community": "Community", "general.community": "Community",
"general.confirmEmail": "Confirm Email", "general.confirmEmail": "Confirm Email",
@ -52,6 +53,7 @@
"general.noDeletionDescription": "Your account was scheduled for deletion but you logged in. Your account has been reactivated. If you didnt request for your account to be deleted, you should {resetLink} to make sure your account is secure.", "general.noDeletionDescription": "Your account was scheduled for deletion but you logged in. Your account has been reactivated. If you didnt request for your account to be deleted, you should {resetLink} to make sure your account is secure.",
"general.noDeletionLink": "change your password", "general.noDeletionLink": "change your password",
"general.notRequired": "Not Required", "general.notRequired": "Not Required",
"general.okay": "Okay",
"general.other": "Other", "general.other": "Other",
"general.offlineEditor": "Offline Editor", "general.offlineEditor": "Offline Editor",
"general.password": "Password", "general.password": "Password",
@ -193,4 +195,4 @@
"report.tooLongError": "That's too long! Please find a way to shorten your text.", "report.tooLongError": "That's too long! Please find a way to shorten your text.",
"report.tooShortError": "That's too short. Please describe in detail what's inappropriate or disrespectful about the project.", "report.tooShortError": "That's too short. Please describe in detail what's inappropriate or disrespectful about the project.",
"report.send": "Send" "report.send": "Send"
} }

9
src/lib/truncate.js Normal file
View file

@ -0,0 +1,9 @@
const lodashTruncate = require('lodash.truncate');
/*
* Function that applies regex for word boundaries, replaces removed string
* with indication of ellipsis (...)
*/
module.exports.truncateAtWordBoundary = (str, length) => (
lodashTruncate(str, {length: length, separator: /[.,:;]*\s+/})
);

View file

@ -19,7 +19,9 @@ module.exports.getInitialState = () => ({
original: module.exports.Status.NOT_FETCHED, original: module.exports.Status.NOT_FETCHED,
parent: module.exports.Status.NOT_FETCHED, parent: module.exports.Status.NOT_FETCHED,
remixes: module.exports.Status.NOT_FETCHED, remixes: module.exports.Status.NOT_FETCHED,
studios: module.exports.Status.NOT_FETCHED projectStudios: module.exports.Status.NOT_FETCHED,
curatedStudios: module.exports.Status.NOT_FETCHED,
studioRequests: {}
}, },
projectInfo: {}, projectInfo: {},
remixes: [], remixes: [],
@ -28,7 +30,9 @@ module.exports.getInitialState = () => ({
loved: false, loved: false,
original: {}, original: {},
parent: {}, parent: {},
studios: [] projectStudios: [],
curatedStudios: [],
currentStudioIds: []
}); });
module.exports.previewReducer = (state, action) => { module.exports.previewReducer = (state, action) => {
@ -53,9 +57,25 @@ module.exports.previewReducer = (state, action) => {
return Object.assign({}, state, { return Object.assign({}, state, {
parent: action.info parent: action.info
}); });
case 'SET_STUDIOS': case 'SET_PROJECT_STUDIOS':
// also initialize currentStudioIds, to keep track of which studios
// the project is currently in.
return Object.assign({}, state, { return Object.assign({}, state, {
studios: action.items projectStudios: action.items,
currentStudioIds: action.items.map(item => item.id)
});
case 'SET_CURATED_STUDIOS':
return Object.assign({}, state, {curatedStudios: action.items});
case 'ADD_PROJECT_TO_STUDIO':
// add studio id to our studios-that-this-project-belongs-to set.
return Object.assign({}, state, {
currentStudioIds: state.currentStudioIds.concat(action.studioId)
});
case 'REMOVE_PROJECT_FROM_STUDIO':
return Object.assign({}, state, {
currentStudioIds: state.currentStudioIds.filter(item => (
item !== action.studioId
))
}); });
case 'SET_COMMENTS': case 'SET_COMMENTS':
return Object.assign({}, state, { return Object.assign({}, state, {
@ -73,6 +93,10 @@ module.exports.previewReducer = (state, action) => {
state = JSON.parse(JSON.stringify(state)); state = JSON.parse(JSON.stringify(state));
state.status[action.infoType] = action.status; state.status[action.infoType] = action.status;
return state; return state;
case 'SET_STUDIO_FETCH_STATUS':
state = JSON.parse(JSON.stringify(state));
state.status.studioRequests[action.studioId] = action.status;
return state;
case 'ERROR': case 'ERROR':
log.error(action.error); log.error(action.error);
return state; return state;
@ -116,17 +140,38 @@ module.exports.setRemixes = items => ({
items: items items: items
}); });
module.exports.setStudios = items => ({ module.exports.setProjectStudios = items => ({
type: 'SET_STUDIOS', type: 'SET_PROJECT_STUDIOS',
items: items items: items
}); });
module.exports.setCuratedStudios = items => ({
type: 'SET_CURATED_STUDIOS',
items: items
});
module.exports.addProjectToStudio = studioId => ({
type: 'ADD_PROJECT_TO_STUDIO',
studioId: studioId
});
module.exports.removeProjectFromStudio = studioId => ({
type: 'REMOVE_PROJECT_FROM_STUDIO',
studioId: studioId
});
module.exports.setFetchStatus = (type, status) => ({ module.exports.setFetchStatus = (type, status) => ({
type: 'SET_FETCH_STATUS', type: 'SET_FETCH_STATUS',
infoType: type, infoType: type,
status: status status: status
}); });
module.exports.setStudioFetchStatus = (studioId, status) => ({
type: 'SET_STUDIO_FETCH_STATUS',
studioId: studioId,
status: status
});
module.exports.getProjectInfo = (id, token) => (dispatch => { module.exports.getProjectInfo = (id, token) => (dispatch => {
const opts = { const opts = {
uri: `/projects/${id}` uri: `/projects/${id}`
@ -333,26 +378,89 @@ module.exports.getRemixes = id => (dispatch => {
}); });
}); });
module.exports.getStudios = id => (dispatch => { module.exports.getProjectStudios = id => (dispatch => {
dispatch(module.exports.setFetchStatus('studios', module.exports.Status.FETCHING)); dispatch(module.exports.setFetchStatus('projectStudios', module.exports.Status.FETCHING));
api({ api({
uri: `/projects/${id}/studios?limit=5` uri: `/projects/${id}/studios`
}, (err, body, res) => { }, (err, body, res) => {
if (err) { if (err) {
dispatch(module.exports.setFetchStatus('studios', module.exports.Status.ERROR)); dispatch(module.exports.setFetchStatus('projectStudios', module.exports.Status.ERROR));
dispatch(module.exports.setError(err)); dispatch(module.exports.setError(err));
return; return;
} }
if (typeof body === 'undefined') { if (typeof body === 'undefined') {
dispatch(module.exports.setFetchStatus('studios', module.exports.Status.ERROR)); dispatch(module.exports.setFetchStatus('projectStudios', module.exports.Status.ERROR));
dispatch(module.exports.setError('No studios info')); dispatch(module.exports.setError('No projectStudios info'));
return; return;
} }
if (res.statusCode === 404) { // NotFound if (res.statusCode === 404) { // NotFound
body = []; body = [];
} }
dispatch(module.exports.setFetchStatus('studios', module.exports.Status.FETCHED)); dispatch(module.exports.setFetchStatus('projectStudios', module.exports.Status.FETCHED));
dispatch(module.exports.setStudios(body)); dispatch(module.exports.setProjectStudios(body));
});
});
module.exports.getCuratedStudios = username => (dispatch => {
dispatch(module.exports.setFetchStatus('curatedStudios', module.exports.Status.FETCHING));
api({
uri: `/users/${username}/studios/curate`
}, (err, body, res) => {
if (err) {
dispatch(module.exports.setFetchStatus('curatedStudios', module.exports.Status.ERROR));
dispatch(module.exports.setError(err));
return;
}
if (typeof body === 'undefined') {
dispatch(module.exports.setFetchStatus('curatedStudios', module.exports.Status.ERROR));
dispatch(module.exports.setError('No curated studios info'));
return;
}
if (res.statusCode === 404) { // NotFound
body = [];
}
dispatch(module.exports.setFetchStatus('curatedStudios', module.exports.Status.FETCHED));
dispatch(module.exports.setCuratedStudios(body));
});
});
module.exports.addToStudio = (studioId, projectId, token) => (dispatch => {
dispatch(module.exports.setStudioFetchStatus(studioId, module.exports.Status.FETCHING));
api({
uri: `/studios/${studioId}/project/${projectId}`,
authentication: token,
method: 'POST'
}, (err, body) => {
if (err) {
dispatch(module.exports.setError(err));
return;
}
if (typeof body === 'undefined') {
dispatch(module.exports.setError('Add to studio returned no data'));
return;
}
dispatch(module.exports.setStudioFetchStatus(studioId, module.exports.Status.FETCHED));
dispatch(module.exports.addProjectToStudio(studioId));
});
});
module.exports.leaveStudio = (studioId, projectId, token) => (dispatch => {
dispatch(module.exports.setStudioFetchStatus(studioId, module.exports.Status.FETCHING));
api({
uri: `/studios/${studioId}/project/${projectId}`,
authentication: token,
method: 'DELETE'
}, (err, body) => {
if (err) {
dispatch(module.exports.setError(err));
return;
}
if (typeof body === 'undefined') {
dispatch(module.exports.setError('Leave studio returned no data'));
return;
}
dispatch(module.exports.setStudioFetchStatus(studioId, module.exports.Status.FETCHED));
dispatch(module.exports.removeProjectFromStudio(studioId));
}); });
}); });

View file

@ -1,7 +1,9 @@
{ {
"addToStudio.title": "Add to Studio",
"addToStudio.finishing": "Finishing up...",
"preview.musicExtensionChip": "Music", "preview.musicExtensionChip": "Music",
"preview.penExtensionChip": "Pen", "preview.penExtensionChip": "Pen",
"preview.speechExtensionChip": "Google Speech", "preview.speechExtensionChip": "Google Speech",
"preview.translateExtensionChip": "Google Translate", "preview.translateExtensionChip": "Google Translate",
"preview.videoMotionChip": "Video Motion" "preview.videoMotionChip": "Video Motion"
} }

View file

@ -19,6 +19,7 @@ const RemixCredit = require('./remix-credit.jsx');
const RemixList = require('./remix-list.jsx'); const RemixList = require('./remix-list.jsx');
const StudioList = require('./studio-list.jsx'); const StudioList = require('./studio-list.jsx');
const InplaceInput = require('../../components/forms/inplace-input.jsx'); const InplaceInput = require('../../components/forms/inplace-input.jsx');
const AddToStudioModal = require('../../components/modal/addtostudio/container.jsx');
const ReportModal = require('../../components/modal/report/modal.jsx'); const ReportModal = require('../../components/modal/report/modal.jsx');
const ExtensionChip = require('./extension-chip.jsx'); const ExtensionChip = require('./extension-chip.jsx');
@ -41,6 +42,8 @@ const PreviewPresentation = ({
projectInfo, projectInfo,
remixes, remixes,
report, report,
addToStudioOpen,
projectStudios,
studios, studios,
userOwnsProject, userOwnsProject,
onFavoriteClicked, onFavoriteClicked,
@ -48,6 +51,9 @@ const PreviewPresentation = ({
onReportClicked, onReportClicked,
onReportClose, onReportClose,
onReportSubmit, onReportSubmit,
onAddToStudioClicked,
onAddToStudioClosed,
onToggleStudio,
onSeeInside, onSeeInside,
onUpdate onUpdate
}) => { }) => {
@ -55,7 +61,7 @@ const PreviewPresentation = ({
return ( return (
<div className="preview"> <div className="preview">
<ShareBanner shared={isShared} /> <ShareBanner shared={isShared} />
{ projectInfo && projectInfo.author && projectInfo.author.id && ( { projectInfo && projectInfo.author && projectInfo.author.id && (
<div className="inner"> <div className="inner">
<Formsy> <Formsy>
@ -69,7 +75,7 @@ const PreviewPresentation = ({
</a> </a>
<div className="title"> <div className="title">
{editable ? {editable ?
<InplaceInput <InplaceInput
className="project-title" className="project-title"
handleUpdate={onUpdate} handleUpdate={onUpdate}
@ -237,9 +243,24 @@ const PreviewPresentation = ({
{/* eslint-enable react/jsx-sort-props */} {/* eslint-enable react/jsx-sort-props */}
</div> </div>
<FlexRow className="action-buttons"> <FlexRow className="action-buttons">
<Button className="action-button studio-button"> {(isLoggedIn && userOwnsProject) &&
Add to Studio <React.Fragment>
</Button> <Button
className="action-button studio-button"
key="add-to-studio-button"
onClick={onAddToStudioClicked}
>
Add to Studio
</Button>,
<AddToStudioModal
isOpen={addToStudioOpen}
key="add-to-studio-modal"
studios={studios}
onRequestClose={onAddToStudioClosed}
onToggleStudio={onToggleStudio}
/>
</React.Fragment>
}
<Button className="action-button copy-link-button"> <Button className="action-button copy-link-button">
Copy Link Copy Link
</Button> </Button>
@ -283,7 +304,7 @@ const PreviewPresentation = ({
</div> </div>
<FlexRow className="column"> <FlexRow className="column">
<RemixList remixes={remixes} /> <RemixList remixes={remixes} />
<StudioList studios={studios} /> <StudioList studios={projectStudios} />
</FlexRow> </FlexRow>
</FlexRow> </FlexRow>
</Formsy> </Formsy>
@ -294,6 +315,7 @@ const PreviewPresentation = ({
}; };
PreviewPresentation.propTypes = { PreviewPresentation.propTypes = {
addToStudioOpen: PropTypes.bool,
editable: PropTypes.bool, editable: PropTypes.bool,
extensions: PropTypes.arrayOf(PropTypes.object), extensions: PropTypes.arrayOf(PropTypes.object),
faved: PropTypes.bool, faved: PropTypes.bool,
@ -303,17 +325,21 @@ PreviewPresentation.propTypes = {
isShared: PropTypes.bool, isShared: PropTypes.bool,
loveCount: PropTypes.number, loveCount: PropTypes.number,
loved: PropTypes.bool, loved: PropTypes.bool,
onAddToStudioClicked: PropTypes.func,
onAddToStudioClosed: PropTypes.func,
onFavoriteClicked: PropTypes.func, onFavoriteClicked: PropTypes.func,
onLoveClicked: PropTypes.func, onLoveClicked: PropTypes.func,
onReportClicked: PropTypes.func.isRequired, onReportClicked: PropTypes.func.isRequired,
onReportClose: PropTypes.func.isRequired, onReportClose: PropTypes.func.isRequired,
onReportSubmit: PropTypes.func.isRequired, onReportSubmit: PropTypes.func.isRequired,
onSeeInside: PropTypes.func, onSeeInside: PropTypes.func,
onToggleStudio: PropTypes.func,
onUpdate: PropTypes.func, onUpdate: PropTypes.func,
originalInfo: projectShape, originalInfo: projectShape,
parentInfo: projectShape, parentInfo: projectShape,
projectId: PropTypes.string, projectId: PropTypes.string,
projectInfo: projectShape, projectInfo: projectShape,
projectStudios: PropTypes.arrayOf(PropTypes.object),
remixes: PropTypes.arrayOf(PropTypes.object), remixes: PropTypes.arrayOf(PropTypes.object),
report: PropTypes.shape({ report: PropTypes.shape({
category: PropTypes.string, category: PropTypes.string,

View file

@ -1,3 +1,6 @@
// preview view can show either project page or editor page;
// idea is that we shouldn't require a page reload to switch back and forth
const bindAll = require('lodash.bindall'); const bindAll = require('lodash.bindall');
const React = require('react'); const React = require('react');
const PropTypes = require('prop-types'); const PropTypes = require('prop-types');
@ -24,6 +27,7 @@ class Preview extends React.Component {
super(props); super(props);
bindAll(this, [ bindAll(this, [
'addEventListeners', 'addEventListeners',
'handleToggleStudio',
'handleFavoriteToggle', 'handleFavoriteToggle',
'handleLoveToggle', 'handleLoveToggle',
'handlePermissions', 'handlePermissions',
@ -31,6 +35,8 @@ class Preview extends React.Component {
'handleReportClick', 'handleReportClick',
'handleReportClose', 'handleReportClose',
'handleReportSubmit', 'handleReportSubmit',
'handleAddToStudioClick',
'handleAddToStudioClose',
'handleSeeInside', 'handleSeeInside',
'handleUpdate', 'handleUpdate',
'initCounts', 'initCounts',
@ -49,6 +55,7 @@ class Preview extends React.Component {
favoriteCount: 0, favoriteCount: 0,
loveCount: 0, loveCount: 0,
projectId: parts[1] === 'editor' ? 0 : parts[1], projectId: parts[1] === 'editor' ? 0 : parts[1],
addToStudioOpen: false,
report: { report: {
category: '', category: '',
notes: '', notes: '',
@ -68,15 +75,16 @@ class Preview extends React.Component {
const token = this.props.user.token; const token = this.props.user.token;
this.props.getProjectInfo(this.state.projectId, token); this.props.getProjectInfo(this.state.projectId, token);
this.props.getRemixes(this.state.projectId, token); this.props.getRemixes(this.state.projectId, token);
this.props.getStudios(this.state.projectId, token); this.props.getProjectStudios(this.state.projectId, token);
this.props.getCuratedStudios(username);
this.props.getFavedStatus(this.state.projectId, username, token); this.props.getFavedStatus(this.state.projectId, username, token);
this.props.getLovedStatus(this.state.projectId, username, token); this.props.getLovedStatus(this.state.projectId, username, token);
} else { } else {
this.props.getProjectInfo(this.state.projectId); this.props.getProjectInfo(this.state.projectId);
this.props.getRemixes(this.state.projectId); this.props.getRemixes(this.state.projectId);
this.props.getStudios(this.state.projectId); this.props.getProjectStudios(this.state.projectId);
} }
} }
if (this.props.projectInfo.id !== prevProps.projectInfo.id) { if (this.props.projectInfo.id !== prevProps.projectInfo.id) {
this.getExtensions(this.state.projectId); this.getExtensions(this.state.projectId);
@ -142,6 +150,13 @@ class Preview extends React.Component {
handleReportClose () { handleReportClose () {
this.setState({report: {...this.state.report, open: false}}); this.setState({report: {...this.state.report, open: false}});
} }
handleAddToStudioClick () {
this.setState({addToStudioOpen: true});
}
handleAddToStudioClose () {
this.setState({addToStudioOpen: false});
}
// NOTE: this is a copy, change it
handleReportSubmit (formData) { handleReportSubmit (formData) {
this.setState({report: { this.setState({report: {
category: formData.report_category, category: formData.report_category,
@ -196,6 +211,22 @@ class Preview extends React.Component {
); );
} }
} }
handleToggleStudio (event) {
const studioId = parseInt(event.currentTarget.dataset.id, 10);
if (isNaN(studioId)) { // sanity check in case event had no integer data-id
return;
}
const studio = this.props.studios.find(thisStudio => (thisStudio.id === studioId));
// only send add or leave request to server if we know current status
if ((typeof studio !== 'undefined') && ('includesProject' in studio)) {
this.props.toggleStudio(
(studio.includesProject === false),
studioId,
this.props.projectInfo.id,
this.props.user.token
);
}
}
handleFavoriteToggle () { handleFavoriteToggle () {
this.props.setFavedStatus( this.props.setFavedStatus(
!this.props.faved, !this.props.faved,
@ -231,7 +262,7 @@ class Preview extends React.Component {
} }
} }
handlePermissions () { handlePermissions () {
// TODO: handle admins and mods // TODO: handle admins and mods
if (this.props.projectInfo.author.username === this.props.user.username) { if (this.props.projectInfo.author.username === this.props.user.username) {
this.setState({editable: true}); this.setState({editable: true});
} }
@ -280,6 +311,7 @@ class Preview extends React.Component {
this.props.playerMode ? this.props.playerMode ?
<Page> <Page>
<PreviewPresentation <PreviewPresentation
addToStudioOpen={this.state.addToStudioOpen}
comments={this.props.comments} comments={this.props.comments}
editable={this.state.editable} editable={this.state.editable}
extensions={this.state.extensions} extensions={this.state.extensions}
@ -294,17 +326,21 @@ class Preview extends React.Component {
parentInfo={this.props.parent} parentInfo={this.props.parent}
projectId={this.state.projectId} projectId={this.state.projectId}
projectInfo={this.props.projectInfo} projectInfo={this.props.projectInfo}
projectStudios={this.props.projectStudios}
remixes={this.props.remixes} remixes={this.props.remixes}
report={this.state.report} report={this.state.report}
studios={this.props.studios} studios={this.props.studios}
user={this.props.user} user={this.props.user}
userOwnsProject={this.userOwnsProject()} userOwnsProject={this.userOwnsProject()}
onAddToStudioClicked={this.handleAddToStudioClick}
onAddToStudioClosed={this.handleAddToStudioClose}
onFavoriteClicked={this.handleFavoriteToggle} onFavoriteClicked={this.handleFavoriteToggle}
onLoveClicked={this.handleLoveToggle} onLoveClicked={this.handleLoveToggle}
onReportClicked={this.handleReportClick} onReportClicked={this.handleReportClick}
onReportClose={this.handleReportClose} onReportClose={this.handleReportClose}
onReportSubmit={this.handleReportSubmit} onReportSubmit={this.handleReportSubmit}
onSeeInside={this.handleSeeInside} onSeeInside={this.handleSeeInside}
onToggleStudio={this.handleToggleStudio}
onUpdate={this.handleUpdate} onUpdate={this.handleUpdate}
/> />
</Page> : </Page> :
@ -322,18 +358,20 @@ Preview.propTypes = {
comments: PropTypes.arrayOf(PropTypes.object), comments: PropTypes.arrayOf(PropTypes.object),
faved: PropTypes.bool, faved: PropTypes.bool,
fullScreen: PropTypes.bool, fullScreen: PropTypes.bool,
getCuratedStudios: PropTypes.func.isRequired,
getFavedStatus: PropTypes.func.isRequired, getFavedStatus: PropTypes.func.isRequired,
getLovedStatus: PropTypes.func.isRequired, getLovedStatus: PropTypes.func.isRequired,
getOriginalInfo: PropTypes.func.isRequired, getOriginalInfo: PropTypes.func.isRequired,
getParentInfo: PropTypes.func.isRequired, getParentInfo: PropTypes.func.isRequired,
getProjectInfo: PropTypes.func.isRequired, getProjectInfo: PropTypes.func.isRequired,
getProjectStudios: PropTypes.func.isRequired,
getRemixes: PropTypes.func.isRequired, getRemixes: PropTypes.func.isRequired,
getStudios: PropTypes.func.isRequired,
loved: PropTypes.bool, loved: PropTypes.bool,
original: projectShape, original: projectShape,
parent: projectShape, parent: projectShape,
playerMode: PropTypes.bool, playerMode: PropTypes.bool,
projectInfo: projectShape, projectInfo: projectShape,
projectStudios: PropTypes.arrayOf(PropTypes.object),
remixes: PropTypes.arrayOf(PropTypes.object), remixes: PropTypes.arrayOf(PropTypes.object),
sessionStatus: PropTypes.string, sessionStatus: PropTypes.string,
setFavedStatus: PropTypes.func.isRequired, setFavedStatus: PropTypes.func.isRequired,
@ -341,6 +379,7 @@ Preview.propTypes = {
setLovedStatus: PropTypes.func.isRequired, setLovedStatus: PropTypes.func.isRequired,
setPlayer: PropTypes.func.isRequired, setPlayer: PropTypes.func.isRequired,
studios: PropTypes.arrayOf(PropTypes.object), studios: PropTypes.arrayOf(PropTypes.object),
toggleStudio: PropTypes.func.isRequired,
updateProject: PropTypes.func.isRequired, updateProject: PropTypes.func.isRequired,
user: PropTypes.shape({ user: PropTypes.shape({
id: PropTypes.number, id: PropTypes.number,
@ -359,6 +398,40 @@ Preview.defaultProps = {
user: {} user: {}
}; };
// Build consolidated curatedStudios object from all studio info.
// We add flags to indicate whether the project is currently in the studio,
// and the status of requests to join/leave studios.
const consolidateStudiosInfo = (curatedStudios, projectStudios, currentStudioIds, studioRequests) => {
const consolidatedStudios = [];
projectStudios.forEach(projectStudio => {
const includesProject = (currentStudioIds.indexOf(projectStudio.id) !== -1);
const consolidatedStudio =
Object.assign({}, projectStudio, {includesProject: includesProject});
consolidatedStudios.push(consolidatedStudio);
});
// copy the curated studios that project is not in
curatedStudios.forEach(curatedStudio => {
if (!projectStudios.some(projectStudio => (projectStudio.id === curatedStudio.id))) {
const includesProject = (currentStudioIds.indexOf(curatedStudio.id) !== -1);
const consolidatedStudio =
Object.assign({}, curatedStudio, {includesProject: includesProject});
consolidatedStudios.push(consolidatedStudio);
}
});
// set studio state to hasRequestOutstanding==true if it's being fetched,
// false if it's not
consolidatedStudios.forEach(consolidatedStudio => {
const id = consolidatedStudio.id;
consolidatedStudio.hasRequestOutstanding =
((id in studioRequests) &&
(studioRequests[id] === previewActions.Status.FETCHING));
});
return consolidatedStudios;
};
const mapStateToProps = state => ({ const mapStateToProps = state => ({
projectInfo: state.preview.projectInfo, projectInfo: state.preview.projectInfo,
comments: state.preview.comments, comments: state.preview.comments,
@ -368,7 +441,10 @@ const mapStateToProps = state => ({
parent: state.preview.parent, parent: state.preview.parent,
remixes: state.preview.remixes, remixes: state.preview.remixes,
sessionStatus: state.session.status, sessionStatus: state.session.status,
studios: state.preview.studios, projectStudios: state.preview.projectStudios,
studios: consolidateStudiosInfo(state.preview.curatedStudios,
state.preview.projectStudios, state.preview.currentStudioIds,
state.preview.status.studioRequests),
user: state.session.session.user, user: state.session.session.user,
playerMode: state.scratchGui.mode.isPlayerOnly, playerMode: state.scratchGui.mode.isPlayerOnly,
fullScreen: state.scratchGui.mode.isFullScreen fullScreen: state.scratchGui.mode.isFullScreen
@ -388,8 +464,18 @@ const mapDispatchToProps = dispatch => ({
getRemixes: id => { getRemixes: id => {
dispatch(previewActions.getRemixes(id)); dispatch(previewActions.getRemixes(id));
}, },
getStudios: id => { getProjectStudios: id => {
dispatch(previewActions.getStudios(id)); dispatch(previewActions.getProjectStudios(id));
},
getCuratedStudios: (username, token) => {
dispatch(previewActions.getCuratedStudios(username, token));
},
toggleStudio: (isAdd, studioId, id, token) => {
if (isAdd === true) {
dispatch(previewActions.addToStudio(studioId, id, token));
} else {
dispatch(previewActions.leaveStudio(studioId, id, token));
}
}, },
getFavedStatus: (id, username, token) => { getFavedStatus: (id, username, token) => {
dispatch(previewActions.getFavedStatus(id, username, token)); dispatch(previewActions.getFavedStatus(id, username, token));

View file

@ -0,0 +1 @@
<svg width="20" height="20" xmlns="http://www.w3.org/2000/svg"><path d="M8.75 11.25h-2.5a1.25 1.25 0 1 1 0-2.5h2.5v-2.5a1.25 1.25 0 0 1 2.5 0v2.5h2.5a1.25 1.25 0 1 1 0 2.5h-2.5v2.5a1.249 1.249 0 1 1-2.5 0v-2.5z" fill="#FFF" fill-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 251 B

View file

@ -1,17 +1 @@
<?xml version="1.0" encoding="utf-8"?> <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"><path d="M12.5 10.3c.6.6.6 1.5 0 2.1-.3.3-.7.4-1.1.4-.4 0-.8-.1-1.1-.4L8 10.1l-2.3 2.3c-.3.3-.7.4-1.1.4-.4 0-.8-.1-1.1-.4-.6-.6-.6-1.5 0-2.1L5.9 8 3.5 5.7c-.5-.6-.5-1.6 0-2.2.6-.5 1.6-.5 2.2 0L8 5.9l2.3-2.3c.6-.6 1.5-.6 2.1 0 .6.6.6 1.5 0 2.1L10.1 8l2.4 2.3z" fill="#fff"/></svg>
<!-- Generator: Adobe Illustrator 19.2.1, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
width="16px" height="16px" viewBox="0 0 16 16" style="enable-background:new 0 0 16 16;" xml:space="preserve">
<style type="text/css">
.st0{fill:#FFFFFF;}
</style>
<g>
<g>
<g>
<path class="st0" d="M12.5,10.3c0.6,0.6,0.6,1.5,0,2.1c-0.3,0.3-0.7,0.4-1.1,0.4c-0.4,0-0.8-0.1-1.1-0.4L8,10.1l-2.3,2.3
c-0.3,0.3-0.7,0.4-1.1,0.4c-0.4,0-0.8-0.1-1.1-0.4c-0.6-0.6-0.6-1.5,0-2.1L5.9,8L3.5,5.7C3,5.1,3,4.1,3.5,3.5
C4.1,3,5.1,3,5.7,3.5L8,5.9l2.3-2.3c0.6-0.6,1.5-0.6,2.1,0c0.6,0.6,0.6,1.5,0,2.1L10.1,8L12.5,10.3z"/>
</g>
</g>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 801 B

After

Width:  |  Height:  |  Size: 342 B

View file

@ -0,0 +1 @@
<svg width="20" height="20" xmlns="http://www.w3.org/2000/svg"><path stroke="#FFF" stroke-width="2.5" d="M6 10l3 3 6-6" fill="none" fill-rule="evenodd" stroke-linecap="round" stroke-linejoin="round"/></svg>

After

Width:  |  Height:  |  Size: 206 B

View file

@ -1,12 +1 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?> <svg width="20" height="20" xmlns="http://www.w3.org/2000/svg"><path d="M16.904 9.164L15.43 7.695l-5.192 3.91a1.353 1.353 0 0 1-1.902-.273 1.343 1.343 0 0 1 0-1.622l3.925-5.174-1.44-1.434a.637.637 0 0 1 .446-1.094L17.36 2a.657.657 0 0 1 .641.64v6.08c0 .563-.694.844-1.096.444zM15.381 18H3.037A1.036 1.036 0 0 1 2 16.965v-12.3c0-.571.465-1.036 1.037-1.036h4.786a1.036 1.036 0 0 1 0 2.07H4.077V15.93h10.265v-2.72c0-.572.465-1.036 1.039-1.036.573 0 1.04.464 1.04 1.035v3.755c0 .572-.467 1.035-1.04 1.035z" fill="#4C97FF" fill-rule="evenodd"/></svg>
<svg width="20px" height="20px" viewBox="0 0 20 20" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<!-- Generator: Sketch 43.2 (39069) - http://www.bohemiancoding.com/sketch -->
<title>open-blue</title>
<desc>Created with Sketch.</desc>
<defs></defs>
<g id="Page-1" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
<g id="open-blue" fill="#4C97FF">
<path d="M16.904265,9.16353035 L15.4298099,7.69520276 L10.2383662,11.6050888 C9.64794591,12.057779 8.79050666,11.9379804 8.33625534,11.3315664 C7.9692373,10.844951 7.97668404,10.1706865 8.33625534,9.70951497 L12.2606889,4.53591307 L10.8213398,3.10151073 C10.4107052,2.6922873 10.7011281,2.00848131 11.2670806,2.00848131 L17.3585163,2 C17.709577,2.00848131 18,2.29896634 18,2.64033925 L18,8.71932149 C18,9.28332892 17.3063891,9.56427246 16.904265,9.16353035 Z M15.3807332,18 L3.03722491,18 C2.46488952,18 2,17.5367082 2,16.9652796 L2,4.66419295 C2,4.09382454 2.46488952,3.62947257 3.03722491,3.62947257 L7.82335296,3.62947257 C8.39781598,3.62947257 8.86164168,4.09382454 8.86164168,4.66419295 C8.86164168,5.23562152 8.39781598,5.69891333 7.82335296,5.69891333 L4.07657745,5.69891333 L4.07657745,15.9305592 L14.3424445,15.9305592 L14.3424445,13.2101776 C14.3424445,12.638749 14.807334,12.1754572 15.3807332,12.1754572 C15.9541324,12.1754572 16.4200857,12.638749 16.4200857,13.2101776 L16.4200857,16.9652796 C16.4200857,17.5367082 15.9541324,18 15.3807332,18 Z" id="open-white"></path>
</g>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 545 B

View file

@ -1,12 +1 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?> <svg width="20" height="20" xmlns="http://www.w3.org/2000/svg"><path d="M16.904 9.164L15.43 7.695l-5.192 3.91a1.353 1.353 0 0 1-1.902-.273 1.343 1.343 0 0 1 0-1.622l3.925-5.174-1.44-1.434a.637.637 0 0 1 .446-1.094L17.36 2a.657.657 0 0 1 .641.64v6.08c0 .563-.694.844-1.096.444zM15.381 18H3.037A1.036 1.036 0 0 1 2 16.965v-12.3c0-.571.465-1.036 1.037-1.036h4.786a1.036 1.036 0 0 1 0 2.07H4.077V15.93h10.265v-2.72c0-.572.465-1.036 1.039-1.036.573 0 1.04.464 1.04 1.035v3.755c0 .572-.467 1.035-1.04 1.035z" fill="#FFF" fill-rule="evenodd"/></svg>
<svg width="20px" height="20px" viewBox="0 0 20 20" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<!-- Generator: Sketch 43.2 (39069) - http://www.bohemiancoding.com/sketch -->
<title>open-modal-icon</title>
<desc>Created with Sketch.</desc>
<defs></defs>
<g id="Page-1" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
<g id="open-modal-icon" fill="#FFFFFF">
<path d="M16.904265,9.16353035 L15.4298099,7.69520276 L10.2383662,11.6050888 C9.64794591,12.057779 8.79050666,11.9379804 8.33625534,11.3315664 C7.9692373,10.844951 7.97668404,10.1706865 8.33625534,9.70951497 L12.2606889,4.53591307 L10.8213398,3.10151073 C10.4107052,2.6922873 10.7011281,2.00848131 11.2670806,2.00848131 L17.3585163,2 C17.709577,2.00848131 18,2.29896634 18,2.64033925 L18,8.71932149 C18,9.28332892 17.3063891,9.56427246 16.904265,9.16353035 Z M15.3807332,18 L3.03722491,18 C2.46488952,18 2,17.5367082 2,16.9652796 L2,4.66419295 C2,4.09382454 2.46488952,3.62947257 3.03722491,3.62947257 L7.82335296,3.62947257 C8.39781598,3.62947257 8.86164168,4.09382454 8.86164168,4.66419295 C8.86164168,5.23562152 8.39781598,5.69891333 7.82335296,5.69891333 L4.07657745,5.69891333 L4.07657745,15.9305592 L14.3424445,15.9305592 L14.3424445,13.2101776 C14.3424445,12.638749 14.807334,12.1754572 15.3807332,12.1754572 C15.9541324,12.1754572 16.4200857,12.638749 16.4200857,13.2101776 L16.4200857,16.9652796 C16.4200857,17.5367082 15.9541324,18 15.3807332,18 Z"></path>
</g>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 542 B

View file

@ -0,0 +1 @@
<svg width="20" height="20" xmlns="http://www.w3.org/2000/svg"><path d="M15 10a5 5 0 1 0-5 5" stroke="#FFF" stroke-width="2.5" fill="none" fill-rule="evenodd" stroke-linecap="round" stroke-linejoin="round"/></svg>

After

Width:  |  Height:  |  Size: 213 B