Merge pull request #2044 from LLK/release/august-2018

[Master] Release for August 2018
This commit is contained in:
chrisgarrity 2018-08-30 10:10:22 -04:00 committed by GitHub
commit 17bf7c4061
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
55 changed files with 1976 additions and 367 deletions

View file

@ -5,3 +5,4 @@ intl/*
locales/*
**/*.min.js
**/node_modules/*
scratch-gui/*

View file

@ -18,11 +18,26 @@ env:
- API_HOST_VAR=API_HOST_$TRAVIS_BRANCH
- API_HOST=${!API_HOST_VAR}
- API_HOST=${API_HOST:-$API_HOST_STAGING}
- ASSET_HOST_master=https://assets.scratch.mit.edu
- ASSET_HOST_STAGING=https://assets.scratch.ly
- ASSET_HOST_VAR=ASSET_HOST_$TRAVIS_BRANCH
- ASSET_HOST=${!ASSET_HOST_VAR}
- ASSET_HOST=${ASSET_HOST:-$ASSET_HOST_STAGING}
- BACKPACK_HOST_master=https://backpack.scratch.mit.edu
- BACKPACK_HOST_STAGING=https://backpack.scratch.ly
- BACKPACK_HOST_VAR=BACKPACK_HOST_$TRAVIS_BRANCH
- BACKPACK_HOST=${!BACKPACK_HOST_VAR}
- BACKPACK_HOST=${BACKPACK_HOST:-$BACKPACK_HOST_STAGING}
- ROOT_URL_master=https://scratch.mit.edu
- ROOT_URL_STAGING=https://scratch.ly
- ROOT_URL_VAR=ROOT_URL_$TRAVIS_BRANCH
- ROOT_URL=${!ROOT_URL_VAR}
- ROOT_URL=${ROOT_URL:-$ROOT_URL_STAGING}
- PROJECT_HOST_master=https://projects.scratch.mit.edu
- PROJECT_HOST_STAGING=https://projects.scratch.ly
- PROJECT_HOST_VAR=PROJECT_HOST_$TRAVIS_BRANCH
- PROJECT_HOST=${!PROJECT_HOST_VAR}
- PROJECT_HOST=${PROJECT_HOST:-$PROJECT_HOST_STAGING}
- PATH=$PATH:$PWD/test/integration/node_modules/chromedriver/bin
- AWS_ACCESS_KEY_ID=$EB_AWS_ACCESS_KEY_ID
- AWS_SECRET_ACCESS_KEY=$EB_AWS_SECRET_ACCESS_KEY
@ -69,6 +84,7 @@ addons:
install:
- sudo -H pip install -r requirements.txt
- npm --production=false install
- npm --production=false update
jobs:
include:
- stage: test
@ -94,8 +110,8 @@ jobs:
- cd test/integration
- npm install
- cd -
script: npm run smoke
script: npm run smoke-sauce
stages:
- test
- name: smoke
if: branch IN (travis) and type != pull_request
if: type != pull_request

View file

@ -82,8 +82,11 @@ To stop the process that is making the site available to your web browser (creat
`npm start` can be configured with the following environment variables
| Variable | Default | Description |
| ------------- | ----------------------------- | ---------------------------------------------- |
| --------------- | ---------------------------------- | ---------------------------------------------- |
| `API_HOST` | `https://api.scratch.mit.edu` | Hostname for API requests |
| `ASSETS_HOST` | `https://assets.scratch.mit.edu` | Hostname for asset requests |
| `BACKPACK_HOST` | `https://backpack.scratch.mit.edu` | Hostname for backpack requests |
| `PROJECTS_HOST` | `https://projects.scratch.mit.edu` | Hostname for project requests |
| `SENTRY_DSN` | `''` | DSN for Sentry |
| `FALLBACK` | `''` | Pass-through location for old site |
| `GA_TRACKER` | `''` | Where to log Google Analytics data |

View file

@ -21,7 +21,18 @@ routes.forEach(route => {
app.get(route.pattern, handler(route));
});
app.use(webpackDevMiddleware(compiler));
var middlewareOptions = {};
if (process.env.USE_DOCKER_WATCHOPTIONS) {
middlewareOptions = {
watchOptions: {
aggregateTimeout: 500,
poll: 2500,
ignored: ['node_modules', 'build']
}
};
}
app.use(webpackDevMiddleware(compiler, middlewareOptions));
var proxyHost = process.env.FALLBACK || '';
if (proxyHost !== '') {

View file

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

View file

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

View file

@ -5,6 +5,7 @@ $ui-blue-10percent: hsla(215, 100, 65, .1);
$ui-blue-25percent: hsla(215, 100, 65, .25);
$ui-orange: hsla(38, 100, 55, 1); // #FFAB19 Control Primary
$ui-orange-10percent: hsla(35, 90, 55, .1);
$ui-orange-25percent: hsla(35, 90, 55, .25);
$ui-light-gray: hsla(0, 0, 98, 1); //#FAFAFA
@ -13,7 +14,6 @@ $ui-dark-gray: hsla(0, 0, 70, 1); //#B3B3B3
$background-color: hsla(0, 0, 99, 1); //#FDFDFD
/* UI Secondary Colors */
/* 3.0 colors */
/* Using www naming convention for now, should be consistent with gui */
@ -27,20 +27,25 @@ $ui-coral-dark: hsla(350, 100, 60, 1); // #FF3355 More Blocks tertiary
$ui-white: hsla(0, 100%, 100%, 1); //#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
/* modals */
$ui-mint-green: hsl(163, 69, 44);
$ui-light-mint: hsl(163, 53, 67);
/* Overlay UI Gray Colors */
$active-gray: hsla(0, 0, 0, .1);
$active-dark-gray: hsla(0, 0, 0, .2);
$box-shadow-gray: hsla(0, 0, 0, .25);
$overlay-gray: hsla(0, 0, 0, .75);
$transparent-light-blue: rgba(229, 240, 254, 0);
/* Typography Colors */
$header-gray: hsla(225, 15, 40, 1); //#575E75
$type-gray: hsla(225, 15, 40, 1); //#575E75
$type-gray-75percent: hsla(225, 15, 40, .75);
$type-white: hsla(0, 100, 100, 1); //#FFF
$link-blue: $ui-blue;

View file

