Merge pull request #2292 from LLK/release/october-2018

[Master] Release for October 2018
This commit is contained in:
Ray Schamp 2018-11-16 15:47:44 +00:00 committed by GitHub
commit a454fdf583
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
99 changed files with 3137 additions and 994 deletions

View file

@ -14,7 +14,7 @@ env:
global: global:
- CXX=g++-4.8 - CXX=g++-4.8
- API_HOST_master=https://api.scratch.mit.edu - API_HOST_master=https://api.scratch.mit.edu
- API_HOST_STAGING=https://api-staging.scratch.mit.edu - API_HOST_STAGING=https://api.scratch.ly
- API_HOST_VAR=API_HOST_$TRAVIS_BRANCH - API_HOST_VAR=API_HOST_$TRAVIS_BRANCH
- API_HOST=${!API_HOST_VAR} - API_HOST=${!API_HOST_VAR}
- API_HOST=${API_HOST:-$API_HOST_STAGING} - API_HOST=${API_HOST:-$API_HOST_STAGING}
@ -28,6 +28,11 @@ env:
- BACKPACK_HOST_VAR=BACKPACK_HOST_$TRAVIS_BRANCH - BACKPACK_HOST_VAR=BACKPACK_HOST_$TRAVIS_BRANCH
- BACKPACK_HOST=${!BACKPACK_HOST_VAR} - BACKPACK_HOST=${!BACKPACK_HOST_VAR}
- BACKPACK_HOST=${BACKPACK_HOST:-$BACKPACK_HOST_STAGING} - BACKPACK_HOST=${BACKPACK_HOST:-$BACKPACK_HOST_STAGING}
- CLOUDDATA_HOST_master=clouddata.scratch.mit.edu
- CLOUDDATA_HOST_STAGING=varserver2.scratch.ly
- CLOUDDATA_HOST_VAR=CLOUDDATA_HOST_$TRAVIS_BRANCH
- CLOUDDATA_HOST=${!CLOUDDATA_HOST_VAR}
- CLOUDDATA_HOST=${CLOUDDATA_HOST:-$CLOUDDATA_HOST_STAGING}
- ROOT_URL_master=https://scratch.mit.edu - ROOT_URL_master=https://scratch.mit.edu
- ROOT_URL_STAGING=https://scratch.ly - ROOT_URL_STAGING=https://scratch.ly
- ROOT_URL_VAR=ROOT_URL_$TRAVIS_BRANCH - ROOT_URL_VAR=ROOT_URL_$TRAVIS_BRANCH

View file

@ -1,6 +1,6 @@
[main] [main]
host = https://www.transifex.com host = https://www.transifex.com
lang_map = zh_CN:zh-cn, zh_TW:zh-tw, pt_BR:pt-br lang_map = zh_CN:zh-cn, zh_TW:zh-tw, pt_BR:pt-br, es_419:es-419, aa_DJ:aa-dj
[scratch-website.explore-l10njson] [scratch-website.explore-l10njson]
file_filter = localizations/explore/<lang>.json file_filter = localizations/explore/<lang>.json
@ -156,3 +156,19 @@ source_file = src/views/microbit/l10n.json
source_lang = en source_lang = en
type = KEYVALUEJSON type = KEYVALUEJSON
[scratch-website.3faq-l10njson]
file_filter = localizations/preview-faq/<lang>.json
source_file = src/views/preview-faq/l10n.json
source_lang = en
type = KEYVALUEJSON
[scratch-website.search-l10njson]
file_filter = localizations/search/<lang>.json
source_file = src/views/search/l10n.json
source_lang = en
type = KEYVALUEJSON
[scratch-website.wedo2-legacy-l10njson]
source_file = src/views/wedo2-legacy/l10n.json
source_lang = en
type = KEYVALUEJSON

View file

@ -84,9 +84,9 @@ To stop the process that is making the site available to your web browser (creat
| Variable | Default | Description | | Variable | Default | Description |
| --------------- | ---------------------------------- | ---------------------------------------------- | | --------------- | ---------------------------------- | ---------------------------------------------- |
| `API_HOST` | `https://api.scratch.mit.edu` | Hostname for API requests | | `API_HOST` | `https://api.scratch.mit.edu` | Hostname for API requests |
| `ASSETS_HOST` | `https://assets.scratch.mit.edu` | Hostname for asset requests | | `ASSET_HOST` | `https://assets.scratch.mit.edu` | Hostname for asset requests |
| `BACKPACK_HOST` | `https://backpack.scratch.mit.edu` | Hostname for backpack requests | | `BACKPACK_HOST` | `https://backpack.scratch.mit.edu` | Hostname for backpack requests |
| `PROJECTS_HOST` | `https://projects.scratch.mit.edu` | Hostname for project requests | | `PROJECT_HOST` | `https://projects.scratch.mit.edu` | Hostname for project requests |
| `SENTRY_DSN` | `''` | DSN for Sentry | | `SENTRY_DSN` | `''` | DSN for Sentry |
| `FALLBACK` | `''` | Pass-through location for old site | | `FALLBACK` | `''` | Pass-through location for old site |
| `GA_TRACKER` | `''` | Where to log Google Analytics data | | `GA_TRACKER` | `''` | Where to log Google Analytics data |

View file

@ -100,7 +100,7 @@
"redux-thunk": "2.0.1", "redux-thunk": "2.0.1",
"sass-lint": "1.5.1", "sass-lint": "1.5.1",
"sass-loader": "6.0.6", "sass-loader": "6.0.6",
"scratch-gui": "develop", "scratch-gui": "latest",
"scratchr2_translations": "git://github.com/LLK/scratchr2_translations.git#master", "scratchr2_translations": "git://github.com/LLK/scratchr2_translations.git#master",
"slick-carousel": "1.6.0", "slick-carousel": "1.6.0",
"source-map-support": "0.3.2", "source-map-support": "0.3.2",

View file

@ -28,6 +28,7 @@ $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-light-primary: hsl(215, 100, 95);
$ui-light-primary-transparent: hsla(215, 100, 95, 0);
$ui-border: hsla(0, 0, 85, 1); //#D9D9D9 $ui-border: hsla(0, 0, 85, 1); //#D9D9D9

View file

@ -38,6 +38,31 @@ $desktop: 942px;
$tablet: 640px; $tablet: 640px;
$mobile: 480px; $mobile: 480px;
/* Media Queries */
/* Width */
/*
* ... small | medium | intermediate | big ...
* ... medium-and-smaller |
* ... intermediate-and-smaller |
*/
$small: "only screen and (max-width : #{$mobile}-1)";
$medium: "only screen and (min-width : #{$mobile}) and (max-width : #{$tablet}-1)";
$intermediate: "only screen and (min-width : #{$tablet}) and (max-width : #{$desktop}-1)";
$big: "only screen and (min-width : #{$desktop})";
$medium-and-smaller: "only screen and (max-width : #{$tablet}-1)";
$intermediate-and-smaller: "only screen and (max-width : #{$desktop}-1)";
$medium-and-intermediate: "only screen and (min-width : #{$mobile}) and (max-width : #{$desktop}-1)";
/* Height */
$small-height: "only screen and (max-height : #{$mobile} - 1)";
$medium-height: "only screen and (min-height : #{$mobile}) and (max-height : #{$tablet} - 1)";
// //
// Column-widths in a function, in ems // Column-widths in a function, in ems
// //
@ -48,7 +73,7 @@ $mobile: 480px;
//4 columns //4 columns
@mixin submobile ($parent-selector, $child-selector) { @mixin submobile ($parent-selector, $child-selector) {
@media only screen and (max-width: $mobile - 1) { @media #{$small} {
#{$parent-selector} { #{$parent-selector} {
text-align: center; text-align: center;
} }
@ -64,7 +89,7 @@ $mobile: 480px;
//6 columns //6 columns
@mixin mobile ($parent-selector, $child-selector) { @mixin mobile ($parent-selector, $child-selector) {
@media only screen and (min-width: $mobile) and (max-width: $tablet - 1) { @media #{$medium} {
#{$parent-selector} { #{$parent-selector} {
text-align: center; text-align: center;
} }
@ -80,7 +105,7 @@ $mobile: 480px;
//8 columns //8 columns
@mixin tablet ($parent-selector, $child-selector) { @mixin tablet ($parent-selector, $child-selector) {
@media only screen and (min-width: $tablet) and (max-width: $desktop - 1) { @media #{$intermediate} {
#{$parent-selector} { #{$parent-selector} {
text-align: center; text-align: center;
} }
@ -94,7 +119,7 @@ $mobile: 480px;
//12 columns //12 columns
@mixin desktop ($parent-selector, $child-selector) { @mixin desktop ($parent-selector, $child-selector) {
@media only screen and (min-width: $desktop) { @media #{$big} {
#{$child-selector} { #{$child-selector} {
margin: 0 auto; margin: 0 auto;
width: $desktop; width: $desktop;

View file

@ -9,7 +9,7 @@ $base-bg: $ui-white;
border-radius: 10px 10px 0 0; border-radius: 10px 10px 0 0;
//4 columns //4 columns
@media only screen and (max-width: $mobile - 1) { @media #{$small} {
width: $cols4; width: $cols4;
.box-header { .box-header {
@ -22,7 +22,7 @@ $base-bg: $ui-white;
} }
//6 columns //6 columns
@media only screen and (min-width: $mobile) and (max-width: $tablet - 1) { @media #{$medium} {
width: $cols6; width: $cols6;
.box-header { .box-header {
@ -35,7 +35,7 @@ $base-bg: $ui-white;
} }
//8 columns //8 columns
@media only screen and (min-width: $tablet) and (max-width: $desktop - 1) { @media #{$intermediate} {
width: $cols8; width: $cols8;
.box-header { .box-header {
@ -48,7 +48,7 @@ $base-bg: $ui-white;
} }
//12 columns //12 columns
@media only screen and (min-width: $desktop) { @media #{$big} {
width: $cols12; width: $cols12;
.box-header { .box-header {

View file

@ -81,7 +81,7 @@
} }
} }
@media only screen and (max-width: $mobile - 1) { @media #{$small} {
.card { .card {
width: 22.5rem; width: 22.5rem;
@ -95,7 +95,7 @@
} }
} }
@media only screen and (max-width: $tablet - 1) { @media #{$medium-and-smaller} {
.card { .card {
.input { .input {
width: 90%; width: 90%;
@ -103,7 +103,7 @@
} }
} }
@media only screen and (max-width: $desktop - 1) { @media #{$intermediate-and-smaller} {
.card { .card {
.validation-message { .validation-message {
position: relative; position: relative;

View file

@ -9,7 +9,8 @@
border-radius: 0 0 5px 5px; border-radius: 0 0 5px 5px;
background-color: $ui-blue; background-color: $ui-blue;
padding: 10px; padding: 10px;
max-width: 260px; min-width: 9rem;
max-width: 16.25rem;
overflow: visible; overflow: visible;
color: $type-white; color: $type-white;
font-size: .8125rem; font-size: .8125rem;
@ -88,8 +89,4 @@
content: ""; content: "";
} }
} }
@media only screen and (max-width: $tablet - 1) {
min-width: 160px;
}
} }

View file

@ -25,7 +25,7 @@
} }
} }
@media only screen and (max-width: $tablet - 1) { @media #{$medium-and-smaller} {
flex-direction: column; flex-direction: column;
&.uneven { &.uneven {

View file

@ -51,7 +51,7 @@
justify-content: space-between; justify-content: space-between;
align-items: flex-start; align-items: flex-start;
@media only screen and (max-width: $tablet - 1) { @media #{$medium-and-smaller} {
align-items: center; align-items: center;
} }
} }
@ -103,7 +103,7 @@
} }
} }
@media only screen and (max-width: $tablet - 1) { @media #{$medium-and-smaller} {
text-align: center; text-align: center;
.family { .family {
@ -122,7 +122,7 @@
} }
} }
@media only screen and (min-width: $tablet) and (max-width: $desktop - 1) { @media #{$intermediate} {
ul { ul {
li { li {
margin-left: 0; margin-left: 0;

View file

@ -36,7 +36,7 @@
} }
.inplace-textarea { .inplace-textarea {
transition: all 1s ease; transition: all .2s ease;
border: 2px dashed $ui-blue-25percent; border: 2px dashed $ui-blue-25percent;
border-radius: 8px; border-radius: 8px;
background-color: $ui-light-gray; background-color: $ui-light-gray;
@ -49,7 +49,7 @@
resize: none; resize: none;
&:focus { &:focus {
transition: all 1s ease; transition: all .2s ease;
outline: none; outline: none;
border: 2px solid $ui-blue; border: 2px solid $ui-blue;
box-shadow: 0 0 0 4px $ui-blue-25percent; box-shadow: 0 0 0 4px $ui-blue-25percent;

View file

@ -13,9 +13,9 @@
border-radius: 5px; border-radius: 5px;
background: $ui-light-gray url("../../../static/svgs/forms/carot.svg") no-repeat right center; background: $ui-light-gray url("../../../static/svgs/forms/carot.svg") no-repeat right center;
padding-right: 4rem; padding-right: 4rem;
padding-left: 1rem;
width: 100%; width: 100%;
height: 3rem; height: 3rem;
text-indent: 1rem;
color: $type-gray; color: $type-gray;
font-size: .875rem; font-size: .875rem;
appearance: none; appearance: none;

View file

@ -94,21 +94,21 @@
} }
//4 columns //4 columns
@media only screen and (max-width: $mobile - 1) { @media #{$small} {
.flex-row { .flex-row {
width: $cols4; width: $cols4;
} }
} }
//6 columns //6 columns
@media only screen and (min-width: $mobile) and (max-width: $tablet - 1) { @media #{$medium} {
.flex-row { .flex-row {
width: $cols6; width: $cols6;
} }
} }
// 8 columns // 8 columns
@media only screen and (min-width: $tablet) and (max-width: $desktop - 1) { @media #{$intermediate} {
.flex-row { .flex-row {
width: $cols9; width: $cols9;
} }

View file

@ -9,14 +9,14 @@
// column-count required for Firefox, IE and Edge // column-count required for Firefox, IE and Edge
//4 columns //4 columns
@media only screen and (max-width: $mobile - 1) { @media #{$small} {
.masonry { .masonry {
column-count: 1; column-count: 1;
} }
} }
//6 columns //6 columns
@media only screen and (min-width: $mobile) and (max-width: $tablet - 1) { @media #{$medium} {
.masonry { .masonry {
column-count: 1; column-count: 1;
} }
@ -24,14 +24,14 @@
//8 columns //8 columns
@media only screen and (min-width: $tablet) and (max-width: $desktop - 1) { @media #{$intermediate} {
.masonry { .masonry {
column-count: 2; column-count: 2;
} }
} }
// 12 columns // 12 columns
@media only screen and (min-width: $desktop) { @media #{$big} {
.masonry { .masonry {
column-count: 3; column-count: 3;
} }

View file

@ -8,7 +8,8 @@ class AddToStudioModal extends React.Component {
super(props); super(props);
bindAll(this, [ bindAll(this, [
'handleRequestClose', 'handleRequestClose',
'handleSubmit' 'handleSubmit',
'handleToggleStudio'
]); ]);
this.state = { this.state = {
@ -48,6 +49,14 @@ class AddToStudioModal extends React.Component {
}); });
} }
handleToggleStudio (id) {
const studioId = parseInt(id, 10);
if (isNaN(studioId)) { // sanity check in case event had no integer data-id
return;
}
this.props.onToggleStudio(this.props.studios.find(studio => studio.id === studioId));
}
render () { render () {
return ( return (
<AddToStudioModalPresentation <AddToStudioModalPresentation
@ -56,7 +65,7 @@ class AddToStudioModal extends React.Component {
waitingToClose={this.state.waitingToClose} waitingToClose={this.state.waitingToClose}
onRequestClose={this.handleRequestClose} onRequestClose={this.handleRequestClose}
onSubmit={this.handleSubmit} onSubmit={this.handleSubmit}
onToggleStudio={this.props.onToggleStudio} onToggleStudio={this.handleToggleStudio}
/> />
); );
} }

View file

@ -1,52 +1,41 @@
@import "../../../colors"; @import "../../../colors";
@import "../../../frameless"; @import "../../../frameless";
.mod-addToStudio * {
box-sizing: border-box;
}
.mod-addToStudio { .mod-addToStudio {
margin: 100px auto;
outline: none;
padding: 0;
width: 36.25rem; /* 580px; */
height: 388px; /* 24.25rem; */
overflow: hidden; overflow: hidden;
user-select: none;
@media #{$small}, #{$small-height} {
overflow: hidden;
}
} }
.addToStudio-modal-header { .addToStudio-modal-header {
box-shadow: inset 0 -1px 0 0 $ui-blue-dark; box-shadow: inset 0 -1px 0 0 $ui-blue-dark;
background-color: $ui-blue; 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 { .addToStudio-modal-content {
margin: 0 auto; margin: 0 auto;
box-shadow: none;
width: 100%; width: 100%;
font-size: .875rem;
} }
.studio-list-outer-scrollbox { .studio-list-outer-scrollbox {
position: relative; position: relative;
background-color: $ui-blue-10percent; background-color: $ui-blue-10percent;
min-height: 15rem;
max-height: calc(100% - 8rem);
flex: 1;
@media #{$small-height} {
min-height: 0;
}
} }
.studio-list-inner-scrollbox { .studio-list-inner-scrollbox {
margin-right: .5rem; margin-right: .5rem;
padding-right: .5rem; padding-right: .5rem;
height: 16.9375rem; height: 100%;
overflow: scroll; overflow: scroll;
overflow-x: hidden; overflow-x: hidden;
@ -93,35 +82,32 @@
display: flex; display: flex;
position: relative; position: relative;
transition: all .5s; transition: all .5s;
margin: .21875rem .21875rem; margin: .21875rem;
border-radius: .5rem; border-radius: .5rem;
background-color: $ui-white; background-color: $ui-white;
cursor: pointer; cursor: pointer;
padding: 0; padding: 0;
width: 16.1875rem; /* 259px */ width: 48%;
height: 2.5rem; height: 2.5rem;
box-sizing: border-box;
justify-content: space-between; justify-content: space-between;
align-items: center;
@media #{$small} {
min-width: 98%;
flex-shrink: 1;
}
} }
.studio-selector-button-text { .studio-selector-button-text {
position: absolute; margin: auto 2.18375rem auto .6875rem;
/* per spec, should be: min-width: 0;
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: 1.25rem; /* diff from spec, in case we ever do valign to middle; changed to match line-height because else with overflow hidden it cuts off some letters */
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
line-height: 1.25rem;
white-space: nowrap; white-space: nowrap;
font-family: "Helvetica Neue"; font-family: "Helvetica Neue";
font-size: .875rem; font-size: .875rem;
font-weight: regular; font-weight: regular;
flex-shrink: 1;
} }
.studio-selector-button-selected { .studio-selector-button-selected {
@ -144,7 +130,7 @@
.studio-status-icon { .studio-status-icon {
position: absolute; position: absolute;
margin: .5rem .625rem .5rem 14.0625rem; right: .625rem;
border-radius: .75rem; border-radius: .75rem;
padding: .0625rem .075rem; padding: .0625rem .075rem;
width: 1.5rem; width: 1.5rem;

View file

@ -37,70 +37,69 @@ const AddToStudioModalPresentation = ({
return ( return (
<Modal <Modal
useStandardSizes
className="mod-addToStudio" className="mod-addToStudio"
contentLabel={contentLabel} contentLabel={contentLabel}
isOpen={isOpen} isOpen={isOpen}
onRequestClose={onRequestClose} onRequestClose={onRequestClose}
> >
<div> <div className="addToStudio-modal-header modal-header">
<div className="addToStudio-modal-header"> <div className="addToStudio-content-label content-label">
<div className="addToStudio-content-label"> {contentLabel}
{contentLabel} </div>
</div>
<div className="addToStudio-modal-content 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>
</div> </div>
<div className="addToStudio-modal-content">
<div className="studio-list-outer-scrollbox">
<div className="studio-list-inner-scrollbox"> <Form
<div className="studio-list-container"> className="add-to-studio"
{studioButtons} 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> </div>
<div className="studio-list-bottom-gradient" /> </Button>
</div> {waitingToClose ? [
</div>
<Form
className="add-to-studio"
onSubmit={onSubmit}
>
<FlexRow className="action-buttons">
<Button <Button
className="action-button close-button white" className="action-button submit-button submit-button-waiting"
key="closeButton" disabled="disabled"
name="closeButton" key="submitButton"
type="button" type="submit"
onClick={onRequestClose}
> >
<div className="action-button-text"> <div className="action-button-text">
<FormattedMessage id="general.close" /> <Spinner />
<FormattedMessage id="addToStudio.finishing" />
</div> </div>
</Button> </Button>
{waitingToClose ? [ ] : [
<Button <Button
className="action-button submit-button submit-button-waiting" className="action-button submit-button"
disabled="disabled" key="submitButton"
key="submitButton" type="submit"
type="submit" >
> <div className="action-button-text">
<div className="action-button-text"> <FormattedMessage id="general.okay" />
<Spinner /> </div>
<FormattedMessage id="addToStudio.finishing" /> </Button>
</div> ]}
</Button> </FlexRow>
] : [ </Form>
<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> </div>
</Modal> </Modal>
); );

View file

@ -23,11 +23,19 @@ class Modal extends React.Component {
return this.modal.portal.requestClose(); return this.modal.portal.requestClose();
} }
render () { render () {
// bodyOpenClassName prop cannot be blank string or null here; both cause
// an error, because ReactModal does not correctly handle them.
// If we're not setting it to a class name, we must omit the prop entirely.
const bodyOpenClassNameProp = this.props.useStandardSizes ?
{bodyOpenClassName: classNames('overflow-hidden')} : {};
return ( return (
<ReactModal <ReactModal
appElement={document.getElementById('app')} appElement={document.getElementById('app')}
{...bodyOpenClassNameProp}
className={{ className={{
base: classNames('modal-content', this.props.className), base: classNames('modal-content', this.props.className, {
'modal-sizes': this.props.useStandardSizes
}),
afterOpen: classNames('modal-content', this.props.className), afterOpen: classNames('modal-content', this.props.className),
beforeClose: classNames('modal-content', this.props.className) beforeClose: classNames('modal-content', this.props.className)
}} }}
@ -60,7 +68,8 @@ class Modal extends React.Component {
Modal.propTypes = { Modal.propTypes = {
children: PropTypes.node, children: PropTypes.node,
className: PropTypes.string, className: PropTypes.string,
overlayClassName: PropTypes.string overlayClassName: PropTypes.string,
useStandardSizes: PropTypes.bool
}; };
module.exports = Modal; module.exports = Modal;

View file

@ -1,6 +1,12 @@
@import "../../../colors"; @import "../../../colors";
@import "../../../frameless"; @import "../../../frameless";
.overflow-hidden {
/* to avoid double scroll bars this
gets added to body while modal is open */
overflow: hidden;
}
.modal-content { .modal-content {
position: relative; position: relative;
margin: 3.75rem auto; margin: 3.75rem auto;
@ -10,9 +16,27 @@
padding: 0; padding: 0;
width: 48.75rem; width: 48.75rem;
.modal-content { /* content inside of content */
display: flex;
border-radius: 0;
flex-direction: column;
}
&:focus { &:focus {
outline: none; outline: none;
} }
@media #{$intermediate-and-smaller} {
margin-top: 0;
width: 100%;
overflow: auto;
}
@media #{$small}, #{$small-height} {
border-radius: 0;
box-shadow: none;
height: 100%;
}
} }
.modal-overlay { .modal-overlay {
@ -43,23 +67,6 @@ $modal-close-size: 1rem;
padding-top: $modal-close-size / 2; padding-top: $modal-close-size / 2;
} }
@media only screen and (max-width: $desktop - 1) {
.modal-content {
top: 0;
left: 0;
margin-top: 0;
border-radius: 0;
box-shadow: none;
width: 100%;
height: 100%;
overflow: scroll;
}
.modal-content-close {
position: fixed;
}
}
/* Close button, Submit button, etc. */ /* Close button, Submit button, etc. */
.action-buttons { .action-buttons {
display: flex; display: flex;
@ -68,6 +75,11 @@ $modal-close-size: 1rem;
justify-content: flex-end !important; justify-content: flex-end !important;
align-items: flex-start; align-items: flex-start;
flex-wrap: nowrap; flex-wrap: nowrap;
@media #{$intermediate-and-smaller} {
justify-content: center !important; //overwriting flex row properties
flex-direction: row !important;
}
} }
/* setting overall modal to contain overflow looks good, but isn't /* setting overall modal to contain overflow looks good, but isn't
@ -108,3 +120,46 @@ row to appear to contain overflow. */
overflow: visible; overflow: visible;
color: $type-white; color: $type-white;
} }
.modal-sizes * {
box-sizing: border-box;
}
.modal-sizes {
margin: 100px auto;
outline: none;
padding: 0;
max-width: 36.25rem; /* 580px; */
user-select: none;
@media #{$medium}, #{$medium-height} {
margin: 40px auto;
}
@media #{$small}, #{$small-height} {
margin: 0 auto;
width: auto;
}
.modal-header {
padding-top: .75rem;
width: 100%;
height: 3rem;
@media #{$small}, #{$small-height} {
border-radius: 0;
}
}
.content-label {
text-align: center;
color: $type-white;
font-family: "Helvetica Neue", Helvetica, Arial, sans-serif;
font-size: 1rem;
font-weight: bold;
}
.modal-content {
font-size: .875rem;
}
}

View file

@ -0,0 +1,81 @@
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 Button = require('../../forms/button.jsx');
const FlexRow = require('../../flex-row/flex-row.jsx');
require('../../forms/button.scss');
require('./modal.scss');
const DeleteModal = ({
intl,
onDelete,
onReport,
onRequestClose,
...modalProps
}) => (
<Modal
useStandardSizes
className="mod-report"
contentLabel={intl.formatMessage({id: 'comments.deleteModal.title'})}
onRequestClose={onRequestClose}
{...modalProps}
>
<div>
<div className="report-modal-header">
<div className="report-content-label">
<FormattedMessage id="comments.deleteModal.title" />
</div>
</div>
<div className="report-modal-content">
<div>
<div className="instructions">
<FormattedMessage id="comments.deleteModal.body" />
</div>
</div>
</div>
<FlexRow className="action-buttons">
<div className="action-buttons-overflow-fix">
<Button
className="action-button submit-button"
type="button"
onClick={onRequestClose}
>
<div className="action-button-text">
<FormattedMessage id="general.close" />
</div>
</Button>
<Button
className="action-button submit-button"
type="button"
onClick={onReport}
>
<FormattedMessage id="general.report" />
</Button>
<Button
className="action-button submit-button"
type="button"
onClick={onDelete}
>
<FormattedMessage id="comments.delete" />
</Button>
</div>
</FlexRow>
</div>
</Modal>
);
DeleteModal.propTypes = {
intl: intlShape,
onDelete: PropTypes.func,
onReport: PropTypes.func,
onRequestClose: PropTypes.func
};
module.exports = injectIntl(DeleteModal);

View file

@ -0,0 +1,44 @@
@import "../../../colors";
@import "../../../frameless";
$medium-and-small: "screen and (max-width : #{$tablet}-1)";
.mod-report * {
box-sizing: border-box;
}
.mod-report {
margin: 100px auto;
outline: none;
padding: 0;
width: 36.25rem; /* 580px; */
user-select: none;
}
.report-modal-header {
border-radius: 1rem 1rem 0 0;
box-shadow: inset 0 -1px 0 0 $ui-coral-dark;
background-color: $ui-coral;
padding-top: .75rem;
width: 100%;
height: 3rem;
box-sizing: border-box;
}
.report-content-label {
text-align: center;
color: $type-white;
font-family: "Helvetica Neue", Helvetica, Arial, sans-serif;
font-size: 1rem;
font-weight: bold;
}
.report-modal-content {
margin: 1rem auto;
width: 80%;
font-size: .875rem;
.instructions {
line-height: 1.5rem;
}
}

View file

@ -0,0 +1,84 @@
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 Button = require('../../forms/button.jsx');
const FlexRow = require('../../flex-row/flex-row.jsx');
require('../../forms/button.scss');
require('./modal.scss');
const ReportModal = ({
intl,
isConfirmed,
onReport,
onRequestClose,
...modalProps
}) => (
<Modal
useStandardSizes
className="mod-report"
contentLabel={intl.formatMessage({id: 'comments.reportModal.title'})}
onRequestClose={onRequestClose}
{...modalProps}
>
<div>
<div className="report-modal-header">
<div className="report-content-label">
<FormattedMessage id="comments.reportModal.title" />
</div>
</div>
<div className="report-modal-content">
<div>
<div className="instructions">
{isConfirmed ? (
<FormattedMessage id="comments.reportModal.reported" />
) : (
<FormattedMessage id="comments.reportModal.prompt" />
)}
</div>
</div>
</div>
<FlexRow className="action-buttons">
<div className="action-buttons-overflow-fix">
<Button
className="action-button submit-button"
type="button"
onClick={onRequestClose}
>
<div className="action-button-text">
<FormattedMessage id="general.close" />
</div>
</Button>
{isConfirmed ? null : (
<Button
className="action-button submit-button"
type="button"
onClick={onReport}
>
<div className="action-button-text">
<FormattedMessage id="general.report" />
</div>
</Button>
)}
</div>
</FlexRow>
</div>
</Modal>
);
ReportModal.propTypes = {
intl: intlShape,
isConfirmed: PropTypes.bool,
isOwnSpace: PropTypes.bool,
onReport: PropTypes.func,
onRequestClose: PropTypes.func,
type: PropTypes.string
};
module.exports = injectIntl(ReportModal);

View file

@ -111,6 +111,7 @@ class ReportModal extends React.Component {
const contentLabel = intl.formatMessage({id: `report.${type}`}); const contentLabel = intl.formatMessage({id: `report.${type}`});
return ( return (
<Modal <Modal
useStandardSizes
className="mod-report" className="mod-report"
contentLabel={contentLabel} contentLabel={contentLabel}
isOpen={isOpen} isOpen={isOpen}
@ -118,8 +119,8 @@ class ReportModal extends React.Component {
{...modalProps} {...modalProps}
> >
<div> <div>
<div className="report-modal-header"> <div className="report-modal-header modal-header">
<div className="report-content-label"> <div className="report-content-label content-label">
{contentLabel} {contentLabel}
</div> </div>
</div> </div>
@ -130,7 +131,7 @@ class ReportModal extends React.Component {
onValid={this.handleValid} onValid={this.handleValid}
onValidSubmit={onReport} onValidSubmit={onReport}
> >
<div className="report-modal-content"> <div className="report-modal-content modal-content">
{isConfirmed ? ( {isConfirmed ? (
<div className="received"> <div className="received">
<div className="received-header"> <div className="received-header">

View file

@ -1,40 +1,15 @@
@import "../../../colors"; @import "../../../colors";
@import "../../../frameless"; @import "../../../frameless";
.mod-report * {
box-sizing: border-box;
}
.mod-report {
margin: 100px auto;
outline: none;
padding: 0;
width: 36.25rem; /* 580px; */
user-select: none;
}
.report-modal-header { .report-modal-header {
border-radius: 1rem 1rem 0 0;
box-shadow: inset 0 -1px 0 0 $ui-coral-dark; box-shadow: inset 0 -1px 0 0 $ui-coral-dark;
background-color: $ui-coral; background-color: $ui-coral;
padding-top: .75rem;
width: 100%;
height: 3rem;
box-sizing: border-box;
}
.report-content-label {
text-align: center;
color: $type-white;
font-family: "Helvetica Neue", Helvetica, Arial, sans-serif;
font-size: 1rem;
font-weight: bold;
} }
.report-modal-content { .report-modal-content {
margin: 1rem auto; margin: 1rem auto;
width: 80%; width: 80%;
font-size: .875rem;
.instructions { .instructions {
line-height: 1.5rem; line-height: 1.5rem;
@ -58,28 +33,33 @@
.validation-message { .validation-message {
$arrow-border-width: 1rem; $arrow-border-width: 1rem;
display: block; display: block;
position: absolute; position: relative;
top: 0; z-index: 1;
left: 100%; /* position to the right of parent */ margin-top: $arrow-border-width;
margin-left: $arrow-border-width;
border: 1px solid $active-gray; border: 1px solid $active-gray;
border-radius: 5px; border-radius: 5px;
background-color: $ui-orange; background-color: $ui-orange;
padding: 1rem; padding: 1rem;
min-width: 12rem; min-width: 12rem;
max-width: 18.75rem;
min-height: 1rem; min-height: 1rem;
overflow: visible; overflow: visible;
color: $type-white; color: $type-white;
@media #{$medium-and-smaller} {
position: relative;
margin-top: calc($arrow-border-width / 2);
max-width: 100%;
}
/* arrow on box that points to the left */ /* arrow on box that points to the left */
&:before { &:before {
display: block; display: block;
position: absolute; position: absolute;
top: 1rem; top: -.5rem;
left: -$arrow-border-width / 2; left: calc(50% - calc(#{$arrow-border-width} / 2));
transform: rotate(45deg); transform: rotate(135deg);
border-bottom: 1px solid $active-gray; border-bottom: 1px solid $active-gray;
border-left: 1px solid $active-gray; border-left: 1px solid $active-gray;
@ -90,6 +70,10 @@
height: $arrow-border-width; height: $arrow-border-width;
content: ""; content: "";
@media #{$medium-and-smaller} {
display: none;
}
} }
} }
} }
@ -100,10 +84,13 @@
.form-group.has-error { .form-group.has-error {
.textarea, select { .textarea, select {
margin: 0;
border: 1px solid $ui-orange; border: 1px solid $ui-orange;
} }
margin-bottom: 1rem;
} }
.report-text .textarea { .report-text .textarea {
margin-bottom: 0; margin-bottom: 0;
min-height: 8rem;
} }

View file

@ -62,7 +62,7 @@
box-shadow: none; box-shadow: none;
} }
@media only screen and (max-width: $mobile - 1) { @media #{$small} {
.modal-content.mod-ttt { .modal-content.mod-ttt {
overflow: scroll; overflow: scroll;
} }
@ -76,7 +76,7 @@
} }
} }
@media only screen and (min-width: $mobile) and (max-width: $tablet - 1) { @media #{$medium} {
.modal-content.mod-ttt { .modal-content.mod-ttt {
overflow: scroll; overflow: scroll;
} }
@ -90,7 +90,7 @@
} }
} }
@media only screen and (min-width: $tablet) and (max-width: $desktop - 1) { @media #{$intermediate} {
.modal-content.mod-ttt { .modal-content.mod-ttt {
overflow: scroll; overflow: scroll;
} }

View file

@ -40,7 +40,7 @@
font-weight: bold; font-weight: bold;
} }
@media only screen and (max-width: $mobile - 1) { @media #{$small} {
.li-right-ul.mod-2016 { .li-right-ul.mod-2016 {
flex-flow: row nowrap; flex-flow: row nowrap;
} }
@ -55,7 +55,7 @@
} }
} }
@media only screen and (max-width: $tablet - 1) { @media #{$medium-and-smaller} {
height: 100px; height: 100px;
.ul.mod-2016 { .ul.mod-2016 {

View file

@ -45,7 +45,7 @@
font-weight: bold; font-weight: bold;
} }
@media only screen and (max-width: $mobile - 1) { @media #{$small} {
.li-right-ul.mod-2018 { .li-right-ul.mod-2018 {
flex-flow: row nowrap; flex-flow: row nowrap;
} }
@ -60,7 +60,7 @@
} }
} }
@media only screen and (max-width: $tablet - 1) { @media #{$medium-and-smaller} {
height: 100px; height: 100px;
.ul.mod-2018 { .ul.mod-2018 {

View file

@ -56,7 +56,7 @@
} }
//4 columns //4 columns
@media only screen and (max-width: $mobile - 1) { @media #{$small} {
.account-nav { .account-nav {
margin-left: 0; margin-left: 0;
@ -74,7 +74,7 @@
//6 columns //6 columns
@media only screen and (min-width: $mobile) and (max-width: $tablet - 1) { @media #{$medium} {
.account-nav { .account-nav {
margin-left: 0; margin-left: 0;
@ -91,7 +91,7 @@
} }
//8 columns //8 columns
@media only screen and (min-width: $tablet) and (max-width: $desktop - 1) { @media #{$intermediate} {
.account-nav { .account-nav {
margin-left: 0; margin-left: 0;
} }

View file

@ -166,7 +166,7 @@
} }
//4 columns //4 columns
@media only screen and (max-width: $mobile - 1) { @media #{$small} {
#navigation .inner { #navigation .inner {
width: $cols4; width: $cols4;
@ -190,7 +190,7 @@
//6 columns //6 columns
@media only screen and (min-width: $mobile) and (max-width: $tablet - 1) { @media #{$medium} {
#navigation .inner { #navigation .inner {
width: $cols6; width: $cols6;
@ -212,7 +212,7 @@
} }
//8 columns //8 columns
@media only screen and (min-width: $tablet) and (max-width: $desktop - 1) { @media #{$intermediate} {
#navigation .inner { #navigation .inner {
width: $cols8; width: $cols8;

View file

@ -6,19 +6,19 @@
font-size: 4.5rem; font-size: 4.5rem;
} }
@media only screen and (max-width: $mobile - 1) { @media #{$small} {
h1 { h1 {
font-size: 2.5rem; font-size: 2.5rem;
} }
} }
@media only screen and (min-width: $mobile) and (max-width: $tablet - 1) { @media #{$medium} {
h1 { h1 {
font-size: 3rem; font-size: 3rem;
} }
} }
@media only screen and (min-width: $tablet) and (max-width: $desktop - 1) { @media #{$intermediate} {
h1 { h1 {
font-size: 3.5rem; font-size: 3.5rem;
} }
@ -52,7 +52,7 @@
} }
} }
@media only screen and (max-width: $tablet - 1) { @media #{$medium-and-smaller} {
margin-top: 50px; margin-top: 50px;
} }
} }
@ -79,7 +79,7 @@
font-size: 4rem; font-size: 4rem;
} }
@media only screen and (max-width: $tablet - 1) { @media #{$medium-and-smaller} {
h1, h1,
.title-banner-h1.mod-2017 { .title-banner-h1.mod-2017 {
font-size: 2.5rem; font-size: 2.5rem;
@ -96,7 +96,7 @@
width: 125px; width: 125px;
} }
@media only screen and (max-width: $tablet - 1) { @media #{$medium-and-smaller} {
img { img {
transform: translate(0, 5px); transform: translate(0, 5px);
width: 85px; width: 85px;
@ -108,7 +108,7 @@ section {
padding: 64px 0; padding: 64px 0;
} }
@media only screen and (max-width: $mobile - 1) { @media #{$small} {
#view { #view {
width: 100%; width: 100%;
min-width: 100%; min-width: 100%;

View file

@ -10,7 +10,7 @@
min-height: 27.375rem; min-height: 27.375rem;
} }
@media only screen and (max-width: $desktop - 1) { @media #{$intermediate-and-smaller} {
.modal-content.mod-registration { .modal-content.mod-registration {
width: 100%; width: 100%;
overflow: scroll; overflow: scroll;

View file

@ -444,18 +444,20 @@ class DemographicsStep extends React.Component {
handleChooseGender (name, gender) { handleChooseGender (name, gender) {
this.setState({otherDisabled: gender !== 'other'}); this.setState({otherDisabled: gender !== 'other'});
} }
handleValidSubmit (formData, reset, invalidate) { handleValidSubmit (formData) {
return this.props.onNextStep(formData);
}
isValidBirthdate (year, month) {
const birthdate = new Date( const birthdate = new Date(
formData.user.birth.year, year,
formData.user.birth.month - 1, month - 1,
1 1
); );
if (((Date.now() - birthdate) / (24 * 3600 * 1000 * 365.25)) < this.props.birthOffset) { return (((Date.now() - birthdate) / (24 * 3600 * 1000 * 365.25)) >= this.props.birthOffset);
return invalidate({ }
'user.birth.year': this.props.intl.formatMessage({id: 'teacherRegistration.validationAge'}) birthDateValidator (values) {
}); const isValid = this.isValidBirthdate(values['user.birth.year'], values['user.birth.month']);
} return isValid ? true : this.props.intl.formatMessage({id: 'teacherRegistration.validationAge'});
return this.props.onNextStep(formData);
} }
render () { render () {
const countryOptions = getCountryOptions(this.props.intl, DEFAULT_COUNTRY); const countryOptions = getCountryOptions(this.props.intl, DEFAULT_COUNTRY);
@ -485,6 +487,9 @@ class DemographicsStep extends React.Component {
} }
name="user.birth.month" name="user.birth.month"
options={this.getMonthOptions()} options={this.getMonthOptions()}
validations={{
birthDateVal: values => this.birthDateValidator(values)
}}
/> />
<Select <Select
required required

View file

@ -155,7 +155,7 @@
} }
} }
@media only screen and (max-width: $mobile - 1) { @media #{$small} {
.registration-step { .registration-step {
&.demographics-step { &.demographics-step {
.radio { .radio {
@ -174,7 +174,7 @@
} }
} }
@media only screen and (max-width: $desktop - 1) { @media #{$intermediate-and-smaller} {
.registration-step { .registration-step {
.form { .form {
text-align: left; text-align: left;

View file

@ -25,7 +25,7 @@
} }
} }
@media only screen and (max-width: $tablet - 1) { @media #{$medium-and-smaller} {
.slide { .slide {
padding: 0; padding: 0;
} }

View file

@ -53,7 +53,7 @@ a.social-messages-profile-link {
margin-left: 1.5rem; margin-left: 1.5rem;
} }
@media only screen and (max-width: $mobile - 1) { @media #{$small} {
.social-message { .social-message {
text-align: left; text-align: left;
} }
@ -67,7 +67,7 @@ a.social-messages-profile-link {
} }
} }
@media only screen and (min-width: $mobile) and (max-width: $tablet - 1) { @media #{$medium} {
.social-message { .social-message {
text-align: left; text-align: left;
} }

View file

@ -16,7 +16,7 @@
#{$extras} { #{$extras} {
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
line-height: normal; line-height: 1.2em;
white-space: nowrap; white-space: nowrap;
word-wrap: break-word; word-wrap: break-word;
} }

View file

@ -45,6 +45,7 @@
float: left; float: left;
max-width: 164px; max-width: 164px;
overflow: hidden; overflow: hidden;
text-align: left;
.thumbnail-creator a { .thumbnail-creator a {
color: $type-gray; color: $type-gray;

View file

@ -58,7 +58,7 @@
} }
} }
@media only screen and (max-width: $desktop - 1) { @media #{$intermediate-and-smaller} {
.tooltip { .tooltip {
display: block; display: block;

View file

@ -84,6 +84,8 @@
"general.website": "Website", "general.website": "Website",
"general.whatsHappening": "What's Happening?", "general.whatsHappening": "What's Happening?",
"general.wiki": "Scratch Wiki", "general.wiki": "Scratch Wiki",
"general.copyLink": "Copy Link",
"general.report": "Report",
"general.all": "All", "general.all": "All",
"general.animations": "Animations", "general.animations": "Animations",
@ -202,5 +204,44 @@
"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",
"report.sending": "Sending...", "report.sending": "Sending...",
"report.textMissing": "Please tell us why you are reporting this project" "report.textMissing": "Please tell us why you are reporting this project",
"comments.delete": "Delete",
"comments.restore": "Restore",
"comments.reportModal.title": "Report Comment",
"comments.reportModal.reported": "The comment has been reported, and the Scratch Team has been notified.",
"comments.reportModal.prompt": "Are you sure you want to report this comment?",
"comments.deleteModal.title": "Delete Comment",
"comments.deleteModal.body": "Delete this comment? If the comment is mean or disrespectful, please click Report instead to let the Scratch Team know about it.",
"comments.reply": "reply",
"comments.isEmpty": "You can't post an empty comment",
"comments.isFlood": "Woah, seems like you're commenting really quickly. Please wait longer between posts.",
"comments.isBad": "Hmm...the bad word detector thinks there is a problem with your comment. Please change it and remember to be respectful.",
"comments.hasChatSite": "Uh oh! The comment contains a link to a website with unmoderated chat. For safety reasons, please do not link to these sites!",
"comments.isSpam": "Hmm, seems like you've posted the same comment a bunch of times. Please don't spam.",
"comments.isMuted": "Hmm, the filterbot is pretty sure your recent comments weren't ok for Scratch, so your account has been muted for the rest of the day. :/",
"comments.isUnconstructive": "Hmm, the filterbot thinks your comment may be mean or disrespectful. Remember, most projects on Scratch are made by people who are just learning how to program.",
"comments.isDisallowed": "Hmm, it looks like comments have been turned off for this page. :/",
"comments.isIPMuted": "Sorry, the Scratch Team had to prevent your network from sharing comments or projects because it was used to break our community guidelines too many times. You can still share comments and projects from another network. If you'd like to appeal this block, you can contact appeals@scratch.mit.edu and reference Case Number {appealId}.",
"comments.isTooLong": "That comment is too long! Please find a way to shorten your text.",
"comments.error": "Oops! Something went wrong posting your comment",
"comments.posting": "Posting...",
"comments.post": "Post",
"comments.cancel": "Cancel",
"comments.lengthWarning": "{remainingCharacters, plural, one {1 character left} other {{remainingCharacters} characters left}}",
"comments.seeMoreReplies": "{repliesCount, plural, one {See 1 more reply} other {See all {repliesCount} replies}}",
"comments.status.delbyusr": "Deleted by project owner",
"comments.status.censbyfilter": "Censored by filter",
"comments.status.delbyparentcomment": "Parent comment deleted",
"comments.status.censbyadmin": "Censored by admin",
"comments.status.delbyadmin": "Deleted by admin",
"comments.status.parentcommentcensored": "Parent comment censored",
"comments.status.delbyclass": "Deleted by class",
"comments.status.hiddenduetourl": "Hidden due to URL",
"comments.status.markedbyfilter": "Marked by filter",
"comments.status.censbyunconstructive": "Censored unconstructive",
"comments.status.suspended": "Suspended",
"comments.status.acctdel": "Account deleted",
"comments.status.deleted": "Deleted",
"comments.status.reported": "Reported"
} }

View file

@ -4,27 +4,72 @@ const reactStringReplace = require('react-string-replace');
/** /**
* Helper method that replaces @mentions and #hashtags in plain text * Helper method that replaces @mentions and #hashtags in plain text
* *
* @param {string} text string to convert * @param {string} text string to convert
* @return {string} string with links for @mentions and #hashtags * @param {?object} opts options object of boolean flags, defaults to all true
* @property {boolean} opts.hashtag If #hashtags should be converted to search links
* @property {boolean} opts.usernames If @usernames should be converted to /users/username links
* @property {boolean} opts.scratchLinks If scratch-domain links should be converted to <a> links
* @return {Array} Array with strings and react components for links
*/ */
module.exports = text => { module.exports = (text, opts) => {
let replacedText; opts = opts || {
usernames: true,
hashtags: true,
scratchLinks: true
};
let replacedText = [text];
// Match @-mentions (username is alphanumeric, underscore and dash) // Match @-mentions (username is alphanumeric, underscore and dash)
replacedText = reactStringReplace(text, /@([\w-]+)/g, (match, i) => ( if (opts.usernames) {
<a replacedText = reactStringReplace(replacedText, /@([\w-]+)/g, (match, i) => (
href={`/users/${match}`} <a
key={match + i} href={`/users/${match}`}
>@{match}</a> key={match + i}
)); >@{match}</a>
));
}
// Match hashtags // Match hashtags
replacedText = reactStringReplace(replacedText, /(#[\w-]+)/g, (match, i) => ( if (opts.hashtags) {
<a replacedText = reactStringReplace(replacedText, /(#[\w-]+)/g, (match, i) => (
href={`/search/projects?q=${match}`} <a
key={match + i} href={`/search/projects?q=${match}`}
>{match}</a> key={match + i}
)); >{match}</a>
));
}
// Match scratch links
/*
Ported from the python...
"Oh boy a giant regex!" Said nobody ever.
(^|\s)(https?://(?:[\w-]+\.)*scratch\.mit\.edu(?:/(?:\S*[\w:/#[\]@\$&\'()*+=])?)?(?![^?!,:;\w\s]\S))
(^|\s)
Only begin capturing after a space, or at the beginning of a word
https?
URLs beginning with http or https
://(?:[\w-]+\.)*scratch\.mit\.edu
allow *.scratch.mit.edu urls
(?:/...)?
optionally followed by a slash
(?:\S*[\w:/#[\]@\$&\'()*+=])?
optionally that slash is followed by anything that's not a space, until
that string is followed by URL-valid characters that aren't punctuation
(?![^?!,:;\w\s]\S))
Don't capture if this string is embedded in another string (e.g., the
beginning of a non-scratch URL), but allow punctuation
*/
if (opts.scratchLinks) {
// eslint-disable-next-line max-len
const linkRegexp = /((?:^|\s)https?:\/\/(?:[\w-]+\.)*(?:scratch\.mit\.edu|scratch-wiki\.info)(?:\/(?:\S*[\w:/#[\]@$&'()*+=])?)?(?![^?!,:;\w\s]\S))/g;
replacedText = reactStringReplace(replacedText, linkRegexp, (match, i) => (
<a
href={match}
key={match + i}
>{match}</a>
));
}
return replacedText; return replacedText;
}; };

View file

@ -5,25 +5,25 @@ const EXTENSION_INFO = {
hasStatus: true hasStatus: true
}, },
music: { music: {
l10nId: 'preview.musicExtensionChip', l10nId: 'project.musicExtensionChip',
icon: 'extension-music.svg' icon: 'extension-music.svg'
}, },
pen: { pen: {
l10nId: 'preview.penExtensionChip', l10nId: 'project.penExtensionChip',
icon: 'extension-pen.svg' icon: 'extension-pen.svg'
}, },
speak: { speak: {
name: 'Amazon Polly' name: 'Amazon Polly'
}, },
speech: { speech: {
l10nId: 'preview.speechExtensionChip' l10nId: 'project.speechExtensionChip'
}, },
translate: { translate: {
l10nId: 'preview.translateExtensionChip', l10nId: 'project.translateExtensionChip',
icon: 'extension-translate.svg' icon: 'extension-translate.svg'
}, },
videoSensing: { videoSensing: {
l10nId: 'preview.videoMotionChip', l10nId: 'project.videoMotionChip',
icon: 'extension-videomotion.svg' icon: 'extension-videomotion.svg'
}, },
wedo2: { wedo2: {

View file

@ -1,6 +1,6 @@
import ScratchStorage from 'scratch-storage'; import ScratchStorage from 'scratch-storage';
const PROJECT_SERVER = 'https://projects.scratch.mit.edu'; const PROJECT_HOST = process.env.PROJECT_HOST || 'https://projects.scratch.mit.edu';
/** /**
* Wrapper for ScratchStorage which adds default web sources. * Wrapper for ScratchStorage which adds default web sources.
@ -14,8 +14,8 @@ class Storage extends ScratchStorage {
projectAsset => { projectAsset => {
const [projectId, revision] = projectAsset.assetId.split('.'); const [projectId, revision] = projectAsset.assetId.split('.');
return revision ? return revision ?
`${PROJECT_SERVER}/internalapi/project/${projectId}/get/${revision}` : `${PROJECT_HOST}/internalapi/project/${projectId}/get/${revision}` :
`${PROJECT_SERVER}/internalapi/project/${projectId}/get/`; `${PROJECT_HOST}/internalapi/project/${projectId}/get/`;
} }
); );
} }

View file

@ -37,7 +37,8 @@ module.exports.getInitialState = () => ({
parent: {}, parent: {},
projectStudios: [], projectStudios: [],
curatedStudios: [], curatedStudios: [],
currentStudioIds: [] currentStudioIds: [],
moreCommentsToLoad: false
}); });
module.exports.previewReducer = (state, action) => { module.exports.previewReducer = (state, action) => {
@ -46,6 +47,8 @@ module.exports.previewReducer = (state, action) => {
} }
switch (action.type) { switch (action.type) {
case 'RESET_TO_INTIAL_STATE':
return module.exports.getInitialState();
case 'SET_PROJECT_INFO': case 'SET_PROJECT_INFO':
return Object.assign({}, state, { return Object.assign({}, state, {
projectInfo: action.info projectInfo: action.info
@ -86,6 +89,51 @@ module.exports.previewReducer = (state, action) => {
return Object.assign({}, state, { return Object.assign({}, state, {
comments: [...state.comments, ...action.items] // TODO: consider a different way of doing this? comments: [...state.comments, ...action.items] // TODO: consider a different way of doing this?
}); });
case 'UPDATE_COMMENT':
if (action.topLevelCommentId) {
return Object.assign({}, state, {
replies: Object.assign({}, state.replies, {
[action.topLevelCommentId]: state.replies[action.topLevelCommentId].map(comment => {
if (comment.id === action.commentId) {
return Object.assign({}, comment, action.comment);
}
return comment;
})
})
});
}
return Object.assign({}, state, {
comments: state.comments.map(comment => {
if (comment.id === action.commentId) {
return Object.assign({}, comment, action.comment);
}
return comment;
})
});
case 'ADD_NEW_COMMENT':
if (action.topLevelCommentId) {
return Object.assign({}, state, {
replies: Object.assign({}, state.replies, {
// Replies to comments go at the end of the thread
[action.topLevelCommentId]: state.replies[action.topLevelCommentId].concat(action.comment)
})
});
}
// Reply to the top level project, put the reply at the beginning
return Object.assign({}, state, {
comments: [action.comment, ...state.comments],
replies: Object.assign({}, state.replies, {[action.comment.id]: []})
});
case 'UPDATE_ALL_REPLIES':
return Object.assign({}, state, {
replies: Object.assign({}, state.replies, {
[action.commentId]: state.replies[action.commentId].map(reply =>
Object.assign({}, reply, action.comment)
)
})
});
case 'SET_REPLIES': case 'SET_REPLIES':
return Object.assign({}, state, { return Object.assign({}, state, {
replies: merge({}, state.replies, action.replies) replies: merge({}, state.replies, action.replies)
@ -106,6 +154,10 @@ module.exports.previewReducer = (state, action) => {
state = JSON.parse(JSON.stringify(state)); state = JSON.parse(JSON.stringify(state));
state.status.studioRequests[action.studioId] = action.status; state.status.studioRequests[action.studioId] = action.status;
return state; return state;
case 'SET_MORE_COMMENTS_TO_LOAD':
return Object.assign({}, state, {
moreCommentsToLoad: action.moreCommentsToLoad
});
case 'ERROR': case 'ERROR':
log.error(action.error); log.error(action.error);
return state; return state;
@ -119,6 +171,10 @@ module.exports.setError = error => ({
error: error error: error
}); });
module.exports.resetProject = () => ({
type: 'RESET_TO_INTIAL_STATE'
});
module.exports.setProjectInfo = info => ({ module.exports.setProjectInfo = info => ({
type: 'SET_PROJECT_INFO', type: 'SET_PROJECT_INFO',
info: info info: info
@ -191,6 +247,60 @@ module.exports.setStudioFetchStatus = (studioId, status) => ({
status: status status: status
}); });
module.exports.setCommentDeleted = (commentId, topLevelCommentId) => ({
type: 'UPDATE_COMMENT',
commentId: commentId,
topLevelCommentId: topLevelCommentId,
comment: {
visibility: 'deleted'
}
});
module.exports.setRepliesDeleted = commentId => ({
type: 'UPDATE_ALL_REPLIES',
commentId: commentId,
comment: {
visibility: 'deleted'
}
});
module.exports.setCommentReported = (commentId, topLevelCommentId) => ({
type: 'UPDATE_COMMENT',
commentId: commentId,
topLevelCommentId: topLevelCommentId,
comment: {
visibility: 'reported'
}
});
module.exports.setCommentRestored = (commentId, topLevelCommentId) => ({
type: 'UPDATE_COMMENT',
commentId: commentId,
topLevelCommentId: topLevelCommentId,
comment: {
visibility: 'visible'
}
});
module.exports.setRepliesRestored = commentId => ({
type: 'UPDATE_ALL_REPLIES',
commentId: commentId,
comment: {
visibility: 'visible'
}
});
module.exports.addNewComment = (comment, topLevelCommentId) => ({
type: 'ADD_NEW_COMMENT',
comment: comment,
topLevelCommentId: topLevelCommentId
});
module.exports.setMoreCommentsToLoad = moreCommentsToLoad => ({
type: 'SET_MORE_COMMENTS_TO_LOAD',
moreCommentsToLoad: moreCommentsToLoad
});
module.exports.getProjectInfo = (id, token) => (dispatch => { module.exports.getProjectInfo = (id, token) => (dispatch => {
const opts = { const opts = {
uri: `/projects/${id}` uri: `/projects/${id}`
@ -276,11 +386,13 @@ module.exports.getFavedStatus = (id, username, token) => (dispatch => {
}); });
}); });
module.exports.getTopLevelComments = (id, offset) => (dispatch => { module.exports.getTopLevelComments = (id, offset, isAdmin, token) => (dispatch => {
const COMMENT_LIMIT = 20;
dispatch(module.exports.setFetchStatus('comments', module.exports.Status.FETCHING)); dispatch(module.exports.setFetchStatus('comments', module.exports.Status.FETCHING));
api({ api({
uri: `/comments/project/${id}`, uri: `${isAdmin ? '/admin' : ''}/projects/${id}/comments`,
params: {offset: offset || 0} authentication: isAdmin ? token : null,
params: {offset: offset || 0, limit: COMMENT_LIMIT}
}, (err, body) => { }, (err, body) => {
if (err) { if (err) {
dispatch(module.exports.setFetchStatus('comments', module.exports.Status.ERROR)); dispatch(module.exports.setFetchStatus('comments', module.exports.Status.ERROR));
@ -294,16 +406,53 @@ module.exports.getTopLevelComments = (id, offset) => (dispatch => {
} }
dispatch(module.exports.setFetchStatus('comments', module.exports.Status.FETCHED)); dispatch(module.exports.setFetchStatus('comments', module.exports.Status.FETCHED));
dispatch(module.exports.setComments(body)); dispatch(module.exports.setComments(body));
dispatch(module.exports.getReplies(id, body.map(comment => comment.id))); dispatch(module.exports.getReplies(id, body.map(comment => comment.id), isAdmin, token));
// If we loaded a full page of comments, assume there are more to load.
// This will be wrong (1 / COMMENT_LIMIT) of the time, but does not require
// any more server query complexity, so seems worth it. In the case of a project with
// number of comments divisible by the COMMENT_LIMIT, the load more button will be
// clickable, but upon clicking it will go away.
dispatch(module.exports.setMoreCommentsToLoad(body.length === COMMENT_LIMIT));
}); });
}); });
module.exports.getReplies = (projectId, commentIds) => (dispatch => { module.exports.getCommentById = (projectId, commentId, isAdmin, token) => (dispatch => {
dispatch(module.exports.setFetchStatus('comments', module.exports.Status.FETCHING));
api({
uri: `${isAdmin ? '/admin' : ''}/projects/comments/${commentId}`,
authentication: isAdmin ? token : null
}, (err, body) => {
if (err) {
dispatch(module.exports.setFetchStatus('comments', module.exports.Status.ERROR));
dispatch(module.exports.setError(err));
return;
}
if (!body) {
dispatch(module.exports.setFetchStatus('comments', module.exports.Status.ERROR));
dispatch(module.exports.setError('No comment info'));
return;
}
if (body.parent_id) {
// If the comment is a reply, load the parent
return dispatch(module.exports.getCommentById(projectId, body.parent_id, isAdmin, token));
}
// If the comment is not a reply, show it as top level and load replies
dispatch(module.exports.setFetchStatus('comments', module.exports.Status.FETCHED));
dispatch(module.exports.setComments([body]));
dispatch(module.exports.getReplies(projectId, [body.id], isAdmin, token));
});
});
module.exports.getReplies = (projectId, commentIds, isAdmin, token) => (dispatch => {
dispatch(module.exports.setFetchStatus('replies', module.exports.Status.FETCHING)); dispatch(module.exports.setFetchStatus('replies', module.exports.Status.FETCHING));
const fetchedReplies = {}; const fetchedReplies = {};
async.eachLimit(commentIds, 10, (parentId, callback) => { async.eachLimit(commentIds, 10, (parentId, callback) => {
api({ api({
uri: `/comments/project/${projectId}/${parentId}` uri: `${isAdmin ? '/admin' : ''}/projects/${projectId}/comments/${parentId}/replies`,
authentication: isAdmin ? token : null
}, (err, body) => { }, (err, body) => {
if (err) { if (err) {
return callback(`Error fetching comment replies: ${err}`); return callback(`Error fetching comment replies: ${err}`);
@ -562,7 +711,63 @@ module.exports.updateProject = (id, jsonData, username, token) => (dispatch => {
}); });
}); });
module.exports.reportProject = (id, jsonData) => (dispatch => { module.exports.deleteComment = (projectId, commentId, topLevelCommentId, token) => (dispatch => {
/* TODO fetching/fetched/error states updates for comment deleting */
api({
uri: `/proxy/comments/project/${projectId}/comment/${commentId}`,
authentication: token,
withCredentials: true,
method: 'DELETE',
useCsrf: true
}, (err, body, res) => {
if (err || res.statusCode !== 200) {
log.error(err || res.body);
return;
}
dispatch(module.exports.setCommentDeleted(commentId, topLevelCommentId));
if (!topLevelCommentId) {
dispatch(module.exports.setRepliesDeleted(commentId));
}
});
});
module.exports.reportComment = (projectId, commentId, topLevelCommentId, token) => (dispatch => {
api({
uri: `/proxy/project/${projectId}/comment/${commentId}/report`,
authentication: token,
withCredentials: true,
method: 'POST',
useCsrf: true
}, (err, body, res) => {
if (err || res.statusCode !== 200) {
log.error(err || res.body);
return;
}
// TODO use the reportId in the response for unreporting functionality
dispatch(module.exports.setCommentReported(commentId, topLevelCommentId));
});
});
module.exports.restoreComment = (projectId, commentId, topLevelCommentId, token) => (dispatch => {
api({
uri: `/proxy/admin/project/${projectId}/comment/${commentId}/undelete`,
authentication: token,
withCredentials: true,
method: 'PUT',
useCsrf: true
}, (err, body, res) => {
if (err || res.statusCode !== 200) {
log.error(err || res.body);
return;
}
dispatch(module.exports.setCommentRestored(commentId, topLevelCommentId));
if (!topLevelCommentId) {
dispatch(module.exports.setRepliesRestored(commentId));
}
});
});
module.exports.reportProject = (id, jsonData, token) => (dispatch => {
dispatch(module.exports.setFetchStatus('report', module.exports.Status.FETCHING)); dispatch(module.exports.setFetchStatus('report', module.exports.Status.FETCHING));
// scratchr2 will fail if no thumbnail base64 string provided. We don't yet have // 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 way to get the actual project thumbnail in www/gui, so for now just submit
@ -572,11 +777,12 @@ module.exports.reportProject = (id, jsonData) => (dispatch => {
'0lEQVR42mP8/x8AAwMCAO+ip1sAAAAASUVORK5CYII=' '0lEQVR42mP8/x8AAwMCAO+ip1sAAAAASUVORK5CYII='
}); });
api({ api({
host: '', uri: `/proxy/projects/${id}/report`,
uri: `/site-api/projects/all/${id}/report/`, authentication: token,
withCredentials: true,
method: 'POST', method: 'POST',
json: jsonData, useCsrf: true,
useCsrf: true json: jsonData
}, (err, body, res) => { }, (err, body, res) => {
if (err || res.statusCode !== 200) { if (err || res.statusCode !== 200) {
dispatch(module.exports.setFetchStatus('report', module.exports.Status.ERROR)); dispatch(module.exports.setFetchStatus('report', module.exports.Status.ERROR));

View file

@ -10,6 +10,7 @@
div { div {
display: inline-block; display: inline-block;
width: calc(50% - 10px); width: calc(50% - 10px);
min-width: calc(50% - 10px);
ul { ul {
margin: 0; margin: 0;
@ -29,8 +30,9 @@
} }
iframe { iframe {
display: block;
border: 1px solid $ui-gray; border: 1px solid $ui-gray;
width: 460px; width: 100%;
height: 290px; height: 290px;
} }
} }

View file

@ -1,11 +1,11 @@
@import "../../../../frameless"; @import "../../../../frameless";
#view { #view {
@media only screen and (max-width: $tablet - 1) { @media #{$medium-and-smaller} {
margin-top: 100px; margin-top: 100px;
} }
@media only screen and (max-width: $desktop - 1) { @media #{$intermediate-and-smaller} {
text-align: left; text-align: left;
} }
} }
@ -49,7 +49,7 @@
} }
//8 columns //8 columns
@media only screen and (max-width: $desktop - 1) { @media #{$intermediate-and-smaller} {
.details { .details {
width: 100%; width: 100%;
} }

View file

@ -25,7 +25,7 @@
margin-top: 1.2rem; margin-top: 1.2rem;
} }
@media only screen and (max-width: $tablet - 1) { @media #{$medium-and-smaller} {
img { img {
width: 50%; width: 50%;
} }
@ -37,7 +37,7 @@
} }
} }
@media only screen and (max-width: $desktop - 1) { @media #{$intermediate-and-smaller} {
.uneven { .uneven {
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
@ -80,7 +80,7 @@
margin: 15px 0; margin: 15px 0;
} }
@media only screen and (max-width: $desktop - 1) { @media #{$intermediate-and-smaller} {
.flex-row { .flex-row {
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
@ -156,7 +156,7 @@
} }
} }
@media only screen and (max-width: $mobile - 1) { @media #{$small} {
.flex-row { .flex-row {
table { table {
width: 100%; width: 100%;
@ -164,7 +164,7 @@
} }
} }
@media only screen and (min-width: $mobile) and (max-width: $desktop - 1) { @media #{$medium-and-intermediate} {
.flex-row { .flex-row {
table { table {
width: $cols6; width: $cols6;
@ -172,7 +172,7 @@
} }
} }
@media only screen and (max-width: $desktop - 1) { @media #{$intermediate-and-smaller} {
.flex-row { .flex-row {
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;

View file

@ -48,7 +48,7 @@
} }
} }
@media only screen and (max-width: $mobile - 1) { @media #{$small} {
h3 { h3 {
display: none; display: none;
margin-top: 0; margin-top: 0;
@ -60,7 +60,7 @@
} }
@media only screen and (max-width: $desktop - 1) { @media #{$intermediate-and-smaller} {
h1 { h1 {
font-size: 2.5rem; font-size: 2.5rem;
} }
@ -85,7 +85,7 @@
max-width: 125px; max-width: 125px;
} }
@media only screen and (max-width: $tablet - 1) { @media #{$medium-and-smaller} {
margin: .5rem; margin: .5rem;
width: 125px; width: 125px;
} }
@ -93,7 +93,7 @@
} }
} }
@media only screen and (max-width: $tablet - 1) { @media #{$medium-and-smaller} {
.index { .index {
.flex-row { .flex-row {
align-items: center; align-items: center;

View file

@ -19,13 +19,13 @@
width: 100%; width: 100%;
} }
@media only screen and (max-width: $tablet - 1) { @media #{$medium-and-smaller} {
img { img {
width: 30%; width: 30%;
} }
} }
@media only screen and (min-width: $tablet) and (max-width: $desktop - 1) { @media #{$intermediate} {
img { img {
width: 70%; width: 70%;
} }
@ -36,7 +36,7 @@
.lodging { .lodging {
text-align: left; text-align: left;
@media only screen and (max-width: $desktop - 1) { @media #{$intermediate-and-smaller} {
.uneven { .uneven {
.short { .short {
display: none; display: none;
@ -50,7 +50,7 @@
align-items: center; align-items: center;
} }
@media only screen and (max-width: $desktop - 1) { @media #{$intermediate-and-smaller} {
.flex-row { .flex-row {
flex-direction: column-reverse; flex-direction: column-reverse;
} }
@ -69,13 +69,13 @@
justify-content: flex-start; justify-content: flex-start;
} }
@media only screen and (max-width: $tablet - 1) { @media #{$medium-and-smaller} {
ul { ul {
max-height: 100%; max-height: 100%;
} }
} }
@media only screen and (max-width: $desktop - 1) { @media #{$intermediate-and-smaller} {
div { div {
text-align: left; text-align: left;
} }
@ -105,7 +105,7 @@
margin: 0; margin: 0;
} }
@media only screen and (max-width: $tablet - 1) { @media #{$medium-and-smaller} {
margin-top: 0; margin-top: 0;
} }
} }

View file

@ -102,7 +102,7 @@
} }
} }
@media only screen and (max-width: $mobile - 1) { @media #{$small} {
.sub-nav { .sub-nav {
flex-wrap: nowrap; flex-wrap: nowrap;
} }
@ -124,7 +124,7 @@
} }
} }
@media only screen and (max-width: $tablet - 1) { @media #{$medium-and-smaller} {
.inner { .inner {
h2 { h2 {
&.breaking-title { &.breaking-title {

View file

@ -79,7 +79,7 @@ td {
color: $type-white; color: $type-white;
} }
@media only screen and (max-width: $mobile - 1) { @media #{$small} {
.index.mod-2017 { .index.mod-2017 {
text-align: left; text-align: left;
} }
@ -123,7 +123,7 @@ td {
} }
} }
@media only screen and (min-width: $mobile) and (max-width: $tablet - 1) { @media #{$medium} {
.index.mod-2017 { .index.mod-2017 {
text-align: left; text-align: left;
} }
@ -159,7 +159,7 @@ td {
} }
} }
@media only screen and (min-width: $tablet) and (max-width: $desktop - 1) { @media #{$intermediate} {
.index.mod-2017 { .index.mod-2017 {
text-align: left; text-align: left;
} }

View file

@ -1,11 +1,11 @@
@import "../../../../frameless"; @import "../../../../frameless";
#view { #view {
@media only screen and (max-width: $tablet - 1) { @media #{$medium-and-smaller} {
margin-top: 100px; margin-top: 100px;
} }
@media only screen and (max-width: $desktop - 1) { @media #{$intermediate-and-smaller} {
text-align: left; text-align: left;
} }
} }
@ -49,7 +49,7 @@
} }
//8 columns //8 columns
@media only screen and (max-width: $desktop - 1) { @media #{$intermediate-and-smaller} {
.details { .details {
width: 100%; width: 100%;
} }

View file

@ -25,7 +25,7 @@
margin-top: 1.2rem; margin-top: 1.2rem;
} }
@media only screen and (max-width: $tablet - 1) { @media #{$medium-and-smaller} {
img { img {
width: 50%; width: 50%;
} }
@ -37,7 +37,7 @@
} }
} }
@media only screen and (max-width: $desktop - 1) { @media #{$intermediate-and-smaller} {
.uneven { .uneven {
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
@ -85,7 +85,7 @@
margin: 15px 0; margin: 15px 0;
} }
@media only screen and (max-width: $desktop - 1) { @media #{$intermediate-and-smaller} {
.flex-row { .flex-row {
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
@ -163,7 +163,7 @@
} }
} }
@media only screen and (max-width: $mobile - 1) { @media #{$small} {
.flex-row { .flex-row {
table { table {
width: 100%; width: 100%;
@ -171,7 +171,7 @@
} }
} }
@media only screen and (min-width: $mobile) and (max-width: $desktop - 1) { @media #{$medium-and-intermediate} {
.flex-row { .flex-row {
table { table {
width: $cols6; width: $cols6;
@ -179,7 +179,7 @@
} }
} }
@media only screen and (max-width: $desktop - 1) { @media #{$intermediate-and-smaller} {
.flex-row { .flex-row {
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;

View file

@ -60,7 +60,7 @@
} }
} }
@media only screen and (max-width: $mobile - 1) { @media #{$small} {
h3 { h3 {
display: none; display: none;
margin-top: 0; margin-top: 0;
@ -72,7 +72,7 @@
} }
@media only screen and (max-width: $desktop - 1) { @media #{$intermediate-and-smaller} {
h1 { h1 {
font-size: 2.5rem; font-size: 2.5rem;
} }
@ -97,7 +97,7 @@
max-width: 125px; max-width: 125px;
} }
@media only screen and (max-width: $tablet - 1) { @media #{$medium-and-smaller} {
margin: .5rem; margin: .5rem;
width: 125px; width: 125px;
} }
@ -105,7 +105,7 @@
} }
} }
@media only screen and (max-width: $tablet - 1) { @media #{$medium-and-smaller} {
.index { .index {
.flex-row { .flex-row {
align-items: center; align-items: center;

View file

@ -19,13 +19,13 @@
width: 100%; width: 100%;
} }
@media only screen and (max-width: $tablet - 1) { @media #{$medium-and-smaller} {
img { img {
width: 30%; width: 30%;
} }
} }
@media only screen and (min-width: $tablet) and (max-width: $desktop - 1) { @media #{$intermediate} {
img { img {
width: 70%; width: 70%;
} }
@ -36,7 +36,7 @@
.lodging { .lodging {
text-align: left; text-align: left;
@media only screen and (max-width: $desktop - 1) { @media #{$intermediate-and-smaller} {
.uneven { .uneven {
.short { .short {
display: none; display: none;
@ -50,7 +50,7 @@
align-items: center; align-items: center;
} }
@media only screen and (max-width: $desktop - 1) { @media #{$intermediate-and-smaller} {
.flex-row { .flex-row {
flex-direction: column-reverse; flex-direction: column-reverse;
} }
@ -69,13 +69,13 @@
justify-content: flex-start; justify-content: flex-start;
} }
@media only screen and (max-width: $tablet - 1) { @media #{$medium-and-smaller} {
ul { ul {
max-height: 100%; max-height: 100%;
} }
} }
@media only screen and (max-width: $desktop - 1) { @media #{$intermediate-and-smaller} {
div { div {
text-align: left; text-align: left;
} }
@ -105,7 +105,7 @@
margin: 0; margin: 0;
} }
@media only screen and (max-width: $tablet - 1) { @media #{$medium-and-smaller} {
margin-top: 0; margin-top: 0;
} }
} }

View file

@ -102,7 +102,7 @@
} }
} }
@media only screen and (max-width: $mobile - 1) { @media #{$small} {
.sub-nav { .sub-nav {
flex-wrap: nowrap; flex-wrap: nowrap;
} }
@ -124,7 +124,7 @@
} }
} }
@media only screen and (max-width: $tablet - 1) { @media #{$medium-and-smaller} {
.inner { .inner {
h2 { h2 {
&.breaking-title { &.breaking-title {

View file

@ -170,7 +170,7 @@ $developer-spot: $ui-aqua;
} }
//4 columns //4 columns
@media only screen and (max-width: $mobile - 1) { @media #{$small} {
#view { #view {
text-align: left; text-align: left;
} }
@ -196,7 +196,7 @@ $developer-spot: $ui-aqua;
} }
//6 columns //6 columns
@media only screen and (min-width: $mobile) and (max-width: $tablet - 1) { @media #{$medium} {
#view { #view {
text-align: left; text-align: left;
} }
@ -216,7 +216,7 @@ $developer-spot: $ui-aqua;
} }
//8 columns //8 columns
@media only screen and (min-width: $tablet) and (max-width: $desktop - 1) { @media #{$intermediate} {
#view { #view {
text-align: left; text-align: left;
} }