@ -108,7 +108,7 @@ const Footer = props => (
</a>
</dd>
<dd>
<a href="https://wiki.scratch.mit.edu/">
<a href="https://en.scratch-wiki.info/">
<FormattedMessage id="general.wiki" />
</a>
</dd>

View file

@ -2,7 +2,7 @@
.char-count {
letter-spacing: 1px;
color: lighten($type-gray, 30%);
color: $type-gray-75percent;
font-weight: 500;
&.overmax {

View file

@ -2,7 +2,6 @@
@import "../../frameless";
$base-bg: $ui-light-gray;
$pass-bg: lighten($ui-aqua, 35%);
.row {
label {
@ -32,8 +31,7 @@ $pass-bg: lighten($ui-aqua, 35%);
}
&.pass {
border: 1px solid $active-dark-gray;
background-color: $pass-bg;
border: 1px solid $ui-aqua;
}
/* IE10/11-specific style resets */

View file

@ -28,8 +28,8 @@ module.exports.validationHOCFactory = defaultValidationErrors => (Component => {
<Component
validationErrors={defaults(
{},
defaultValidationErrors,
props.validationErrors
props.validationErrors,
defaultValidationErrors
)}
{...omit(props, ['validationErrors'])}
/>

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,189 @@
@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%;
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;
margin: 3.75rem auto;
border-radius: 1rem;
box-shadow: 0 0 0 1px $active-gray;
box-shadow: 0 0 0 4px $ui-white-15percent;
background-color: $ui-white;
padding: 0;
width: 48.75rem;
&:focus {
outline: none;
}
}
.modal-overlay {
@ -21,10 +25,6 @@
background-color: transparentize($ui-blue, .3);
}
.modal-content:focus {
outline: none;
}
$modal-close-size: 1rem;
.modal-content-close {
position: absolute;
@ -59,3 +59,52 @@ $modal-close-size: 1rem;
position: fixed;
}
}
/* Close button, Submit button, etc. */
.action-buttons {
display: flex;
margin: 1.125rem .8275rem .9375rem .8275rem;
line-height: 1.5rem;
justify-content: flex-end !important;
align-items: flex-start;
flex-wrap: nowrap;
}
/* setting overall modal to contain overflow looks good, but isn't
compatible with elements (like validation popups) that need to bleed
past modal boundary. This class can be used to force modal button
row to appear to contain overflow. */
.action-buttons-overflow-fix {
margin-bottom: .9375rem;
}
.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;
}
.action-button.disabled {
background-color: $active-dark-gray;
}
.error-text
{
display: block;
border: 1px solid $active-gray;
border-radius: 5px;
background-color: $ui-orange;
padding: 1rem;
min-height: 1rem;
overflow: visible;
color: $type-white;
}

View file

@ -1,16 +1,20 @@
const bindAll = require('lodash.bindall');
const PropTypes = require('prop-types');
const React = require('react');
const connect = require('react-redux').connect;
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 classNames = require('classnames');
const Form = require('../../forms/form.jsx');
const Button = require('../../forms/button.jsx');
const Select = require('../../forms/select.jsx');
const Spinner = require('../../spinner/spinner.jsx');
const TextArea = require('../../forms/textarea.jsx');
const FlexRow = require('../../flex-row/flex-row.jsx');
const previewActions = require('../../../redux/preview.js');
require('../../forms/button.scss');
require('./modal.scss');
@ -67,12 +71,24 @@ class ReportModal extends React.Component {
constructor (props) {
super(props);
bindAll(this, [
'handleReportCategorySelect'
'handleCategorySelect',
'handleValid',
'handleInvalid'
]);
this.state = {reportCategory: this.props.report.category};
this.state = {
category: '',
notes: '',
valid: false
};
}
handleReportCategorySelect (name, value) {
this.setState({reportCategory: value});
handleCategorySelect (name, value) {
this.setState({category: value});
}
handleValid () {
this.setState({valid: true});
}
handleInvalid () {
this.setState({valid: false});
}
lookupPrompt (value) {
const prompt = REPORT_OPTIONS.find(item => item.value === value).prompt;
@ -81,17 +97,24 @@ class ReportModal extends React.Component {
render () {
const {
intl,
isConfirmed,
isError,
isOpen,
isWaiting,
onReport, // eslint-disable-line no-unused-vars
report,
onRequestClose,
type,
...modalProps
} = this.props;
const submitEnabled = this.state.valid && !isWaiting;
const submitDisabledParam = submitEnabled ? {} : {disabled: 'disabled'};
const contentLabel = intl.formatMessage({id: `report.${type}`});
return (
<Modal
className="mod-report"
contentLabel={contentLabel}
isOpen={report.open}
isOpen={isOpen}
onRequestClose={onRequestClose}
{...modalProps}
>
<div>
@ -101,9 +124,26 @@ class ReportModal extends React.Component {
</div>
</div>
<Form
className="report"
onInvalid={this.handleInvalid}
onValid={this.handleValid}
onValidSubmit={onReport}
>
<div className="report-modal-content">
{isConfirmed ? (
<div className="received">
<div className="received-header">
<FormattedMessage id="report.receivedHeader" />
</div>
<FormattedMessage id="report.receivedBody" />
</div>
) : (
<div>
<div className="instructions">
<FormattedMessage
id={`report.${type}Instructions`}
key={`report.${type}Instructions`}
values={{
CommunityGuidelinesLink: (
<a href="/community_guidelines">
@ -112,10 +152,7 @@ class ReportModal extends React.Component {
)
}}
/>
<Form
className="report"
onSubmit={onReport}
>
</div>
<Select
required
elementWrapperClassName="report-modal-field"
@ -123,10 +160,16 @@ class ReportModal extends React.Component {
name="report_category"
options={REPORT_OPTIONS.map(option => ({
value: option.value,
label: this.props.intl.formatMessage(option.label)
label: this.props.intl.formatMessage(option.label),
key: option.value
}))}
value={this.state.reportCategory}
onChange={this.handleReportCategorySelect}
validationErrors={{
isDefaultRequiredValue: this.props.intl.formatMessage({
id: 'report.reasonMissing'
})
}}
value={this.state.category}
onChange={this.handleCategorySelect}
/>
<TextArea
required
@ -134,8 +177,11 @@ class ReportModal extends React.Component {
elementWrapperClassName="report-modal-field"
label={null}
name="notes"
placeholder={this.lookupPrompt(this.state.reportCategory)}
placeholder={this.lookupPrompt(this.state.category)}
validationErrors={{
isDefaultRequiredValue: this.props.intl.formatMessage({
id: 'report.textMissing'
}),
maxLength: this.props.intl.formatMessage({id: 'report.tooLongError'}),
minLength: this.props.intl.formatMessage({id: 'report.tooShortError'})
}}
@ -143,30 +189,55 @@ class ReportModal extends React.Component {
maxLength: 500,
minLength: 20
}}
value={report.notes}
value={this.state.notes}
/>
{report.waiting ? [
</div>
)}
{isError && (
<div className="error-text">
<FormattedMessage id="report.error" />
</div>
)}
</div>
<FlexRow className="action-buttons">
<div className="action-buttons-overflow-fix">
{isConfirmed ? (
<Button
className="submit-button white"
disabled="disabled"
key="submitButton"
type="submit"
className="action-button submit-button"
type="button"
onClick={onRequestClose}
>
<Spinner />
<div className="action-button-text">
<FormattedMessage id="general.close" />
</div>
</Button>
] : [
) : (
<Button
className="submit-button white"
className={classNames(
'action-button',
'submit-button',
{disabled: !submitEnabled}
)}
{...submitDisabledParam}
key="submitButton"
type="submit"
>
{isWaiting ? (
<div className="action-button-text">
<Spinner mode="smooth" />
<FormattedMessage id="report.sending" />
</div>
) : (
<div className="action-button-text">
<FormattedMessage id="report.send" />
</div>
)}
</Button>
]}
)}
</div>
</FlexRow>
</Form>
</div>
</div>
</Modal>
);
}
@ -174,15 +245,26 @@ class ReportModal extends React.Component {
ReportModal.propTypes = {
intl: intlShape,
isConfirmed: PropTypes.bool,
isError: PropTypes.bool,
isOpen: PropTypes.bool,
isWaiting: PropTypes.bool,
onReport: PropTypes.func,
onRequestClose: PropTypes.func,
report: PropTypes.shape({
category: PropTypes.string,
notes: PropTypes.string,
open: PropTypes.bool,
waiting: PropTypes.bool
}),
type: PropTypes.string
};
module.exports = injectIntl(ReportModal);
const mapStateToProps = state => ({
isConfirmed: state.preview.status.report === previewActions.Status.FETCHED,
isError: state.preview.status.report === previewActions.Status.ERROR,
isWaiting: state.preview.status.report === previewActions.Status.FETCHING
});
const mapDispatchToProps = () => ({});
const ConnectedReportModal = connect(
mapStateToProps,
mapDispatchToProps
)(ReportModal);
module.exports = injectIntl(ConnectedReportModal);

View file

@ -9,7 +9,7 @@
margin: 100px auto;
outline: none;
padding: 0;
width: 30rem;
width: 36.25rem; /* 580px; */
user-select: none;
}
@ -34,26 +34,45 @@
.report-modal-content {
margin: 1rem auto;
width: 80%;
line-height: 1.5rem;
font-size: .875rem;
.instructions {
line-height: 1.5rem;
}
.received {
margin: 0 auto;
width: 90%;
text-align: center;
line-height: 1.65rem;
.received-header {
font-weight: bold;
}
}
.error-text {
margin-top: .9375rem;
}
.validation-message {
$arrow-border-width: 1rem;
display: block;
position: absolute;
top: 0;
left: 0;
transform: translate(23.5rem, 0);
left: 100%; /* position to the right of parent */
margin-left: $arrow-border-width;
border: 1px solid $active-gray;
border-radius: 5px;
background-color: $ui-orange;
padding: 1rem;
min-width: 12rem;
max-width: 18.75rem;
min-height: 1rem;
overflow: visible;
color: $type-white;
/* arrow on box that points to the left */
&:before {
display: block;
position: absolute;
@ -78,3 +97,13 @@
.report-modal-field {
position: relative;
}
.form-group.has-error {
.textarea, select {
border: 1px solid $ui-orange;
}
}
.report-text .textarea {
margin-bottom: 0;
}

View file

@ -249,7 +249,7 @@ class Navigation extends React.Component {
'message-count': true,
'show': this.props.unreadMessageCount > 0
})}
>{this.props.unreadMessageCount}</span>
>{this.props.unreadMessageCount} </span>
<FormattedMessage id="general.messages" />
</a>
</li>,

View file

@ -41,7 +41,7 @@ a.social-messages-profile-link {
color: $type-gray;
&:hover {
color: darken($type-gray, 10);
color: $link-blue;
}
}

View file

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

View file

@ -18,13 +18,13 @@
animation: circleFadeDelay 1.2s infinite ease-in-out both;
margin: 0 auto;
border-radius: 100%;
background-color: darken($ui-white, 8%);
background-color: $ui-gray;
width: 15%;
height: 15%;
content: "";
.white & {
background-color: $ui-blue-dark
background-color: $ui-blue-dark;
}
}
}
@ -56,3 +56,63 @@
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

@ -106,7 +106,7 @@
font-weight: 500;
&:hover {
background-color: lighten($link-blue, 40%);
background-color: $ui-blue-10percent;
}
}

View file

@ -6,6 +6,7 @@
"general.birthMonth": "Birth Month",
"general.birthYear": "Birth Year",
"general.donate": "Donate",
"general.close": "Close",
"general.collaborators": "Collaborators",
"general.community": "Community",
"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.noDeletionLink": "change your password",
"general.notRequired": "Not Required",
"general.okay": "Okay",
"general.other": "Other",
"general.offlineEditor": "Offline Editor",
"general.password": "Password",
@ -170,6 +172,7 @@
"registration.welcomeStepTitle": "Hurray! Welcome to Scratch!",
"thumbnail.by": "by",
"report.error": "Something went wrong when trying to send your message. Please try again.",
"report.project": "Report Project",
"report.projectInstructions": "From the dropdown below, please select the reason why you feel this project is disrespectful or inappropriate or otherwise breaks the {CommunityGuidelinesLink}.",
"report.CommunityGuidelinesLinkText": "Scratch Community Guidelines",
@ -179,8 +182,11 @@
"report.reasonScary": "Too Violent or Scary",
"report.reasonLanguage": "Inappropriate Language",
"report.reasonMusic": "Inappropriate Music",
"report.reasonMissing": "Please select a reason",
"report.reasonImage": "Inappropriate Images",
"report.reasonPersonal": "Sharing Personal Contact Information",
"report.receivedHeader": "We have received your report!",
"report.receivedBody": "The Scratch Team will review the project based on the Scratch community guidelines.",
"report.promptPlaceholder": "Select a reason why above.",
"report.promptCopy": "Please provide a link to the original project",
"report.promptUncredited": "Please provide links to the uncredited content",
@ -192,5 +198,7 @@
"report.promptImage": "Please say the name of the sprite or the backdrop with the inappropriate image",
"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.send": "Send"
"report.send": "Send",
"report.sending": "Sending...",
"report.textMissing": "Please tell us why you are reporting this project"
}

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

@ -74,7 +74,7 @@ p {
padding: 1.25em;
&.orange {
background-color: lighten($ui-orange, 30);
background-color: $ui-orange-10percent;
}
}
@ -138,7 +138,7 @@ p {
}
::selection {
background-color: lighten($ui-blue, 30);
background-color: $ui-blue-25percent;
}
ol,

View file

@ -1,4 +1,7 @@
const defaults = require('lodash.defaults');
const keyMirror = require('keymirror');
const async = require('async');
const merge = require('lodash.merge');
const api = require('../lib/api');
const log = require('../lib/log');
@ -19,16 +22,22 @@ module.exports.getInitialState = () => ({
original: module.exports.Status.NOT_FETCHED,
parent: module.exports.Status.NOT_FETCHED,
remixes: module.exports.Status.NOT_FETCHED,
studios: module.exports.Status.NOT_FETCHED
report: module.exports.Status.NOT_FETCHED,
projectStudios: module.exports.Status.NOT_FETCHED,
curatedStudios: module.exports.Status.NOT_FETCHED,
studioRequests: {}
},
projectInfo: {},
remixes: [],
comments: [],
replies: {},
faved: false,
loved: false,
original: {},
parent: {},
studios: []
projectStudios: [],
curatedStudios: [],
currentStudioIds: []
});
module.exports.previewReducer = (state, action) => {
@ -53,13 +62,33 @@ module.exports.previewReducer = (state, action) => {
return Object.assign({}, state, {
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, {
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':
return Object.assign({}, state, {
comments: action.items
comments: [...state.comments, ...action.items] // TODO: consider a different way of doing this?
});
case 'SET_REPLIES':
return Object.assign({}, state, {
replies: merge({}, state.replies, action.replies)
});
case 'SET_LOVED':
return Object.assign({}, state, {
@ -73,6 +102,10 @@ module.exports.previewReducer = (state, action) => {
state = JSON.parse(JSON.stringify(state));
state.status[action.infoType] = action.status;
return state;
case 'SET_STUDIO_FETCH_STATUS':
state = JSON.parse(JSON.stringify(state));
state.status.studioRequests[action.studioId] = action.status;
return state;
case 'ERROR':
log.error(action.error);
return state;
@ -116,17 +149,48 @@ module.exports.setRemixes = items => ({
items: items
});
module.exports.setStudios = items => ({
type: 'SET_STUDIOS',
module.exports.setProjectStudios = items => ({
type: 'SET_PROJECT_STUDIOS',
items: items
});
module.exports.setComments = items => ({
type: 'SET_COMMENTS',
items: items
});
module.exports.setReplies = replies => ({
type: 'SET_REPLIES',
replies: replies
});
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) => ({
type: 'SET_FETCH_STATUS',
infoType: type,
status: status
});
module.exports.setStudioFetchStatus = (studioId, status) => ({
type: 'SET_STUDIO_FETCH_STATUS',
studioId: studioId,
status: status
});
module.exports.getProjectInfo = (id, token) => (dispatch => {
const opts = {
uri: `/projects/${id}`
@ -212,7 +276,57 @@ module.exports.getFavedStatus = (id, username, token) => (dispatch => {
});
});
module.exports.getTopLevelComments = (id, offset) => (dispatch => {
dispatch(module.exports.setFetchStatus('comments', module.exports.Status.FETCHING));
api({
uri: `/comments/project/${id}`,
params: {offset: offset || 0}
}, (err, body) => {
if (err) {
dispatch(module.exports.setFetchStatus('comments', module.exports.Status.ERROR));
dispatch(module.exports.setError(err));
return;
}
if (typeof body === 'undefined') {
dispatch(module.exports.setFetchStatus('comments', module.exports.Status.ERROR));
dispatch(module.exports.setError('No comment info'));
return;
}
dispatch(module.exports.setFetchStatus('comments', module.exports.Status.FETCHED));
dispatch(module.exports.setComments(body));
dispatch(module.exports.getReplies(id, body.map(comment => comment.id)));
});
});
module.exports.getReplies = (projectId, commentIds) => (dispatch => {
dispatch(module.exports.setFetchStatus('replies', module.exports.Status.FETCHING));
const fetchedReplies = {};
async.eachLimit(commentIds, 10, (parentId, callback) => {
api({
uri: `/comments/project/${projectId}/${parentId}`
}, (err, body) => {
if (err) {
return callback(`Error fetching comment replies: ${err}`);
}
if (typeof body === 'undefined') {
return callback('No comment reply information');
}
fetchedReplies[parentId] = body;
callback(null, body);
});
}, err => {
if (err) {
dispatch(module.exports.setFetchStatus('replies', module.exports.Status.ERROR));
dispatch(module.exports.setError(err));
return;
}
dispatch(module.exports.setFetchStatus('replies', module.exports.Status.FETCHED));
dispatch(module.exports.setReplies(fetchedReplies));
});
});
module.exports.setFavedStatus = (faved, id, username, token) => (dispatch => {
dispatch(module.exports.setFetchStatus('faved', module.exports.Status.FETCHING));
if (faved) {
api({
uri: `/projects/${id}/favorites/user/${username}`,
@ -272,6 +386,7 @@ module.exports.getLovedStatus = (id, username, token) => (dispatch => {
});
module.exports.setLovedStatus = (loved, id, username, token) => (dispatch => {
dispatch(module.exports.setFetchStatus('loved', module.exports.Status.FETCHING));
if (loved) {
api({
uri: `/projects/${id}/loves/user/${username}`,
@ -333,30 +448,94 @@ module.exports.getRemixes = id => (dispatch => {
});
});
module.exports.getStudios = id => (dispatch => {
dispatch(module.exports.setFetchStatus('studios', module.exports.Status.FETCHING));
module.exports.getProjectStudios = id => (dispatch => {
dispatch(module.exports.setFetchStatus('projectStudios', module.exports.Status.FETCHING));
api({
uri: `/projects/${id}/studios?limit=5`
uri: `/projects/${id}/studios`
}, (err, body, res) => {
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));
return;
}
if (typeof body === 'undefined') {
dispatch(module.exports.setFetchStatus('studios', module.exports.Status.ERROR));
dispatch(module.exports.setError('No studios info'));
dispatch(module.exports.setFetchStatus('projectStudios', module.exports.Status.ERROR));
dispatch(module.exports.setError('No projectStudios info'));
return;
}
if (res.statusCode === 404) { // NotFound
body = [];
}
dispatch(module.exports.setFetchStatus('studios', module.exports.Status.FETCHED));
dispatch(module.exports.setStudios(body));
dispatch(module.exports.setFetchStatus('projectStudios', module.exports.Status.FETCHED));
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));
});
});
module.exports.updateProject = (id, jsonData, username, token) => (dispatch => {
dispatch(module.exports.setFetchStatus('project', module.exports.Status.FETCHING));
api({
uri: `/projects/${id}`,
authentication: token,
@ -382,3 +561,27 @@ module.exports.updateProject = (id, jsonData, username, token) => (dispatch => {
dispatch(module.exports.setProjectInfo(body));
});
});
module.exports.reportProject = (id, jsonData) => (dispatch => {
dispatch(module.exports.setFetchStatus('report', module.exports.Status.FETCHING));
// scratchr2 will fail if no thumbnail base64 string provided. We don't yet have
// a way to get the actual project thumbnail in www/gui, so for now just submit
// a minimal base64 png string.
defaults(jsonData, {
thumbnail: 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC' +
'0lEQVR42mP8/x8AAwMCAO+ip1sAAAAASUVORK5CYII='
});
api({
host: '',
uri: `/site-api/projects/all/${id}/report/`,
method: 'POST',
json: jsonData,
useCsrf: true
}, (err, body, res) => {
if (err || res.statusCode !== 200) {
dispatch(module.exports.setFetchStatus('report', module.exports.Status.ERROR));
return;
}
dispatch(module.exports.setFetchStatus('report', module.exports.Status.FETCHED));
});
});

View file

@ -67,7 +67,7 @@
display: block;
&:hover {
background-color: lighten($ui-blue, 40);
background-color: $ui-blue-25percent;
}
}
}

View file

@ -27,7 +27,7 @@
}
.conf2017-title-band {
background-color: lighten($ui-blue, 10%);
background-color: $ui-blue-dark;
padding: 1.5rem;
text-align: center;
color: $type-white;

View file

@ -174,6 +174,14 @@ const Credits = () => (
<span className="name">Tracy Tang</span>
</li>
<li>
<img
alt="Bryce Avatar"
src="//cdn.scratch.mit.edu/get_image/user/2029640_170x170.png"
/>
<span className="name">Bryce Taylor</span>
</li>
<li>
<img
alt="Matthew Avatar"
@ -198,6 +206,14 @@ const Credits = () => (
<span className="name">Chris Willis-Ford</span>
</li>
<li>
<img
alt="Kathy Avatar"
src="//cdn.scratch.mit.edu/get_image/user/26779669_170x170.png"
/>
<span className="name">Kathy Wu</span>
</li>
<li>
<img
alt="Julia Avatar"

View file

@ -54,9 +54,9 @@
}
.admin-message {
border: 1px solid darken($ui-gray, 10);
border: 1px solid $active-dark-gray;
border-radius: 5px;
background-color: lighten($ui-blue, 40);
background-color: $ui-blue-25percent;
padding: 1rem;
}

View file

@ -0,0 +1,69 @@
const React = require('react');
const PropTypes = require('prop-types');
const FlexRow = require('../../../components/flex-row/flex-row.jsx');
const Avatar = require('../../../components/avatar/avatar.jsx');
const FormattedRelative = require('react-intl').FormattedRelative;
require('./comment.scss');
const Comment = ({
author,
content,
datetimeCreated,
id
}) => (
<div
className="flex-row comment"
id={`comments-${id}`}
>
<a href={`/users/${author.username}`}>
<Avatar src={author.image} />
</a>
<FlexRow className="comment-body column">
<FlexRow className="comment-top-row">
<a
className="username"
href={`/users/${author.username}`}
>{author.username}</a>
<div className="action-list">
{/* TODO: Hook these up to API calls/logic */}
<span className="comment-delete">Delete</span>
<span className="comment-report">Report</span>
</div>
</FlexRow>
<div className="comment-bubble">
{/* TODO: at the moment, comment content does not properly display
* emojis/easter eggs
* @user links in replies
* links to scratch.mit.edu pages
*/}
<span className="comment-content">{content}</span>
<FlexRow className="comment-bottom-row">
<span className="comment-time">
<FormattedRelative value={new Date(datetimeCreated)} />
</span>
<a
className="comment-reply"
href={`#comments-${id}`}
>
reply
</a>
</FlexRow>
</div>
</FlexRow>
</div>
);
Comment.propTypes = {
author: PropTypes.shape({
id: PropTypes.number,
image: PropTypes.string,
username: PropTypes.string
}),
content: PropTypes.string,
datetimeCreated: PropTypes.string,
id: PropTypes.number
};
module.exports = Comment;

View file

@ -0,0 +1,214 @@
@import "../../../colors";
.compose-comment {
width: 100%;
.textarea-row {
width: 100%;
textarea {
&:not(:focus) {
border: 1px solid $active-dark-gray;
}
}
}
.compose-bottom-row {
width: 100%;
justify-content: space-between;
.compose-post {
margin-right: .5rem;
}
.compose-cancel {
background-color: $ui-dark-gray;
}
.compose-limit {
margin-left: auto;
height: 100%;
font-size: .75rem;
}
.button {
margin-left: 0;
border-radius: .25rem;
}
}
}
.comment-container {
position: relative;
width: 100%;
justify-content: flex-end;
}
.comment {
position: relative;
width: 100%;
flex-wrap: nowrap;
justify-content: space-between;
align-items: flex-start;
.comment-top-row {
margin-bottom: 8px;
width: 100%;
.username {
margin-right: auto;
}
.comment-delete,
.comment-report {
opacity: .5;
font-size: .75rem;
font-weight: 500;
&:before {
display: inline-block;
margin-right: .5rem;
background-repeat: no-repeat;
background-position: center center;
background-size: contain;
content: "";
}
}
.comment-delete {
margin-right: 1rem;
&:before {
background-image: url("/svgs/project/delete-gray.svg");
width: 1rem;
height: 1rem;
vertical-align: -.25rem;
}
}
.comment-report {
&:before {
margin-right: .25rem;
background-image: url("/svgs/project/report-gray.svg");
width: .75rem;
height: .75rem;
vertical-align: -.125rem;
}
}
}
.avatar {
margin-right: .5rem;
}
.comment-body {
margin-bottom: 1.5rem;
min-width: 50%;
flex-grow: 1;
align-items: flex-start;
.comment-bubble {
position: relative;
margin-left: .5rem;
border: 1px solid $active-gray;
border-radius: 0 .5rem .5rem .5rem;
background-color: $ui-white;
padding: .75rem;
width: calc(100% - .5rem);
box-sizing: border-box;
&:before {
display: inline-block;
position: absolute;
top: -1px;
left: -11px; // width + 1px
border-width: 1px 0 1px 1px;
border-style: solid;
border-radius: 0 0 0 8px;
border-color: $active-gray transparent $active-gray $active-gray;
background: $ui-white;
width: 10px;
height: 9px;
content: "";
}
}
.comment-content {
overflow-wrap: break-word;
}
.comment-bottom-row {
padding-top: 1rem;
font-size: .75rem;
justify-content: space-between;
.comment-time {
color: $ui-dark-gray;
}
.comment-reply {
display: inline-flex;
&:after {
margin-left: .25rem;
background-image: url("/svgs/project/comment-reply.svg");
background-size: cover;
width: 1rem;
height: 1rem;
content: "";
}
}
}
}
}
.replies {
width: calc(100% - 4rem);
&.collapsed .comment {
&:last-child {
&:after {
position: absolute;
bottom: 0;
background: linear-gradient(
transparent,
$ui-light-primary
);
width: 100%;
height: 100%;
content: "";
pointer-events: none;
}
}
}
}
.expand-thread {
margin-bottom: 24px;
width: 100%;
overflow: hidden;
text-align: center;
&:before,
&:after {
display: inline-block;
position: relative;
background-color: $active-gray;
width: 50%;
height: 2px;
vertical-align: middle;
content: "";
}
&:before {
right: .5em;
margin-left: -50%;
}
&:after {
left: .5em;
margin-right: -50%;
}
}

View file

@ -0,0 +1,26 @@
const React = require('react');
const FlexRow = require('../../../components/flex-row/flex-row.jsx');
const InplaceInput = require('../../../components/forms/inplace-input.jsx');
const Button = require('../../../components/forms/button.jsx');
require('./comment.scss');
const onUpdate = update => update;
const ComposeComment = () => (
<FlexRow className="compose-comment column">
<InplaceInput
handleUpdate={onUpdate}
name="compose-comment"
type="textarea"
/>
<FlexRow className="compose-bottom-row">
<Button className="compose-post">Post</Button>
<Button className="compose-cancel">Cancel</Button>
<span className="compose-limit">500 characters left</span>
</FlexRow>
</FlexRow>
);
module.exports = ComposeComment;

View file

@ -0,0 +1,85 @@
const React = require('react');
const PropTypes = require('prop-types');
const bindAll = require('lodash.bindall');
const classNames = require('classnames');
const FlexRow = require('../../../components/flex-row/flex-row.jsx');
const Comment = require('./comment.jsx');
require('./comment.scss');
class TopLevelComment extends React.Component {
constructor (props) {
super(props);
bindAll(this, [
'handleExpandThread'
]);
this.state = {
expanded: false
};
}
handleExpandThread () {
this.setState({
expanded: true
});
}
render () {
const {
author,
content,
datetimeCreated,
id,
replies
} = this.props;
return (
<FlexRow className="comment-container">
<Comment {...{author, content, datetimeCreated, id}} />
{replies.length > 0 &&
<FlexRow
className={classNames(
'replies',
'column',
{collapsed: !this.state.expanded && replies.length > 3}
)}
key={id}
>
{(this.state.expanded ? replies : replies.slice(0, 3)).map(reply => (
<Comment
author={reply.author}
content={reply.content}
datetimeCreated={reply.datetime_created}
id={reply.id}
key={reply.id}
/>
))}
</FlexRow>
}
{!this.state.expanded && replies.length > 3 &&
<a
className="expand-thread"
onClick={this.handleExpandThread}
>See all {replies.length} replies</a>
}
</FlexRow>
);
}
}
TopLevelComment.propTypes = {
author: PropTypes.shape({
id: PropTypes.number,
image: PropTypes.string,
username: PropTypes.string
}),
content: PropTypes.string,
datetimeCreated: PropTypes.string,
id: PropTypes.number,
parentId: PropTypes.number,
projectId: PropTypes.string,
replies: PropTypes.arrayOf(PropTypes.object)
};
module.exports = TopLevelComment;

View file

@ -1,4 +1,6 @@
{
"addToStudio.title": "Add to Studio",
"addToStudio.finishing": "Finishing up...",
"preview.musicExtensionChip": "Music",
"preview.penExtensionChip": "Pen",
"preview.speechExtensionChip": "Google Speech",

View file

@ -19,13 +19,18 @@ const RemixCredit = require('./remix-credit.jsx');
const RemixList = require('./remix-list.jsx');
const StudioList = require('./studio-list.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 TopLevelComment = require('./comment/top-level-comment.jsx');
const ExtensionChip = require('./extension-chip.jsx');
const projectShape = require('./projectshape.jsx').projectShape;
require('./preview.scss');
const PreviewPresentation = ({
assetHost,
backpackOptions,
comments,
editable,
extensions,
faved,
@ -37,17 +42,25 @@ const PreviewPresentation = ({
loveCount,
originalInfo,
parentInfo,
projectHost,
projectId,
projectInfo,
remixes,
report,
reportOpen,
replies,
addToStudioOpen,
projectStudios,
studios,
userOwnsProject,
onFavoriteClicked,
onLoadMore,
onLoveClicked,
onReportClicked,
onReportClose,
onReportSubmit,
onAddToStudioClicked,
onAddToStudioClosed,
onToggleStudio,
onSeeInside,
onUpdate
}) => {
@ -57,14 +70,14 @@ const PreviewPresentation = ({
<ShareBanner shared={isShared} />
{ projectInfo && projectInfo.author && projectInfo.author.id && (
<div className="inner">
<Formsy>
<div className="inner">
<FlexRow className="preview-row">
<FlexRow className="project-header">
<a href={`/users/${projectInfo.author.username}`}>
<Avatar
alt={projectInfo.author.username}
src={`https://cdn2.scratch.mit.edu/get_image/user/${projectInfo. author.id}_48x48.png`}
src={`https://cdn2.scratch.mit.edu/get_image/user/${projectInfo.author.id}_48x48.png`}
/>
</a>
<div className="title">
@ -114,10 +127,13 @@ const PreviewPresentation = ({
<div className="guiPlayer">
<IntlGUI
isPlayerOnly
assetHost={assetHost}
backpackOptions={backpackOptions}
basePath="/"
className="guiPlayer"
isFullScreen={isFullScreen}
previewInfoVisible="false"
projectHost={projectHost}
projectId={projectId}
/>
</div>
@ -237,9 +253,24 @@ const PreviewPresentation = ({
{/* eslint-enable react/jsx-sort-props */}
</div>
<FlexRow className="action-buttons">
<Button className="action-button studio-button">
{(isLoggedIn && userOwnsProject) &&
<React.Fragment>
<Button
className="action-button studio-button"
key="add-to-studio-button"
onClick={onAddToStudioClicked}
>
Add to Studio
</Button>
</Button>,
<AddToStudioModal
isOpen={addToStudioOpen}
key="add-to-studio-modal"
studios={studios}
onRequestClose={onAddToStudioClosed}
onToggleStudio={onToggleStudio}
/>
</React.Fragment>
}
<Button className="action-button copy-link-button">
Copy Link
</Button>
@ -253,8 +284,8 @@ const PreviewPresentation = ({
Report
</Button>,
<ReportModal
isOpen={reportOpen}
key="report-modal"
report={report}
type="project"
onReport={onReportSubmit}
onRequestClose={onReportClose}
@ -277,23 +308,59 @@ const PreviewPresentation = ({
))}
</FlexRow>
</FlexRow>
</div>
<div className="project-lower-container">
<div className="inner">
<FlexRow className="preview-row">
<div className="comments-container">
<div className="project-title" />
<FlexRow className="comments-header">
<h4>Comments</h4>
{/* TODO: Add toggle comments component and logic*/}
</FlexRow>
<FlexRow className="comments-list">
{comments.map(comment => (
<TopLevelComment
author={comment.author}
content={comment.content}
datetimeCreated={comment.datetime_created}
id={comment.id}
key={comment.id}
parentId={comment.parent_id}
projectId={projectId}
replies={replies && replies[comment.id] ? replies[comment.id] : []}
/>
))}
{comments.length < projectInfo.stats.comments &&
<Button
className="button load-more-button"
onClick={onLoadMore}
>
Load More
</Button>
}
</FlexRow>
</div>
<FlexRow className="column">
<RemixList remixes={remixes} />
<StudioList studios={studios} />
<StudioList studios={projectStudios} />
</FlexRow>
</FlexRow>
</Formsy>
</div>
</div>
</Formsy>
)}
</div>
);
};
PreviewPresentation.propTypes = {
addToStudioOpen: PropTypes.bool,
assetHost: PropTypes.string,
backpackOptions: PropTypes.shape({
host: PropTypes.string,
visible: PropTypes.bool
}),
comments: PropTypes.arrayOf(PropTypes.object),
editable: PropTypes.bool,
extensions: PropTypes.arrayOf(PropTypes.object),
faved: PropTypes.bool,
@ -303,24 +370,26 @@ PreviewPresentation.propTypes = {
isShared: PropTypes.bool,
loveCount: PropTypes.number,
loved: PropTypes.bool,
onAddToStudioClicked: PropTypes.func,
onAddToStudioClosed: PropTypes.func,
onFavoriteClicked: PropTypes.func,
onLoadMore: PropTypes.func,
onLoveClicked: PropTypes.func,
onReportClicked: PropTypes.func.isRequired,
onReportClose: PropTypes.func.isRequired,
onReportSubmit: PropTypes.func.isRequired,
onSeeInside: PropTypes.func,
onToggleStudio: PropTypes.func,
onUpdate: PropTypes.func,
originalInfo: projectShape,
parentInfo: projectShape,
projectHost: PropTypes.string,
projectId: PropTypes.string,
projectInfo: projectShape,
projectStudios: PropTypes.arrayOf(PropTypes.object),
remixes: PropTypes.arrayOf(PropTypes.object),
report: PropTypes.shape({
category: PropTypes.string,
notes: PropTypes.string,
open: PropTypes.bool,
waiting: PropTypes.bool
}),
replies: PropTypes.objectOf(PropTypes.array),
reportOpen: PropTypes.bool,
studios: PropTypes.arrayOf(PropTypes.object),
userOwnsProject: PropTypes.bool
};

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 React = require('react');
const PropTypes = require('prop-types');
@ -24,13 +27,17 @@ class Preview extends React.Component {
super(props);
bindAll(this, [
'addEventListeners',
'handleToggleStudio',
'handleFavoriteToggle',
'handleLoadMore',
'handleLoveToggle',
'handlePermissions',
'handlePopState',
'handleReportClick',
'handleReportClose',
'handleReportSubmit',
'handleAddToStudioClick',
'handleAddToStudioClose',
'handleSeeInside',
'handleUpdate',
'initCounts',
@ -49,12 +56,8 @@ class Preview extends React.Component {
favoriteCount: 0,
loveCount: 0,
projectId: parts[1] === 'editor' ? 0 : parts[1],
report: {
category: '',
notes: '',
open: false,
waiting: false
}
addToStudioOpen: false,
reportOpen: false
};
this.getExtensions(this.state.projectId);
this.addEventListeners();
@ -66,17 +69,19 @@ class Preview extends React.Component {
if (this.props.user) {
const username = this.props.user.username;
const token = this.props.user.token;
this.props.getTopLevelComments(this.state.projectId, this.props.comments.length);
this.props.getProjectInfo(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.getLovedStatus(this.state.projectId, username, token);
} else {
this.props.getTopLevelComments(this.state.projectId, this.props.comments.length);
this.props.getProjectInfo(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) {
this.getExtensions(this.state.projectId);
@ -107,7 +112,7 @@ class Preview extends React.Component {
getExtensions (projectId) {
storage
.load(storage.AssetType.Project, projectId, storage.DataFormat.JSON)
.then(projectAsset => {
.then(projectAsset => { // NOTE: this is turning up null, breaking the line below.
let input = projectAsset.data;
if (typeof input === 'object' && !(input instanceof ArrayBuffer) &&
!ArrayBuffer.isView(input)) { // taken from scratch-vm
@ -137,31 +142,19 @@ class Preview extends React.Component {
});
}
handleReportClick () {
this.setState({report: {...this.state.report, open: true}});
this.setState({reportOpen: true});
}
handleReportClose () {
this.setState({report: {...this.state.report, open: false}});
this.setState({reportOpen: false});
}
handleAddToStudioClick () {
this.setState({addToStudioOpen: true});
}
handleAddToStudioClose () {
this.setState({addToStudioOpen: false});
}
handleReportSubmit (formData) {
this.setState({report: {
category: formData.report_category,
notes: formData.notes,
open: this.state.report.open,
waiting: true}
});
const data = {
...formData,
id: this.state.projectId,
user: this.props.user.username
};
console.log('submit report data', data); // eslint-disable-line no-console
this.setState({report: {
category: '',
notes: '',
open: false,
waiting: false}
});
this.props.reportProject(this.state.projectId, formData);
}
handlePopState () {
const path = window.location.pathname.toLowerCase();
@ -196,6 +189,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 () {
this.props.setFavedStatus(
!this.props.faved,
@ -213,6 +222,9 @@ class Preview extends React.Component {
}));
}
}
handleLoadMore () {
this.props.getTopLevelComments(this.state.projectId, this.props.comments.length);
}
handleLoveToggle () {
this.props.setLovedStatus(
!this.props.loved,
@ -280,6 +292,9 @@ class Preview extends React.Component {
this.props.playerMode ?
<Page>
<PreviewPresentation
addToStudioOpen={this.state.addToStudioOpen}
assetHost={this.props.assetHost}
backpackOptions={this.props.backpackOptions}
comments={this.props.comments}
editable={this.state.editable}
extensions={this.state.extensions}
@ -292,26 +307,37 @@ class Preview extends React.Component {
loved={this.props.loved}
originalInfo={this.props.original}
parentInfo={this.props.parent}
projectHost={this.props.projectHost}
projectId={this.state.projectId}
projectInfo={this.props.projectInfo}
projectStudios={this.props.projectStudios}
remixes={this.props.remixes}
report={this.state.report}
replies={this.props.replies}
reportOpen={this.state.reportOpen}
studios={this.props.studios}
user={this.props.user}
userOwnsProject={this.userOwnsProject()}
onAddToStudioClicked={this.handleAddToStudioClick}
onAddToStudioClosed={this.handleAddToStudioClose}
onFavoriteClicked={this.handleFavoriteToggle}
onLoadMore={this.handleLoadMore}
onLoveClicked={this.handleLoveToggle}
onReportClicked={this.handleReportClick}
onReportClose={this.handleReportClose}
onReportSubmit={this.handleReportSubmit}
onSeeInside={this.handleSeeInside}
onToggleStudio={this.handleToggleStudio}
onUpdate={this.handleUpdate}
/>
</Page> :
<IntlGUI
enableCommunity
hideIntro
assetHost={this.props.assetHost}
backpackOptions={this.props.backpackOptions}
basePath="/"
className="gui"
projectHost={this.props.projectHost}
projectId={this.state.projectId}
/>
);
@ -319,28 +345,40 @@ class Preview extends React.Component {
}
Preview.propTypes = {
assetHost: PropTypes.string.isRequired,
backpackOptions: PropTypes.shape({
host: PropTypes.string,
visible: PropTypes.bool
}),
comments: PropTypes.arrayOf(PropTypes.object),
faved: PropTypes.bool,
fullScreen: PropTypes.bool,
getCuratedStudios: PropTypes.func.isRequired,
getFavedStatus: PropTypes.func.isRequired,
getLovedStatus: PropTypes.func.isRequired,
getOriginalInfo: PropTypes.func.isRequired,
getParentInfo: PropTypes.func.isRequired,
getProjectInfo: PropTypes.func.isRequired,
getProjectStudios: PropTypes.func.isRequired,
getRemixes: PropTypes.func.isRequired,
getStudios: PropTypes.func.isRequired,
getTopLevelComments: PropTypes.func.isRequired,
loved: PropTypes.bool,
original: projectShape,
parent: projectShape,
playerMode: PropTypes.bool,
projectHost: PropTypes.string.isRequired,
projectInfo: projectShape,
projectStudios: PropTypes.arrayOf(PropTypes.object),
remixes: PropTypes.arrayOf(PropTypes.object),
replies: PropTypes.objectOf(PropTypes.array),
reportProject: PropTypes.func,
sessionStatus: PropTypes.string,
setFavedStatus: PropTypes.func.isRequired,
setFullScreen: PropTypes.func.isRequired,
setLovedStatus: PropTypes.func.isRequired,
setPlayer: PropTypes.func.isRequired,
studios: PropTypes.arrayOf(PropTypes.object),
toggleStudio: PropTypes.func.isRequired,
updateProject: PropTypes.func.isRequired,
user: PropTypes.shape({
id: PropTypes.number,
@ -355,10 +393,50 @@ Preview.propTypes = {
};
Preview.defaultProps = {
assetHost: process.env.ASSET_HOST,
backpackOptions: {
host: process.env.BACKPACK_HOST,
visible: true
},
projectHost: process.env.PROJECT_HOST,
sessionStatus: sessionActions.Status.NOT_FETCHED,
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 => ({
projectInfo: state.preview.projectInfo,
comments: state.preview.comments,
@ -367,14 +445,17 @@ const mapStateToProps = state => ({
original: state.preview.original,
parent: state.preview.parent,
remixes: state.preview.remixes,
replies: state.preview.replies,
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,
playerMode: state.scratchGui.mode.isPlayerOnly,
fullScreen: state.scratchGui.mode.isFullScreen
});
const mapDispatchToProps = dispatch => ({
getOriginalInfo: id => {
dispatch(previewActions.getOriginalInfo(id));
@ -388,8 +469,21 @@ const mapDispatchToProps = dispatch => ({
getRemixes: id => {
dispatch(previewActions.getRemixes(id));
},
getStudios: id => {
dispatch(previewActions.getStudios(id));
getProjectStudios: 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));
}
},
getTopLevelComments: (id, offset) => {
dispatch(previewActions.getTopLevelComments(id, offset));
},
getFavedStatus: (id, username, token) => {
dispatch(previewActions.getFavedStatus(id, username, token));
@ -406,6 +500,9 @@ const mapDispatchToProps = dispatch => ({
refreshSession: () => {
dispatch(sessionActions.refreshSession());
},
reportProject: (id, formData) => {
dispatch(previewActions.reportProject(id, formData));
},
setOriginalInfo: info => {
dispatch(previewActions.setOriginalInfo(info));
},

View file

@ -1,18 +1,14 @@
@import "../../colors";
@import "../../frameless";
/* stage size constants
* this is a hack right now - stage includes padding of .5rem (8px) for alignment in gui
* in www the player is placed with margin -.5rem to align the edge.
* the height is calculated from the actual height on the page (404)
*/
$gui-width: 496px;
/* stage size constants */
$player-width: 482px;
$player-height: 406px;
$stage-width: 480px;
$stage-height: 404px;
/* override view padding for share banner */
#view {
padding: 0 0 20px 0;
padding: 0;
}
.gui {
@ -174,14 +170,13 @@ $stage-height: 404px;
.guiPlayer {
display: inline-block;
margin-left: -.5rem;
width: $gui-width;
width: $player-width;
}
.project-notes {
// not 1.5rem because of stage padding
margin-left: 1rem;
height: $stage-height;
height: $player-height;
align-items: flex-start;
flex: 1;
flex-flow: column;
@ -423,6 +418,15 @@ $stage-height: 404px;
// }
}
.comments-header {
padding: 0 0 1rem 0;
justify-content: space-between;
h4 {
font-size: 1.25rem;
}
}
.studio-button,
.copy-link-button,
.report-button {
@ -458,6 +462,22 @@ $stage-height: 404px;
}
}
.project-lower-container {
margin-top: 1rem;
background-color: $ui-blue-10percent;
padding: 1rem 0;
min-height: 12rem;
}
.create-comment {
margin-bottom: 2rem;
}
.load-more-button {
margin-left: 0;
width: 100%;
}
.extension-list {
justify-content: flex-start;
}

View file

@ -0,0 +1,4 @@
{
"search.trending": "Trending",
"search.popular": "Popular"
}

View file

@ -8,14 +8,18 @@ const React = require('react');
const api = require('../../lib/api');
const Button = require('../../components/forms/button.jsx');
const Form = require('../../components/forms/form.jsx');
const Grid = require('../../components/grid/grid.jsx');
const navigationActions = require('../../redux/navigation.js');
const Select = require('../../components/forms/select.jsx');
const TitleBanner = require('../../components/title-banner/title-banner.jsx');
const Tabs = require('../../components/tabs/tabs.jsx');
const Page = require('../../components/page/www/page.jsx');
const render = require('../../lib/render.jsx');
const ACCEPTABLE_MODES = ['trending', 'popular', ''];
require('./search.scss');
class Search extends React.Component {
@ -23,14 +27,31 @@ class Search extends React.Component {
super(props);
bindAll(this, [
'getSearchState',
'handleChangeSortMode',
'handleGetSearchMore',
'getTab'
]);
this.state = this.getSearchState();
this.state.loaded = [];
this.state.loadNumber = 16;
this.state.mode = '';
this.state.offset = 0;
this.state.loadMore = false;
let mode = '';
const query = window.location.search;
const m = query.lastIndexOf('mode=');
if (m !== -1) {
mode = query.substring(m + 5, query.length).toLowerCase();
}
while (mode.indexOf('/') > -1) {
mode = mode.substring(0, mode.indexOf('/'));
}
while (mode.indexOf('&') > -1) {
mode = mode.substring(0, mode.indexOf('&'));
}
mode = decodeURIComponent(mode.split('+').join(' '));
this.state.mode = mode;
}
componentDidMount () {
const query = window.location.search;
@ -65,6 +86,13 @@ class Search extends React.Component {
loadNumber: 16
};
}
handleChangeSortMode (name, value) {
if (ACCEPTABLE_MODES.indexOf(value) !== -1) {
const term = this.props.searchTerm.split(' ').join('+');
window.location =
`${window.location.origin}/search/${this.state.tab}?q=${term}&mode=${value}`;
}
}
handleGetSearchMore () {
let termText = '';
if (this.props.searchTerm !== '') {
@ -73,7 +101,8 @@ class Search extends React.Component {
const locale = this.props.intl.locale;
const loadNumber = this.state.loadNumber;
const offset = this.state.offset;
const queryString = `limit=${loadNumber}&offset=${offset}&language=${locale}&mode=popular${termText}`;
const mode = this.state.mode;
const queryString = `limit=${loadNumber}&offset=${offset}&language=${locale}&mode=${mode}${termText}`;
api({
uri: `/search/${this.state.tab}?${queryString}`
@ -167,6 +196,25 @@ class Search extends React.Component {
{this.getTab('projects')}
{this.getTab('studios')}
</Tabs>
<div className="sort-controls">
<Form className="sort-mode">
<Select
name="sort"
options={[
{
value: 'trending',
label: this.props.intl.formatMessage({id: 'search.trending'})
},
{
value: 'popular',
label: this.props.intl.formatMessage({id: 'search.popular'})
}
]}
value={this.state.mode}
onChange={this.handleChangeSortMode}
/>
</Form>
</div>
{this.getProjectBox()}
</div>
</div>

View file

@ -105,16 +105,41 @@ $base-bg: $ui-white;
}
}
/* HACK: sort controls are terrible. There's some sort of magic formula for height of formsy components that I can't control. */
.sort-controls {
display: flex;
margin: 0 auto;
border-bottom: 1px solid $ui-border;
padding: 8px 0;
width: 58.75rem;
justify-content: space-between;
}
.sort-mode {
margin-top: -4px;
width: 13.75rem;
.select {
select {
margin-bottom: 0;
border: 0;
background-color: transparent;
height: 32px;
color: $header-gray;
&:focus,
&:active {
background-color: transparent;
}
}
.help-block {
display: none;
}
}
}
.tab-background {
box-shadow: 0 0 1px $box-shadow-gray;

View file

@ -466,7 +466,7 @@ const Terms = () => (
<p>
This document, together with all appendices, constitutes the entire Terms
of Use and supersedes all previous agreements with the Scratch Team relating
to the use of Scratch. Revision date: 4 March 2015.
to the use of Scratch. Revision date: April 2016.
</p>
</section>
<section id="appendix-a">

View file

@ -31,8 +31,6 @@ $base-bg: $ui-white;
max-width: calc(100% - 2rem);
}
$darken-button: rgba(0, 0, 0, .1);
.tips-button {
margin-right: .75rem;
background-color: $ui-blue;
@ -41,7 +39,8 @@ $darken-button: rgba(0, 0, 0, .1);
&.getting-started-button {
margin-right: 0;
background-color: $darken-button;
background-color: $ui-white;
color: $link-blue;
}
img {

View file

@ -1,22 +1,34 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg width="27px" height="28px" viewBox="0 0 27 28" 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>blocks 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="12-col" transform="translate(-496.000000, -285.000000)" fill="#FFFFFF">
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 22.1.0, 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="27px" height="28px" viewBox="0 0 27 28" style="enable-background:new 0 0 27 28;" xml:space="preserve">
<style type="text/css">
.st0{fill:#4C97FF;}
</style>
<title>blocks icon</title>
<desc>Created with Sketch.</desc>
<g id="Page-1">
<g id="_x31_2-col" transform="translate(-496.000000, -285.000000)">
<g id="Band---Masthead" transform="translate(0.000000, 52.000000)">
<g id="buttons-v1" transform="translate(474.000000, 214.000000)">
<g id="guide-tutorial">
<g id="blocks-icon" transform="translate(22.000000, 19.000000)">
<path d="M0.850455083,0.114928725 L2.97546016,0.114928725 C3.19321976,0.114928725 3.40235116,0.203530037 3.55642635,0.360996823 L4.71835875,1.54850635 C4.87243394,1.70597313 5.08156534,1.79457444 5.29973581,1.79457444 L7.90545549,1.79457444 C8.12362597,1.79457444 8.33275736,1.70597313 8.48683256,1.54850635 L9.64876495,0.360996823 C9.80284014,0.203530037 10.0119715,0.114928725 10.230142,0.114928725 L25.5024861,0.114928725 C25.9560835,0.114928725 26.3242205,0.491169366 26.3242205,0.954751584 L26.3242205,5.99368874 C26.3242205,6.45727096 25.9560835,6.8335116 25.5024861,6.8335116 L10.230142,6.8335116 C10.0119715,6.8335116 9.80284014,6.92211291 9.64876495,7.0795797 L8.48683256,8.26708922 C8.33275736,8.42455601 8.12362597,8.51315732 7.90545549,8.51315732 L5.29973581,8.51315732 C5.08156534,8.51315732 4.87243394,8.42455601 4.71835875,8.26708922 L3.55642635,7.0795797 C3.40235116,6.92211291 3.19321976,6.8335116 2.97546016,6.8335116 L0.850455083,6.8335116 C0.396857712,6.8335116 0.028720716,6.45727096 0.028720716,5.99368874 L0.028720716,0.954751584 C0.028720716,0.491169366 0.396857712,0.114928725 0.850455083,0.114928725 Z" id="Stroke-1"></path>
<path d="M0.850455083,9.8008857 L2.97546016,9.8008857 C3.19321976,9.8008857 3.40235116,9.88948701 3.55642635,10.0469538 L4.71835875,11.2344633 C4.87243394,11.3919301 5.08156534,11.4805314 5.29973581,11.4805314 L7.90545549,11.4805314 C8.12362597,11.4805314 8.33275736,11.3919301 8.48683256,11.2344633 L9.64876495,10.0469538 C9.80284014,9.88948701 10.0119715,9.8008857 10.230142,9.8008857 L19.7503455,9.8008857 C20.2039429,9.8008857 20.5720799,10.1771263 20.5720799,10.6407086 L20.5720799,15.6796457 C20.5720799,16.1432279 20.2039429,16.5194686 19.7503455,16.5194686 L10.230142,16.5194686 C10.0119715,16.5194686 9.80284014,16.6080699 9.64876495,16.7655367 L8.48683256,17.9530462 C8.33275736,18.110513 8.12362597,18.1991143 7.90545549,18.1991143 L5.29973581,18.1991143 C5.08156534,18.1991143 4.87243394,18.110513 4.71835875,17.9530462 L3.55642635,16.7655367 C3.40235116,16.6080699 3.19321976,16.5194686 2.97546016,16.5194686 L0.850455083,16.5194686 C0.396857712,16.5194686 0.028720716,16.1432279 0.028720716,15.6796457 L0.028720716,10.6407086 C0.028720716,10.1771263 0.396857712,9.8008857 0.850455083,9.8008857 Z" id="Stroke-3"></path>
<path d="M0.850455083,19.4868427 L2.97546016,19.4868427 C3.19321976,19.4868427 3.40235116,19.575444 3.55642635,19.7329108 L4.71835875,20.9204203 C4.87243394,21.0778871 5.08156534,21.1664884 5.29973581,21.1664884 L7.90545549,21.1664884 C8.12362597,21.1664884 8.33275736,21.0778871 8.48683256,20.9204203 L9.64876495,19.7329108 C9.80284014,19.575444 10.0119715,19.4868427 10.230142,19.4868427 L23.037283,19.4868427 C23.4908804,19.4868427 23.8590174,19.8630833 23.8590174,20.3266655 L23.8590174,25.3656027 C23.8590174,25.8291849 23.4908804,26.2054256 23.037283,26.2054256 L10.230142,26.2054256 C10.0119715,26.2054256 9.80284014,26.2940269 9.64876495,26.4514937 L8.48683256,27.6390032 C8.33275736,27.79647 8.12362597,27.8850713 7.90545549,27.8850713 L5.29973581,27.8850713 C5.08156534,27.8850713 4.87243394,27.79647 4.71835875,27.6390032 L3.55642635,26.4514937 C3.40235116,26.2940269 3.19321976,26.2054256 2.97546016,26.2054256 L0.850455083,26.2054256 C0.396857712,26.2054256 0.028720716,25.8291849 0.028720716,25.3656027 L0.028720716,20.3266655 C0.028720716,19.8630833 0.396857712,19.4868427 0.850455083,19.4868427 Z" id="Stroke-5"></path>
</g>
<path id="Stroke-1" class="st0" d="M0.9,0.1H3c0.2,0,0.4,0.1,0.6,0.2l1.2,1.2c0.2,0.2,0.4,0.2,0.6,0.2h2.6
c0.2,0,0.4-0.1,0.6-0.2l1.2-1.2c0.2-0.2,0.4-0.2,0.6-0.2h15.3c0.5,0,0.8,0.4,0.8,0.8v5c0,0.5-0.4,0.8-0.8,0.8H10.2
c-0.2,0-0.4,0.1-0.6,0.2L8.5,8.3C8.3,8.4,8.1,8.5,7.9,8.5H5.3c-0.2,0-0.4-0.1-0.6-0.2L3.6,7.1C3.4,6.9,3.2,6.8,3,6.8H0.9
C0.4,6.8,0,6.5,0,6V1C0,0.5,0.4,0.1,0.9,0.1z"/>
<path id="Stroke-3" class="st0" d="M0.9,9.8H3c0.2,0,0.4,0.1,0.6,0.2l1.2,1.2c0.2,0.2,0.4,0.2,0.6,0.2h2.6
c0.2,0,0.4-0.1,0.6-0.2L9.6,10c0.2-0.2,0.4-0.2,0.6-0.2h9.5c0.5,0,0.8,0.4,0.8,0.8v5c0,0.5-0.4,0.8-0.8,0.8h-9.5
c-0.2,0-0.4,0.1-0.6,0.2L8.5,18c-0.2,0.2-0.4,0.2-0.6,0.2H5.3c-0.2,0-0.4-0.1-0.6-0.2l-1.2-1.2c-0.2-0.2-0.4-0.2-0.6-0.2H0.9
c-0.5,0-0.8-0.4-0.8-0.8v-5C0,10.2,0.4,9.8,0.9,9.8z"/>
<path id="Stroke-5" class="st0" d="M0.9,19.5H3c0.2,0,0.4,0.1,0.6,0.2l1.2,1.2c0.2,0.2,0.4,0.2,0.6,0.2h2.6
c0.2,0,0.4-0.1,0.6-0.2l1.2-1.2c0.2-0.2,0.4-0.2,0.6-0.2H23c0.5,0,0.8,0.4,0.8,0.8v5c0,0.5-0.4,0.8-0.8,0.8H10.2
c-0.2,0-0.4,0.1-0.6,0.2l-1.2,1.2c-0.2,0.2-0.4,0.2-0.6,0.2H5.3c-0.2,0-0.4-0.1-0.6-0.2l-1.2-1.2c-0.2-0.2-0.4-0.2-0.6-0.2
H0.9c-0.5,0-0.8-0.4-0.8-0.8v-5C0,19.9,0.4,19.5,0.9,19.5z"/>
</g>
</g>
</g>
</g>
</g>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 4.4 KiB

After

Width:  |  Height:  |  Size: 2.1 KiB

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"?>
<!-- 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>
<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>

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="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>
<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>

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="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>
<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>

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

View file

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<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 51.1 (57501) - http://www.bohemiancoding.com/sketch -->
<title>comment-reply</title>
<desc>Created with Sketch.</desc>
<defs></defs>
<g id="comment-reply" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
<path d="M9.4379644,9.95564876 C9.4397719,9.83000629 9.4365177,9.69150012 9.42535762,9.54182983 C9.39320024,9.16970313 9.30581606,8.72820912 9.12405695,8.26937328 C9.07512181,8.1566514 9.02758481,8.03887149 8.96746449,7.92470447 C8.89406178,7.78886016 8.89825622,7.75634423 8.7905989,7.59665491 C8.62352034,7.33435977 8.47531676,7.14143195 8.29006229,6.92104623 C7.92234964,6.50701011 7.47144725,6.15222524 6.99467914,5.89065268 C6.51441566,5.63486073 6.01387905,5.47228111 5.5671711,5.38846228 C5.12465758,5.30464345 4.71639867,5.29597253 4.48150998,5.29741768 C4.35847305,5.29452738 4.20677411,5.32270785 4.12917695,5.32632073 C4.04388999,5.33571422 3.99845021,5.34004968 3.99845021,5.34004968 C3.50070989,5.39279773 3.05540009,5.0185033 3.0050668,4.5040291 C2.96242332,4.07337373 3.20989533,3.68173747 3.58180242,3.53433194 C3.58180242,3.53433194 3.62514497,3.51771269 3.70553842,3.48591934 C3.79501983,3.454126 3.88310309,3.40571339 4.07395015,3.34862988 C4.46403315,3.22506936 4.96317161,3.09789597 5.6119118,3.03286412 C6.25575847,2.96999999 7.02893374,2.9880644 7.85034507,3.16076009 C8.66826104,3.33851382 9.53091772,3.67740202 10.3222689,4.17525697 C10.7053612,4.422378 11.1087266,4.72441482 11.4247078,5.00910981 C11.5596289,5.12183169 11.8001102,5.36533984 11.9385268,5.51708083 C12.1000127,5.68616364 12.2468182,5.86536252 12.3922255,6.04311624 C12.9591741,6.76424721 13.37722,7.545352 13.6547522,8.2534766 C13.9134224,8.91884719 14.055538,9.51573832 14.1351159,9.95564876 L15.4897593,9.95564876 C16.1024852,9.95564876 16.6500676,10.3333021 16.8842064,10.9178352 C17.1192892,11.5033442 16.9908905,12.1708245 16.5575451,12.6187388 L12.7603439,16.5426249 C12.4752234,16.8373311 12.0956921,17.0002978 11.6935022,17.0002978 C11.2903682,17.0002978 10.9108369,16.8373311 10.6266605,16.5426249 L6.82851524,12.6187388 C6.39611391,12.1708245 6.26677115,11.5033442 6.50185397,10.9178352 C6.73693679,10.3333021 7.28451925,9.95564876 7.89630104,9.95564876 L9.4379644,9.95564876 Z" fill="#4C97FF" transform="translate(10.000298, 10.000000) scale(-1, 1) rotate(-90.000000) translate(-10.000298, -10.000000) "></path>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.5 KiB

View file

@ -3,6 +3,8 @@ const bindAll = require('lodash.bindall');
const headless = process.env.SMOKE_HEADLESS || false;
const remote = process.env.SMOKE_REMOTE || false;
const ci = process.env.CI || false;
const buildID = process.env.TRAVIS_BUILD_NUMBER;
const {SAUCE_USERNAME, SAUCE_ACCESS_KEY} = process.env;
const {By, until} = webdriver;
@ -24,7 +26,13 @@ class SeleniumHelper {
}
buildDriver (name) {
if (remote === 'true'){
this.driver = this.getSauceDriver(SAUCE_USERNAME, SAUCE_ACCESS_KEY, name);
let nameToUse;
if (ci === 'true'){
nameToUse = 'travis ' + buildID + ' : ' + name;
} else {
nameToUse = name;
}
this.driver = this.getSauceDriver(SAUCE_USERNAME, SAUCE_ACCESS_KEY, nameToUse);
} else {
this.driver = this.getDriver();
}

View file

@ -165,6 +165,9 @@ module.exports = {
'process.env.NODE_ENV': '"' + (process.env.NODE_ENV || 'development') + '"',
'process.env.SENTRY_DSN': '"' + (process.env.SENTRY_DSN || '') + '"',
'process.env.API_HOST': '"' + (process.env.API_HOST || 'https://api.scratch.mit.edu') + '"',
'process.env.ASSET_HOST': '"' + (process.env.ASSET_HOST || 'https://assets.scratch.mit.edu') + '"',
'process.env.BACKPACK_HOST': '"' + (process.env.BACKPACK_HOST || 'https://backpack.scratch.mit.edu') + '"',
'process.env.PROJECT_HOST': '"' + (process.env.PROJECT_HOST || 'https://projects.scratch.mit.edu') + '"',
'process.env.SCRATCH_ENV': '"' + (process.env.SCRATCH_ENV || 'development') + '"'
}),
new webpack.optimize.CommonsChunkPlugin({