View file

@ -111,7 +111,7 @@
color: $ui-white; color: $ui-white;
} }
@media only screen and (max-width: $mobile - 1) { @media #{$small} {
.inner { .inner {
.installation-column { .installation-column {
max-width: 100%; max-width: 100%;
@ -119,7 +119,7 @@
} }
} }
@media only screen and (max-width: $desktop - 1) { @media #{$intermediate-and-smaller} {
.three-col-row { .three-col-row {
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;

View file

@ -120,7 +120,7 @@ $base-bg: $ui-white;
} }
//4 columns //4 columns
@media only screen and (max-width: $mobile - 1) { @media #{$small} {
.outer { .outer {
.tabs { .tabs {
width: $cols4; width: $cols4;
@ -139,7 +139,7 @@ $base-bg: $ui-white;
} }
//6 columns //6 columns
@media only screen and (min-width: $mobile) and (max-width: $tablet - 1) { @media #{$medium} {
.outer { .outer {
.tabs { .tabs {
width: $cols6; width: $cols6;
@ -158,7 +158,7 @@ $base-bg: $ui-white;
} }
// 8 columns // 8 columns
@media only screen and (min-width: $tablet) and (max-width: $desktop - 1) { @media #{$intermediate} {
.outer { .outer {
.tabs { .tabs {
width: $cols8; width: $cols8;

View file

@ -14,7 +14,7 @@
} }
} }
@media only screen and (max-width: $tablet - 1){ @media #{$medium-and-smaller}{
.guidelines-footer { .guidelines-footer {
img { img {
display: none; display: none;

View file

@ -129,7 +129,7 @@
word-wrap: break-word; word-wrap: break-word;
} }
@media only screen and (max-width: $mobile - 1) { @media #{$small} {
.flex-row.admin-message-header, .flex-row.admin-message-header,
.flex-row.mod-comment-message { .flex-row.mod-comment-message {
flex-direction: row; flex-direction: row;
@ -144,7 +144,7 @@
} }
} }
@media only screen and (min-width: $mobile) and (max-width: $tablet - 1) { @media #{$medium} {
.flex-row.admin-message-header, .flex-row.admin-message-header,
.flex-row.mod-comment-message { .flex-row.mod-comment-message {
flex-direction: row; flex-direction: row;
@ -159,7 +159,7 @@
} }
} }
@media only screen and (min-width: $tablet) and (max-width: $desktop - 1) { @media #{$intermediate} {
.comment-text { .comment-text {
max-width: 23.75rem; max-width: 23.75rem;
} }

View file

@ -26,7 +26,7 @@
"microbit.closeScratchCopiesTitle": "Close other copies of Scratch", "microbit.closeScratchCopiesTitle": "Close other copies of Scratch",
"microbit.closeScratchCopiesText": "Only one copy of Scratch can connect with the micro:bit at a time. If you have Scratch open in other browser tabs, close it and try again.", "microbit.closeScratchCopiesText": "Only one copy of Scratch can connect with the micro:bit at a time. If you have Scratch open in other browser tabs, close it and try again.",
"microbit.otherComputerConnectedTitle": "Make sure no other computer is connected to your micro:bit", "microbit.otherComputerConnectedTitle": "Make sure no other computer is connected to your micro:bit",
"microbit.otherComputerConnectedText": "Only one computer can be connected to an micro:bit at a time. If you have another computer connected to your micro:bit, disconnect the micro:bit or close Scratch on that computer and try again.", "microbit.otherComputerConnectedText": "Only one computer can be connected to a micro:bit at a time. If you have another computer connected to your micro:bit, disconnect the micro:bit or close Scratch on that computer and try again.",
"microbit.resetButtonTitle": "Make sure you arent hitting the “reset” button", "microbit.resetButtonTitle": "Make sure you arent hitting the “reset” button",
"microbit.resetButtonText": "Sometimes while using the micro:bit you can accidentally press the “reset” button on the back in-between the USB and power ports. Make sure you keep your fingers (and toes) away from it while using Scratch!", "microbit.resetButtonText": "Sometimes while using the micro:bit you can accidentally press the “reset” button on the back in-between the USB and power ports. Make sure you keep your fingers (and toes) away from it while using Scratch!",
"microbit.imgAltMicrobitIllustration": "Illustration of the micro:bit circuit board.", "microbit.imgAltMicrobitIllustration": "Illustration of the micro:bit circuit board.",

View file

@ -0,0 +1,54 @@
const connect = require('react-redux').connect;
const previewActions = require('../../redux/preview.js');
const AddToStudioModal = require('../../components/modal/addtostudio/container.jsx');
// 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 => ({
studios: consolidateStudiosInfo(state.preview.curatedStudios,
state.preview.projectStudios, state.preview.currentStudioIds,
state.preview.status.studioRequests)
});
const mapDispatchToProps = () => ({});
const ConnectedAddToStudioModal = connect(
mapStateToProps,
mapDispatchToProps
)(AddToStudioModal);
module.exports = ConnectedAddToStudioModal;

View file

@ -1,69 +1,282 @@
const React = require('react'); const React = require('react');
const PropTypes = require('prop-types'); const PropTypes = require('prop-types');
const bindAll = require('lodash.bindall');
const classNames = require('classnames');
const FlexRow = require('../../../components/flex-row/flex-row.jsx'); const FlexRow = require('../../../components/flex-row/flex-row.jsx');
const Avatar = require('../../../components/avatar/avatar.jsx'); const Avatar = require('../../../components/avatar/avatar.jsx');
const EmojiText = require('../../../components/emoji-text/emoji-text.jsx');
const FormattedRelative = require('react-intl').FormattedRelative; const FormattedRelative = require('react-intl').FormattedRelative;
const FormattedMessage = require('react-intl').FormattedMessage;
const ComposeComment = require('./compose-comment.jsx');
const DeleteCommentModal = require('../../../components/modal/comments/delete-comment.jsx');
const ReportCommentModal = require('../../../components/modal/comments/report-comment.jsx');
const decorateText = require('../../../lib/decorate-text.jsx');
require('./comment.scss'); require('./comment.scss');
const Comment = ({ class Comment extends React.Component {
author, constructor (props) {
content, super(props);
datetimeCreated, bindAll(this, [
id 'handleDelete',
}) => ( 'handleCancelDelete',
<div 'handleConfirmDelete',
className="flex-row comment" 'handleReport',
id={`comments-${id}`} 'handleConfirmReport',
> 'handleCancelReport',
<a href={`/users/${author.username}`}> 'handlePostReply',
<Avatar src={author.image} /> 'handleToggleReplying',
</a> 'handleRestore',
<FlexRow className="comment-body column"> 'setRef'
<FlexRow className="comment-top-row"> ]);
<a this.state = {
className="username" deleting: false,
href={`/users/${author.username}`} reporting: false,
>{author.username}</a> reportConfirmed: false,
<div className="action-list"> replying: false
{/* TODO: Hook these up to API calls/logic */} };
<span className="comment-delete">Delete</span> }
<span className="comment-report">Report</span>
</div> componentDidMount () {
</FlexRow> if (this.props.highlighted) {
<div className="comment-bubble"> this.ref.scrollIntoView({behavior: 'smooth'});
{/* TODO: at the moment, comment content does not properly display }
* emojis/easter eggs }
* @user links in replies
* links to scratch.mit.edu pages handlePostReply (comment) {
*/} this.setState({replying: false});
<span className="comment-content">{content}</span> this.props.onAddComment(comment);
<FlexRow className="comment-bottom-row"> }
<span className="comment-time">
<FormattedRelative value={new Date(datetimeCreated)} /> handleToggleReplying () {
</span> this.setState({replying: !this.state.replying});
<a }
className="comment-reply"
href={`#comments-${id}`} handleDelete () {
this.setState({deleting: true});
}
handleConfirmDelete () {
this.setState({deleting: false});
this.props.onDelete(this.props.id);
}
handleCancelDelete () {
this.setState({deleting: false});
}
handleReport () {
this.setState({reporting: true});
}
handleRestore () {
this.props.onRestore(this.props.id);
}
handleConfirmReport () {
this.setState({
reporting: false,
reportConfirmed: true,
deleting: false // To close delete modal if reported from delete modal
});
this.props.onReport(this.props.id);
}
handleCancelReport () {
this.setState({
reporting: false,
reportConfirmed: false
});
}
setRef (ref) {
this.ref = ref;
}
render () {
const {
author,
canDelete,
canReply,
canReport,
canRestore,
content,
datetimeCreated,
highlighted,
id,
parentId,
projectId,
replyUsername,
visibility
} = this.props;
const visible = visibility === 'visible';
let commentText = content;
if (replyUsername) {
commentText = `@${replyUsername} ${commentText}`;
}
commentText = decorateText(commentText, {
scratchLinks: true,
usernames: true,
hashtags: false
});
return (
<div
className={classNames('flex-row', 'comment', {
'highlighted-comment': highlighted
})}
id={`comments-${id}`}
ref={this.setRef}
>
<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}{author.scratchteam ? '*' : ''}
</a>
<div className="action-list">
{visible ? (
<React.Fragment>
{canDelete && (
<span
className="comment-delete"
onClick={this.handleDelete}
>
<FormattedMessage id="comments.delete" />
</span>
)}
{canReport && (
<span
className="comment-report"
onClick={this.handleReport}
>
<FormattedMessage id="comments.report" />
</span>
)}
</React.Fragment>
) : (
<React.Fragment>
<span className="comment-visibility">
<FormattedMessage id={`comments.status.${visibility}`} />
</span>
{canRestore && (
<span
className="comment-restore"
onClick={this.handleRestore}
>
<FormattedMessage id="comments.restore" />
</span>
)}
</React.Fragment>
)}
</div>
</FlexRow>
<div
className={classNames({
'comment-bubble': true,
'comment-bubble-reported': !visible
})}
> >
reply {/* TODO: at the moment, comment content does not properly display
</a> * emojis/easter eggs
* @user links in replies
* links to scratch.mit.edu pages
*/}
<span className="comment-content">
{commentText.map(fragment => {
if (typeof fragment === 'string') {
return (
<EmojiText
as="span"
text={fragment}
/>
);
}
return fragment;
})}
</span>
<FlexRow className="comment-bottom-row">
<span className="comment-time">
<FormattedRelative value={new Date(datetimeCreated)} />
</span>
{(canReply && visible) ? (
<span
className="comment-reply"
onClick={this.handleToggleReplying}
>
<FormattedMessage id="comments.reply" />
</span>
) : null}
</FlexRow>
</div>
{this.state.replying ? (
<FlexRow className="comment-reply-row">
<ComposeComment
commenteeId={author.id}
parentId={parentId || id}
projectId={projectId}
onAddComment={this.handlePostReply}
onCancel={this.handleToggleReplying}
/>
</FlexRow>
) : null}
</FlexRow> </FlexRow>
{this.state.deleting ? (
<DeleteCommentModal
isOpen
key="delete-comment-modal"
onDelete={this.handleConfirmDelete}
onReport={this.handleConfirmReport}
onRequestClose={this.handleCancelDelete}
/>
) : null}
{(this.state.reporting || this.state.reportConfirmed) ? (
<ReportCommentModal
isOpen
isConfirmed={this.state.reportConfirmed}
key="report-comment-modal"
onReport={this.handleConfirmReport}
onRequestClose={this.handleCancelReport}
/>
) : null}
</div> </div>
</FlexRow> );
</div> }
); }
Comment.propTypes = { Comment.propTypes = {
author: PropTypes.shape({ author: PropTypes.shape({
id: PropTypes.number, id: PropTypes.number,
image: PropTypes.string, image: PropTypes.string,
scratchteam: PropTypes.bool,
username: PropTypes.string username: PropTypes.string
}), }),
canDelete: PropTypes.bool,
canReply: PropTypes.bool,
canReport: PropTypes.bool,
canRestore: PropTypes.bool,
content: PropTypes.string, content: PropTypes.string,
datetimeCreated: PropTypes.string, datetimeCreated: PropTypes.string,
id: PropTypes.number highlighted: PropTypes.bool,
id: PropTypes.number,
onAddComment: PropTypes.func,
onDelete: PropTypes.func,
onReport: PropTypes.func,
onRestore: PropTypes.func,
parentId: PropTypes.number,
projectId: PropTypes.string,
replyUsername: PropTypes.string,
visibility: PropTypes.string
}; };
module.exports = Comment; module.exports = Comment;

View file

@ -1,8 +1,30 @@
@import "../../../colors"; @import "../../../colors";
.compose-comment { .compose-comment {
margin-left: .5rem;
width: 100%; width: 100%;
.compose-error-row {
width: 100%;
justify-content: flex-start;
.compose-error-tip {
margin-bottom: .5rem;
border: 1px solid $active-gray;
border-radius: 5px;
background-color: $ui-orange;
padding: .25rem;
width: 100%;
text-align: left;
color: $type-white;
font-size: .85rem;
}
}
.full-width-form {
width: 100%;
}
.textarea-row { .textarea-row {
width: 100%; width: 100%;
@ -51,6 +73,17 @@
justify-content: space-between; justify-content: space-between;
align-items: flex-start; align-items: flex-start;
&.highlighted-comment:before {
position: absolute;
top: -.5rem;
left: -.5rem;
border-radius: .5rem;
background: $ui-blue-10percent;
width: calc(100% + 1rem);
height: 100%;
content: "";
}
.comment-top-row { .comment-top-row {
margin-bottom: 8px; margin-bottom: 8px;
width: 100%; width: 100%;
@ -60,8 +93,10 @@
} }
.comment-delete, .comment-delete,
.comment-report { .comment-report,
.comment-restore {
opacity: .5; opacity: .5;
cursor: pointer;
font-size: .75rem; font-size: .75rem;
font-weight: 500; font-weight: 500;
@ -75,6 +110,11 @@
} }
} }
.comment-visibility {
opacity: .5;
font-size: .75rem;
}
.comment-delete { .comment-delete {
margin-right: 1rem; margin-right: 1rem;
@ -95,6 +135,18 @@
vertical-align: -.125rem; vertical-align: -.125rem;
} }
} }
.comment-restore {
margin-left: 1rem;
&:before {
margin-right: .25rem;
background-image: url("/svgs/project/restore-gray.svg");
width: .75rem;
height: .75rem;
vertical-align: -.125rem;
}
}
} }
.avatar { .avatar {
@ -131,6 +183,19 @@
height: 9px; height: 9px;
content: ""; content: "";
} }
&.comment-bubble-reported {
$reported-outline: #ff6680;
$reported-background: rgb(236, 206, 223);
border-color: $reported-outline;
background-color: $reported-background;
&:before {
border-color: $reported-outline transparent $reported-outline $reported-outline;
background: $reported-background;
}
}
} }
.comment-content { .comment-content {
@ -148,6 +213,9 @@
.comment-reply { .comment-reply {
display: inline-flex; display: inline-flex;
cursor: pointer;
color: $ui-blue;
font-weight: bold;
&:after { &:after {
margin-left: .25rem; margin-left: .25rem;
@ -172,7 +240,7 @@
position: absolute; position: absolute;
bottom: 0; bottom: 0;
background: linear-gradient( background: linear-gradient(
transparent, $ui-light-primary-transparent,
$ui-light-primary $ui-light-primary
); );
width: 100%; width: 100%;
@ -184,6 +252,16 @@
} }
} }
.comments-root-reply {
margin-bottom: 1.5rem;
}
.comment-reply-row {
margin-top: 1.5rem;
margin-left: .5rem;
width: 100%;
}
.expand-thread { .expand-thread {
margin-bottom: 24px; margin-bottom: 24px;
width: 100%; width: 100%;
@ -211,4 +289,3 @@
margin-right: -50%; margin-right: -50%;
} }
} }

View file

@ -1,26 +1,196 @@
const React = require('react'); const React = require('react');
const PropTypes = require('prop-types');
const bindAll = require('lodash.bindall');
const classNames = require('classnames');
const keyMirror = require('keymirror');
const FormattedMessage = require('react-intl').FormattedMessage;
const Formsy = require('formsy-react').default;
const FlexRow = require('../../../components/flex-row/flex-row.jsx'); const FlexRow = require('../../../components/flex-row/flex-row.jsx');
const Avatar = require('../../../components/avatar/avatar.jsx');
const InplaceInput = require('../../../components/forms/inplace-input.jsx'); const InplaceInput = require('../../../components/forms/inplace-input.jsx');
const Button = require('../../../components/forms/button.jsx'); const Button = require('../../../components/forms/button.jsx');
const connect = require('react-redux').connect;
const api = require('../../../lib/api');
require('./comment.scss'); require('./comment.scss');
const onUpdate = update => update; const onUpdate = update => update;
const ComposeComment = () => ( const MAX_COMMENT_LENGTH = 500;
<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; const ComposeStatus = keyMirror({
EDITING: null,
SUBMITTING: null,
REJECTED: null
});
class ComposeComment extends React.Component {
constructor (props) {
super(props);
bindAll(this, [
'handlePost',
'handleCancel',
'handleInput'
]);
this.state = {
message: '',
status: ComposeStatus.EDITING,
error: null,
appealId: null
};
}
handleInput (event) {
this.setState({
message: event.target.value,
status: ComposeStatus.EDITING,
error: null,
appealId: null
});
}
handlePost () {
this.setState({status: ComposeStatus.SUBMITTING});
api({
uri: `/proxy/comments/project/${this.props.projectId}`,
authentication: this.props.user.token,
withCredentials: true,
method: 'POST',
useCsrf: true,
json: {
content: this.state.message,
parent_id: this.props.parentId || '',
commentee_id: this.props.commenteeId || ''
}
}, (err, body, res) => {
if (err || res.statusCode !== 200) {
body = {rejected: 'error'};
}
if (body.rejected && this.state.status === ComposeStatus.SUBMITTING) {
// Note: does not reset the message state
this.setState({
status: ComposeStatus.REJECTED,
error: body.rejected,
appealId: body.appealId
});
return;
}
// Clear the text field and reset status on successful submission
this.setState({
message: '',
status: ComposeStatus.EDITING,
error: null,
appealId: null
});
// Add the username, which isn't included right now from scratch-api
if (body.author) body.author.username = this.props.user.username;
this.props.onAddComment(body);
});
}
handleCancel () {
this.setState({
message: '',
status: ComposeStatus.EDITING,
error: null,
appealId: null
});
if (this.props.onCancel) this.props.onCancel();
}
render () {
return (
<div
className="flex-row comment"
>
<a href={`/users/${this.props.user.username}`}>
<Avatar src={this.props.user.thumbnailUrl} />
</a>
<FlexRow className="compose-comment column">
{this.state.error ? (
<FlexRow className="compose-error-row">
<div className="compose-error-tip">
<FormattedMessage
id={`comments.${this.state.error}`}
values={{
appealId: this.state.appealId
}}
/>
</div>
</FlexRow>
) : null}
<Formsy className="full-width-form">
<InplaceInput
className={classNames('compose-input',
MAX_COMMENT_LENGTH - this.state.message.length >= 0 ?
'compose-valid' : 'compose-invalid')}
handleUpdate={onUpdate}
name="compose-comment"
type="textarea"
value={this.state.message}
onInput={this.handleInput}
/>
<FlexRow className="compose-bottom-row">
<Button
className="compose-post"
disabled={this.state.status === ComposeStatus.SUBMITTING}
onClick={this.handlePost}
>
{this.state.status === ComposeStatus.SUBMITTING ? (
<FormattedMessage id="comments.posting" />
) : (
<FormattedMessage id="comments.post" />
)}
</Button>
<Button
className="compose-cancel"
onClick={this.handleCancel}
>
<FormattedMessage id="comments.cancel" />
</Button>
<span
className={classNames('compose-limit',
MAX_COMMENT_LENGTH - this.state.message.length >= 0 ?
'compose-valid' : 'compose-invalid')}
>
<FormattedMessage
id="comments.lengthWarning"
values={{
remainingCharacters: MAX_COMMENT_LENGTH - this.state.message.length
}}
/>
</span>
</FlexRow>
</Formsy>
</FlexRow>
</div>
);
}
}
ComposeComment.propTypes = {
commenteeId: PropTypes.number,
onAddComment: PropTypes.func,
onCancel: PropTypes.func,
parentId: PropTypes.number,
projectId: PropTypes.string,
user: PropTypes.shape({
id: PropTypes.number,
username: PropTypes.string,
token: PropTypes.string,
thumbnailUrl: PropTypes.string
})
};
const mapStateToProps = state => ({
user: state.session.session.user
});
const ConnectedComposeComment = connect(
mapStateToProps
)(ComposeComment);
module.exports = ConnectedComposeComment;

View file

@ -2,6 +2,7 @@ const React = require('react');
const PropTypes = require('prop-types'); const PropTypes = require('prop-types');
const bindAll = require('lodash.bindall'); const bindAll = require('lodash.bindall');
const classNames = require('classnames'); const classNames = require('classnames');
const FormattedMessage = require('react-intl').FormattedMessage;
const FlexRow = require('../../../components/flex-row/flex-row.jsx'); const FlexRow = require('../../../components/flex-row/flex-row.jsx');
const Comment = require('./comment.jsx'); const Comment = require('./comment.jsx');
@ -12,11 +13,18 @@ class TopLevelComment extends React.Component {
constructor (props) { constructor (props) {
super(props); super(props);
bindAll(this, [ bindAll(this, [
'handleExpandThread' 'handleExpandThread',
'handleAddComment',
'handleDeleteReply',
'handleReportReply',
'handleRestoreReply'
]); ]);
this.state = { this.state = {
expanded: false expanded: this.props.defaultExpanded
}; };
// A cache of {userId: username, ...} in order to show reply usernames
this.authorUsernameCache = {};
} }
handleExpandThread () { handleExpandThread () {
@ -25,18 +33,83 @@ class TopLevelComment extends React.Component {
}); });
} }
handleDeleteReply (replyId) {
// Only apply topLevelCommentId for deleting replies
// The top level comment itself just gets passed onDelete directly
this.props.onDelete(replyId, this.props.id);
}
handleReportReply (replyId) {
// Only apply topLevelCommentId for reporting replies
// The top level comment itself just gets passed onReport directly
this.props.onReport(replyId, this.props.id);
}
handleRestoreReply (replyId) {
this.props.onRestore(replyId, this.props.id);
}
handleAddComment (comment) {
this.props.onAddComment(comment, this.props.id);
}
authorUsername (authorId) {
if (this.authorUsernameCache[authorId]) return this.authorUsernameCache[authorId];
// If the cache misses, rebuild it. Every reply has a parent id that is
// either a reply to this top level comment or to one of the replies.
this.authorUsernameCache[this.props.author.id] = this.props.author.username;
const replies = this.props.replies;
for (let i = 0; i < replies.length; i++) {
this.authorUsernameCache[replies[i].author.id] = replies[i].author.username;
}
// Default to top level author if no author is found from authorId
// This can happen if there is no commentee_id stored with the comment
return this.authorUsernameCache[authorId] || this.props.author.username;
}
render () { render () {
const { const {
author, author,
canDelete,
canReply,
canReport,
canRestore,
content, content,
datetimeCreated, datetimeCreated,
highlightedCommentId,
id, id,
replies onDelete,
onReport,
onRestore,
replies,
projectId,
visibility
} = this.props; } = this.props;
const parentVisible = visibility === 'visible';
return ( return (
<FlexRow className="comment-container"> <FlexRow className="comment-container">
<Comment {...{author, content, datetimeCreated, id}} /> <Comment
highlighted={highlightedCommentId === id}
projectId={projectId}
onAddComment={this.handleAddComment}
{...{
author,
content,
datetimeCreated,
canDelete,
canReply,
canReport,
canRestore,
id,
onDelete,
onReport,
onRestore,
visibility
}}
/>
{replies.length > 0 && {replies.length > 0 &&
<FlexRow <FlexRow
className={classNames( className={classNames(
@ -49,10 +122,23 @@ class TopLevelComment extends React.Component {
{(this.state.expanded ? replies : replies.slice(0, 3)).map(reply => ( {(this.state.expanded ? replies : replies.slice(0, 3)).map(reply => (
<Comment <Comment
author={reply.author} author={reply.author}
canDelete={canDelete}
canReply={canReply}
canReport={canReport}
canRestore={canRestore && parentVisible}
content={reply.content} content={reply.content}
datetimeCreated={reply.datetime_created} datetimeCreated={reply.datetime_created}
highlighted={highlightedCommentId === reply.id}
id={reply.id} id={reply.id}
key={reply.id} key={reply.id}
parentId={id}
projectId={projectId}
replyUsername={this.authorUsername(reply.commentee_id)}
visibility={reply.visibility}
onAddComment={this.handleAddComment}
onDelete={this.handleDeleteReply}
onReport={this.handleReportReply}
onRestore={this.handleRestoreReply}
/> />
))} ))}
</FlexRow> </FlexRow>
@ -61,7 +147,14 @@ class TopLevelComment extends React.Component {
<a <a
className="expand-thread" className="expand-thread"
onClick={this.handleExpandThread} onClick={this.handleExpandThread}
>See all {replies.length} replies</a> >
<FormattedMessage
id="comments.seeMoreReplies"
values={{
repliesCount: replies.length
}}
/>
</a>
} }
</FlexRow> </FlexRow>
); );
@ -72,14 +165,31 @@ TopLevelComment.propTypes = {
author: PropTypes.shape({ author: PropTypes.shape({
id: PropTypes.number, id: PropTypes.number,
image: PropTypes.string, image: PropTypes.string,
scratchteam: PropTypes.bool,
username: PropTypes.string username: PropTypes.string
}), }),
canDelete: PropTypes.bool,
canReply: PropTypes.bool,
canReport: PropTypes.bool,
canRestore: PropTypes.bool,
content: PropTypes.string, content: PropTypes.string,
datetimeCreated: PropTypes.string, datetimeCreated: PropTypes.string,
defaultExpanded: PropTypes.bool,
deletable: PropTypes.bool,
highlightedCommentId: PropTypes.oneOfType([PropTypes.number, PropTypes.bool]),
id: PropTypes.number, id: PropTypes.number,
onAddComment: PropTypes.func,
onDelete: PropTypes.func,
onReport: PropTypes.func,
onRestore: PropTypes.func,
parentId: PropTypes.number, parentId: PropTypes.number,
projectId: PropTypes.string, projectId: PropTypes.string,
replies: PropTypes.arrayOf(PropTypes.object) replies: PropTypes.arrayOf(PropTypes.object),
visibility: PropTypes.string
};
TopLevelComment.defaultProps = {
defaultExpanded: false
}; };
module.exports = TopLevelComment; module.exports = TopLevelComment;

View file

@ -19,7 +19,7 @@ const ExtensionChip = props => (
} }
{props.hasStatus && ( {props.hasStatus && (
<div className="extension-status"> <div className="extension-status">
Needs Connection <FormattedMessage id="project.needsConnection" />
</div> </div>
)} )}
</div> </div>

View file

@ -1,10 +1,24 @@
{ {
"addToStudio.title": "Add to Studio", "addToStudio.title": "Add to Studio",
"addToStudio.finishing": "Finishing up...", "addToStudio.finishing": "Finishing up...",
"preview.titleMaxLength": "Title is too long", "addToStudio.inviteUser": "Invite user to add to studio",
"preview.musicExtensionChip": "Music", "project.titleMaxLength": "Title is too long",
"preview.penExtensionChip": "Pen", "project.musicExtensionChip": "Music",
"preview.speechExtensionChip": "Google Speech", "project.penExtensionChip": "Pen",
"preview.translateExtensionChip": "Google Translate", "project.speechExtensionChip": "Google Speech",
"preview.videoMotionChip": "Video Motion" "project.translateExtensionChip": "Google Translate",
"project.videoMotionChip": "Video Motion",
"project.needsConnection": "Needs Connection",
"project.comments.header": "Comments",
"project.comments.turnOff": "Turn off commenting",
"project.comments.turnedOff": "Sorry, comment posting has been turned off for this project.",
"project.share.notShared": "This project is not shared — so only you can see it. Click share to let everyone see it!",
"project.share.shareButton": "Share",
"project.seeInsideButton": "See inside",
"project.remixButton": "Remix",
"project.remixes": "Remixes",
"project.inviteToRemix": "Invite user to remix",
"project.instructionsLabel": "Instructions",
"project.notesAndCreditsLabel": "Notes and Credits",
"project.credit": "Thanks to {userLink} for the original project {projectLink}."
} }

View file

@ -1,12 +1,12 @@
const FormattedDate = require('react-intl').FormattedDate;
const injectIntl = require('react-intl').injectIntl; const injectIntl = require('react-intl').injectIntl;
const PropTypes = require('prop-types'); const PropTypes = require('prop-types');
const intlShape = require('react-intl').intlShape; const intlShape = require('react-intl').intlShape;
const FormattedMessage = require('react-intl').FormattedMessage;
const MediaQuery = require('react-responsive').default; const MediaQuery = require('react-responsive').default;
const React = require('react'); const React = require('react');
const Formsy = require('formsy-react').default; const Formsy = require('formsy-react').default;
const classNames = require('classnames'); const classNames = require('classnames');
const approx = require('approximate-number');
const GUI = require('scratch-gui').default; const GUI = require('scratch-gui').default;
const IntlGUI = injectIntl(GUI); const IntlGUI = injectIntl(GUI);
@ -15,15 +15,15 @@ const decorateText = require('../../lib/decorate-text.jsx');
const FlexRow = require('../../components/flex-row/flex-row.jsx'); const FlexRow = require('../../components/flex-row/flex-row.jsx');
const Button = require('../../components/forms/button.jsx'); const Button = require('../../components/forms/button.jsx');
const Avatar = require('../../components/avatar/avatar.jsx'); const Avatar = require('../../components/avatar/avatar.jsx');
const CappedNumber = require('../../components/cappednumber/cappednumber.jsx');
const ShareBanner = require('./share-banner.jsx'); const ShareBanner = require('./share-banner.jsx');
const RemixCredit = require('./remix-credit.jsx'); const RemixCredit = require('./remix-credit.jsx');
const RemixList = require('./remix-list.jsx'); const RemixList = require('./remix-list.jsx');
const Stats = require('./stats.jsx');
const StudioList = require('./studio-list.jsx'); const StudioList = require('./studio-list.jsx');
const Subactions = require('./subactions.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 TopLevelComment = require('./comment/top-level-comment.jsx'); const TopLevelComment = require('./comment/top-level-comment.jsx');
const ComposeComment = require('./comment/compose-comment.jsx');
const ExtensionChip = require('./extension-chip.jsx'); const ExtensionChip = require('./extension-chip.jsx');
const projectShape = require('./projectshape.jsx').projectShape; const projectShape = require('./projectshape.jsx').projectShape;
@ -44,6 +44,12 @@ const onKeyPress = e => {
const PreviewPresentation = ({ const PreviewPresentation = ({
assetHost, assetHost,
backpackOptions, backpackOptions,
canAddToStudio,
canDeleteComments,
canReport,
canRestoreComments,
canShare,
cloudHost,
comments, comments,
editable, editable,
extensions, extensions,
@ -55,6 +61,7 @@ const PreviewPresentation = ({
isShared, isShared,
loved, loved,
loveCount, loveCount,
moreCommentsToLoad,
originalInfo, originalInfo,
parentInfo, parentInfo,
projectHost, projectHost,
@ -65,29 +72,36 @@ const PreviewPresentation = ({
replies, replies,
addToStudioOpen, addToStudioOpen,
projectStudios, projectStudios,
studios, singleCommentId,
userOwnsProject, userOwnsProject,
onAddComment,
onDeleteComment,
onFavoriteClicked, onFavoriteClicked,
onLoadMore, onLoadMore,
onLoveClicked, onLoveClicked,
onReportClicked, onReportClicked,
onReportClose, onReportClose,
onReportComment,
onReportSubmit, onReportSubmit,
onRestoreComment,
onAddToStudioClicked, onAddToStudioClicked,
onAddToStudioClosed, onAddToStudioClosed,
onToggleStudio, onToggleStudio,
onToggleComments,
onSeeInside, onSeeInside,
onShare,
onUpdate onUpdate
}) => { }) => {
const shareDate = ((projectInfo.history && projectInfo.history.shared)) ? projectInfo.history.shared : ''; const shareDate = ((projectInfo.history && projectInfo.history.shared)) ? projectInfo.history.shared : '';
return ( return (
<div className="preview"> <div className="preview">
<ShareBanner shared={isShared} /> {canShare && !isShared && (
<ShareBanner onShare={onShare} />
)}
{ projectInfo && projectInfo.author && projectInfo.author.id && ( { projectInfo && projectInfo.author && projectInfo.author.id && (
<Formsy onKeyPress={onKeyPress}> <React.Fragment>
<div className="inner"> <div className="inner">
<FlexRow className="preview-row"> <FlexRow className="preview-row force-row">
<FlexRow className="project-header"> <FlexRow className="project-header">
<a href={`/users/${projectInfo.author.username}`}> <a href={`/users/${projectInfo.author.username}`}>
<Avatar <Avatar
@ -97,21 +111,22 @@ const PreviewPresentation = ({
</a> </a>
<div className="title"> <div className="title">
{editable ? {editable ?
<Formsy onKeyPress={onKeyPress}>
<InplaceInput <InplaceInput
className="project-title" className="project-title"
handleUpdate={onUpdate} handleUpdate={onUpdate}
name="title" name="title"
validationErrors={{ validationErrors={{
maxLength: intl.formatMessage({ maxLength: intl.formatMessage({
id: 'preview.titleMaxLength' id: 'project.titleMaxLength'
}) })
}} }}
validations={{ validations={{
maxLength: 100 maxLength: 100
}} }}
value={projectInfo.title} value={projectInfo.title}
/> : />
</Formsy> :
<React.Fragment> <React.Fragment>
<div <div
className="project-title no-edit" className="project-title no-edit"
@ -125,20 +140,22 @@ const PreviewPresentation = ({
} }
</div> </div>
</FlexRow> </FlexRow>
<div className="project-buttons"> <MediaQuery minWidth={frameless.mobile}>
{/* TODO: Hide Remix button for now until implemented */} <div className="project-buttons">
{(!userOwnsProject && false) && {/* TODO: Hide Remix button for now until implemented */}
<Button className="button remix-button"> {(!userOwnsProject && false) &&
Remix <Button className="button remix-button">
<FormattedMessage id="project.remixButton" />
</Button>
}
<Button
className="button see-inside-button"
onClick={onSeeInside}
>
<FormattedMessage id="project.seeInsideButton" />
</Button> </Button>
} </div>
<Button </MediaQuery>
className="button see-inside-button"
onClick={onSeeInside}
>
See Inside
</Button>
</div>
</FlexRow> </FlexRow>
<FlexRow className="preview-row"> <FlexRow className="preview-row">
<div className="guiPlayer"> <div className="guiPlayer">
@ -148,12 +165,39 @@ const PreviewPresentation = ({
backpackOptions={backpackOptions} backpackOptions={backpackOptions}
basePath="/" basePath="/"
className="guiPlayer" className="guiPlayer"
cloudHost={cloudHost}
isFullScreen={isFullScreen} isFullScreen={isFullScreen}
previewInfoVisible="false" previewInfoVisible="false"
projectHost={projectHost} projectHost={projectHost}
projectId={projectId} projectId={projectId}
/> />
</div> </div>
<MediaQuery maxWidth={frameless.tablet - 1}>
<FlexRow className="preview-row force-center">
<Stats
faved={faved}
favoriteCount={favoriteCount}
loveCount={loveCount}
loved={loved}
projectInfo={projectInfo}
onFavoriteClicked={onFavoriteClicked}
onLoveClicked={onLoveClicked}
/>
<Subactions
addToStudioOpen={addToStudioOpen}
canReport={canReport}
projectInfo={projectInfo}
reportOpen={reportOpen}
shareDate={shareDate}
onAddToStudioClicked={onAddToStudioClicked}
onAddToStudioClosed={onAddToStudioClosed}
onReportClicked={onReportClicked}
onReportClose={onReportClose}
onReportSubmit={onReportSubmit}
onToggleStudio={onToggleStudio}
/>
</FlexRow>
</MediaQuery>
<FlexRow className="project-notes"> <FlexRow className="project-notes">
<RemixCredit projectInfo={parentInfo} /> <RemixCredit projectInfo={parentInfo} />
<RemixCredit projectInfo={originalInfo} /> <RemixCredit projectInfo={originalInfo} />
@ -175,158 +219,115 @@ const PreviewPresentation = ({
</MediaQuery> </MediaQuery>
<FlexRow className="description-block"> <FlexRow className="description-block">
<div className="project-textlabel"> <div className="project-textlabel">
Instructions <FormattedMessage id="project.instructionsLabel" />
</div> </div>
{editable ? {editable ?
<InplaceInput <Formsy
className={classNames( className="project-description-form"
'project-description-edit', onKeyPress={onKeyPress}
{remixes: parentInfo && parentInfo.author} >
)} <InplaceInput
handleUpdate={onUpdate} className={classNames(
name="instructions" 'project-description-edit',
placeholder="Tell people how to use your project (such as which keys to press)." {remixes: parentInfo && parentInfo.author}
type="textarea" )}
validationErrors={{ handleUpdate={onUpdate}
maxLength: 'Sorry description is too long' name="instructions"
// maxLength: props.intl.formatMessage({ placeholder="Tell people how to use your project (such as which keys to press)."
// id: 'project.descriptionMaxLength' type="textarea"
// }) validationErrors={{
}} maxLength: 'Sorry description is too long'
validations={{ // maxLength: props.intl.formatMessage({
// TODO: actual 5000 // id: 'project.descriptionMaxLength'
maxLength: 1000 // })
}} }}
value={projectInfo.instructions} validations={{
/> : // TODO: actual 5000
maxLength: 1000
}}
value={projectInfo.instructions}
/>
</Formsy> :
<div className="project-description"> <div className="project-description">
{decorateText(projectInfo.instructions)} {decorateText(projectInfo.instructions, {
usernames: true,
hashtags: true,
scratchLinks: false
})}
</div> </div>
} }
</FlexRow> </FlexRow>
<FlexRow className="description-block"> <FlexRow className="description-block">
<div className="project-textlabel"> <div className="project-textlabel">
Notes and Credits <FormattedMessage id="project.notesAndCreditsLabel" />
</div> </div>
{editable ? {editable ?
<InplaceInput <Formsy
className={classNames( className="project-description-form"
'project-description-edit', onKeyPress={onKeyPress}
'last', >
{remixes: parentInfo && parentInfo.author} <InplaceInput
)} className={classNames(
handleUpdate={onUpdate} 'project-description-edit',
name="description" 'last',
placeholder="How did you make this project? Did you use ideas scripts or artwork from other people? Thank them here." {remixes: parentInfo && parentInfo.author}
type="textarea" )}
validationErrors={{ handleUpdate={onUpdate}
maxLength: 'Sorry description is too long' name="description"
// maxLength: props.intl.formatMessage({ placeholder="How did you make this project? Did you use ideas scripts or artwork from other people? Thank them here."
// id: 'project.descriptionMaxLength' type="textarea"
// }) validationErrors={{
}} maxLength: 'Sorry description is too long'
validations={{ // maxLength: props.intl.formatMessage({
// TODO: actual 5000 // id: 'project.descriptionMaxLength'
maxLength: 1000 // })
}} }}
value={projectInfo.description} validations={{
/> : // TODO: actual 5000
maxLength: 1000
}}
value={projectInfo.description}
/>
</Formsy> :
<div className="project-description last"> <div className="project-description last">
{decorateText(projectInfo.description)} {decorateText(projectInfo.description, {
usernames: true,
hashtags: true,
scratchLinks: false
})}
</div> </div>
} }
</FlexRow> </FlexRow>
{/* eslint-enable max-len */} {/* eslint-enable max-len */}
</FlexRow> </FlexRow>
</FlexRow> </FlexRow>
<FlexRow className="preview-row"> <MediaQuery minWidth={frameless.tablet}>
<FlexRow className="stats"> <FlexRow className="preview-row">
<div <Stats
className={classNames('project-loves', {loved: loved})} faved={faved}
key="loves" favoriteCount={favoriteCount}
onClick={onLoveClicked} loveCount={loveCount}
> loved={loved}
{approx(loveCount, {decimal: false})} projectInfo={projectInfo}
</div> onFavoriteClicked={onFavoriteClicked}
<div onLoveClicked={onLoveClicked}
className={classNames('project-favorites', {favorited: faved})} />
key="favorites" <Subactions
onClick={onFavoriteClicked} addToStudioOpen={addToStudioOpen}
> canAddToStudio={canAddToStudio}
{approx(favoriteCount, {decimal: false})} canReport={canReport}
</div> projectInfo={projectInfo}
<div reportOpen={reportOpen}
className="project-remixes" shareDate={shareDate}
key="remixes" onAddToStudioClicked={onAddToStudioClicked}
> onAddToStudioClosed={onAddToStudioClosed}
{approx(projectInfo.stats.remixes, {decimal: false})} onReportClicked={onReportClicked}
</div> onReportClose={onReportClose}
<div onReportSubmit={onReportSubmit}
className="project-views" onToggleStudio={onToggleStudio}
key="views" />
>
<CappedNumber value={projectInfo.stats.views} />
</div>
</FlexRow> </FlexRow>
<FlexRow className="subactions"> </MediaQuery>
<div className="share-date">
<div className="copyleft">&copy;</div>
{' '}
{/* eslint-disable react/jsx-sort-props */}
{shareDate === null ?
'Unshared' :
<FormattedDate
value={Date.parse(shareDate)}
day="2-digit"
month="short"
year="numeric"
/>
}
{/* eslint-enable react/jsx-sort-props */}
</div>
<FlexRow className="action-buttons">
{(isLoggedIn && userOwnsProject) &&
<React.Fragment>
<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">
Copy Link
</Button>
{(isLoggedIn && !userOwnsProject) &&
<React.Fragment>
<Button
className="action-button report-button"
key="report-button"
onClick={onReportClicked}
>
Report
</Button>,
<ReportModal
isOpen={reportOpen}
key="report-modal"
type="project"
onReport={onReportSubmit}
onRequestClose={onReportClose}
/>
</React.Fragment>
}
</FlexRow>
</FlexRow>
</FlexRow>
<MediaQuery minWidth={frameless.tablet}> <MediaQuery minWidth={frameless.tablet}>
<FlexRow className="preview-row"> <FlexRow className="preview-row">
<FlexRow className="extension-list"> <FlexRow className="extension-list">
@ -348,29 +349,71 @@ const PreviewPresentation = ({
<FlexRow className="preview-row"> <FlexRow className="preview-row">
<div className="comments-container"> <div className="comments-container">
<FlexRow className="comments-header"> <FlexRow className="comments-header">
<h4>Comments</h4> <h4><FormattedMessage id="project.comments.header" /></h4>
{/* TODO: Add toggle comments component and logic*/} {userOwnsProject ? (
<div>
<label>
<input
checked={!projectInfo.comments_allowed}
className="comments-allowed-input"
type="checkbox"
onChange={onToggleComments}
/>
<FormattedMessage id="project.comments.turnOff" />
</label>
</div>
) : null}
</FlexRow> </FlexRow>
<FlexRow className="comments-root-reply">
{projectInfo.comments_allowed ? (
isLoggedIn ? (
<ComposeComment
projectId={projectId}
onAddComment={onAddComment}
/>
) : (
/* TODO add box for signing in to leave a comment */
null
)
) : (
<div className="comments-turned-off">
<FormattedMessage id="project.comments.turnedOff" />
</div>
)}
</FlexRow>
<FlexRow className="comments-list"> <FlexRow className="comments-list">
{comments.map(comment => ( {comments.map(comment => (
<TopLevelComment <TopLevelComment
author={comment.author} author={comment.author}
canDelete={canDeleteComments}
canReply={isLoggedIn && projectInfo.comments_allowed}
canReport={isLoggedIn}
canRestore={canRestoreComments}
content={comment.content} content={comment.content}
datetimeCreated={comment.datetime_created} datetimeCreated={comment.datetime_created}
defaultExpanded={!!singleCommentId}
highlightedCommentId={singleCommentId}
id={comment.id} id={comment.id}
key={comment.id} key={comment.id}
parentId={comment.parent_id} parentId={comment.parent_id}
projectId={projectId} projectId={projectId}
replies={replies && replies[comment.id] ? replies[comment.id] : []} replies={replies && replies[comment.id] ? replies[comment.id] : []}
visibility={comment.visibility}
onAddComment={onAddComment}
onDelete={onDeleteComment}
onReport={onReportComment}
onRestore={onRestoreComment}
/> />
))} ))}
{comments.length < projectInfo.stats.comments && {moreCommentsToLoad &&
<Button <Button
className="button load-more-button" className="button load-more-button"
onClick={onLoadMore} onClick={onLoadMore}
> >
Load More <FormattedMessage id="general.loadMore" />
</Button> </Button>
} }
</FlexRow> </FlexRow>
</div> </div>
@ -381,7 +424,7 @@ const PreviewPresentation = ({
</FlexRow> </FlexRow>
</div> </div>
</div> </div>
</Formsy> </React.Fragment>
)} )}
</div> </div>
); );
@ -394,6 +437,12 @@ PreviewPresentation.propTypes = {
host: PropTypes.string, host: PropTypes.string,
visible: PropTypes.bool visible: PropTypes.bool
}), }),
canAddToStudio: PropTypes.bool,
canDeleteComments: PropTypes.bool,
canReport: PropTypes.bool,
canRestoreComments: PropTypes.bool,
canShare: PropTypes.bool,
cloudHost: PropTypes.string,
comments: PropTypes.arrayOf(PropTypes.object), comments: PropTypes.arrayOf(PropTypes.object),
editable: PropTypes.bool, editable: PropTypes.bool,
extensions: PropTypes.arrayOf(PropTypes.object), extensions: PropTypes.arrayOf(PropTypes.object),
@ -405,15 +454,22 @@ PreviewPresentation.propTypes = {
isShared: PropTypes.bool, isShared: PropTypes.bool,
loveCount: PropTypes.number, loveCount: PropTypes.number,
loved: PropTypes.bool, loved: PropTypes.bool,
moreCommentsToLoad: PropTypes.bool,
onAddComment: PropTypes.func,
onAddToStudioClicked: PropTypes.func, onAddToStudioClicked: PropTypes.func,
onAddToStudioClosed: PropTypes.func, onAddToStudioClosed: PropTypes.func,
onDeleteComment: PropTypes.func,
onFavoriteClicked: PropTypes.func, onFavoriteClicked: PropTypes.func,
onLoadMore: PropTypes.func, onLoadMore: PropTypes.func,
onLoveClicked: PropTypes.func, onLoveClicked: PropTypes.func,
onReportClicked: PropTypes.func.isRequired, onReportClicked: PropTypes.func.isRequired,
onReportClose: PropTypes.func.isRequired, onReportClose: PropTypes.func.isRequired,
onReportComment: PropTypes.func.isRequired,
onReportSubmit: PropTypes.func.isRequired, onReportSubmit: PropTypes.func.isRequired,
onRestoreComment: PropTypes.func,
onSeeInside: PropTypes.func, onSeeInside: PropTypes.func,
onShare: PropTypes.func,
onToggleComments: PropTypes.func,
onToggleStudio: PropTypes.func, onToggleStudio: PropTypes.func,
onUpdate: PropTypes.func, onUpdate: PropTypes.func,
originalInfo: projectShape, originalInfo: projectShape,
@ -425,7 +481,7 @@ PreviewPresentation.propTypes = {
remixes: PropTypes.arrayOf(PropTypes.object), remixes: PropTypes.arrayOf(PropTypes.object),
replies: PropTypes.objectOf(PropTypes.array), replies: PropTypes.objectOf(PropTypes.array),
reportOpen: PropTypes.bool, reportOpen: PropTypes.bool,
studios: PropTypes.arrayOf(PropTypes.object), singleCommentId: PropTypes.oneOfType([PropTypes.number, PropTypes.bool]),
userOwnsProject: PropTypes.bool userOwnsProject: PropTypes.bool
}; };

View file

@ -12,6 +12,7 @@ const render = require('../../lib/render.jsx');
const storage = require('../../lib/storage.js').default; const storage = require('../../lib/storage.js').default;
const log = require('../../lib/log'); const log = require('../../lib/log');
const EXTENSION_INFO = require('../../lib/extensions.js').default; const EXTENSION_INFO = require('../../lib/extensions.js').default;
const jar = require('../../lib/jar.js');
const PreviewPresentation = require('./presentation.jsx'); const PreviewPresentation = require('./presentation.jsx');
const projectShape = require('./projectshape.jsx').projectShape; const projectShape = require('./projectshape.jsx').projectShape;
@ -23,6 +24,8 @@ const sessionActions = require('../../redux/session.js');
const navigationActions = require('../../redux/navigation.js'); const navigationActions = require('../../redux/navigation.js');
const previewActions = require('../../redux/preview.js'); const previewActions = require('../../redux/preview.js');
const frameless = require('../../lib/frameless');
const GUI = require('scratch-gui'); const GUI = require('scratch-gui');
const IntlGUI = injectIntl(GUI.default); const IntlGUI = injectIntl(GUI.default);
@ -31,6 +34,9 @@ class Preview extends React.Component {
super(props); super(props);
bindAll(this, [ bindAll(this, [
'addEventListeners', 'addEventListeners',
'fetchCommunityData',
'handleAddComment',
'handleDeleteComment',
'handleToggleStudio', 'handleToggleStudio',
'handleFavoriteToggle', 'handleFavoriteToggle',
'handleLoadMore', 'handleLoadMore',
@ -38,63 +44,70 @@ class Preview extends React.Component {
'handlePopState', 'handlePopState',
'handleReportClick', 'handleReportClick',
'handleReportClose', 'handleReportClose',
'handleReportComment',
'handleReportSubmit', 'handleReportSubmit',
'handleRestoreComment',
'handleAddToStudioClick', 'handleAddToStudioClick',
'handleAddToStudioClose', 'handleAddToStudioClose',
'handleSeeInside', 'handleSeeInside',
'handleShare',
'handleUpdateProjectId',
'handleUpdateProjectTitle', 'handleUpdateProjectTitle',
'handleUpdate', 'handleUpdate',
'handleToggleComments',
'initCounts', 'initCounts',
'pushHistory', 'pushHistory',
'renderLogin' 'renderLogin',
'setScreenFromOrientation'
]); ]);
const pathname = window.location.pathname.toLowerCase(); const pathname = window.location.pathname.toLowerCase();
const parts = pathname.split('/').filter(Boolean); const parts = pathname.split('/').filter(Boolean);
// parts[0]: 'preview' // parts[0]: 'preview'
// parts[1]: either :id or 'editor' // parts[1]: either :id or 'editor'
// parts[2]: undefined if no :id, otherwise either 'editor' or 'fullscreen' // parts[2]: undefined if no :id, otherwise either 'editor' or 'fullscreen'
// Get single-comment id from url hash, using the #comments-{id} scheme from scratch2
const commentHashPrefix = '#comments-';
const singleCommentId = window.location.hash.indexOf(commentHashPrefix) !== -1 &&
parseInt(window.location.hash.replace(commentHashPrefix, ''), 10);
this.state = { this.state = {
extensions: [], extensions: [],
favoriteCount: 0, favoriteCount: 0,
loveCount: 0, loveCount: 0,
projectId: parts[1] === 'editor' ? 0 : parts[1], projectId: parts[1] === 'editor' ? '0' : parts[1],
singleCommentId: singleCommentId,
addToStudioOpen: false, addToStudioOpen: false,
reportOpen: false reportOpen: false
}; };
this.getExtensions(this.state.projectId);
this.addEventListeners(); this.addEventListeners();
/* In the beginning, if user is on mobile and landscape, go to fullscreen */
this.setScreenFromOrientation();
} }
componentDidUpdate (prevProps) { componentDidUpdate (prevProps, prevState) {
if (this.props.sessionStatus !== prevProps.sessionStatus && if (this.state.projectId > 0 &&
this.props.sessionStatus === sessionActions.Status.FETCHED && ((this.props.sessionStatus !== prevProps.sessionStatus &&
this.state.projectId) { this.props.sessionStatus === sessionActions.Status.FETCHED) ||
if (this.props.user) { (this.state.projectId !== prevState.projectId))) {
const username = this.props.user.username; this.fetchCommunityData();
const token = this.props.user.token; this.getExtensions(this.state.projectId);
this.props.getTopLevelComments(this.state.projectId, this.props.comments.length); }
this.props.getProjectInfo(this.state.projectId, token); if (this.state.projectId === '0' && this.state.projectId !== prevState.projectId) {
this.props.getRemixes(this.state.projectId, token); this.props.resetProject();
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.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); if (typeof this.props.projectInfo.id === 'undefined') {
this.initCounts(this.props.projectInfo.stats.favorites, this.props.projectInfo.stats.loves); this.initCounts(0, 0);
if (this.props.projectInfo.remix.parent !== null) { } else {
this.props.getParentInfo(this.props.projectInfo.remix.parent); this.initCounts(this.props.projectInfo.stats.favorites, this.props.projectInfo.stats.loves);
} if (this.props.projectInfo.remix.parent !== null) {
if (this.props.projectInfo.remix.root !== null && this.props.getParentInfo(this.props.projectInfo.remix.parent);
this.props.projectInfo.remix.root !== this.props.projectInfo.remix.parent }
) { if (this.props.projectInfo.remix.root !== null &&
this.props.getOriginalInfo(this.props.projectInfo.remix.root); this.props.projectInfo.remix.root !== this.props.projectInfo.remix.parent
) {
this.props.getOriginalInfo(this.props.projectInfo.remix.root);
}
} }
} }
if (this.props.playerMode !== prevProps.playerMode || this.props.fullScreen !== prevProps.fullScreen) { if (this.props.playerMode !== prevProps.playerMode || this.props.fullScreen !== prevProps.fullScreen) {
@ -106,41 +119,113 @@ class Preview extends React.Component {
} }
addEventListeners () { addEventListeners () {
window.addEventListener('popstate', this.handlePopState); window.addEventListener('popstate', this.handlePopState);
window.addEventListener('orientationchange', this.setScreenFromOrientation);
} }
removeEventListeners () { removeEventListeners () {
window.removeEventListener('popstate', this.handlePopState); window.removeEventListener('popstate', this.handlePopState);
window.removeEventListener('orientationchange', this.setScreenFromOrientation);
}
fetchCommunityData () {
if (this.props.userPresent) {
const username = this.props.user.username;
const token = this.props.user.token;
if (this.state.singleCommentId) {
this.props.getCommentById(this.state.projectId, this.state.singleCommentId,
this.props.isAdmin, token);
} else {
this.props.getTopLevelComments(this.state.projectId, this.props.comments.length,
this.props.isAdmin, token);
}
this.props.getProjectInfo(this.state.projectId, token);
this.props.getRemixes(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 {
if (this.state.singleCommentId) {
this.props.getCommentById(this.state.projectId, this.state.singleCommentId);
} 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.getProjectStudios(this.state.projectId);
}
}
setScreenFromOrientation () {
/*
* If the user is on a mobile device, switching to
* landscape format should make the fullscreen mode active
*/
const isMobileDevice = screen.height <= frameless.mobile || screen.width <= frameless.mobile;
const isAModalOpen = this.state.addToStudioOpen || this.state.reportOpen;
if (this.props.playerMode && isMobileDevice && !isAModalOpen) {
const isLandscape = screen.height < screen.width;
if (isLandscape) {
this.props.setFullScreen(true);
} else {
this.props.setFullScreen(false);
}
}
} }
getExtensions (projectId) { getExtensions (projectId) {
storage if (projectId > 0) {
.load(storage.AssetType.Project, projectId, storage.DataFormat.JSON) storage
.then(projectAsset => { // NOTE: this is turning up null, breaking the line below. .load(storage.AssetType.Project, projectId, storage.DataFormat.JSON)
let input = projectAsset.data; .then(projectAsset => { // NOTE: this is turning up null, breaking the line below.
if (typeof input === 'object' && !(input instanceof ArrayBuffer) && let input = projectAsset.data;
!ArrayBuffer.isView(input)) { // taken from scratch-vm if (typeof input === 'object' && !(input instanceof ArrayBuffer) &&
// If the input is an object and not any ArrayBuffer !ArrayBuffer.isView(input)) { // taken from scratch-vm
// or an ArrayBuffer view (this includes all typed arrays and DataViews) // If the input is an object and not any ArrayBuffer
// turn the object into a JSON string, because we suspect // or an ArrayBuffer view (this includes all typed arrays and DataViews)
// this is a project.json as an object // turn the object into a JSON string, because we suspect
// validate expects a string or buffer as input // this is a project.json as an object
// TODO not sure if we need to check that it also isn't a data view // validate expects a string or buffer as input
input = JSON.stringify(input); // TODO not sure if we need to check that it also isn't a data view
} input = JSON.stringify(input);
parser(projectAsset.data, false, (err, projectData) => {
if (err) {
log.error(`Unhandled project parsing error: ${err}`);
return;
} }
const extensionSet = new Set(); parser(projectAsset.data, false, (err, projectData) => {
if (projectData[0].extensions) { if (err) {
projectData[0].extensions.forEach(extension => { log.error(`Unhandled project parsing error: ${err}`);
extensionSet.add(EXTENSION_INFO[extension]); return;
}
const extensionSet = new Set();
if (projectData[0].extensions) {
projectData[0].extensions.forEach(extension => {
extensionSet.add(EXTENSION_INFO[extension]);
});
}
this.setState({
extensions: Array.from(extensionSet)
}); });
}
this.setState({
extensions: Array.from(extensionSet)
}); });
}); });
} else { // projectId is default or invalid; empty the extensions array
this.setState({
extensions: []
}); });
}
}
handleToggleComments () {
this.props.updateProject(
this.props.projectInfo.id,
{comments_allowed: !this.props.projectInfo.comments_allowed},
this.props.user.username,
this.props.user.token
);
}
handleAddComment (comment, topLevelCommentId) {
this.props.handleAddComment(comment, topLevelCommentId);
}
handleDeleteComment (id, topLevelCommentId) {
this.props.handleDeleteComment(this.state.projectId, id, topLevelCommentId, this.props.user.token);
}
handleReportComment (id, topLevelCommentId) {
this.props.handleReportComment(this.state.projectId, id, topLevelCommentId, this.props.user.token);
}
handleRestoreComment (id, topLevelCommentId) {
this.props.handleRestoreComment(this.state.projectId, id, topLevelCommentId, this.props.user.token);
} }
handleReportClick () { handleReportClick () {
this.setState({reportOpen: true}); this.setState({reportOpen: true});
@ -155,7 +240,7 @@ class Preview extends React.Component {
this.setState({addToStudioOpen: false}); this.setState({addToStudioOpen: false});
} }
handleReportSubmit (formData) { handleReportSubmit (formData) {
this.props.reportProject(this.state.projectId, formData); this.props.reportProject(this.state.projectId, formData, this.props.user.token);
} }
handlePopState () { handlePopState () {
const path = window.location.pathname.toLowerCase(); const path = window.location.pathname.toLowerCase();
@ -190,17 +275,12 @@ class Preview extends React.Component {
); );
} }
} }
handleToggleStudio (id) { handleToggleStudio (studio) {
const studioId = parseInt(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 // only send add or leave request to server if we know current status
if ((typeof studio !== 'undefined') && ('includesProject' in studio)) { if ((typeof studio !== 'undefined') && ('includesProject' in studio)) {
this.props.toggleStudio( this.props.toggleStudio(
(studio.includesProject === false), (studio.includesProject === false),
studioId, studio.id,
this.props.projectInfo.id, this.props.projectInfo.id,
this.props.user.token this.props.user.token
); );
@ -224,7 +304,8 @@ class Preview extends React.Component {
} }
} }
handleLoadMore () { handleLoadMore () {
this.props.getTopLevelComments(this.state.projectId, this.props.comments.length); this.props.getTopLevelComments(this.state.projectId, this.props.comments.length,
this.props.isAdmin, this.props.user && this.props.user.token);
} }
handleLoveToggle () { handleLoveToggle () {
this.props.setLovedStatus( this.props.setLovedStatus(
@ -247,7 +328,12 @@ class Preview extends React.Component {
this.props.setPlayer(false); this.props.setPlayer(false);
} }
handleShare () { handleShare () {
// This is just a placeholder, but enables the button in the editor this.props.updateProject(
this.props.projectInfo.id,
{isPublished: true},
this.props.user.username,
this.props.user.token
);
} }
handleUpdate (jsonData) { handleUpdate (jsonData) {
this.props.updateProject( this.props.updateProject(
@ -262,6 +348,28 @@ class Preview extends React.Component {
title: title title: title
}); });
} }
handleSetLanguage (locale) {
jar.set('scratchlanguage', locale);
}
handleUpdateProjectId (projectId, callback) {
this.setState({projectId: projectId}, () => {
const parts = window.location.pathname.toLowerCase()
.split('/')
.filter(Boolean);
let newUrl;
if (projectId === '0') {
newUrl = `/${parts[0]}/editor`;
} else {
newUrl = `/${parts[0]}/${projectId}/editor`;
}
history.pushState(
{projectId: projectId},
{projectId: projectId},
newUrl
);
if (callback) callback();
});
}
initCounts (favorites, loves) { initCounts (favorites, loves) {
this.setState({ this.setState({
favoriteCount: favorites, favoriteCount: favorites,
@ -293,6 +401,12 @@ class Preview extends React.Component {
addToStudioOpen={this.state.addToStudioOpen} addToStudioOpen={this.state.addToStudioOpen}
assetHost={this.props.assetHost} assetHost={this.props.assetHost}
backpackOptions={this.props.backpackOptions} backpackOptions={this.props.backpackOptions}
canAddToStudio={this.props.canAddToStudio}
canDeleteComments={this.props.isAdmin || this.props.userOwnsProject}
canReport={this.props.canReport}
canRestoreComments={this.props.isAdmin}
canShare={this.props.canShare}
cloudHost={this.props.cloudHost}
comments={this.props.comments} comments={this.props.comments}
editable={this.props.isEditable} editable={this.props.isEditable}
extensions={this.state.extensions} extensions={this.state.extensions}
@ -303,6 +417,7 @@ class Preview extends React.Component {
isShared={this.props.isShared} isShared={this.props.isShared}
loveCount={this.state.loveCount} loveCount={this.state.loveCount}
loved={this.props.loved} loved={this.props.loved}
moreCommentsToLoad={this.props.moreCommentsToLoad}
originalInfo={this.props.original} originalInfo={this.props.original}
parentInfo={this.props.parent} parentInfo={this.props.parent}
projectHost={this.props.projectHost} projectHost={this.props.projectHost}
@ -312,37 +427,52 @@ class Preview extends React.Component {
remixes={this.props.remixes} remixes={this.props.remixes}
replies={this.props.replies} replies={this.props.replies}
reportOpen={this.state.reportOpen} reportOpen={this.state.reportOpen}
studios={this.props.studios} singleCommentId={this.state.singleCommentId}
userOwnsProject={this.props.userOwnsProject} userOwnsProject={this.props.userOwnsProject}
onAddComment={this.handleAddComment}
onAddToStudioClicked={this.handleAddToStudioClick} onAddToStudioClicked={this.handleAddToStudioClick}
onAddToStudioClosed={this.handleAddToStudioClose} onAddToStudioClosed={this.handleAddToStudioClose}
onDeleteComment={this.handleDeleteComment}
onFavoriteClicked={this.handleFavoriteToggle} onFavoriteClicked={this.handleFavoriteToggle}
onLoadMore={this.handleLoadMore} onLoadMore={this.handleLoadMore}
onLoveClicked={this.handleLoveToggle} onLoveClicked={this.handleLoveToggle}
onReportClicked={this.handleReportClick} onReportClicked={this.handleReportClick}
onReportClose={this.handleReportClose} onReportClose={this.handleReportClose}
onReportComment={this.handleReportComment}
onReportSubmit={this.handleReportSubmit} onReportSubmit={this.handleReportSubmit}
onRestoreComment={this.handleRestoreComment}
onSeeInside={this.handleSeeInside} onSeeInside={this.handleSeeInside}
onShare={this.handleShare}
onToggleComments={this.handleToggleComments}
onToggleStudio={this.handleToggleStudio} onToggleStudio={this.handleToggleStudio}
onUpdate={this.handleUpdate} onUpdate={this.handleUpdate}
/> />
</Page> : </Page> :
<React.Fragment> <React.Fragment>
<IntlGUI <IntlGUI
enableCommunity
hideIntro hideIntro
assetHost={this.props.assetHost} assetHost={this.props.assetHost}
backpackOptions={this.props.backpackOptions} backpackOptions={this.props.backpackOptions}
basePath="/" basePath="/"
canCreateCopy={this.props.canCreateCopy}
canCreateNew={this.props.canCreateNew}
canRemix={this.props.canRemix}
canSave={this.props.canSave}
canShare={this.props.canShare}
className="gui" className="gui"
cloudHost={this.props.cloudHost}
enableCommunity={this.props.enableCommunity}
isShared={this.props.isShared}
projectHost={this.props.projectHost} projectHost={this.props.projectHost}
projectId={this.state.projectId} projectId={this.state.projectId}
projectTitle={this.props.projectInfo.title} projectTitle={this.props.projectInfo.title}
renderLogin={this.renderLogin} renderLogin={this.renderLogin}
onLogOut={this.props.handleLogOut} onLogOut={this.props.handleLogOut}
onOpenRegistration={this.props.handleOpenRegistration} onOpenRegistration={this.props.handleOpenRegistration}
onSetLanguage={this.handleSetLanguage}
onShare={this.handleShare} onShare={this.handleShare}
onToggleLoginOpen={this.props.handleToggleLoginOpen} onToggleLoginOpen={this.props.handleToggleLoginOpen}
onUpdateProjectId={this.handleUpdateProjectId}
onUpdateProjectTitle={this.handleUpdateProjectTitle} onUpdateProjectTitle={this.handleUpdateProjectTitle}
/> />
<Registration /> <Registration />
@ -359,9 +489,19 @@ Preview.propTypes = {
host: PropTypes.string, host: PropTypes.string,
visible: PropTypes.bool visible: PropTypes.bool
}), }),
canAddToStudio: PropTypes.bool,
canCreateCopy: PropTypes.bool,
canCreateNew: PropTypes.bool,
canRemix: PropTypes.bool,
canReport: PropTypes.bool,
canSave: PropTypes.bool,
canShare: PropTypes.bool,
cloudHost: PropTypes.string,
comments: PropTypes.arrayOf(PropTypes.object), comments: PropTypes.arrayOf(PropTypes.object),
enableCommunity: PropTypes.bool,
faved: PropTypes.bool, faved: PropTypes.bool,
fullScreen: PropTypes.bool, fullScreen: PropTypes.bool,
getCommentById: PropTypes.func.isRequired,
getCuratedStudios: PropTypes.func.isRequired, getCuratedStudios: PropTypes.func.isRequired,
getFavedStatus: PropTypes.func.isRequired, getFavedStatus: PropTypes.func.isRequired,
getLovedStatus: PropTypes.func.isRequired, getLovedStatus: PropTypes.func.isRequired,
@ -371,14 +511,20 @@ Preview.propTypes = {
getProjectStudios: PropTypes.func.isRequired, getProjectStudios: PropTypes.func.isRequired,
getRemixes: PropTypes.func.isRequired, getRemixes: PropTypes.func.isRequired,
getTopLevelComments: PropTypes.func.isRequired, getTopLevelComments: PropTypes.func.isRequired,
handleAddComment: PropTypes.func,
handleDeleteComment: PropTypes.func,
handleLogIn: PropTypes.func, handleLogIn: PropTypes.func,
handleLogOut: PropTypes.func, handleLogOut: PropTypes.func,
handleOpenRegistration: PropTypes.func, handleOpenRegistration: PropTypes.func,
handleReportComment: PropTypes.func,
handleRestoreComment: PropTypes.func,
handleToggleLoginOpen: PropTypes.func, handleToggleLoginOpen: PropTypes.func,
isAdmin: PropTypes.bool,
isEditable: PropTypes.bool, isEditable: PropTypes.bool,
isLoggedIn: PropTypes.bool, isLoggedIn: PropTypes.bool,
isShared: PropTypes.bool, isShared: PropTypes.bool,
loved: PropTypes.bool, loved: PropTypes.bool,
moreCommentsToLoad: PropTypes.bool,
original: projectShape, original: projectShape,
parent: projectShape, parent: projectShape,
playerMode: PropTypes.bool, playerMode: PropTypes.bool,
@ -388,12 +534,12 @@ Preview.propTypes = {
remixes: PropTypes.arrayOf(PropTypes.object), remixes: PropTypes.arrayOf(PropTypes.object),
replies: PropTypes.objectOf(PropTypes.array), replies: PropTypes.objectOf(PropTypes.array),
reportProject: PropTypes.func, reportProject: PropTypes.func,
resetProject: PropTypes.func,
sessionStatus: PropTypes.string, sessionStatus: PropTypes.string,
setFavedStatus: PropTypes.func.isRequired, setFavedStatus: PropTypes.func.isRequired,
setFullScreen: PropTypes.func.isRequired, setFullScreen: PropTypes.func.isRequired,
setLovedStatus: PropTypes.func.isRequired, setLovedStatus: PropTypes.func.isRequired,
setPlayer: PropTypes.func.isRequired, setPlayer: PropTypes.func.isRequired,
studios: PropTypes.arrayOf(PropTypes.object),
toggleStudio: PropTypes.func.isRequired, toggleStudio: PropTypes.func.isRequired,
updateProject: PropTypes.func.isRequired, updateProject: PropTypes.func.isRequired,
user: PropTypes.shape({ user: PropTypes.shape({
@ -406,7 +552,8 @@ Preview.propTypes = {
email: PropTypes.string, email: PropTypes.string,
classroomId: PropTypes.string classroomId: PropTypes.string
}), }),
userOwnsProject: PropTypes.bool userOwnsProject: PropTypes.bool,
userPresent: PropTypes.bool
}; };
Preview.defaultProps = { Preview.defaultProps = {
@ -415,56 +562,37 @@ Preview.defaultProps = {
host: process.env.BACKPACK_HOST, host: process.env.BACKPACK_HOST,
visible: true visible: true
}, },
cloudHost: process.env.CLOUDDATA_HOST,
projectHost: process.env.PROJECT_HOST, projectHost: process.env.PROJECT_HOST,
sessionStatus: sessionActions.Status.NOT_FETCHED, sessionStatus: sessionActions.Status.NOT_FETCHED,
user: {} user: {},
}; userPresent: false
// 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 => {
const projectInfoPresent = Object.keys(state.preview.projectInfo).length > 0; const projectInfoPresent = state.preview.projectInfo &&
const userPresent = state.session.session.user && Object.keys(state.preview.projectInfo).length > 0 && state.preview.projectInfo.id > 0;
const userPresent = state.session.session.user !== null &&
typeof state.session.session.user !== 'undefined' &&
Object.keys(state.session.session.user).length > 0; Object.keys(state.session.session.user).length > 0;
const isLoggedIn = state.session.status === sessionActions.Status.FETCHED && const isLoggedIn = state.session.status === sessionActions.Status.FETCHED &&
userPresent; userPresent;
const isAdmin = isLoggedIn && state.session.session.permissions.admin;
const authorPresent = projectInfoPresent && state.preview.projectInfo.author && const authorPresent = projectInfoPresent && state.preview.projectInfo.author &&
Object.keys(state.preview.projectInfo.author).length > 0; Object.keys(state.preview.projectInfo.author).length > 0;
const userOwnsProject = isLoggedIn && authorPresent &&
state.session.session.user.id === state.preview.projectInfo.author.id;
return { return {
canAddToStudio: userOwnsProject,
canCreateCopy: userOwnsProject && projectInfoPresent,
canCreateNew: isLoggedIn,
canRemix: isLoggedIn && projectInfoPresent && !userOwnsProject,
canReport: isLoggedIn && !userOwnsProject,
canSave: isLoggedIn && userOwnsProject,
canShare: userOwnsProject && state.permissions.social,
comments: state.preview.comments, comments: state.preview.comments,
enableCommunity: projectInfoPresent,
faved: state.preview.faved, faved: state.preview.faved,
fullScreen: state.scratchGui.mode.isFullScreen, fullScreen: state.scratchGui.mode.isFullScreen,
// project is editable iff logged in user is the author of the project, or // project is editable iff logged in user is the author of the project, or
@ -473,12 +601,11 @@ const mapStateToProps = state => {
((authorPresent && state.preview.projectInfo.author.username === state.session.session.user.username) || ((authorPresent && state.preview.projectInfo.author.username === state.session.session.user.username) ||
state.permissions.admin === true), state.permissions.admin === true),
isLoggedIn: isLoggedIn, isLoggedIn: isLoggedIn,
isAdmin: isAdmin,
// if we don't have projectInfo, assume it's shared until we know otherwise // if we don't have projectInfo, assume it's shared until we know otherwise
isShared: !projectInfoPresent || ( isShared: !projectInfoPresent || state.preview.projectInfo.is_published,
state.preview.projectInfo.history &&
state.preview.projectInfo.history.shared &&
state.preview.projectInfo.history.shared.length > 0),
loved: state.preview.loved, loved: state.preview.loved,
moreCommentsToLoad: state.preview.moreCommentsToLoad,
original: state.preview.original, original: state.preview.original,
parent: state.preview.parent, parent: state.preview.parent,
playerMode: state.scratchGui.mode.isPlayerOnly, playerMode: state.scratchGui.mode.isPlayerOnly,
@ -487,16 +614,25 @@ const mapStateToProps = state => {
remixes: state.preview.remixes, remixes: state.preview.remixes,
replies: state.preview.replies, replies: state.preview.replies,
sessionStatus: state.session.status, // check if used sessionStatus: state.session.status, // check if used
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,
userOwnsProject: isLoggedIn && authorPresent && userOwnsProject: userOwnsProject,
state.session.session.user.id === state.preview.projectInfo.author.id userPresent: userPresent
}; };
}; };
const mapDispatchToProps = dispatch => ({ const mapDispatchToProps = dispatch => ({
handleAddComment: (comment, topLevelCommentId) => {
dispatch(previewActions.addNewComment(comment, topLevelCommentId));
},
handleDeleteComment: (projectId, commentId, topLevelCommentId, token) => {
dispatch(previewActions.deleteComment(projectId, commentId, topLevelCommentId, token));
},
handleReportComment: (projectId, commentId, topLevelCommentId, token) => {
dispatch(previewActions.reportComment(projectId, commentId, topLevelCommentId, token));
},
handleRestoreComment: (projectId, commentId, topLevelCommentId, token) => {
dispatch(previewActions.restoreComment(projectId, commentId, topLevelCommentId, token));
},
handleOpenRegistration: event => { handleOpenRegistration: event => {
event.preventDefault(); event.preventDefault();
dispatch(navigationActions.setRegistrationOpen(true)); dispatch(navigationActions.setRegistrationOpen(true));
@ -537,8 +673,11 @@ const mapDispatchToProps = dispatch => ({
dispatch(previewActions.leaveStudio(studioId, id, token)); dispatch(previewActions.leaveStudio(studioId, id, token));
} }
}, },
getTopLevelComments: (id, offset) => { getTopLevelComments: (id, offset, isAdmin, token) => {
dispatch(previewActions.getTopLevelComments(id, offset)); dispatch(previewActions.getTopLevelComments(id, offset, isAdmin, token));
},
getCommentById: (projectId, commentId, isAdmin, token) => {
dispatch(previewActions.getCommentById(projectId, commentId, isAdmin, token));
}, },
getFavedStatus: (id, username, token) => { getFavedStatus: (id, username, token) => {
dispatch(previewActions.getFavedStatus(id, username, token)); dispatch(previewActions.getFavedStatus(id, username, token));
@ -552,8 +691,11 @@ const mapDispatchToProps = dispatch => ({
setLovedStatus: (loved, id, username, token) => { setLovedStatus: (loved, id, username, token) => {
dispatch(previewActions.setLovedStatus(loved, id, username, token)); dispatch(previewActions.setLovedStatus(loved, id, username, token));
}, },
reportProject: (id, formData) => { reportProject: (id, formData, token) => {
dispatch(previewActions.reportProject(id, formData)); dispatch(previewActions.reportProject(id, formData, token));
},
resetProject: () => {
dispatch(previewActions.resetProject());
}, },
setOriginalInfo: info => { setOriginalInfo: info => {
dispatch(previewActions.setOriginalInfo(info)); dispatch(previewActions.setOriginalInfo(info));
@ -600,6 +742,9 @@ render(
preview: previewActions.previewReducer, preview: previewActions.previewReducer,
...GUI.guiReducers ...GUI.guiReducers
}, },
{scratchGui: initGuiState(GUI.guiInitialState)}, {
locales: GUI.initLocale(GUI.localesInitialState, window._locale),
scratchGui: initGuiState(GUI.guiInitialState)
},
GUI.guiMiddleware GUI.guiMiddleware
); );

View file

@ -6,21 +6,15 @@ $player-width: 482px;
$player-height: 406px; $player-height: 406px;
$stage-width: 480px; $stage-width: 480px;
/* screen sizes */
$small: "screen and (max-width : #{$mobile}-1)";
$medium: "screen and (min-width : #{$mobile}) and (max-width : #{$tablet}-1)";
$big: "screen and (min-width : #{$tablet})";
$medium-and-small: "screen and (max-width : #{$tablet}-1)";
/* override view padding for share banner */ /* override view padding for share banner */
#view { #view {
padding: 0; padding: 0;
width: 100%;
} }
.gui { .gui {
position: absolute; position: absolute;
top: 0; top: 0;
z-index: 11;
margin: 0; margin: 0;
width: 100%; width: 100%;
height: 100%; height: 100%;
@ -28,12 +22,24 @@ $medium-and-small: "screen and (max-width : #{$tablet}-1)";
.preview { .preview {
.inner {
margin: 0 auto;
@media #{$medium-and-smaller} {
max-width: 90%;
}
@media #{$intermediate} {
width: 90%;
min-width: 640px;
}
}
.project-title { .project-title {
font-size: 1.75rem; font-size: 1.75rem;
font-weight: 500; font-weight: 500;
&.has-error { &.has-error {
.validation-message { .validation-message {
right: 0; right: 0;
} }
@ -59,6 +65,14 @@ $medium-and-small: "screen and (max-width : #{$tablet}-1)";
.inplace-input { .inplace-input {
height: calc(3rem - 4px); height: calc(3rem - 4px);
} }
@media #{$medium-and-smaller} {
flex-direction: row;
}
@media #{$small} {
margin-right: 0;
}
} }
img { img {
@ -83,13 +97,17 @@ $medium-and-small: "screen and (max-width : #{$tablet}-1)";
text-align: left; text-align: left;
font-size: .8rem; font-size: .8rem;
flex-grow: 1; flex-grow: 1;
@media #{$medium-and-smaller} {
min-width: 100%;
}
} }
.validation-message { .validation-message {
$arrow-border-width: 1rem;
display: block; display: block;
position: absolute; position: absolute;
z-index: 1; z-index: 1;
$arrow-border-width: 1rem;
margin-top: $arrow-border-width; margin-top: $arrow-border-width;
border: 1px solid $active-gray; border: 1px solid $active-gray;
border-radius: 5px; border-radius: 5px;
@ -101,7 +119,7 @@ $medium-and-small: "screen and (max-width : #{$tablet}-1)";
color: $type-white; color: $type-white;
font-size: 1rem; font-size: 1rem;
@media #{$medium-and-small} { @media #{$medium-and-smaller} {
margin-top: calc($arrow-border-width / 2); margin-top: calc($arrow-border-width / 2);
width: 100%; width: 100%;
} }
@ -124,7 +142,7 @@ $medium-and-small: "screen and (max-width : #{$tablet}-1)";
content: ""; content: "";
@media #{$medium-and-small} { @media #{$medium-and-smaller} {
display: none; display: none;
} }
} }
@ -153,7 +171,36 @@ $medium-and-small: "screen and (max-width : #{$tablet}-1)";
} }
.comments-container { .comments-container {
width: 65%; padding-right: 1.5rem;
min-width: 65%;
max-width: 100%;
flex: 1;
@media #{$medium-and-smaller} {
padding: 0;
width: 100%;
}
.comment, .comment-top-row, .comment-bottom-row {
flex-direction: row;
}
.comment-bubble {
text-align: left;
}
.comments-turned-off {
border: 1px solid $ui-blue-25percent;
border-radius: .5rem;
background: $ui-blue-10percent;
padding: 1.5rem 0;
width: 100%;
text-align: center;
}
.comments-allowed-input {
margin-right: 3px;
}
} }
.remix-button, .remix-button,
@ -195,11 +242,27 @@ $medium-and-small: "screen and (max-width : #{$tablet}-1)";
justify-content: space-between; justify-content: space-between;
align-items: flex-start; align-items: flex-start;
flex-wrap: nowrap; flex-wrap: nowrap;
&.force-row {
@media #{$medium-and-smaller} {
flex-direction: row;
}
}
} }
.guiPlayer { .guiPlayer {
display: inline-block; display: inline-block;
width: $player-width; width: $player-width;
@media #{$small} {
width: 100%;
}
}
.force-center {
@media #{$medium-and-smaller} {
align-self: center;
}
} }
.project-notes { .project-notes {
@ -209,26 +272,17 @@ $medium-and-small: "screen and (max-width : #{$tablet}-1)";
flex: 1; flex: 1;
flex-flow: column; flex-flow: column;
@media #{$medium-and-smaller} {
margin-top: 1rem;
margin-left: 0;
width: 100%;
}
> .description-block:first-child { > .description-block:first-child {
margin-top: 1rem; margin-top: 1rem;
} }
} }
.share-date {
margin-right: .75rem;
vertical-align: middle;
line-height: 2rem;
color: $type-gray;
font-size: .875rem;
}
.subactions {
margin-left: 1.5rem;
justify-content: flex-end;
align-items: flex-start;
flex: 1;
}
.remix-credit { .remix-credit {
margin-bottom: 1rem; margin-bottom: 1rem;
border: 1px solid $ui-blue-10percent; border: 1px solid $ui-blue-10percent;
@ -237,7 +291,11 @@ $medium-and-small: "screen and (max-width : #{$tablet}-1)";
padding: .5rem; padding: .5rem;
width: calc(100% - 1rem); width: calc(100% - 1rem);
flex-wrap: nowrap; flex-wrap: nowrap;
align-items: flex-start; align-items: center;
@media #{$medium-and-smaller} {
flex-direction: row;
}
} }
.credit-text { .credit-text {
@ -278,6 +336,12 @@ $medium-and-small: "screen and (max-width : #{$tablet}-1)";
margin-bottom: 0; margin-bottom: 0;
} }
.project-description-form {
display: flex;
width: 100%;
flex-grow: 1;
}
.project-description-edit { .project-description-edit {
display: flex; display: flex;
margin-bottom: .75rem; margin-bottom: .75rem;
@ -312,146 +376,6 @@ $medium-and-small: "screen and (max-width : #{$tablet}-1)";
} }
} }
.copyleft {
display: inline-block;
transform: scale(-1, 1);
margin: 0;
text-align: right;
}
.stats {
line-height: 2rem;
justify-content: flex-start;
}
.project-loves,
.project-favorites,
.project-remixes,
.project-views {
display: inline;
padding-right: 2rem;
font-size: 1rem;
font-weight: bold;
&:before {
display: inline-block;
margin-right: .5rem;
background-repeat: no-repeat;
background-position: center center;
background-size: contain;
width: 1.5rem;
height: 1.5rem;
vertical-align: -.25rem;
content: "";
}
}
.project-loves {
cursor: pointer;
&:before {
opacity: .5;
background-image: url("/svgs/project/love-gray.svg");
}
}
.project-loves.loved {
&:before {
opacity: 1;
background-image: url("/svgs/project/love-red.svg");
}
}
.project-favorites {
cursor: pointer;
&:before {
opacity: .5;
background-image: url("/svgs/project/fav-gray.svg");
}
}
.project-favorites.favorited {
&:before {
opacity: 1;
background-image: url("/svgs/project/fav-yellow.svg");
}
}
.project-remixes {
&:before {
opacity: .5;
background-image: url("/svgs/project/remix-gray.svg");
}
}
.project-views {
&:before {
opacity: .5;
background-image: url("/svgs/project/views-gray.svg");
}
}
.action-buttons {
display: flex;
margin-top: 0;
color: $type-white;
font-size: .8rem;
font-weight: 500;
justify-content: flex-end;
flex-wrap: wrap;
}
.action-button {
margin: 0 0 0 .5rem;
border-radius: 19px;
background-color: $ui-blue;
padding: 0 .75rem;
height: 2rem;
text-decoration: none;
line-height: .875rem;
font-size: .75rem;
font-weight: normal;
// &:hover {
// transition: background-color .25s ease;
// border-color: transparent;
// background-color: $active-gray;
// }
//
// &:active {
// border: 0 solid transparent;
// box-shadow: inset 0 0 5px $box-shadow-gray;
// background-color: $active-dark-gray;
// padding: calc(.75em + 1px) calc(1.5em + 1px);
// }
//
// &.report {
// border: 1px solid $ui-coral;
// background-color: $ui-coral;
//
// &:hover {
// transition: background-color .25s ease;
// border-color: transparent;
// background-color: $active-gray;
// }
//
// &:active {
// border: 0 solid transparent;
// box-shadow: inset 0 0 5px $box-shadow-gray;
// background-color: $active-dark-gray;
// padding: calc(.75em + 1px) calc(1.5em + 1px);
// }
// }
}
.comments-header { .comments-header {
padding: 0 0 1rem 0; padding: 0 0 1rem 0;
justify-content: space-between; justify-content: space-between;
@ -461,42 +385,6 @@ $medium-and-small: "screen and (max-width : #{$tablet}-1)";
} }
} }
.studio-button,
.copy-link-button,
.report-button {
&:before {
display: inline-block;
margin-right: .25rem;
background-repeat: no-repeat;
background-position: center center;
background-size: contain;
width: .875rem;
height: .875rem;
vertical-align: bottom;
content: "";
}
}
.studio-button {
&:before {
background-image: url("/svgs/project/studio-add-white.svg");
}
}
.copy-link-button {
&:before {
background-image: url("/svgs/project/copy-link-white.svg");
}
}
.report-button {
background-color: $ui-coral;
&:before {
background-image: url("/svgs/project/report-white.svg");
}
}
.project-lower-container { .project-lower-container {
margin-top: 1rem; margin-top: 1rem;
background-color: $ui-blue-10percent; background-color: $ui-blue-10percent;
@ -517,7 +405,7 @@ $medium-and-small: "screen and (max-width : #{$tablet}-1)";
justify-content: flex-start; justify-content: flex-start;
flex-direction: row; flex-direction: row;
@media #{$medium-and-small} { @media #{$medium-and-smaller} {
justify-content: center; justify-content: center;
} }
@ -542,11 +430,21 @@ $medium-and-small: "screen and (max-width : #{$tablet}-1)";
.thumbnail-column { .thumbnail-column {
display: inline-block; display: inline-block;
width: 100%; width: 100%;
/* TODO: the following can be transferred to
src/components/thumbnailcolumn/thumbnailcolumn.scss
after testing */
@media #{$medium-and-smaller} {
flex-direction: row;
.thumbnail {
display: inline-block;
}
}
}
@media #{$medium-and-smaller} {
margin-top: 1rem;
} }
} }
} }
.report-text textarea {
// override min-height from default settings (for teacher registration)
min-height: 8rem;
}

View file

@ -1,4 +1,5 @@
const React = require('react'); const React = require('react');
const FormattedMessage = require('react-intl').FormattedMessage;
const FlexRow = require('../../components/flex-row/flex-row.jsx'); const FlexRow = require('../../components/flex-row/flex-row.jsx');
const Avatar = require('../../components/avatar/avatar.jsx'); const Avatar = require('../../components/avatar/avatar.jsx');
const projectShape = require('./projectshape.jsx').projectShape; const projectShape = require('./projectshape.jsx').projectShape;
@ -13,16 +14,24 @@ const RemixCredit = props => {
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`}
/> />
<div className="credit-text"> <div className="credit-text">
Thanks to <a <FormattedMessage
href={`/users/${projectInfo.author.username}`} id="project.credit"
> values={{
{projectInfo.author.username} userLink: (
</a> for the original project <a <a href={`/users/${projectInfo.author.username}`}>
href={`/preview/${projectInfo.id}`} {projectInfo.author.username}
title={projectInfo.title} </a>
> ),
{projectInfo.title} projectLink: (
</a>. <a
href={`/preview/${projectInfo.id}`}
title={projectInfo.title}
>
{projectInfo.title}
</a>
)
}}
/>
</div> </div>
</FlexRow> </FlexRow>
); );

View file

@ -1,5 +1,6 @@
const React = require('react'); const React = require('react');
const PropTypes = require('prop-types'); const PropTypes = require('prop-types');
const FormattedMessage = require('react-intl').FormattedMessage;
const FlexRow = require('../../components/flex-row/flex-row.jsx'); const FlexRow = require('../../components/flex-row/flex-row.jsx');
const ThumbnailColumn = require('../../components/thumbnailcolumn/thumbnailcolumn.jsx'); const ThumbnailColumn = require('../../components/thumbnailcolumn/thumbnailcolumn.jsx');
const projectShape = require('./projectshape.jsx').projectShape; const projectShape = require('./projectshape.jsx').projectShape;
@ -10,11 +11,11 @@ const RemixList = props => {
return ( return (
<FlexRow className="remix-list"> <FlexRow className="remix-list">
<div className="list-title"> <div className="list-title">
Remixes <FormattedMessage id="project.remixes" />
</div> </div>
{remixes.length === 0 ? ( {remixes.length === 0 ? (
// TODO: style remix invitation // TODO: style remix invitation
<span>Invite user to remix</span> <FormattedMessage id="project.inviteToRemix" />
) : ( ) : (
<ThumbnailColumn <ThumbnailColumn
cards cards

View file

@ -1,30 +1,29 @@
const PropTypes = require('prop-types'); const PropTypes = require('prop-types');
const React = require('react'); const React = require('react');
const FormattedMessage = require('react-intl').FormattedMessage;
const FlexRow = require('../../components/flex-row/flex-row.jsx'); const FlexRow = require('../../components/flex-row/flex-row.jsx');
const Button = require('../../components/forms/button.jsx'); const Button = require('../../components/forms/button.jsx');
require('./share-banner.scss'); require('./share-banner.scss');
const ShareBanner = props => { const ShareBanner = ({onShare}) => (
if (props.shared) return null; <div className="share-banner-outer">
return ( <FlexRow className="inner share-banner">
<div className="shareBanner"> <span className="share-text">
<div className="inner"> <FormattedMessage id="project.share.notShared" />
<FlexRow className="preview-row"> </span>
<span className="share-text"> <Button
This project is not shared so only you can see it. Click share to let everyone see it! className="button share-button"
</span> onClick={onShare}
<Button className="button share-button"> >
Share <FormattedMessage id="project.share.shareButton" />
</Button> </Button>
</FlexRow> </FlexRow>
</div> </div>
</div> );
);
};
ShareBanner.propTypes = { ShareBanner.propTypes = {
shared: PropTypes.bool.isRequired onShare: PropTypes.func
}; };
module.exports = ShareBanner; module.exports = ShareBanner;

View file

@ -2,29 +2,34 @@
$navigation-height: 50px; $navigation-height: 50px;
.shareBanner { .share-banner-outer {
background-color: $ui-orange-25percent; background-color: $ui-orange-25percent;
width: 100%; width: 100%;
overflow: hidden; overflow: hidden;
color: $ui-orange; color: $ui-orange;
} }
.share-banner {
align-items: center;
justify-content: space-between;
}
.share-button { .share-button {
margin-top: 0;
background-color: $ui-orange; background-color: $ui-orange;
font-size: .875rem; font-size: .875rem;
font-weight: normal; font-weight: normal;
&:before { // don't show an image in share button, for now.
display: inline-block; // &:before {
margin-right: .5rem; // display: inline-block;
background-image: url("/svgs/project/share-white.svg"); // margin-right: .5rem;
background-repeat: no-repeat; // background-image: url("/svgs/project/share-white.svg");
background-position: center center; // background-repeat: no-repeat;
background-size: contain; // background-position: center center;
width: 1.25rem; // background-size: contain;
height: 1.25rem; // width: 1.25rem;
vertical-align: middle; // height: 1.25rem;
content: ""; // vertical-align: middle;
} // content: "";
// }
} }

View file

@ -0,0 +1,53 @@
const PropTypes = require('prop-types');
const React = require('react');
const FlexRow = require('../../components/flex-row/flex-row.jsx');
const classNames = require('classnames');
const approx = require('approximate-number');
const CappedNumber = require('../../components/cappednumber/cappednumber.jsx');
const projectShape = require('./projectshape.jsx').projectShape;
require('./stats.scss');
const Stats = props => (
<FlexRow className="stats">
<div
className={classNames('project-loves', {loved: props.loved})}
key="loves"
onClick={props.onLoveClicked}
>
{approx(props.loveCount, {decimal: false})}
</div>
<div
className={classNames('project-favorites', {favorited: props.faved})}
key="favorites"
onClick={props.onFavoriteClicked}
>
{approx(props.favoriteCount, {decimal: false})}
</div>
<div
className="project-remixes"
key="remixes"
>
{approx(props.projectInfo.stats.remixes, {decimal: false})}
</div>
<div
className="project-views"
key="views"
>
<CappedNumber value={props.projectInfo.stats.views} />
</div>
</FlexRow>
);
Stats.propTypes = {
faved: PropTypes.bool,
favoriteCount: PropTypes.number,
loveCount: PropTypes.number,
loved: PropTypes.bool,
onFavoriteClicked: PropTypes.func,
onLoveClicked: PropTypes.func,
projectInfo: projectShape
};
module.exports = Stats;

View file

@ -0,0 +1,94 @@
@import "../../frameless";
.stats {
line-height: 2rem;
justify-content: flex-start;
@media #{$medium-and-smaller} {
margin: 0;
width: 100%;
justify-content: center;
flex-direction: row;
}
& > div {
@media #{$medium-and-smaller} {
padding: 0 1rem;
}
}
}
.project-loves,
.project-favorites,
.project-remixes,
.project-views {
display: inline;
padding-right: 2rem;
font-size: 1rem;
font-weight: bold;
&:before {
display: inline-block;
margin-right: .5rem;
background-repeat: no-repeat;
background-position: center center;
background-size: contain;
width: 1.5rem;
height: 1.5rem;
vertical-align: -.25rem;
content: "";
}
}
.project-loves {
cursor: pointer;
&:before {
opacity: .5;
background-image: url("/svgs/project/love-gray.svg");
}
}
.project-loves.loved {
&:before {
opacity: 1;
background-image: url("/svgs/project/love-red.svg");
}
}
.project-favorites {
cursor: pointer;
&:before {
opacity: .5;
background-image: url("/svgs/project/fav-gray.svg");
}
}
.project-favorites.favorited {
&:before {
opacity: 1;
background-image: url("/svgs/project/fav-yellow.svg");
}
}
.project-remixes {
&:before {
opacity: .5;
background-image: url("/svgs/project/remix-gray.svg");
}
}
.project-views {
&:before {
opacity: .5;
background-image: url("/svgs/project/views-gray.svg");
}
}

View file

@ -1,5 +1,6 @@
const React = require('react'); const React = require('react');
const PropTypes = require('prop-types'); const PropTypes = require('prop-types');
const FormattedMessage = require('react-intl').FormattedMessage;
const FlexRow = require('../../components/flex-row/flex-row.jsx'); const FlexRow = require('../../components/flex-row/flex-row.jsx');
const ThumbnailColumn = require('../../components/thumbnailcolumn/thumbnailcolumn.jsx'); const ThumbnailColumn = require('../../components/thumbnailcolumn/thumbnailcolumn.jsx');
const projectShape = require('./projectshape.jsx').projectShape; const projectShape = require('./projectshape.jsx').projectShape;
@ -10,11 +11,11 @@ const StudioList = props => {
return ( return (
<FlexRow className="studio-list"> <FlexRow className="studio-list">
<div className="list-title"> <div className="list-title">
Studios <FormattedMessage id="general.studios" />
</div> </div>
{studios.length === 0 ? ( {studios.length === 0 ? (
// TODO: style remix invitation // TODO: style remix invitation
<span>Invite user to add to studio</span> <FormattedMessage id="addToStudio.inviteUser" />
) : ( ) : (
<ThumbnailColumn <ThumbnailColumn
cards cards

View file

@ -0,0 +1,91 @@
const FormattedDate = require('react-intl').FormattedDate;
const FormattedMessage = require('react-intl').FormattedMessage;
const PropTypes = require('prop-types');
const React = require('react');
const FlexRow = require('../../components/flex-row/flex-row.jsx');
const Button = require('../../components/forms/button.jsx');
const AddToStudioModal = require('./add-to-studio.jsx');
const ReportModal = require('../../components/modal/report/modal.jsx');
require('./subactions.scss');
const Subactions = props => (
<FlexRow className="subactions">
<div className="share-date">
<div className="copyleft">&copy;</div>
{' '}
{/* eslint-disable react/jsx-sort-props */}
{props.shareDate === null ?
'Unshared' :
<FormattedDate
value={Date.parse(props.shareDate)}
day="2-digit"
month="short"
year="numeric"
/>
}
{/* eslint-enable react/jsx-sort-props */}
</div>
<FlexRow className="action-buttons">
{props.canAddToStudio &&
<React.Fragment>
<Button
className="action-button studio-button"
key="add-to-studio-button"
onClick={props.onAddToStudioClicked}
>
<FormattedMessage id="addToStudio.title" />
</Button>
{props.addToStudioOpen && (
<AddToStudioModal
isOpen
key="add-to-studio-modal"
onRequestClose={props.onAddToStudioClosed}
onToggleStudio={props.onToggleStudio}
/>
)}
</React.Fragment>
}
<Button className="action-button copy-link-button">
<FormattedMessage id="general.copyLink" />
</Button>
{(props.canReport) &&
<React.Fragment>
<Button
className="action-button report-button"
key="report-button"
onClick={props.onReportClicked}
>
<FormattedMessage id="general.report" />
</Button>
{props.reportOpen && (
<ReportModal
isOpen
key="report-modal"
type="project"
onReport={props.onReportSubmit}
onRequestClose={props.onReportClose}
/>
)}
</React.Fragment>
}
</FlexRow>
</FlexRow>
);
Subactions.propTypes = {
addToStudioOpen: PropTypes.bool,
canAddToStudio: PropTypes.bool,
canReport: PropTypes.bool,
onAddToStudioClicked: PropTypes.func,
onAddToStudioClosed: PropTypes.func,
onReportClicked: PropTypes.func.isRequired,
onReportClose: PropTypes.func.isRequired,
onReportSubmit: PropTypes.func.isRequired,
onToggleStudio: PropTypes.func,
reportOpen: PropTypes.bool,
shareDate: PropTypes.string
};
module.exports = Subactions;

View file

@ -0,0 +1,110 @@
@import "../../colors";
@import "../../frameless";
.subactions {
margin-left: 1.5rem;
justify-content: flex-end;
align-items: flex-start;
flex: 1;
@media #{$medium-and-smaller} {
margin-top: 1rem;
width: 100%;
}
.share-date {
margin-right: .75rem;
vertical-align: middle;
line-height: 2rem;
color: $type-gray;
font-size: .875rem;
@media #{$small} {
margin-top: 1rem;
width: 100%;
order: 100;
}
.copyleft {
display: inline-block;
transform: scale(-1, 1);
margin: 0;
text-align: right;
@media #{$medium-and-smaller} {
padding: 0;
}
}
}
.action-buttons {
display: flex;
margin-top: 0;
color: $type-white;
font-size: .8rem;
font-weight: 500;
justify-content: flex-end;
flex-wrap: wrap;
.action-button {
margin: 0 0 0 .5rem;
border-radius: 19px;
background-color: $ui-blue;
padding: 0 .75rem;
height: 2rem;
text-decoration: none;
line-height: .875rem;
font-size: .75rem;
font-weight: normal;
&.studio-button,
&.copy-link-button,
&.report-button {
&:before {
display: inline-block;
margin-right: .25rem;
background-repeat: no-repeat;
background-position: center center;
background-size: contain;
width: .875rem;
height: .875rem;
vertical-align: bottom;
content: "";
}
}
&.studio-button {
&:before {
background-image: url("/svgs/project/studio-add-white.svg");
}
}
&.copy-link-button {
&:before {
background-image: url("/svgs/project/copy-link-white.svg");
}
}
&.report-button {
background-color: $ui-coral;
&:before {
background-image: url("/svgs/project/report-white.svg");
}
}
}
}
}
.subactions, .subactions .action-buttons {
@media #{$medium-and-smaller} {
margin: 0;
justify-content: center;
flex-direction: row;
}
& > div, .action-button {
@media #{$medium-and-smaller} {
padding: 0 1rem;
}
}
}

View file

@ -78,7 +78,12 @@ class Search extends React.Component {
while (term.indexOf('&') > -1) { while (term.indexOf('&') > -1) {
term = term.substring(0, term.indexOf('&')); term = term.substring(0, term.indexOf('&'));
} }
term = decodeURIComponent(term.split('+').join(' ')); try {
term = decodeURIComponent(term);
} catch (e) {
// Error means that term was not URI encoded and decoding failed.
// We can silence this error because not all query strings are intended to be decoded.
}
this.props.dispatch(navigationActions.setSearchTerm(term)); this.props.dispatch(navigationActions.setSearchTerm(term));
} }
componentDidUpdate (prevProps) { componentDidUpdate (prevProps) {

View file

@ -169,7 +169,7 @@ $base-bg: $ui-white;
} }
//4 columns //4 columns
@media only screen and (max-width: $mobile - 1) { @media #{$small} {
.outer { .outer {
.search { .search {
width: $cols4; width: $cols4;
@ -197,7 +197,7 @@ $base-bg: $ui-white;
//6 columns //6 columns
@media only screen and (min-width: $mobile) and (max-width: $tablet - 1) { @media #{$medium} {
.outer { .outer {
.tabs { .tabs {
width: $cols6; width: $cols6;
@ -216,7 +216,7 @@ $base-bg: $ui-white;
} }
// 8 columns // 8 columns
@media only screen and (min-width: $tablet) and (max-width: $desktop - 1) { @media #{$intermediate} {
.outer { .outer {
.tabs { .tabs {
width: $cols8; width: $cols8;

View file

@ -65,7 +65,7 @@
} }
} }
@media only screen and (min-width: $tablet) and (max-width: $desktop - 1) { @media #{$intermediate} {
.beta-middle-banner { .beta-middle-banner {
margin: 20px auto 40px auto; margin: 20px auto 40px auto;
width: $cols8; width: $cols8;

View file

@ -43,5 +43,15 @@
"betabanner.title": "The Next Generation of Scratch", "betabanner.title": "The Next Generation of Scratch",
"betabanner.subtitle": "Scratch 3.0 is coming in January! Try the Beta version now.", "betabanner.subtitle": "Scratch 3.0 is coming in January! Try the Beta version now.",
"betabanner.callToAction": "Try it!" "betabanner.callToAction": "Try it!",
"hocbanner.title": "Get Creative with Coding!",
"hocbanner.moreActivities": "See more activities",
"hocbanner.gettingStarted": "Getting Started",
"hocbanner.animationTalk": "Create Animations that Talk",
"hocbanner.adventureGame": "Animate an Adventure Game",
"hocbanner.name": "Animate a Name",
"hocbanner.fly": "Make it Fly",
"hocbanner.pong": "Pong Game"
} }

View file

@ -71,7 +71,10 @@ class Splash extends React.Component {
getNews () { getNews () {
api({ api({
uri: '/news?limit=3' uri: '/news?limit=3'
}, (err, body) => { }, (err, body, resp) => {
if (resp.statusCode !== 200) {
return log.error(`Unexpected status code ${resp.statusCode} received from news request`);
}
if (!body) return log.error('No response body'); if (!body) return log.error('No response body');
if (!err) return this.setState({news: body}); if (!err) return this.setState({news: body});
}); });

View file

@ -96,7 +96,7 @@
} }
//4 columns //4 columns
@media only screen and (max-width: $mobile - 1) { @media #{$small} {
.splash { .splash {
.splash-header { .splash-header {
flex-wrap: wrap; flex-wrap: wrap;
@ -119,7 +119,7 @@
} }
//6 columns //6 columns
@media only screen and (min-width: $mobile) and (max-width: $tablet - 1) { @media #{$medium} {
.splash { .splash {
.splash-header { .splash-header {
flex-wrap: wrap; flex-wrap: wrap;
@ -143,7 +143,7 @@
} }
//6 columns //6 columns
@media only screen and (min-width: $tablet) and (max-width: $desktop - 1) { @media #{$intermediate} {
.splash { .splash {
.splash-header { .splash-header {
margin: 0 auto; margin: 0 auto;

View file

@ -247,7 +247,7 @@ $story-width: $cols3;
} }
//4 columns //4 columns
@media only screen and (max-width: $mobile - 1) { @media #{$small} {
$story-width: $cols4; $story-width: $cols4;
.stories { .stories {
@ -311,7 +311,7 @@ $story-width: $cols3;
} }
//6 columns //6 columns
@media only screen and (min-width: $mobile) and (max-width: $tablet - 1) { @media #{$medium} {
$story-width: $cols3; $story-width: $cols3;
.stories { .stories {
@ -366,7 +366,7 @@ $story-width: $cols3;
//8 columns //8 columns
@media only screen and (min-width: $tablet) and (max-width: $desktop - 1) { @media #{$intermediate} {
$story-width: $cols4; $story-width: $cols4;
.stories { .stories {
@ -500,7 +500,7 @@ $story-width: $cols3;
} }
// 12 columns // 12 columns
@media only screen and (min-width: $desktop) { @media #{$big} {
$story-width: $cols3; $story-width: $cols3;
.stories { .stories {

View file

@ -83,7 +83,7 @@ img.tips-icon {
height: 1.75rem; height: 1.75rem;
} }
//4 columns //4 columns
@media only screen and (max-width: $mobile - 1) { @media #{$small} {
.title-banner { .title-banner {
&.masthead { &.masthead {
@ -121,7 +121,7 @@ img.tips-icon {
} }
//6 columns //6 columns
@media only screen and (min-width: $mobile) and (max-width: $tablet - 1) { @media #{$medium} {
.title-banner { .title-banner {
&.masthead { &.masthead {
@ -152,7 +152,7 @@ img.tips-icon {
//8 columns //8 columns
@media only screen and (min-width: $tablet) and (max-width: $desktop - 1) { @media #{$intermediate} {
.title-banner { .title-banner {
&.masthead { &.masthead {
padding-bottom: 2rem; padding-bottom: 2rem;
@ -189,7 +189,7 @@ img.tips-icon {
} }
// 12 columns // 12 columns
@media only screen and (min-width: $desktop) { @media #{$big} {
.title-banner { .title-banner {
&.masthead { &.masthead {
padding-bottom: 1.25rem; padding-bottom: 1.25rem;

View file

@ -156,7 +156,7 @@
// Responsive Behavior // Responsive Behavior
//4 columns //4 columns
@media only screen and (max-width: $mobile - 1) { @media #{$small} {
.wedo { .wedo {
.inner { .inner {
margin: 0 auto; margin: 0 auto;
@ -186,7 +186,7 @@
} }
//6 columns //6 columns
@media only screen and (min-width: $mobile) and (max-width: $tablet - 1) { @media #{$medium} {
.wedo { .wedo {
.project-list, .project-list,
.columns3 { .columns3 {
@ -210,7 +210,7 @@
} }
//8 columns //8 columns
@media only screen and (min-width: $tablet) and (max-width: $desktop - 1) { @media #{$intermediate} {
#view { #view {
text-align: center; text-align: center;
} }

12
static/cdn/README.txt Normal file
View file

@ -0,0 +1,12 @@
# Maintenance and Trouble Status Pages
The contents of these two files are loaded directly from our CDN provider. This allows us to present information about the backend systems that are unable to respond, either due to maintenance or some other backend systems trouble.
#### maintenance.html
A general message about work being performed on the systems
#### trouble.html
A message about trouble occurring that is currently being worked on.

208
static/cdn/maintenance.html Normal file

File diff suppressed because one or more lines are too long

208
static/cdn/trouble.html Normal file

File diff suppressed because one or more lines are too long

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 101 KiB

After

Width:  |  Height:  |  Size: 83 KiB

View file

@ -1,13 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?> <?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"> <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 (57462) - http://www.bohemiancoding.com/sketch --> <!-- Generator: Sketch 51.3 (57544) - http://www.bohemiancoding.com/sketch -->
<title>scratch-link</title> <title>scratch-link</title>
<desc>Created with Sketch.</desc>
<defs></defs> <defs></defs>
<g id="scratch-link" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd"> <g id="scratch-link" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
<g transform="translate(1.000000, 2.000000)" fill="#FFFFFF" id="Combined-Shape"> <path d="M11.25425,13.6746875 C11.089875,13.6746875 10.92925,13.6121875 10.808625,13.4990625 C10.563,13.2628125 10.56425,12.8796875 10.8105,12.6471875 C11.454875,12.0378125 11.808625,11.2290625 11.808625,10.3678125 C11.808625,9.5084375 11.454875,8.7003125 10.8105,8.0884375 C10.563,7.8540625 10.563,7.4715625 10.808625,7.2353125 C11.053,7.0009375 11.44925,7.0009375 11.6955,7.2334375 C12.578,8.0715625 13.066125,9.1846875 13.066125,10.3678125 C13.066125,11.5515625 12.578,12.6646875 11.6955,13.5009375 C11.57675,13.6121875 11.418,13.6746875 11.25425,13.6746875 M13.53925,16.4265625 C13.373625,16.4265625 13.214875,16.3640625 13.093625,16.2509375 C12.848,16.0146875 12.849875,15.6315625 13.0955,15.3971875 C14.508,14.0571875 15.284875,12.2784375 15.284875,10.3878125 C15.284875,8.4946875 14.508,6.7178125 13.0955,5.3778125 C12.848,5.1434375 12.848,4.7609375 13.093625,4.5240625 C13.338,4.2896875 13.734875,4.2878125 13.9805,4.5221875 C15.633,6.0846875 16.543,8.1690625 16.543,10.3859375 C16.543,12.6003125 15.633,14.6846875 13.9805,16.2509375 C13.86175,16.3640625 13.703,16.4265625 13.53925,16.4265625 M15.80425,19.1259375 C15.641125,19.1259375 15.4805,19.0634375 15.35925,18.9496875 C15.113625,18.7140625 15.113625,18.3309375 15.361125,18.0965625 C17.541125,16.0321875 18.7405,13.2921875 18.7405,10.3759375 C18.7405,7.4621875 17.541125,4.7196875 15.361125,2.6553125 C15.113625,2.4209375 15.111125,2.0378125 15.35925,1.8015625 C15.603625,1.5671875 15.999875,1.5653125 16.246125,1.7996875 C18.6655,4.0928125 19.999875,7.1378125 19.999875,10.3759375 C19.999875,13.6140625 18.6655,16.6590625 16.246125,18.9521875 C16.12675,19.0653125 15.968625,19.1259375 15.80425,19.1259375" id="lines" fill="#FFFFFF"></path>
<path d="M15.0548943,7.16762464 C15.1312951,7.62680704 15.146653,8.08968312 15.1137987,8.5434222 L16.6392874,9.35758641 C16.8795708,9.47520087 16.992714,9.75514272 16.9015385,10.0067016 L16.249896,11.8045504 C16.1587205,12.0566924 15.8916093,12.1991906 15.6313023,12.1346485 L13.940959,11.7781114 C13.6689877,12.1492288 13.3598852,12.4937128 13.0060698,12.7965943 L13.5811171,14.5979423 C13.6684045,14.8514452 13.5500124,15.1300263 13.3070073,15.2433638 L11.5737007,16.0516959 C11.3306956,16.1648391 11.041228,16.0765796 10.9032011,15.846794 L9.89288317,14.2484038 C9.43350637,14.3246102 8.9708247,14.3399681 8.51689121,14.3073083 L7.70331021,15.8320193 C7.58550135,16.0726916 7.30517069,16.1858347 7.05322302,16.094076 L5.25265259,15.4387399 C5.00089933,15.3469812 4.85878996,15.0802588 4.9231377,14.8201462 L5.27986916,13.1292196 C4.91127906,12.8624973 4.56660065,12.5533948 4.26391353,12.1995794 L2.46256548,12.7746266 C2.20886818,12.8619141 1.93048156,12.743522 1.81714399,12.5005169 L1.00881189,10.7670159 C0.895474326,10.5240108 0.983928174,10.2347375 1.21351937,10.0965163 L2.80704948,9.0887256 C2.73570319,8.62682154 2.71295791,8.16141821 2.7506723,7.70534628 L1.22654445,6.89176528 C0.985677811,6.77395642 0.87272905,6.49304255 0.964682172,6.24109488 L1.62099029,4.44577336 C1.71313782,4.1942145 1.98005459,4.05249394 2.23997283,4.11723049 L3.92623367,4.47571158 C4.19276164,4.10712148 4.50711301,3.76011023 4.860734,3.4572287 L4.28588119,1.65588065 C4.19859376,1.40237775 4.31698584,1.12379673 4.55999091,1.01045916 L6.29329754,0.202127064 C6.53630262,0.0887894948 6.82577027,0.177243343 6.96379715,0.40722335 L7.97411507,2.00541915 C8.43349187,1.92921276 8.90142245,1.91132759 9.35788319,1.93660011 L10.1689369,0.419081998 C10.2867458,0.178604172 10.5670765,0.0656554113 10.8186353,0.157219725 L12.6120128,0.810028569 C12.8637661,0.901592883 13.0058754,1.16850966 12.9413333,1.42842789 L12.5846018,3.11954884 C12.9584408,3.38393836 13.3003976,3.70062258 13.6083336,4.05191072 L15.4046272,3.47919636 C15.6581301,3.39190893 15.9365167,3.51030101 16.0498543,3.75330608 L16.8581863,5.48680711 C16.9715239,5.72981219 16.8830701,6.01908544 16.6532845,6.15730673 L15.0548943,7.16762464 Z M11.5238166,9.21341636 C11.5193453,8.67841638 11.3420488,8.20523689 11.013506,7.82614897 C11.0531644,7.73341823 11.0834914,7.63524418 11.101571,7.53259883 C11.1320924,7.36774419 11.1952738,5.46783328 11.1952738,5.45208655 C11.1952738,4.74095649 10.615172,4.15152337 9.90170912,4.13908151 C9.5562531,4.13247177 9.24131852,4.26447213 9.00414556,4.47792779 C8.92852238,4.4643195 8.85076075,4.45518251 8.77377674,4.45071122 C8.72984143,4.44857278 8.68707253,4.44740635 8.64624768,4.44740635 C7.91703804,4.44740635 7.3075813,4.80744268 6.97553916,5.4351734 C6.75314091,5.85275332 6.69909658,6.28957925 6.66293743,6.58546224 C6.661771,6.59109995 6.661771,6.59673767 6.66060458,6.60237539 L6.65263401,6.67566572 C6.5896471,7.18033867 6.62230698,7.60705559 6.72495232,7.96262062 C6.5183008,8.19182301 6.39077174,8.49314931 6.38513402,8.82499704 C6.384162,8.93114166 6.37502501,9.42220633 6.35811186,9.71128517 C6.34450357,9.96070558 6.33322814,10.3604003 6.35577901,10.8458273 L6.35694543,10.8559363 C6.35694543,10.8705166 6.35811186,10.8841249 6.35927828,10.8977332 C6.41001774,11.5795082 6.98467615,12.1147026 7.66761762,12.1147026 C7.69463979,12.1147026 7.72068993,12.1135362 7.7477121,12.1112033 C8.09763941,12.0909853 8.41821171,11.9350732 8.65091338,11.6720445 C8.67676912,11.6428839 8.70165284,11.6123625 8.72537013,11.5806746 C8.82801548,11.5942829 8.93299367,11.5999206 9.03699985,11.6010871 C9.64081887,11.6010871 10.2279191,11.3819937 10.6872959,10.9857982 C11.228128,10.5240886 11.5327592,9.87613981 11.5238166,9.21341636 Z"></path> <path d="M3.66025,9.6121875 C2.720875,9.4678125 2.219,8.8484375 2.467125,6.9328125 L2.4965,6.7021875 C2.70525,5.0384375 2.880875,4.6671875 3.69525,4.7096875 C3.933375,4.7259375 4.207125,4.8721875 4.48275,5.0946875 C4.7365,5.3834375 5.300875,5.7584375 5.619,6.5009375 C5.84775,7.0459375 5.925875,7.4015625 5.959,7.7840625 L6.009625,8.2528125 L6.009625,8.2509375 C6.0765,8.5709375 6.3265,8.8346875 6.672125,8.8934375 C7.117125,8.9734375 7.547125,8.6828125 7.62525,8.2446875 C7.634625,8.1921875 7.835875,3.1471875 7.835875,3.0653125 C7.835875,2.6196875 7.474625,2.2609375 7.019625,2.2528125 C6.5665,2.2509375 6.19775,2.6140625 6.19775,3.0578125 C6.19775,3.0671875 6.19525,3.7628125 6.179625,4.4659375 C5.533375,3.7746875 4.707125,3.1534375 3.779625,3.1009375 C1.300875,2.9678125 1.029625,5.2103125 0.867125,6.5028125 L0.842125,6.7315625 C0.508375,9.2859375 1.37525,10.8996875 3.408375,11.2121875 C5.619,11.5534375 7.094,12.0534375 7.10775,13.3915625 C7.11525,13.9109375 6.83775,14.4459375 6.3515,14.8565625 C5.76775,15.3465625 4.984625,15.5653125 4.245875,15.4459375 C4.01775,15.4109375 3.80275,15.3503125 3.59775,15.2821875 C3.309,15.1240625 2.578375,14.6865625 2.209,14.1821875 C1.8965,13.7565625 1.789,13.0884375 1.755875,12.6646875 C1.759625,12.4759375 1.759625,12.3484375 1.759625,12.3346875 C1.76775,11.8878125 1.404625,11.5246875 0.95525,11.5146875 C0.50025,11.5046875 0.12525,11.8640625 0.11775,12.3115625 C0.11775,12.3271875 0.09775,13.9071875 0.037125,14.8584375 C-0.046625,16.3915625 0.037125,17.6903125 0.037125,17.7471875 C0.07025,18.1921875 0.459,18.5284375 0.912125,18.4990625 C1.363375,18.4753125 1.707125,18.0890625 1.675875,17.6434375 C1.675875,17.6353125 1.634625,17.0065625 1.640875,16.1371875 C2.242125,16.5146875 3.0315,16.8815625 3.97275,17.0396875 C5.199,17.2428125 6.453375,16.8934375 7.412125,16.0828125 C8.269625,15.3603125 8.762125,14.3740625 8.748375,13.3740625 C8.720875,10.3915625 5.300875,9.8684375 3.66025,9.6121875" id="S" fill="#FFFFFF"></path>
<path d="M8.67620534,7.84765006 C8.33774787,7.79496656 8.1571465,7.56848582 8.24696117,6.86785358 L8.25726459,6.78328781 C8.33269337,6.17480309 8.39606909,6.03930346 8.68884161,6.05505019 C8.7745738,6.0604935 8.87333106,6.11473224 8.97189392,6.19560433 C9.06345824,6.30155454 9.26661048,6.43860941 9.38111448,6.70999748 C9.4633474,6.90906724 9.49153599,7.03892915 9.50320023,7.17890008 L9.52147421,7.35036446 L9.52147421,7.34958685 C9.54558031,7.4670069 9.6355894,7.56362572 9.760008,7.58442696 C9.92000254,7.61378197 10.0745538,7.50724854 10.1027424,7.34686519 C10.1062416,7.32781359 10.1789488,5.48272463 10.1789488,5.452592 C10.1789488,5.2900702 10.0485036,5.15806984 9.88500981,5.15534819 C9.72151599,5.15437617 9.58912682,5.28715414 9.58912682,5.44928713 C9.58912682,5.45298081 9.58834921,5.70765013 9.5831003,5.96426349 C9.35039864,5.71153821 9.05276601,5.48466867 8.71916864,5.46503386 C7.8270484,5.41740486 7.72965196,6.23759561 7.67094193,6.71038629 L7.66199935,6.79417444 C7.54185764,7.72828596 7.85329295,8.31771908 8.58541865,8.43241748 C9.38111448,8.55722489 9.91222638,8.73996471 9.91708648,9.22947414 C9.92000254,9.41979571 9.81988445,9.6151718 9.6453096,9.76505733 C9.43477,9.94410347 9.15288411,10.0247812 8.88752256,9.98123465 C8.80509524,9.96859838 8.72791683,9.94604751 8.65404328,9.9213582 C8.5498427,9.86342579 8.28700841,9.70343124 8.15384163,9.51913619 C8.04147608,9.36361294 8.00259527,9.11905263 7.99034781,8.96391819 C7.99190304,8.89432153 7.99209745,8.84863658 7.99209745,8.84338767 C7.99501351,8.68008825 7.86398517,8.54692147 7.70243539,8.54303339 C7.53835836,8.53953412 7.40402516,8.67134007 7.40091469,8.83444508 C7.40091469,8.83988839 7.39352734,9.41804608 7.37233729,9.76622376 C7.34201026,10.3268851 7.37233729,10.8020086 7.37233729,10.8226154 C7.38439035,10.985526 7.52455567,11.1083894 7.68766068,11.0975028 C7.84998808,11.0887546 7.97401787,10.9476172 7.96274243,10.7845122 C7.96274243,10.781985 7.94816213,10.552005 7.95010617,10.2335711 C8.16647789,10.3717924 8.45069663,10.5061256 8.78954291,10.5638636 C9.23084014,10.6379316 9.68244078,10.5102081 10.0271192,10.2137419 C10.3362216,9.94954679 10.5131293,9.58873285 10.5082692,9.22344761 C10.497577,8.13245201 9.26661048,7.94096401 8.67620534,7.84765006"></path>
</g>
</g> </g>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 6.4 KiB

After

Width:  |  Height:  |  Size: 4 KiB

View file

@ -1,6 +1,6 @@
{ {
"dependencies": { "dependencies": {
"selenium-webdriver": "3.6.0", "selenium-webdriver": "3.6.0",
"chromedriver": "2.37.0" "chromedriver": "2.43.1"
} }
} }

View file

@ -61,7 +61,7 @@ class SeleniumHelper {
let driverConfig = { let driverConfig = {
browserName: 'chrome', browserName: 'chrome',
platform: 'macOS 10.13', platform: 'macOS 10.13',
version: '67.0' version: '70.0'
}; };
var driver = new webdriver.Builder() var driver = new webdriver.Builder()
.withCapabilities({ .withCapabilities({

View file

@ -167,6 +167,7 @@ module.exports = {
'process.env.API_HOST': '"' + (process.env.API_HOST || 'https://api.scratch.mit.edu') + '"', '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.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.BACKPACK_HOST': '"' + (process.env.BACKPACK_HOST || 'https://backpack.scratch.mit.edu') + '"',
'process.env.CLOUDDATA_HOST': '"' + (process.env.CLOUDDATA_HOST || 'clouddata.scratch.mit.edu') + '"',
'process.env.PROJECT_HOST': '"' + (process.env.PROJECT_HOST || 'https://projects.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') + '"' 'process.env.SCRATCH_ENV': '"' + (process.env.SCRATCH_ENV || 'development') + '"'
}), }),