Merge branch 'develop' into add-parents

This commit is contained in:
apple502j 2018-10-19 21:14:57 +09:00 committed by GitHub
commit 9cb2c34a1d
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
166 changed files with 5053 additions and 1948 deletions

View file

@ -14,15 +14,30 @@ 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}
- ASSET_HOST_master=https://assets.scratch.mit.edu
- ASSET_HOST_STAGING=https://assets.scratch.ly
- ASSET_HOST_VAR=ASSET_HOST_$TRAVIS_BRANCH
- ASSET_HOST=${!ASSET_HOST_VAR}
- ASSET_HOST=${ASSET_HOST:-$ASSET_HOST_STAGING}
- BACKPACK_HOST_master=https://backpack.scratch.mit.edu
- BACKPACK_HOST_STAGING=https://backpack.scratch.ly
- BACKPACK_HOST_VAR=BACKPACK_HOST_$TRAVIS_BRANCH
- BACKPACK_HOST=${!BACKPACK_HOST_VAR}
- BACKPACK_HOST=${BACKPACK_HOST:-$BACKPACK_HOST_STAGING}
- ROOT_URL_master=https://scratch.mit.edu - ROOT_URL_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
- ROOT_URL=${!ROOT_URL_VAR} - ROOT_URL=${!ROOT_URL_VAR}
- ROOT_URL=${ROOT_URL:-$ROOT_URL_STAGING} - ROOT_URL=${ROOT_URL:-$ROOT_URL_STAGING}
- PROJECT_HOST_master=https://projects.scratch.mit.edu
- PROJECT_HOST_STAGING=https://projects.scratch.ly
- PROJECT_HOST_VAR=PROJECT_HOST_$TRAVIS_BRANCH
- PROJECT_HOST=${!PROJECT_HOST_VAR}
- PROJECT_HOST=${PROJECT_HOST:-$PROJECT_HOST_STAGING}
- PATH=$PATH:$PWD/test/integration/node_modules/chromedriver/bin - PATH=$PATH:$PWD/test/integration/node_modules/chromedriver/bin
- AWS_ACCESS_KEY_ID=$EB_AWS_ACCESS_KEY_ID - AWS_ACCESS_KEY_ID=$EB_AWS_ACCESS_KEY_ID
- AWS_SECRET_ACCESS_KEY=$EB_AWS_SECRET_ACCESS_KEY - AWS_SECRET_ACCESS_KEY=$EB_AWS_SECRET_ACCESS_KEY
@ -69,6 +84,7 @@ addons:
install: install:
- sudo -H pip install -r requirements.txt - sudo -H pip install -r requirements.txt
- npm --production=false install - npm --production=false install
- npm --production=false update
jobs: jobs:
include: include:
- stage: test - stage: test

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,6 +156,23 @@ 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
[scratch-website.parents-l10njson] [scratch-website.parents-l10njson]
source_file = src/views/parents/l10n.json source_file = src/views/parents/l10n.json
source_lang = en source_lang = en

View file

@ -1,11 +1,25 @@
ESLINT=./node_modules/.bin/eslint ESLINT=./node_modules/.bin/eslint
NODE= NODE_OPTIONS=--max_old_space_size=8000 node NODE= NODE_OPTIONS=--max_old_space_size=8000 node
SASSLINT=./node_modules/.bin/sass-lint -v SASSLINT=./node_modules/.bin/sass-lint -v
SCRATCH_DOCKER_CONFIG=./node_modules/.bin/docker_config.sh
S3CMD=s3cmd sync -P --delete-removed --add-header=Cache-Control:no-cache,public,max-age=3600 S3CMD=s3cmd sync -P --delete-removed --add-header=Cache-Control:no-cache,public,max-age=3600
TAP=./node_modules/.bin/tap TAP=./node_modules/.bin/tap
WATCH= NODE_OPTIONS=--max_old_space_size=8000 ./node_modules/.bin/watch WATCH= NODE_OPTIONS=--max_old_space_size=8000 ./node_modules/.bin/watch
WEBPACK= NODE_OPTIONS=--max_old_space_size=8000 ./node_modules/.bin/webpack WEBPACK= NODE_OPTIONS=--max_old_space_size=8000 ./node_modules/.bin/webpack
# ------------------------------------
$(SCRATCH_DOCKER_CONFIG):
npm install scratch-docker
docker-up: $(SCRATCH_DOCKER_CONFIG)
$(SCRATCH_DOCKER_CONFIG) network create
docker-compose up
docker-down:
docker-compose down
# ------------------------------------ # ------------------------------------
build: build:

View file

@ -81,14 +81,17 @@ To stop the process that is making the site available to your web browser (creat
`npm start` can be configured with the following environment variables `npm start` can be configured with the following environment variables
| 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 |
| `SENTRY_DSN` | `''` | DSN for Sentry | | `ASSETS_HOST` | `https://assets.scratch.mit.edu` | Hostname for asset requests |
| `FALLBACK` | `''` | Pass-through location for old site | | `BACKPACK_HOST` | `https://backpack.scratch.mit.edu` | Hostname for backpack requests |
| `GA_TRACKER` | `''` | Where to log Google Analytics data | | `PROJECTS_HOST` | `https://projects.scratch.mit.edu` | Hostname for project requests |
| `NODE_ENV` | `null` | If not `production`, app acts like development | | `SENTRY_DSN` | `''` | DSN for Sentry |
| `PORT` | `8333` | Port for devserver (http://localhost:XXXX) | | `FALLBACK` | `''` | Pass-through location for old site |
| `GA_TRACKER` | `''` | Where to log Google Analytics data |
| `NODE_ENV` | `null` | If not `production`, app acts like development |
| `PORT` | `8333` | Port for devserver (http://localhost:XXXX) |
**NOTE:** Because by default `API_HOST=https://api.scratch.mit.edu`, please be aware that, by default, you will be seeing and interacting with real data on the Scratch website. **NOTE:** Because by default `API_HOST=https://api.scratch.mit.edu`, please be aware that, by default, you will be seeing and interacting with real data on the Scratch website.

View file

@ -4,8 +4,9 @@ volumes:
runtime_data: runtime_data:
networks: networks:
scratch-api_scratch_network: default:
external: true external:
name: scratchapi_scratch_network
services: services:
app: app:
@ -13,7 +14,7 @@ services:
hostname: scratch-www-app hostname: scratch-www-app
environment: environment:
- API_HOST=http://localhost:8491 - API_HOST=http://localhost:8491
- FALLBACK=http://localhost:8080 - FALLBACK=http://scratchr2-app:8080
- USE_DOCKER_WATCHOPTIONS=true - USE_DOCKER_WATCHOPTIONS=true
build: build:
context: ./ context: ./
@ -35,5 +36,3 @@ services:
- runtime_data:/runtime - runtime_data:/runtime
ports: ports:
- "8333:8333" - "8333:8333"
networks:
- scratch-api_scratch_network

View file

@ -31,6 +31,7 @@
"lodash.defaults": "4.0.1", "lodash.defaults": "4.0.1",
"newrelic": "1.25.4", "newrelic": "1.25.4",
"raven": "0.10.0", "raven": "0.10.0",
"scratch-docker": "^1.0.2",
"scratch-parser": "^4.2.0", "scratch-parser": "^4.2.0",
"scratch-storage": "^0.5.1" "scratch-storage": "^0.5.1"
}, },
@ -77,7 +78,6 @@
"lodash.merge": "3.3.2", "lodash.merge": "3.3.2",
"lodash.omit": "3.1.0", "lodash.omit": "3.1.0",
"lodash.range": "3.0.1", "lodash.range": "3.0.1",
"lodash.truncate": "4.4.2",
"minilog": "2.0.8", "minilog": "2.0.8",
"node-dir": "0.1.16", "node-dir": "0.1.16",
"node-sass": "4.6.1", "node-sass": "4.6.1",
@ -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": "latest", "scratch-gui": "develop",
"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

@ -7,9 +7,9 @@ $base-bg: $ui-white;
display: inline-block; display: inline-block;
border: 1px solid $ui-border; border: 1px solid $ui-border;
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

@ -65,7 +65,7 @@
margin: 0 0 -3rem -4rem; margin: 0 0 -3rem -4rem;
} }
.row { .row {
margin-bottom: 1.2rem; margin-bottom: 1.2rem;
&.has-error { &.has-error {
@ -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;
@ -33,8 +34,8 @@
} }
input { input {
// 100% minus border and padding
margin-bottom: 12px; margin-bottom: 12px;
// 100% minus border and padding
width: calc(100% - 30px); width: calc(100% - 30px);
} }
@ -88,8 +89,4 @@
content: ""; content: "";
} }
} }
@media only screen and (max-width: $tablet - 1) {
min-width: 160px;
}
} }

View file

@ -10,8 +10,16 @@ class ExtensionLanding extends React.Component {
'onSetOS' 'onSetOS'
]); ]);
// @todo use bowser for browser detection
let detectedOS = OS_ENUM.WINDOWS;
if (window.navigator && window.navigator.platform) {
if (window.navigator.platform === 'MacIntel') {
detectedOS = OS_ENUM.MACOS;
}
}
this.state = { this.state = {
OS: OS_ENUM.WINDOWS OS: detectedOS
}; };
} }

View file

@ -10,7 +10,11 @@
padding: 4rem 0; padding: 4rem 0;
} }
h2 { h1 {
font-size: 2rem;
}
h1, h2 {
margin-bottom: 1rem; margin-bottom: 1rem;
} }
@ -46,6 +50,7 @@
} }
.screenshot { .screenshot {
border: 1px solid $ui-border;
border-radius: .5rem; border-radius: .5rem;
} }
@ -84,15 +89,15 @@
margin-bottom: 5rem; margin-bottom: 5rem;
align-items: flex-start; align-items: flex-start;
h2 { h1, h2 {
display: flex; display: flex;
margin-bottom: 2rem; margin-bottom: 2rem;
color: $ui-white; color: $ui-white;
}
h2 img { img {
padding-right: .5rem; padding-right: .5rem;
max-height: 100%; max-height: 100%;
}
} }
span { span {

View file

@ -35,7 +35,10 @@ const InstallScratchLink = ({
<FormattedMessage id="installScratchLink.windowsDownload" /> : <FormattedMessage id="installScratchLink.windowsDownload" /> :
<FormattedMessage id="installScratchLink.macosDownload" /> <FormattedMessage id="installScratchLink.macosDownload" />
} }
<img src="/svgs/extensions/download-white.svg" /> <img
alt=""
src="/svgs/extensions/download-white.svg"
/>
</button> </button>
</a> </a>
</Step> </Step>
@ -50,6 +53,7 @@ const InstallScratchLink = ({
</span> </span>
<div className="step-image"> <div className="step-image">
<img <img
alt=""
className="screenshot" className="screenshot"
src={`/images/scratchlink/${ src={`/images/scratchlink/${
currentOS === OS_ENUM.WINDOWS ? 'windows' : 'mac' currentOS === OS_ENUM.WINDOWS ? 'windows' : 'mac'

View file

@ -3,12 +3,15 @@ const React = require('react');
const ProjectCard = props => ( const ProjectCard = props => (
<a <a
download
className="project-card" className="project-card"
href={props.cardUrl} href={props.cardUrl}
target="_blank"
> >
<div className="project-card-image"> <div className="project-card-image">
<img src={props.imageSrc} /> <img
alt={props.imageAlt}
src={props.imageSrc}
/>
</div> </div>
<div className="project-card-info"> <div className="project-card-info">
<h4>{props.title}</h4> <h4>{props.title}</h4>
@ -20,6 +23,7 @@ const ProjectCard = props => (
ProjectCard.propTypes = { ProjectCard.propTypes = {
cardUrl: PropTypes.string, cardUrl: PropTypes.string,
description: PropTypes.string, description: PropTypes.string,
imageAlt: PropTypes.string,
imageSrc: PropTypes.string, imageSrc: PropTypes.string,
title: PropTypes.string title: PropTypes.string
}; };

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

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

View file

@ -7,7 +7,7 @@
$thumbnail-width: 220px; $thumbnail-width: 220px;
$thumbnail-inner-width: 204px; $thumbnail-inner-width: 204px;
$project-height: 208px; $project-height: 208px;
$gallery-height: 164px; $gallery-height: 164px;
@ -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

@ -3,7 +3,7 @@ const connect = require('react-redux').connect;
const PropTypes = require('prop-types'); const PropTypes = require('prop-types');
const React = require('react'); const React = require('react');
const sessionActions = require('../../redux/session.js'); const navigationActions = require('../../redux/navigation.js');
const IframeModal = require('../modal/iframe/modal.jsx'); const IframeModal = require('../modal/iframe/modal.jsx');
const Registration = require('../registration/registration.jsx'); const Registration = require('../registration/registration.jsx');
@ -15,10 +15,7 @@ class Intro extends React.Component {
super(props); super(props);
bindAll(this, [ bindAll(this, [
'handleShowVideo', 'handleShowVideo',
'handleCloseVideo', 'handleCloseVideo'
'handleJoinClick',
'handleCloseRegistration',
'handleCompleteRegistration'
]); ]);
this.state = { this.state = {
videoOpen: false videoOpen: false
@ -30,17 +27,6 @@ class Intro extends React.Component {
handleCloseVideo () { handleCloseVideo () {
this.setState({videoOpen: false}); this.setState({videoOpen: false});
} }
handleJoinClick (e) {
e.preventDefault();
this.setState({registrationOpen: true});
}
handleCloseRegistration () {
this.setState({registrationOpen: false});
}
handleCompleteRegistration () {
this.props.dispatch(sessionActions.refreshSession());
this.closeRegistration();
}
render () { render () {
return ( return (
<div className="intro"> <div className="intro">
@ -92,7 +78,7 @@ class Intro extends React.Component {
<a <a
className="sprite sprite-3" className="sprite sprite-3"
href="#" href="#"
onClick={this.handleJoinClick} onClick={this.props.handleOpenRegistration}
> >
<img <img
alt="Gobo" alt="Gobo"
@ -111,10 +97,7 @@ class Intro extends React.Component {
<div className="text subtext">{this.props.messages['intro.itsFree']}</div> <div className="text subtext">{this.props.messages['intro.itsFree']}</div>
</a> </a>
<Registration <Registration
isOpen={this.state.registrationOpen}
key="registration" key="registration"
onRegistrationDone={this.handleCompleteRegistration}
onRequestClose={this.handleCloseRegistration}
/> />
</div> </div>
<div <div
@ -160,7 +143,7 @@ class Intro extends React.Component {
} }
Intro.propTypes = { Intro.propTypes = {
dispatch: PropTypes.func.isRequired, handleOpenRegistration: PropTypes.func,
messages: PropTypes.shape({ messages: PropTypes.shape({
'intro.aboutScratch': PropTypes.string, 'intro.aboutScratch': PropTypes.string,
'intro.forEducators': PropTypes.string, 'intro.forEducators': PropTypes.string,
@ -194,6 +177,17 @@ const mapStateToProps = state => ({
session: state.session session: state.session
}); });
const ConnectedIntro = connect(mapStateToProps)(Intro); const mapDispatchToProps = dispatch => ({
handleOpenRegistration: event => {
event.preventDefault();
dispatch(navigationActions.handleOpenRegistration());
}
});
const ConnectedIntro = connect(
mapStateToProps,
mapDispatchToProps
)(Intro);
module.exports = ConnectedIntro; module.exports = ConnectedIntro;

View file

@ -0,0 +1,60 @@
const React = require('react');
const connect = require('react-redux').connect;
const FormattedMessage = require('react-intl').FormattedMessage;
const PropTypes = require('prop-types');
const injectIntl = require('react-intl').injectIntl;
const intlShape = require('react-intl').intlShape;
const navigationActions = require('../../redux/navigation.js');
const Modal = require('../modal/base/modal.jsx');
const CanceledDeletionModal = ({
canceledDeletionOpen,
handleCloseCanceledDeletion,
intl
}) => (
<Modal
isOpen={canceledDeletionOpen}
style={{
content: {
padding: 15
}
}}
onRequestClose={handleCloseCanceledDeletion}
>
<h4><FormattedMessage id="general.noDeletionTitle" /></h4>
<p>
<FormattedMessage
id="general.noDeletionDescription"
values={{
resetLink: <a href="/accounts/password_reset/">
{intl.formatMessage({id: 'general.noDeletionLink'})}
</a>
}}
/>
</p>
</Modal>
);
CanceledDeletionModal.propTypes = {
canceledDeletionOpen: PropTypes.bool,
handleCloseCanceledDeletion: PropTypes.func,
intl: intlShape
};
const mapStateToProps = state => ({
canceledDeletionOpen: state.navigation && state.navigation.canceledDeletionOpen
});
const mapDispatchToProps = dispatch => ({
handleCloseCanceledDeletion: () => {
dispatch(navigationActions.setCanceledDeletionOpen(false));
}
});
const ConnectedCanceledDeletionModal = connect(
mapStateToProps,
mapDispatchToProps
)(CanceledDeletionModal);
module.exports = injectIntl(ConnectedCanceledDeletionModal);

View file

@ -0,0 +1,34 @@
const PropTypes = require('prop-types');
const React = require('react');
const connect = require('react-redux').connect;
const Login = require('./login.jsx');
require('./login-dropdown.scss');
const ConnectedLogin = ({
error,
onLogIn
}) => (
<Login
error={error}
key="login-dropdown-presentation"
onLogIn={onLogIn}
/>
);
ConnectedLogin.propTypes = {
error: PropTypes.string,
onLogIn: PropTypes.func
};
const mapStateToProps = state => ({
error: state.navigation && state.navigation.loginError
});
const mapDispatchToProps = () => ({});
module.exports = connect(
mapStateToProps,
mapDispatchToProps
)(ConnectedLogin);

View file

@ -0,0 +1,50 @@
const PropTypes = require('prop-types');
const React = require('react');
const connect = require('react-redux').connect;
const navigationActions = require('../../redux/navigation.js');
const Dropdown = require('../dropdown/dropdown.jsx');
const ConnectedLogin = require('./connected-login.jsx');
require('./login-dropdown.scss');
const LoginDropdown = ({
isOpen,
onClose,
onLogIn
}) => (
<Dropdown
className={'with-arrow'}
isOpen={isOpen}
key="login-dropdown"
onRequestClose={onClose}
>
<ConnectedLogin
onLogIn={onLogIn}
/>
</Dropdown>
);
LoginDropdown.propTypes = {
isOpen: PropTypes.bool,
onClose: PropTypes.func,
onLogIn: PropTypes.func
};
const mapStateToProps = state => ({
isOpen: state.navigation && state.navigation.loginOpen
});
const mapDispatchToProps = dispatch => ({
onClose: () => {
dispatch(navigationActions.setLoginOpen(false));
},
onLogIn: (formData, callback) => {
dispatch(navigationActions.handleLogIn(formData, callback));
}
});
module.exports = connect(
mapStateToProps,
mapDispatchToProps
)(LoginDropdown);

View file

View file

@ -3,8 +3,6 @@ const FormattedMessage = require('react-intl').FormattedMessage;
const PropTypes = require('prop-types'); const PropTypes = require('prop-types');
const React = require('react'); const React = require('react');
const log = require('../../lib/log.js');
const Form = require('../forms/form.jsx'); const Form = require('../forms/form.jsx');
const Input = require('../forms/input.jsx'); const Input = require('../forms/input.jsx');
const Button = require('../forms/button.jsx'); const Button = require('../forms/button.jsx');
@ -24,8 +22,7 @@ class Login extends React.Component {
} }
handleSubmit (formData) { handleSubmit (formData) {
this.setState({waiting: true}); this.setState({waiting: true});
this.props.onLogIn(formData, err => { this.props.onLogIn(formData, () => {
if (err) log.error(err);
this.setState({waiting: false}); this.setState({waiting: false});
}); });
} }
@ -48,9 +45,6 @@ class Login extends React.Component {
key="usernameInput" key="usernameInput"
maxLength="30" maxLength="30"
name="username" name="username"
ref={input => {
this.username = input;
}}
type="text" type="text"
/> />
<label <label
@ -63,9 +57,6 @@ class Login extends React.Component {
required required
key="passwordInput" key="passwordInput"
name="password" name="password"
ref={input => {
this.password = input;
}}
type="password" type="password"
/> />
{this.state.waiting ? [ {this.state.waiting ? [
@ -75,7 +66,10 @@ class Login extends React.Component {
key="submitButton" key="submitButton"
type="submit" type="submit"
> >
<Spinner /> <Spinner
className="spinner"
color="blue"
/>
</Button> </Button>
] : [ ] : [
<Button <Button

View file

@ -2,6 +2,26 @@
.login { .login {
padding: 10px; padding: 10px;
width: 200px;
line-height: 1.5rem;
white-space: normal; // override any parent, such as in gui, who sets nowrap
color: $type-white;
font-size: .8125rem;
.button {
padding: .75em;
}
.row {
margin-bottom: 1.25rem;
}
.input {
margin-bottom: 12px;
// 100% minus border and padding
width: calc(100% - 30px);
height: 2.25rem;
}
label { label {
padding-top: 5px; padding-top: 5px;
@ -15,7 +35,7 @@
.spinner { .spinner {
margin: 0 .8rem; margin: 0 .8rem;
width: 1rem; width: 1rem;
height: 1rem; vertical-align: middle;
} }
.submit-button { .submit-button {
@ -24,13 +44,19 @@
a { a {
margin-top: 15px; margin-top: 15px;
color: $ui-white;
&:link,
&:visited,
&:active {
color: $ui-white;
}
&:hover { &:hover {
background-color: transparent; background-color: transparent;
} }
} }
.error { .error {
border: 1px solid $active-dark-gray; border: 1px solid $active-dark-gray;
border-radius: 5px; border-radius: 5px;

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

@ -0,0 +1,48 @@
const React = require('react');
const PropTypes = require('prop-types');
/**
* Higher-order component for building an animated studio button
* it is used to decorate the onToggleStudio function with noticing
* when the button has first been clicked.
* This is needed so the buttons don't play the animation when they are
* first rendered but when they are first clicked.
* @param {React.Component} Component a studio button component
* @return {React.Component} a wrapped studio button component
*/
const AnimateHOC = Component => {
class WrappedComponent extends React.Component {
constructor (props) {
super(props);
this.state = {
wasClicked: false
};
this.handleClick = this.handleClick.bind(this);
}
handleClick () {
this.setState({ // else tell the state that the button has been clicked
wasClicked: true
}, () => this.props.onClick(this.props.id)); // callback after state has been updated
}
render () {
const {wasClicked} = this.state;
return (<Component
{...this.props}
wasClicked={wasClicked}
onClick={this.handleClick}
/>);
}
}
WrappedComponent.propTypes = {
id: PropTypes.number,
onClick: PropTypes.func
};
return WrappedComponent;
};
module.exports = AnimateHOC;

View file

@ -1,53 +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%;
line-height: 1.5rem;
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;
@ -90,34 +78,36 @@
pointer-events: none; /* pass clicks through to buttons underneath */ pointer-events: none; /* pass clicks through to buttons underneath */
} }
.studio-selector-button { .studio-selector-button {
display: flex; display: flex;
position: relative; position: relative;
margin: .21875rem .21875rem; transition: all .5s;
margin: .21875rem;
border-radius: .5rem; border-radius: .5rem;
background-color: $ui-white; background-color: $ui-white;
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 overflow: hidden;
but in practice, our css seems to vertically align text to top, where text-overflow: ellipsis;
invision spec aligned to middle. white-space: nowrap;
*/
margin: .575rem 2.18375rem .175rem .6875rem;
width: 13.3125rem;
height: 1rem; /* diff from spec, in case we ever do valign to middle */
line-height: 1.25rem;
font-family: "Helvetica Neue"; font-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 {
@ -140,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;
@ -161,30 +151,30 @@
background-color: $ui-blue; background-color: $ui-blue;
} }
.studio-status-icon-plus-img { .studio-status-icon-plus-img,
.studio-status-icon-checkmark-img {
animation-direction: normal;
width: 1.4rem; width: 1.4rem;
height: 1.4rem; height: 1.4rem;
transform-origin: center;
} }
.studio-status-icon--img { .studio-status-icon-with-animation {
width: 1.4rem; animation-name: bump;
height: 1.4rem; animation-duration: .25s;
animation-timing-function: cubic-bezier(.3, -3, .6, 3);
animation-iteration-count: 1;
} }
.action-button-text .spinner-smooth { @keyframes bump {
margin: .2125rem auto; 0% {
width: 1.875rem; transform: scale(0);
height: 1rem; opacity: 0;
} -webkit-transform: scale(0);
}
.studio-status-icon .spinner-smooth { 100% {
position: unset; /* don't understand why neither relative nor absolute work */ transform: scale(1);
} opacity: 1;
-webkit-transform: scale(1);
.studio-status-icon .spinner-smooth .circle { }
/* overlay spinner on circle */
position: absolute;
margin: .1875rem; /* stay within boundaries of circle */
width: 75%; /* stay within boundaries of circle */
height: 75%; /* stay within boundaries of circle */
} }

View file

@ -31,76 +31,75 @@ const AddToStudioModalPresentation = ({
includesProject={studio.includesProject} includesProject={studio.includesProject}
key={studio.id} key={studio.id}
title={studio.title} title={studio.title}
onToggleStudio={onToggleStudio} onClick={onToggleStudio}
/> />
)); ));
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 mode="smooth" /> </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

@ -1,29 +1,36 @@
const truncateAtWordBoundary = require('../../../lib/truncate').truncateAtWordBoundary;
const PropTypes = require('prop-types'); const PropTypes = require('prop-types');
const React = require('react'); const React = require('react');
const classNames = require('classnames'); const classNames = require('classnames');
const Spinner = require('../../spinner/spinner.jsx'); const Spinner = require('../../spinner/spinner.jsx');
const AnimateHOC = require('./animate-hoc.jsx');
require('./modal.scss'); require('./modal.scss');
const StudioButton = ({ const StudioButton = ({
hasRequestOutstanding, hasRequestOutstanding,
id,
includesProject, includesProject,
title, title,
onToggleStudio onClick,
wasClicked
}) => { }) => {
const checkmark = ( const checkmark = (
<img <img
alt="checkmark-icon" alt="checkmark-icon"
className="studio-status-icon-checkmark-img" className={classNames(
'studio-status-icon-checkmark-img',
{'studio-status-icon-with-animation': wasClicked}
)}
src="/svgs/modal/confirm.svg" src="/svgs/modal/confirm.svg"
/> />
); );
const plus = ( const plus = (
<img <img
alt="plus-icon" alt="plus-icon"
className="studio-status-icon-plus-img" className={classNames(
'studio-status-icon-plus-img',
{'studio-status-icon-with-animation': wasClicked}
)}
src="/svgs/modal/add.svg" src="/svgs/modal/add.svg"
/> />
); );
@ -35,8 +42,7 @@ const StudioButton = ({
{'studio-selector-button-selected': {'studio-selector-button-selected':
includesProject && !hasRequestOutstanding} includesProject && !hasRequestOutstanding}
)} )}
data-id={id} onClick={onClick}
onClick={onToggleStudio}
> >
<div <div
className={classNames( className={classNames(
@ -44,17 +50,18 @@ const StudioButton = ({
{'studio-selector-button-text-selected': includesProject || hasRequestOutstanding}, {'studio-selector-button-text-selected': includesProject || hasRequestOutstanding},
{'studio-selector-button-text-unselected': !includesProject && !hasRequestOutstanding} {'studio-selector-button-text-unselected': !includesProject && !hasRequestOutstanding}
)} )}
title={title}
> >
{truncateAtWordBoundary(title, 25)} {title}
</div> </div>
<div <div
className={classNames( className={classNames(
'studio-status-icon', 'studio-status-icon',
{'studio-status-icon-unselected': !includesProject} {'studio-status-icon-unselected': !includesProject && !hasRequestOutstanding}
)} )}
> >
{(hasRequestOutstanding ? {(hasRequestOutstanding ?
(<Spinner mode="smooth" />) : <Spinner /> :
(includesProject ? checkmark : plus))} (includesProject ? checkmark : plus))}
</div> </div>
</div> </div>
@ -63,10 +70,10 @@ const StudioButton = ({
StudioButton.propTypes = { StudioButton.propTypes = {
hasRequestOutstanding: PropTypes.bool, hasRequestOutstanding: PropTypes.bool,
id: PropTypes.number,
includesProject: PropTypes.bool, includesProject: PropTypes.bool,
onToggleStudio: PropTypes.func, onClick: PropTypes.func,
title: PropTypes.string title: PropTypes.string,
wasClicked: PropTypes.bool
}; };
module.exports = StudioButton; module.exports = AnimateHOC(StudioButton);

View file

@ -7,7 +7,7 @@ const ReactModal = require('react-modal');
require('./modal.scss'); require('./modal.scss');
ReactModal.setAppElement(document.getElementById('view')); ReactModal.setAppElement(document.getElementById('app'));
/** /**
* Container for pop up windows (See: registration window) * Container for pop up windows (See: registration window)
@ -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('view')} 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,30 +67,27 @@ $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;
margin: 1.125rem .8275rem .9375rem .8275rem; margin: 1.125rem .8275rem .9375rem .8275rem;
line-height: 1.5rem;
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
compatible with elements (like validation popups) that need to bleed
past modal boundary. This class can be used to force modal button
row to appear to contain overflow. */
.action-buttons-overflow-fix {
margin-bottom: .9375rem;
} }
.action-button { .action-button {
@ -83,3 +104,62 @@ $modal-close-size: 1rem;
.action-button-text { .action-button-text {
display: flex; display: flex;
} }
.action-button.disabled {
background-color: $active-dark-gray;
}
.error-text
{
display: block;
border: 1px solid $active-gray;
border-radius: 5px;
background-color: $ui-orange;
padding: 1rem;
min-height: 1rem;
overflow: visible;
color: $type-white;
}
.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="comments.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="comments.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

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

View file

@ -1,66 +1,65 @@
@import "../../../colors"; @import "../../../colors";
@import "../../../frameless"; @import "../../../frameless";
.mod-report * {
box-sizing: border-box;
}
.mod-report {
margin: 100px auto;
outline: none;
padding: 0;
width: 30rem;
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%;
line-height: 1.5rem;
font-size: .875rem; .instructions {
line-height: 1.5rem;
}
.received {
margin: 0 auto;
width: 90%;
text-align: center;
line-height: 1.65rem;
.received-header {
font-weight: bold;
}
}
.error-text {
margin-top: .9375rem;
}
.validation-message { .validation-message {
$arrow-border-width: 1rem; $arrow-border-width: 1rem;
display: block; display: block;
position: absolute; position: relative;
top: 0; z-index: 1;
left: 0; margin-top: $arrow-border-width;
transform: translate(23.5rem, 0);
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;
max-width: 18.75rem; min-width: 12rem;
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 */
&: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;
@ -71,6 +70,10 @@
height: $arrow-border-width; height: $arrow-border-width;
content: ""; content: "";
@media #{$medium-and-smaller} {
display: none;
}
} }
} }
} }
@ -78,3 +81,16 @@
.report-modal-field { .report-modal-field {
position: relative; position: relative;
} }
.form-group.has-error {
.textarea, select {
margin: 0;
border: 1px solid $ui-orange;
}
margin-bottom: 1rem;
}
.report-text .textarea {
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

@ -11,7 +11,7 @@
align-items: center; align-items: center;
list-style-type: none; list-style-type: none;
} }
.li-left-ul.mod-2018 { .li-left-ul.mod-2018 {
padding-left: 0; padding-left: 0;
} }
@ -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

@ -0,0 +1,102 @@
const classNames = require('classnames');
const FormattedMessage = require('react-intl').FormattedMessage;
const injectIntl = require('react-intl').injectIntl;
const PropTypes = require('prop-types');
const React = require('react');
const Avatar = require('../../avatar/avatar.jsx');
const Dropdown = require('../../dropdown/dropdown.jsx');
require('./accountnav.scss');
const AccountNav = ({
classroomId,
isEducator,
isOpen,
isStudent,
profileUrl,
thumbnailUrl,
username,
onClick,
onClickLogout,
onClose
}) => (
<div className="account-nav">
<a
className={classNames([
'ignore-react-onclickoutside',
'user-info',
{open: isOpen}
])}
href="#"
onClick={onClick}
>
<Avatar
alt=""
src={thumbnailUrl}
/>
<span className="profile-name">
{username}
</span>
</a>
<Dropdown
as="ul"
className={process.env.SCRATCH_ENV}
isOpen={isOpen}
onRequestClose={onClose}
>
<li>
<a href={profileUrl}>
<FormattedMessage id="general.profile" />
</a>
</li>
<li>
<a href="/mystuff/">
<FormattedMessage id="general.myStuff" />
</a>
</li>
{isEducator ? [
<li key="my-classes-li">
<a href="/educators/classes/">
<FormattedMessage id="general.myClasses" />
</a>
</li>
] : []}
{isStudent ? [
<li key="my-class-li">
<a href={`/classes/${classroomId}/`}>
<FormattedMessage id="general.myClass" />
</a>
</li>
] : []}
<li>
<a href="/accounts/settings/">
<FormattedMessage id="general.accountSettings" />
</a>
</li>
<li className="divider">
<a
href="#"
onClick={onClickLogout}
>
<FormattedMessage id="navigation.signOut" />
</a>
</li>
</Dropdown>
</div>
);
AccountNav.propTypes = {
classroomId: PropTypes.string,
isEducator: PropTypes.bool,
isOpen: PropTypes.bool,
isStudent: PropTypes.bool,
onClick: PropTypes.func,
onClickLogout: PropTypes.func,
onClose: PropTypes.func,
profileUrl: PropTypes.string,
thumbnailUrl: PropTypes.string,
username: PropTypes.string
};
module.exports = injectIntl(AccountNav);

View file

@ -0,0 +1,98 @@
@import "../../../colors";
@import "../../../frameless";
.account-nav {
.user-info {
display: inline-block;
padding: 14px 15px 4px 15px;
max-width: 260px;
height: 33px;
overflow: hidden;
text-decoration: none;
text-overflow: ellipsis;
white-space: nowrap;
color: $type-white;
font-size: .8125rem;
font-weight: normal;
.avatar {
margin-right: 10px;
border-radius: 3px;
width: 24px;
height: 24px;
vertical-align: middle;
}
&:hover {
background-color: $active-gray;
}
&.open {
background-color: $active-gray;
}
&:after {
display: inline-block;
margin-left: 8px;
background-image: url("/images/dropdown.png");
background-repeat: no-repeat;
background-position: center center;
background-size: 50%;
width: 20px;
height: 20px;
vertical-align: middle;
content: " ";
}
}
.dropdown {
top: 50px;
padding: 0;
padding-top: 5px;
width: 100%;
box-sizing: border-box;
}
}
//4 columns
@media #{$small} {
.account-nav {
margin-left: 0;
.user-info {
.avatar {
margin-right: 0;
}
&:after {
display: none;
}
}
}
}
//6 columns
@media #{$medium} {
.account-nav {
margin-left: 0;
.user-info {
.avatar {
margin-right: 0;
}
&:after {
display: none;
}
}
}
}
//8 columns
@media #{$intermediate} {
.account-nav {
margin-left: 0;
}
}

View file

@ -8,19 +8,17 @@ const PropTypes = require('prop-types');
const React = require('react'); const React = require('react');
const messageCountActions = require('../../../redux/message-count.js'); const messageCountActions = require('../../../redux/message-count.js');
const navigationActions = require('../../../redux/navigation.js');
const sessionActions = require('../../../redux/session.js'); const sessionActions = require('../../../redux/session.js');
const api = require('../../../lib/api');
const Avatar = require('../../avatar/avatar.jsx');
const Button = require('../../forms/button.jsx'); const Button = require('../../forms/button.jsx');
const Dropdown = require('../../dropdown/dropdown.jsx');
const Form = require('../../forms/form.jsx'); const Form = require('../../forms/form.jsx');
const Input = require('../../forms/input.jsx'); const Input = require('../../forms/input.jsx');
const log = require('../../../lib/log.js'); const LoginDropdown = require('../../login/login-dropdown.jsx');
const Login = require('../../login/login.jsx'); const CanceledDeletionModal = require('../../login/canceled-deletion-modal.jsx');
const Modal = require('../../modal/base/modal.jsx');
const NavigationBox = require('../base/navigation.jsx'); const NavigationBox = require('../base/navigation.jsx');
const Registration = require('../../registration/registration.jsx'); const Registration = require('../../registration/registration.jsx');
const AccountNav = require('./accountnav.jsx');
require('./navigation.scss'); require('./navigation.scss');
@ -29,34 +27,16 @@ class Navigation extends React.Component {
super(props); super(props);
bindAll(this, [ bindAll(this, [
'getProfileUrl', 'getProfileUrl',
'handleJoinClick',
'handleLoginClick',
'handleCloseLogin',
'handleLogIn',
'handleLogOut',
'handleAccountNavClick',
'handleCloseAccountNav',
'showCanceledDeletion',
'handleCloseCanceledDeletion',
'handleCloseRegistration',
'handleCompleteRegistration',
'handleSearchSubmit' 'handleSearchSubmit'
]); ]);
this.state = { this.state = {
accountNavOpen: false,
canceledDeletionOpen: false,
loginOpen: false,
loginError: null,
registrationOpen: false,
messageCountIntervalId: -1 // javascript method interval id for getting messsage count. messageCountIntervalId: -1 // javascript method interval id for getting messsage count.
}; };
} }
componentDidMount () { componentDidMount () {
if (this.props.session.session.user) { if (this.props.user) {
const intervalId = setInterval(() => { const intervalId = setInterval(() => {
this.props.dispatch( this.props.getMessageCount(this.props.user.username);
messageCountActions.getCount(this.props.session.session.user.username)
);
}, 120000); // check for new messages every 2 mins. }, 120000); // check for new messages every 2 mins.
this.setState({ // eslint-disable-line react/no-did-mount-set-state this.setState({ // eslint-disable-line react/no-did-mount-set-state
messageCountIntervalId: intervalId messageCountIntervalId: intervalId
@ -64,16 +44,11 @@ class Navigation extends React.Component {
} }
} }
componentDidUpdate (prevProps) { componentDidUpdate (prevProps) {
if (prevProps.session.session.user !== this.props.session.session.user) { if (prevProps.user !== this.props.user) {
this.setState({ // eslint-disable-line react/no-did-update-set-state this.props.closeAccountMenus();
loginOpen: false, if (this.props.user) {
accountNavOpen: false
});
if (this.props.session.session.user) {
const intervalId = setInterval(() => { const intervalId = setInterval(() => {
this.props.dispatch( this.props.getMessageCount(this.props.user.username);
messageCountActions.getCount(this.props.session.session.user.username)
);
}, 120000); // check for new messages every 2 mins. }, 120000); // check for new messages every 2 mins.
this.setState({ // eslint-disable-line react/no-did-update-set-state this.setState({ // eslint-disable-line react/no-did-update-set-state
messageCountIntervalId: intervalId messageCountIntervalId: intervalId
@ -81,7 +56,7 @@ class Navigation extends React.Component {
} else { } else {
// clear message count check, and set to default id. // clear message count check, and set to default id.
clearInterval(this.state.messageCountIntervalId); clearInterval(this.state.messageCountIntervalId);
this.props.dispatch(messageCountActions.setCount(0)); this.props.setMessageCount(0);
this.setState({ // eslint-disable-line react/no-did-update-set-state this.setState({ // eslint-disable-line react/no-did-update-set-state
messageCountIntervalId: -1 messageCountIntervalId: -1
}); });
@ -92,102 +67,25 @@ class Navigation extends React.Component {
// clear message interval if it exists // clear message interval if it exists
if (this.state.messageCountIntervalId !== -1) { if (this.state.messageCountIntervalId !== -1) {
clearInterval(this.state.messageCountIntervalId); clearInterval(this.state.messageCountIntervalId);
this.props.dispatch(messageCountActions.setCount(0)); this.props.setMessageCount(0);
this.setState({ this.setState({
messageCountIntervalId: -1 messageCountIntervalId: -1
}); });
} }
} }
getProfileUrl () { getProfileUrl () {
if (!this.props.session.session.user) return; if (!this.props.user) return;
return `/users/${this.props.session.session.user.username}/`; return `/users/${this.props.user.username}/`;
}
handleJoinClick (e) {
e.preventDefault();
this.setState({registrationOpen: true});
}
handleLoginClick (e) {
e.preventDefault();
this.setState({loginOpen: !this.state.loginOpen});
}
handleCloseLogin () {
this.setState({loginOpen: false});
}
handleLogIn (formData, callback) {
this.setState({loginError: null});
formData.useMessages = true;
api({
method: 'post',
host: '',
uri: '/accounts/login/',
json: formData,
useCsrf: true
}, (err, body) => {
if (err) this.setState({loginError: err.message});
if (body) {
body = body[0];
if (body.success) {
this.handleCloseLogin();
body.messages.map(message => { // eslint-disable-line array-callback-return
if (message.message === 'canceled-deletion') {
this.showCanceledDeletion();
}
});
this.props.dispatch(sessionActions.refreshSession());
} else {
if (body.redirect) {
window.location = body.redirect;
}
// Update login error message to a friendlier one if it exists
this.setState({loginError: body.msg});
}
}
// JS error already logged by api mixin
callback();
});
}
handleLogOut (e) {
e.preventDefault();
api({
host: '',
method: 'post',
uri: '/accounts/logout/',
useCsrf: true
}, err => {
if (err) log.error(err);
this.handleCloseLogin();
window.location = '/';
});
}
handleAccountNavClick (e) {
e.preventDefault();
this.setState({accountNavOpen: true});
}
handleCloseAccountNav () {
this.setState({accountNavOpen: false});
}
showCanceledDeletion () {
this.setState({canceledDeletionOpen: true});
}
handleCloseCanceledDeletion () {
this.setState({canceledDeletionOpen: false});
}
handleCloseRegistration () {
this.setState({registrationOpen: false});
}
handleCompleteRegistration () {
this.props.dispatch(sessionActions.refreshSession());
this.handleCloseRegistration();
} }
handleSearchSubmit (formData) { handleSearchSubmit (formData) {
window.location.href = `/search/projects?q=${encodeURIComponent(formData.q)}`; window.location.href = `/search/projects?q=${encodeURIComponent(formData.q)}`;
} }
render () { render () {
const createLink = this.props.session.session.user ? '/projects/editor/' : '/projects/editor/?tip_bar=home'; const createLink = this.props.user ? '/projects/editor/' : '/projects/editor/?tip_bar=home';
return ( return (
<NavigationBox <NavigationBox
className={classNames({ className={classNames({
'logged-in': this.props.session.session.user 'logged-in': this.props.user
})} })}
> >
<ul> <ul>
@ -235,7 +133,7 @@ class Navigation extends React.Component {
</Form> </Form>
</li> </li>
{this.props.session.status === sessionActions.Status.FETCHED ? ( {this.props.session.status === sessionActions.Status.FETCHED ? (
this.props.session.session.user ? [ this.props.user ? [
<li <li
className="link right messages" className="link right messages"
key="messages" key="messages"
@ -249,7 +147,7 @@ class Navigation extends React.Component {
'message-count': true, 'message-count': true,
'show': this.props.unreadMessageCount > 0 'show': this.props.unreadMessageCount > 0
})} })}
>{this.props.unreadMessageCount}</span> >{this.props.unreadMessageCount} </span>
<FormattedMessage id="general.messages" /> <FormattedMessage id="general.messages" />
</a> </a>
</li>, </li>,
@ -268,66 +166,18 @@ class Navigation extends React.Component {
className="link right account-nav" className="link right account-nav"
key="account-nav" key="account-nav"
> >
<a <AccountNav
className={classNames({ classroomId={this.props.user.classroomId}
'user-info': true, isEducator={this.props.permissions.educator}
'open': this.state.accountNavOpen isOpen={this.props.accountNavOpen}
})} isStudent={this.props.permissions.student}
href="#" profileUrl={this.getProfileUrl()}
onClick={this.handleAccountNavClick} thumbnailUrl={this.props.user.thumbnailUrl}
> username={this.props.user.username}
<Avatar onClick={this.props.handleToggleAccountNav}
alt="" onClickLogout={this.props.handleLogOut}
src={this.props.session.session.user.thumbnailUrl} onClose={this.props.handleCloseAccountNav}
/> />
<span className="profile-name">
{this.props.session.session.user.username}
</span>
</a>
<Dropdown
as="ul"
className={process.env.SCRATCH_ENV}
isOpen={this.state.accountNavOpen}
onRequestClose={this.handleCloseAccountNav}
>
<li>
<a href={this.getProfileUrl()}>
<FormattedMessage id="general.profile" />
</a>
</li>
<li>
<a href="/mystuff/">
<FormattedMessage id="general.myStuff" />
</a>
</li>
{this.props.permissions.educator ? [
<li key="my-classes-li">
<a href="/educators/classes/">
<FormattedMessage id="general.myClasses" />
</a>
</li>
] : []}
{this.props.permissions.student ? [
<li key="my-class-li">
<a href={`/classes/${this.props.session.session.user.classroomId}/`}>
<FormattedMessage id="general.myClass" />
</a>
</li>
] : []}
<li>
<a href="/accounts/settings/">
<FormattedMessage id="general.accountSettings" />
</a>
</li>
<li className="divider">
<a
href="#"
onClick={this.handleLogOut}
>
<FormattedMessage id="navigation.signOut" />
</a>
</li>
</Dropdown>
</li> </li>
] : [ ] : [
<li <li
@ -336,16 +186,13 @@ class Navigation extends React.Component {
> >
<a <a
href="#" href="#"
onClick={this.handleJoinClick} onClick={this.props.handleOpenRegistration}
> >
<FormattedMessage id="general.joinScratch" /> <FormattedMessage id="general.joinScratch" />
</a> </a>
</li>, </li>,
<Registration <Registration
isOpen={this.state.registrationOpen}
key="registration" key="registration"
onRegistrationDone={this.handleCompleteRegistration}
onRequestClose={this.handleCloseRegistration}
/>, />,
<li <li
className="link right login-item" className="link right login-item"
@ -355,53 +202,31 @@ class Navigation extends React.Component {
className="ignore-react-onclickoutside" className="ignore-react-onclickoutside"
href="#" href="#"
key="login-link" key="login-link"
onClick={this.handleLoginClick} onClick={this.props.handleToggleLoginOpen}
> >
<FormattedMessage id="general.signIn" /> <FormattedMessage id="general.signIn" />
</a> </a>
<Dropdown <LoginDropdown
className="login-dropdown with-arrow"
isOpen={this.state.loginOpen}
key="login-dropdown" key="login-dropdown"
onRequestClose={this.handleCloseLogin} />
>
<Login
error={this.state.loginError}
onLogIn={this.handleLogIn}
/>
</Dropdown>
</li> </li>
]) : []} ]) : []}
</ul> </ul>
<Modal <CanceledDeletionModal />
isOpen={this.state.canceledDeletionOpen}
style={{
content: {
padding: 15
}
}}
onRequestClose={this.handleCloseCanceledDeletion}
>
<h4>Your Account Will Not Be Deleted</h4>
<h4><FormattedMessage id="general.noDeletionTitle" /></h4>
<p>
<FormattedMessage
id="general.noDeletionDescription"
values={{
resetLink: <a href="/accounts/password_reset/">
{this.props.intl.formatMessage({id: 'general.noDeletionLink'})}
</a>
}}
/>
</p>
</Modal>
</NavigationBox> </NavigationBox>
); );
} }
} }
Navigation.propTypes = { Navigation.propTypes = {
dispatch: PropTypes.func, accountNavOpen: PropTypes.bool,
closeAccountMenus: PropTypes.func,
getMessageCount: PropTypes.func,
handleCloseAccountNav: PropTypes.func,
handleLogOut: PropTypes.func,
handleOpenRegistration: PropTypes.func,
handleToggleAccountNav: PropTypes.func,
handleToggleLoginOpen: PropTypes.func,
intl: intlShape, intl: intlShape,
permissions: PropTypes.shape({ permissions: PropTypes.shape({
admin: PropTypes.bool, admin: PropTypes.bool,
@ -412,16 +237,15 @@ Navigation.propTypes = {
}), }),
searchTerm: PropTypes.string, searchTerm: PropTypes.string,
session: PropTypes.shape({ session: PropTypes.shape({
session: PropTypes.shape({
user: PropTypes.shape({
classroomId: PropTypes.oneOfType([PropTypes.number, PropTypes.string]),
thumbnailUrl: PropTypes.string,
username: PropTypes.string
})
}),
status: PropTypes.string status: PropTypes.string
}), }),
unreadMessageCount: PropTypes.oneOfType([PropTypes.number, PropTypes.string]) setMessageCount: PropTypes.func,
unreadMessageCount: PropTypes.oneOfType([PropTypes.number, PropTypes.string]),
user: PropTypes.shape({
classroomId: PropTypes.oneOfType([PropTypes.number, PropTypes.string]),
thumbnailUrl: PropTypes.string,
username: PropTypes.string
})
}; };
Navigation.defaultProps = { Navigation.defaultProps = {
@ -431,12 +255,48 @@ Navigation.defaultProps = {
}; };
const mapStateToProps = state => ({ const mapStateToProps = state => ({
accountNavOpen: state.navigation && state.navigation.accountNavOpen,
session: state.session, session: state.session,
permissions: state.permissions, permissions: state.permissions,
searchTerm: state.navigation.searchTerm,
unreadMessageCount: state.messageCount.messageCount, unreadMessageCount: state.messageCount.messageCount,
searchTerm: state.navigation user: state.session && state.session.session && state.session.session.user
}); });
const ConnectedNavigation = connect(mapStateToProps)(Navigation); const mapDispatchToProps = dispatch => ({
closeAccountMenus: () => {
dispatch(navigationActions.closeAccountMenus());
},
getMessageCount: username => {
dispatch(messageCountActions.getCount(username));
},
handleToggleAccountNav: event => {
event.preventDefault();
dispatch(navigationActions.handleToggleAccountNav());
},
handleCloseAccountNav: () => {
dispatch(navigationActions.setAccountNavOpen(false));
},
handleOpenRegistration: event => {
event.preventDefault();
dispatch(navigationActions.setRegistrationOpen(true));
},
handleLogOut: event => {
event.preventDefault();
dispatch(navigationActions.handleLogOut());
},
handleToggleLoginOpen: event => {
event.preventDefault();
dispatch(navigationActions.toggleLoginOpen());
},
setMessageCount: newCount => {
dispatch(messageCountActions.setCount(newCount));
}
});
const ConnectedNavigation = connect(
mapStateToProps,
mapDispatchToProps
)(Navigation);
module.exports = injectIntl(ConnectedNavigation); module.exports = injectIntl(ConnectedNavigation);

View file

@ -163,78 +163,10 @@
background-image: url("/images/mystuff.png"); background-image: url("/images/mystuff.png");
} }
} }
.login-dropdown {
width: 200px;
.button {
padding: .75em;
}
}
.dropdown {
.row {
margin-bottom: 1.25rem;
input {
margin: 0;
height: 2.25rem;
}
}
}
.account-nav {
.user-info {
padding-top: 14px;
max-width: 260px;
}
> a {
display: inline-block;
overflow: hidden;
text-overflow: ellipsis;
font-size: .8125rem;
font-weight: normal;
.avatar {
margin-right: 10px;
border-radius: 3px;
width: 24px;
height: 24px;
vertical-align: middle;
}
&.open {
background-color: $active-gray;
}
&:after {
display: inline-block;
margin-left: 8px;
background-image: url("/images/dropdown.png");
background-repeat: no-repeat;
background-position: center center;
background-size: 50%;
width: 20px;
height: 20px;
vertical-align: middle;
content: " ";
}
}
.dropdown {
top: 50px;
padding: 0;
padding-top: 5px;
width: 100%;
box-sizing: border-box;
}
}
} }
//4 columns //4 columns
@media only screen and (max-width: $mobile - 1) { @media #{$small} {
#navigation .inner { #navigation .inner {
width: $cols4; width: $cols4;
@ -242,20 +174,6 @@
&.login-item { &.login-item {
margin-left: 0; margin-left: 0;
} }
&.account-nav {
margin-left: 0;
> a {
.avatar {
margin-right: 0;
}
&:after {
display: none;
}
}
}
} }
.create, .create,
@ -272,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;
@ -280,20 +198,6 @@
&.login-item { &.login-item {
margin-left: 0; margin-left: 0;
} }
&.account-nav {
margin-left: 0;
> a {
.avatar {
margin-right: 0;
}
&:after {
display: none;
}
}
}
} }
.discuss, .discuss,
@ -308,13 +212,12 @@
} }
//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;
> ul > li { > ul > li {
&.login-item, &.login-item {
&.account-nav {
margin-left: 0; margin-left: 0;
} }
} }

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

@ -1,8 +1,10 @@
const bindAll = require('lodash.bindall'); const bindAll = require('lodash.bindall');
const PropTypes = require('prop-types'); const PropTypes = require('prop-types');
const React = require('react'); const React = require('react');
const connect = require('react-redux').connect;
const IframeModal = require('../modal/iframe/modal.jsx'); const IframeModal = require('../modal/iframe/modal.jsx');
const navigationActions = require('../../redux/navigation.js');
require('./registration.scss'); require('./registration.scss');
@ -26,7 +28,7 @@ class Registration extends React.Component {
handleMessage (e) { handleMessage (e) {
if (e.origin !== window.location.origin) return; if (e.origin !== window.location.origin) return;
if (e.source !== this.registrationIframe.contentWindow) return; if (e.source !== this.registrationIframe.contentWindow) return;
if (e.data === 'registration-done') this.props.onRegistrationDone(); if (e.data === 'registration-done') this.props.handleCompleteRegistration();
if (e.data === 'registration-relaunch') { if (e.data === 'registration-relaunch') {
this.registrationIframe.contentWindow.location.reload(); this.registrationIframe.contentWindow.location.reload();
} }
@ -47,16 +49,32 @@ class Registration extends React.Component {
}} }}
isOpen={this.props.isOpen} isOpen={this.props.isOpen}
src="/accounts/standalone-registration/" src="/accounts/standalone-registration/"
onRequestClose={this.props.onRequestClose} onRequestClose={this.props.handleCloseRegistration}
/> />
); );
} }
} }
Registration.propTypes = { Registration.propTypes = {
isOpen: PropTypes.bool, handleCloseRegistration: PropTypes.func,
onRegistrationDone: PropTypes.func, handleCompleteRegistration: PropTypes.func,
onRequestClose: PropTypes.func isOpen: PropTypes.bool
}; };
module.exports = Registration; const mapStateToProps = state => ({
isOpen: state.navigation.registrationOpen
});
const mapDispatchToProps = dispatch => ({
handleCloseRegistration: () => {
dispatch(navigationActions.setRegistrationOpen(false));
},
handleCompleteRegistration: () => {
dispatch(navigationActions.handleCompleteRegistration());
}
});
module.exports = connect(
mapStateToProps,
mapDispatchToProps
)(Registration);

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

@ -1,29 +1,28 @@
const range = require('lodash.range');
const PropTypes = require('prop-types');
const React = require('react'); const React = require('react');
const PropTypes = require('prop-types');
const classNames = require('classnames');
require('./spinner.scss'); require('./spinner.scss');
// Adapted from http://tobiasahlin.com/spinkit/ // Adapted from http://tobiasahlin.com/spinkit/
const Spinner = ({ const Spinner = ({
mode className,
}) => { color
const spinnerClassName = (mode === 'smooth' ? 'spinner-smooth' : 'spinner'); }) => (
const spinnerDivCount = (mode === 'smooth' ? 24 : 12); <img
return ( alt="loading animation"
<div className={spinnerClassName}> className={classNames('studio-status-icon-spinner', className)}
{range(1, spinnerDivCount + 1).map(id => ( src={`/svgs/modal/spinner-${color}.svg`}
<div />
className={`circle${id} circle`} );
key={`circle${id}`}
/> Spinner.defaultProps = {
))} color: 'white'
</div>
);
}; };
Spinner.propTypes = { Spinner.propTypes = {
mode: PropTypes.string className: PropTypes.string,
color: PropTypes.oneOf(['white', 'blue', 'transparent-gray'])
}; };
module.exports = Spinner; module.exports = Spinner;

View file

@ -1,118 +1,44 @@
@import "../../colors"; .studio-status-icon-spinner {
/* This class can be used on an icon that should spin.
.spinner { It first plays the intro animation, then spins forever. */
position: relative; animation-name: intro, spin;
margin: 0 auto; animation-duration: .25s, .5s;
width: 20px; animation-timing-function: cubic-bezier(.3, -3, .6, 3), linear;
height: 20px; animation-delay: 0s, .25s;
animation-iteration-count: 1, infinite;
.circle { animation-direction: normal;
position: absolute; width: 1.4rem; /* standard is 1.4 rem but can be overwritten by parent */
top: 0; height: 1.4rem;
left: 0; -webkit-animation-name: intro, spin;
width: 100%; -webkit-animation-duration: .25s, .5s;
height: 100%; -webkit-animation-iteration-count: 1, infinite;
-webkit-animation-delay: 0s, .25s;
&:before { -webkit-animation-timing-function: cubic-bezier(.3, -3, .6, 3), linear;
display: block; transform-origin: center;
animation: circleFadeDelay 1.2s infinite ease-in-out both;
margin: 0 auto;
border-radius: 100%;
background-color: $ui-gray;
width: 15%;
height: 15%;
content: "";
.white & {
background-color: $ui-blue-dark;
}
}
}
@for $i from 1 through 12 {
$rotation: 30deg * ($i - 1);
$delay: -1.3s + $i * .1;
.circle#{$i} {
transform: rotate($rotation);
&:before {
animation-delay: $delay;
}
}
}
} }
@keyframes circleFadeDelay { @keyframes intro {
0%, 0% {
39%, transform: scale(0);
opacity: 0;
-webkit-transform: scale(0);
}
100% { 100% {
opacity: 0; transform: scale(1);
}
40% {
opacity: 1; opacity: 1;
-webkit-transform: scale(1);
} }
} }
@keyframes spin {
/*********************/ 0% {
/* type === "smooth" */ transform: rotate(0);
/*********************/ -webkit-transform: rotate(0);
.spinner-smooth {
position: relative;
margin: 0 auto;
width: 20px;
height: 20px;
.circle {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
&:before {
display: block;
animation: circleFadeDelaySmooth 1.8s infinite ease-in-out both;
margin: 0 auto;
border-radius: 100%;
background-color: $ui-white;
width: 30%;
height: 20%;
content: "";
.white & {
background-color: darken($ui-blue, 8%);
}
}
} }
@for $i from 1 through 24 { 100% {
$rotation: 15deg * ($i - 1); transform: rotate(359deg);
$delay: -1.9s + $i * .075; -webkit-transform: rotate(359deg);
.circle#{$i} {
transform: rotate($rotation);
&:before {
animation-delay: $delay;
}
}
}
}
@keyframes circleFadeDelaySmooth {
0%,
35% {
opacity: 0;
},
40% {
opacity: 1;
} }
} }

View file

@ -74,6 +74,7 @@ const Thumbnail = props => {
<a <a
href={props.href} href={props.href}
key="titleElement" key="titleElement"
title={props.title}
> >
{props.title} {props.title}
</a> </a>

View file

@ -5,10 +5,10 @@
$thumbnail-width: 220px; $thumbnail-width: 220px;
$thumbnail-inner-width: 204px; $thumbnail-inner-width: 204px;
$project-height: 208px; $project-height: 208px;
$gallery-height: 164px; $gallery-height: 164px;
margin: 0 auto; margin: 0 auto;
padding: 12px 0; padding: 12px 0;
justify-content: flex-start; justify-content: flex-start;
@ -16,14 +16,13 @@
.thumbnail { .thumbnail {
margin: 7px; margin: 7px;
border-radius: 4px;
box-shadow: 0 0 0 1px $active-gray;
background-color: $ui-white;
padding-bottom: 4px;
width: $thumbnail-width; width: $thumbnail-width;
.thumbnail-image { .thumbnail-image {
margin: 8px auto; margin: 8px auto;
border-radius: 4px;
box-shadow: 0 0 0 1px $active-gray;
background-color: $ui-white;
width: $thumbnail-inner-width; width: $thumbnail-inner-width;
} }
@ -45,10 +44,19 @@
.thumbnail-title { .thumbnail-title {
float: left; float: left;
max-width: 164px; max-width: 164px;
overflow: hidden;
text-align: left;
.thumbnail-creator a { .thumbnail-creator a {
color: $type-gray; color: $type-gray;
} }
a {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
word-wrap: break-word;
}
} }
} }

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

@ -35,6 +35,7 @@ const Raven = require('raven-js');
}; };
window._locale = updateLocale(); window._locale = updateLocale();
document.documentElement.lang = window._locale;
})(); })();
/** /**

View file

@ -106,6 +106,8 @@
"navigation.signOut": "Sign out", "navigation.signOut": "Sign out",
"extensionHeader.requirements": "Requirements", "extensionHeader.requirements": "Requirements",
"extensionInstallation.addExtension": "In the editor, click on the \"Add Extensions\" button on the lower left.",
"oschooser.choose": "Choose your OS:", "oschooser.choose": "Choose your OS:",
@ -172,6 +174,7 @@
"registration.welcomeStepTitle": "Hurray! Welcome to Scratch!", "registration.welcomeStepTitle": "Hurray! Welcome to Scratch!",
"thumbnail.by": "by", "thumbnail.by": "by",
"report.error": "Something went wrong when trying to send your message. Please try again.",
"report.project": "Report Project", "report.project": "Report Project",
"report.projectInstructions": "From the dropdown below, please select the reason why you feel this project is disrespectful or inappropriate or otherwise breaks the {CommunityGuidelinesLink}.", "report.projectInstructions": "From the dropdown below, please select the reason why you feel this project is disrespectful or inappropriate or otherwise breaks the {CommunityGuidelinesLink}.",
"report.CommunityGuidelinesLinkText": "Scratch Community Guidelines", "report.CommunityGuidelinesLinkText": "Scratch Community Guidelines",
@ -181,8 +184,11 @@
"report.reasonScary": "Too Violent or Scary", "report.reasonScary": "Too Violent or Scary",
"report.reasonLanguage": "Inappropriate Language", "report.reasonLanguage": "Inappropriate Language",
"report.reasonMusic": "Inappropriate Music", "report.reasonMusic": "Inappropriate Music",
"report.reasonMissing": "Please select a reason",
"report.reasonImage": "Inappropriate Images", "report.reasonImage": "Inappropriate Images",
"report.reasonPersonal": "Sharing Personal Contact Information", "report.reasonPersonal": "Sharing Personal Contact Information",
"report.receivedHeader": "We have received your report!",
"report.receivedBody": "The Scratch Team will review the project based on the Scratch community guidelines.",
"report.promptPlaceholder": "Select a reason why above.", "report.promptPlaceholder": "Select a reason why above.",
"report.promptCopy": "Please provide a link to the original project", "report.promptCopy": "Please provide a link to the original project",
"report.promptUncredited": "Please provide links to the uncredited content", "report.promptUncredited": "Please provide links to the uncredited content",
@ -194,5 +200,47 @@
"report.promptImage": "Please say the name of the sprite or the backdrop with the inappropriate image", "report.promptImage": "Please say the name of the sprite or the backdrop with the inappropriate image",
"report.tooLongError": "That's too long! Please find a way to shorten your text.", "report.tooLongError": "That's too long! Please find a way to shorten your text.",
"report.tooShortError": "That's too short. Please describe in detail what's inappropriate or disrespectful about the project.", "report.tooShortError": "That's too short. Please describe in detail what's inappropriate or disrespectful about the project.",
"report.send": "Send" "report.send": "Send",
"report.sending": "Sending...",
"report.textMissing": "Please tell us why you are reporting this project",
"comments.report": "Report",
"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.",
"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

@ -37,7 +37,7 @@ const render = (jsx, element, reducers, initialState, enhancer) => {
} }
const allReducers = reducer(reducers); const allReducers = reducer(reducers);
const composeEnhancers = window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || redux.compose; const composeEnhancers = window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || redux.compose;
const enhancers = enhancer ? const enhancers = enhancer ?
composeEnhancers( composeEnhancers(

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

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

View file

@ -1,22 +1,153 @@
const keyMirror = require('keymirror'); const keyMirror = require('keymirror');
const defaults = require('lodash.defaults');
const api = require('../lib/api');
const log = require('../lib/log.js');
const sessionActions = require('./session.js');
const Types = keyMirror({ const Types = keyMirror({
SET_SEARCH_TERM: null SET_SEARCH_TERM: null,
SET_ACCOUNT_NAV_OPEN: null,
TOGGLE_ACCOUNT_NAV_OPEN: null,
SET_LOGIN_ERROR: null,
SET_LOGIN_OPEN: null,
TOGGLE_LOGIN_OPEN: null,
SET_CANCELED_DELETION_OPEN: null,
SET_REGISTRATION_OPEN: null
}); });
module.exports.getInitialState = () => ({
accountNavOpen: false,
canceledDeletionOpen: false,
loginError: null,
loginOpen: false,
registrationOpen: false,
searchTerm: ''
});
module.exports.navigationReducer = (state, action) => { module.exports.navigationReducer = (state, action) => {
if (typeof state === 'undefined') { if (typeof state === 'undefined') {
state = ''; state = module.exports.getInitialState();
} }
switch (action.type) { switch (action.type) {
case Types.SET_SEARCH_TERM: case Types.SET_SEARCH_TERM:
return action.searchTerm; return defaults({searchTerm: action.searchTerm}, state);
case Types.SET_ACCOUNT_NAV_OPEN:
return defaults({accountNavOpen: action.isOpen}, state);
case Types.TOGGLE_ACCOUNT_NAV_OPEN:
return defaults({accountNavOpen: !state.accountNavOpen}, state);
case Types.SET_LOGIN_ERROR:
return defaults({loginError: action.loginError}, state);
case Types.SET_LOGIN_OPEN:
return defaults({loginOpen: action.isOpen}, state);
case Types.TOGGLE_LOGIN_OPEN:
return defaults({loginOpen: !state.loginOpen}, state);
case Types.SET_CANCELED_DELETION_OPEN:
return defaults({canceledDeletionOpen: action.isOpen}, state);
case Types.SET_REGISTRATION_OPEN:
return defaults({registrationOpen: action.isOpen}, state);
default: default:
return state; return state;
} }
}; };
module.exports.setAccountNavOpen = isOpen => ({
type: Types.SET_ACCOUNT_NAV_OPEN,
isOpen: isOpen
});
module.exports.handleToggleAccountNav = () => ({
type: Types.TOGGLE_ACCOUNT_NAV_OPEN
});
module.exports.setCanceledDeletionOpen = isOpen => ({
type: Types.SET_CANCELED_DELETION_OPEN,
isOpen: isOpen
});
module.exports.setLoginError = loginError => ({
type: Types.SET_LOGIN_ERROR,
loginError: loginError
});
module.exports.setLoginOpen = isOpen => ({
type: Types.SET_LOGIN_OPEN,
isOpen: isOpen
});
module.exports.toggleLoginOpen = () => ({
type: Types.TOGGLE_LOGIN_OPEN
});
module.exports.setRegistrationOpen = isOpen => ({
type: Types.SET_REGISTRATION_OPEN,
isOpen: isOpen
});
module.exports.setSearchTerm = searchTerm => ({ module.exports.setSearchTerm = searchTerm => ({
type: Types.SET_SEARCH_TERM, type: Types.SET_SEARCH_TERM,
searchTerm: searchTerm searchTerm: searchTerm
}); });
module.exports.handleCompleteRegistration = () => (dispatch => {
dispatch(sessionActions.refreshSession());
dispatch(module.exports.setRegistrationOpen(false));
});
module.exports.closeAccountMenus = () => (dispatch => {
dispatch(module.exports.setAccountNavOpen(false));
dispatch(module.exports.setRegistrationOpen(false));
});
module.exports.handleLogIn = (formData, callback) => (dispatch => {
dispatch(module.exports.setLoginError(null));
formData.useMessages = true; // NOTE: this may or may not be being used anywhere else
api({
method: 'post',
host: '',
uri: '/accounts/login/',
json: formData,
useCsrf: true
}, (err, body) => {
if (err) dispatch(module.exports.setLoginError(err.message));
if (body) {
body = body[0];
if (body.success) {
dispatch(module.exports.setLoginOpen(false));
body.messages.forEach(message => {
if (message.message === 'canceled-deletion') {
dispatch(module.exports.setCanceledDeletionOpen(true));
}
});
dispatch(sessionActions.refreshSession());
callback({success: true});
} else {
if (body.redirect) {
window.location = body.redirect;
}
// Update login error message to a friendlier one if it exists
dispatch(module.exports.setLoginError(body.msg));
// JS error already logged by api mixin
callback({success: false});
}
} else {
// JS error already logged by api mixin
callback({success: false});
}
});
});
module.exports.handleLogOut = () => (dispatch => {
api({
host: '',
method: 'post',
uri: '/accounts/logout/',
useCsrf: true
}, err => {
if (err) log.error(err);
dispatch(module.exports.setLoginOpen(false));
dispatch(module.exports.setAccountNavOpen(false));
window.location = '/';
});
});

View file

@ -1,4 +1,7 @@
const defaults = require('lodash.defaults');
const keyMirror = require('keymirror'); const keyMirror = require('keymirror');
const async = require('async');
const merge = require('lodash.merge');
const api = require('../lib/api'); const api = require('../lib/api');
const log = require('../lib/log'); const log = require('../lib/log');
@ -19,6 +22,7 @@ module.exports.getInitialState = () => ({
original: module.exports.Status.NOT_FETCHED, original: module.exports.Status.NOT_FETCHED,
parent: module.exports.Status.NOT_FETCHED, parent: module.exports.Status.NOT_FETCHED,
remixes: module.exports.Status.NOT_FETCHED, remixes: module.exports.Status.NOT_FETCHED,
report: module.exports.Status.NOT_FETCHED,
projectStudios: module.exports.Status.NOT_FETCHED, projectStudios: module.exports.Status.NOT_FETCHED,
curatedStudios: module.exports.Status.NOT_FETCHED, curatedStudios: module.exports.Status.NOT_FETCHED,
studioRequests: {} studioRequests: {}
@ -26,6 +30,7 @@ module.exports.getInitialState = () => ({
projectInfo: {}, projectInfo: {},
remixes: [], remixes: [],
comments: [], comments: [],
replies: {},
faved: false, faved: false,
loved: false, loved: false,
original: {}, original: {},
@ -79,7 +84,56 @@ module.exports.previewReducer = (state, action) => {
}); });
case 'SET_COMMENTS': case 'SET_COMMENTS':
return Object.assign({}, state, { return Object.assign({}, state, {
comments: action.items 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':
return Object.assign({}, state, {
replies: merge({}, state.replies, action.replies)
}); });
case 'SET_LOVED': case 'SET_LOVED':
return Object.assign({}, state, { return Object.assign({}, state, {
@ -145,6 +199,16 @@ module.exports.setProjectStudios = items => ({
items: items items: items
}); });
module.exports.setComments = items => ({
type: 'SET_COMMENTS',
items: items
});
module.exports.setReplies = replies => ({
type: 'SET_REPLIES',
replies: replies
});
module.exports.setCuratedStudios = items => ({ module.exports.setCuratedStudios = items => ({
type: 'SET_CURATED_STUDIOS', type: 'SET_CURATED_STUDIOS',
items: items items: items
@ -172,6 +236,55 @@ 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.getProjectInfo = (id, token) => (dispatch => { module.exports.getProjectInfo = (id, token) => (dispatch => {
const opts = { const opts = {
uri: `/projects/${id}` uri: `/projects/${id}`
@ -257,7 +370,59 @@ module.exports.getFavedStatus = (id, username, token) => (dispatch => {
}); });
}); });
module.exports.getTopLevelComments = (id, offset, isAdmin, token) => (dispatch => {
dispatch(module.exports.setFetchStatus('comments', module.exports.Status.FETCHING));
api({
uri: `${isAdmin ? '/admin' : ''}/comments/project/${id}`,
authentication: isAdmin ? token : null,
params: {offset: offset || 0}
}, (err, body) => {
if (err) {
dispatch(module.exports.setFetchStatus('comments', module.exports.Status.ERROR));
dispatch(module.exports.setError(err));
return;
}
if (typeof body === 'undefined') {
dispatch(module.exports.setFetchStatus('comments', module.exports.Status.ERROR));
dispatch(module.exports.setError('No comment info'));
return;
}
dispatch(module.exports.setFetchStatus('comments', module.exports.Status.FETCHED));
dispatch(module.exports.setComments(body));
dispatch(module.exports.getReplies(id, body.map(comment => comment.id), isAdmin, token));
});
});
module.exports.getReplies = (projectId, commentIds, isAdmin, token) => (dispatch => {
dispatch(module.exports.setFetchStatus('replies', module.exports.Status.FETCHING));
const fetchedReplies = {};
async.eachLimit(commentIds, 10, (parentId, callback) => {
api({
uri: `${isAdmin ? '/admin' : ''}/comments/project/${projectId}/${parentId}`,
authentication: isAdmin ? token : null
}, (err, body) => {
if (err) {
return callback(`Error fetching comment replies: ${err}`);
}
if (typeof body === 'undefined') {
return callback('No comment reply information');
}
fetchedReplies[parentId] = body;
callback(null, body);
});
}, err => {
if (err) {
dispatch(module.exports.setFetchStatus('replies', module.exports.Status.ERROR));
dispatch(module.exports.setError(err));
return;
}
dispatch(module.exports.setFetchStatus('replies', module.exports.Status.FETCHED));
dispatch(module.exports.setReplies(fetchedReplies));
});
});
module.exports.setFavedStatus = (faved, id, username, token) => (dispatch => { module.exports.setFavedStatus = (faved, id, username, token) => (dispatch => {
dispatch(module.exports.setFetchStatus('faved', module.exports.Status.FETCHING));
if (faved) { if (faved) {
api({ api({
uri: `/projects/${id}/favorites/user/${username}`, uri: `/projects/${id}/favorites/user/${username}`,
@ -317,6 +482,7 @@ module.exports.getLovedStatus = (id, username, token) => (dispatch => {
}); });
module.exports.setLovedStatus = (loved, id, username, token) => (dispatch => { module.exports.setLovedStatus = (loved, id, username, token) => (dispatch => {
dispatch(module.exports.setFetchStatus('loved', module.exports.Status.FETCHING));
if (loved) { if (loved) {
api({ api({
uri: `/projects/${id}/loves/user/${username}`, uri: `/projects/${id}/loves/user/${username}`,
@ -465,6 +631,7 @@ module.exports.leaveStudio = (studioId, projectId, token) => (dispatch => {
}); });
module.exports.updateProject = (id, jsonData, username, token) => (dispatch => { module.exports.updateProject = (id, jsonData, username, token) => (dispatch => {
dispatch(module.exports.setFetchStatus('project', module.exports.Status.FETCHING));
api({ api({
uri: `/projects/${id}`, uri: `/projects/${id}`,
authentication: token, authentication: token,
@ -490,3 +657,84 @@ module.exports.updateProject = (id, jsonData, username, token) => (dispatch => {
dispatch(module.exports.setProjectInfo(body)); dispatch(module.exports.setProjectInfo(body));
}); });
}); });
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));
// scratchr2 will fail if no thumbnail base64 string provided. We don't yet have
// a way to get the actual project thumbnail in www/gui, so for now just submit
// a minimal base64 png string.
defaults(jsonData, {
thumbnail: 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC' +
'0lEQVR42mP8/x8AAwMCAO+ip1sAAAAASUVORK5CYII='
});
api({
uri: `/proxy/projects/${id}/report`,
authentication: token,
withCredentials: true,
method: 'POST',
useCsrf: true,
json: jsonData
}, (err, body, res) => {
if (err || res.statusCode !== 200) {
dispatch(module.exports.setFetchStatus('report', module.exports.Status.ERROR));
return;
}
dispatch(module.exports.setFetchStatus('report', module.exports.Status.FETCHED));
});
});

View file

@ -4,6 +4,7 @@ const defaults = require('lodash.defaults');
const messageCountReducer = require('./message-count.js').messageCountReducer; const messageCountReducer = require('./message-count.js').messageCountReducer;
const permissionsReducer = require('./permissions.js').permissionsReducer; const permissionsReducer = require('./permissions.js').permissionsReducer;
const sessionReducer = require('./session.js').sessionReducer; const sessionReducer = require('./session.js').sessionReducer;
const navigationReducer = require('./navigation.js').navigationReducer;
/** /**
* Returns a combined reducer to be used for a page in `render.jsx`. * Returns a combined reducer to be used for a page in `render.jsx`.
@ -18,8 +19,9 @@ const sessionReducer = require('./session.js').sessionReducer;
module.exports = opts => { module.exports = opts => {
opts = opts || {}; opts = opts || {};
return combineReducers(defaults(opts, { return combineReducers(defaults(opts, {
session: sessionReducer, messageCount: messageCountReducer,
navigation: navigationReducer,
permissions: permissionsReducer, permissions: permissionsReducer,
messageCount: messageCountReducer session: sessionReducer
})); }));
}; };

View file

@ -77,13 +77,13 @@ module.exports.getActivity = (username, token) => (dispatch => {
api({ api({
uri: `/users/${username}/following/users/activity?limit=5`, uri: `/users/${username}/following/users/activity?limit=5`,
authentication: token authentication: token
}, (err, body) => { }, (err, body, res) => {
if (err) { if (err) {
dispatch(module.exports.setFetchStatus('activity', module.exports.Status.ERROR)); dispatch(module.exports.setFetchStatus('activity', module.exports.Status.ERROR));
dispatch(module.exports.setError(err)); dispatch(module.exports.setError(err));
return; return;
} }
if (typeof body === 'undefined') { if (typeof body === 'undefined' || res.statusCode !== 200) {
dispatch(module.exports.setFetchStatus('activity', module.exports.Status.ERROR)); dispatch(module.exports.setFetchStatus('activity', module.exports.Status.ERROR));
dispatch(module.exports.setError('No session content')); dispatch(module.exports.setError('No session content'));
return; return;
@ -100,13 +100,13 @@ module.exports.getFeaturedGlobal = () => (dispatch => {
dispatch(module.exports.setFetchStatus('featured', module.exports.Status.FETCHING)); dispatch(module.exports.setFetchStatus('featured', module.exports.Status.FETCHING));
api({ api({
uri: '/proxy/featured' uri: '/proxy/featured'
}, (err, body) => { }, (err, body, res) => {
if (err) { if (err) {
dispatch(module.exports.setFetchStatus('featured', module.exports.Status.ERROR)); dispatch(module.exports.setFetchStatus('featured', module.exports.Status.ERROR));
dispatch(module.exports.setError(err)); dispatch(module.exports.setError(err));
return; return;
} }
if (typeof body === 'undefined') { if (typeof body === 'undefined' || res.statusCode !== 200) {
dispatch(module.exports.setFetchStatus('featured', module.exports.Status.ERROR)); dispatch(module.exports.setFetchStatus('featured', module.exports.Status.ERROR));
dispatch(module.exports.setError('No session content')); dispatch(module.exports.setError('No session content'));
return; return;
@ -126,13 +126,13 @@ module.exports.getSharedByFollowing = (username, token) => (dispatch => {
api({ api({
uri: `/users/${username}/following/users/projects`, uri: `/users/${username}/following/users/projects`,
authentication: token authentication: token
}, (err, body) => { }, (err, body, res) => {
if (err) { if (err) {
dispatch(module.exports.setFetchStatus('shared', module.exports.Status.Status.ERROR)); dispatch(module.exports.setFetchStatus('shared', module.exports.Status.Status.ERROR));
dispatch(module.exports.setError(err)); dispatch(module.exports.setError(err));
return; return;
} }
if (typeof body === 'undefined') { if (typeof body === 'undefined' || res.statusCode !== 200) {
dispatch(module.exports.setFetchStatus('shared', module.exports.Status.ERROR)); dispatch(module.exports.setFetchStatus('shared', module.exports.Status.ERROR));
dispatch(module.exports.setError('No session content')); dispatch(module.exports.setError('No session content'));
return; return;
@ -152,13 +152,13 @@ module.exports.getInStudiosFollowing = (username, token) => (dispatch => {
api({ api({
uri: `/users/${username}/following/studios/projects`, uri: `/users/${username}/following/studios/projects`,
authentication: token authentication: token
}, (err, body) => { }, (err, body, res) => {
if (err) { if (err) {
dispatch(module.exports.setFetchStatus('studios', module.exports.Status.ERROR)); dispatch(module.exports.setFetchStatus('studios', module.exports.Status.ERROR));
dispatch(module.exports.setError(err)); dispatch(module.exports.setError(err));
return; return;
} }
if (typeof body === 'undefined') { if (typeof body === 'undefined' || res.statusCode !== 200) {
dispatch(module.exports.setFetchStatus('studios', module.exports.Status.ERROR)); dispatch(module.exports.setFetchStatus('studios', module.exports.Status.ERROR));
dispatch(module.exports.setError('No session content')); dispatch(module.exports.setError('No session content'));
return; return;
@ -178,13 +178,13 @@ module.exports.getLovedByFollowing = (username, token) => (dispatch => {
api({ api({
uri: `/users/${username}/following/users/loves`, uri: `/users/${username}/following/users/loves`,
authentication: token authentication: token
}, (err, body) => { }, (err, body, res) => {
if (err) { if (err) {
dispatch(module.exports.setFetchStatus('loved', module.exports.Status.ERROR)); dispatch(module.exports.setFetchStatus('loved', module.exports.Status.ERROR));
dispatch(module.exports.setError(err)); dispatch(module.exports.setError(err));
return; return;
} }
if (typeof body === 'undefined') { if (typeof body === 'undefined' || res.statusCode !== 200) {
dispatch(module.exports.setFetchStatus('loved', module.exports.Status.ERROR)); dispatch(module.exports.setFetchStatus('loved', module.exports.Status.ERROR));
dispatch(module.exports.setError('No session content')); dispatch(module.exports.setError('No session content'));
return; return;

View file

@ -308,6 +308,13 @@
"view": "wedo2/wedo2", "view": "wedo2/wedo2",
"title": "LEGO WeDo 2.0" "title": "LEGO WeDo 2.0"
}, },
{
"name": "wedo-legacy",
"pattern": "^/wedo-legacy/?$",
"routeAlias": "/wedo-legacy/?$",
"view": "wedo2-legacy/wedo2",
"title": "LEGO WeDo"
},
{ {
"name": "ev3", "name": "ev3",
"pattern": "^/ev3/?$", "pattern": "^/ev3/?$",

View file

@ -35,8 +35,10 @@ const Components = () => (
<Box title="Carousel component in a box!"> <Box title="Carousel component in a box!">
<Carousel /> <Carousel />
</Box> </Box>
<h1>This is a Spinner</h1> <h1>This is a blue Spinner</h1>
<Spinner /> <Spinner
color="blue"
/>
<h1>Colors</h1> <h1>Colors</h1>
<div className="colors"> <div className="colors">
<span className="ui-blue">$ui-blue</span> <span className="ui-blue">$ui-blue</span>

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

@ -29,7 +29,7 @@ const Credits = () => (
/> />
<span className="name">Carl Bowman</span> <span className="name">Carl Bowman</span>
</li> </li>
<li> <li>
<img <img
alt="Karishma Avatar" alt="Karishma Avatar"
@ -86,6 +86,14 @@ const Credits = () => (
<span className="name">DD Liu</span> <span className="name">DD Liu</span>
</li> </li>
<li>
<img
alt="Katelyn Avatar"
src="//cdn.scratch.mit.edu/get_image/user/34607790_170x170.png"
/>
<span className="name">Katelyn Mann</span>
</li>
<li> <li>
<img <img
alt="Shruti Avatar" alt="Shruti Avatar"
@ -446,6 +454,6 @@ const Credits = () => (
</p> </p>
</div> </div>
); );
render(<Page><Credits /></Page>, document.getElementById('app')); render(<Page><Credits /></Page>, document.getElementById('app'));

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

@ -31,19 +31,25 @@ class EV3 extends ExtensionLanding {
render () { render () {
return ( return (
<div className="extension-landing ev3"> <div className="extension-landing ev3">
<ExtensionHeader imageSrc="/images/ev3/ev3-illustration.png"> <ExtensionHeader
imageAlt={this.props.intl.formatMessage({id: 'ev3.imgAltEv3Illustration'})}
imageSrc="/images/ev3/ev3-illustration.png"
>
<FlexRow className="column extension-copy"> <FlexRow className="column extension-copy">
<h2><img src="/images/ev3/ev3.svg" />LEGO MINDSTORMS EV3</h2> <h1><img
alt=""
src="/images/ev3/ev3.svg"
/>LEGO MINDSTORMS EV3</h1>
<FormattedMessage <FormattedMessage
id="ev3.headerText" id="ev3.headerText"
values={{ values={{
ev3Link: ( ev3Link: (
<a <a
href="https://shop.lego.com/en-US/LEGO-MINDSTORMS-EV3-31313" href="https://education.lego.com/en-us/middle-school/intro/mindstorms-ev3"
rel="noopener noreferrer" rel="noopener noreferrer"
target="_blank" target="_blank"
> >
LEGO MINDSTORMS EV3 LEGO MINDSTORMS Education EV3
</a> </a>
) )
}} }}
@ -51,11 +57,17 @@ class EV3 extends ExtensionLanding {
</FlexRow> </FlexRow>
<ExtensionRequirements> <ExtensionRequirements>
<span> <span>
<img src="/svgs/extensions/windows.svg" /> <img
alt=""
src="/svgs/extensions/windows.svg"
/>
Windows 10+ Windows 10+
</span> </span>
<span> <span>
<img src="/svgs/extensions/mac.svg" /> <img
alt=""
src="/svgs/extensions/mac.svg"
/>
macOS 10.13+ macOS 10.13+
</span> </span>
<span> <span>
@ -63,7 +75,10 @@ class EV3 extends ExtensionLanding {
Bluetooth Bluetooth
</span> </span>
<span> <span>
<img src="/svgs/extensions/scratch-link.svg" /> <img
alt=""
src="/svgs/extensions/scratch-link.svg"
/>
Scratch Link Scratch Link
</span> </span>
</ExtensionRequirements> </ExtensionRequirements>
@ -82,13 +97,20 @@ class EV3 extends ExtensionLanding {
<Steps> <Steps>
<Step number={1}> <Step number={1}>
<div className="step-image"> <div className="step-image">
<img src="/images/ev3/ev3-connect-1.png" /> <img
alt=""
src="/images/ev3/ev3-connect-1.png"
/>
</div> </div>
<p><FormattedMessage id="ev3.turnOnEV3" /></p> <p><FormattedMessage id="ev3.turnOnEV3" /></p>
</Step> </Step>
<Step number={2}> <Step number={2}>
<div className="step-image"> <div className="step-image">
<img src="/images/ev3/ev3-connect-2.png" /> <img
alt=""
className="screenshot"
src="/images/ev3/ev3-connect-2.png"
/>
</div> </div>
<p> <p>
<FormattedMessage <FormattedMessage
@ -109,7 +131,11 @@ class EV3 extends ExtensionLanding {
</Step> </Step>
<Step number={3}> <Step number={3}>
<div className="step-image"> <div className="step-image">
<img src="/images/ev3/ev3-connect-3.png" /> <img
alt={this.props.intl.formatMessage({id: 'extensionInstallation.addExtension'})}
className="screenshot"
src="/images/ev3/ev3-connect-3.png"
/>
</div> </div>
<p><FormattedMessage id="ev3.addExtension" /></p> <p><FormattedMessage id="ev3.addExtension" /></p>
</Step> </Step>
@ -119,19 +145,30 @@ class EV3 extends ExtensionLanding {
<Steps> <Steps>
<Step> <Step>
<div className="step-image"> <div className="step-image">
<img src="/images/ev3/ev3-accept-connection.png" /> <img
alt={this.props.intl.formatMessage({id: 'ev3.imgAltAcceptConnection'})}
src="/images/ev3/ev3-accept-connection.png"
/>
</div> </div>
<p><FormattedMessage id="ev3.acceptConnection" /></p> <p><FormattedMessage id="ev3.acceptConnection" /></p>
</Step> </Step>
<Step> <Step>
<div className="step-image"> <div className="step-image">
<img src="/images/ev3/ev3-pin.png" /> <img
alt={this.props.intl.formatMessage({id: 'ev3.imgAltAcceptPasscode'})}
src="/images/ev3/ev3-pin.png"
/>
</div> </div>
<p><FormattedMessage id="ev3.acceptPasscode" /></p> <p><FormattedMessage id="ev3.acceptPasscode" /></p>
</Step> </Step>
<Step> <Step>
<div className="step-image"> <div className="step-image">
<img <img
alt={this.props.intl.formatMessage({id: `ev3.imgAlt${
this.state.OS === OS_ENUM.WINDOWS ?
'WaitForWindows' :
'EnterPasscodeMac'
}`})}
className="screenshot" className="screenshot"
src={`/images/ev3/${ src={`/images/ev3/${
this.state.OS === OS_ENUM.WINDOWS ? this.state.OS === OS_ENUM.WINDOWS ?
@ -170,7 +207,10 @@ class EV3 extends ExtensionLanding {
/> />
</span> </span>
<div className="step-image"> <div className="step-image">
<img src="/images/ev3/ev3-motor-port-a.png" /> <img
alt={this.props.intl.formatMessage({id: 'ev3.imgAltPlugInMotor'})}
src="/images/ev3/ev3-motor-port-a.png"
/>
</div> </div>
</Step> </Step>
<Step <Step
@ -188,7 +228,10 @@ class EV3 extends ExtensionLanding {
/> />
</span> </span>
<div className="step-image"> <div className="step-image">
<img src="/images/ev3/motor-turn-block.png" /> <img
alt=""
src="/images/ev3/motor-turn-block.png"
/>
</div> </div>
</Step> </Step>
</Steps> </Steps>
@ -196,20 +239,23 @@ class EV3 extends ExtensionLanding {
<h3><FormattedMessage id="ev3.starterProjects" /></h3> <h3><FormattedMessage id="ev3.starterProjects" /></h3>
<Steps> <Steps>
<ProjectCard <ProjectCard
cardUrl="https://downloads.scratch.mit.edu/ev3/ev3-wave-hello.sb3" cardUrl="https://beta.scratch.mit.edu/#239075992"
description={this.props.intl.formatMessage({id: 'ev3.waveHelloDescription'})} description={this.props.intl.formatMessage({id: 'ev3.waveHelloDescription'})}
imageAlt={this.props.intl.formatMessage({id: 'ev3.imgAltWaveHello'})}
imageSrc="/images/ev3/starter-wave-hello.png" imageSrc="/images/ev3/starter-wave-hello.png"
title={this.props.intl.formatMessage({id: 'ev3.waveHelloTitle'})} title={this.props.intl.formatMessage({id: 'ev3.waveHelloTitle'})}
/> />
<ProjectCard <ProjectCard
cardUrl="https://downloads.scratch.mit.edu/ev3/ev3-distance-instrument.sb3" cardUrl="https://beta.scratch.mit.edu/#239076020"
description={this.props.intl.formatMessage({id: 'ev3.distanceInstrumentDescription'})} description={this.props.intl.formatMessage({id: 'ev3.distanceInstrumentDescription'})}
imageAlt={this.props.intl.formatMessage({id: 'ev3.imgAltDistanceInstrument'})}
imageSrc="/images/ev3/starter-distance-instrument.png" imageSrc="/images/ev3/starter-distance-instrument.png"
title={this.props.intl.formatMessage({id: 'ev3.distanceInstrumentTitle'})} title={this.props.intl.formatMessage({id: 'ev3.distanceInstrumentTitle'})}
/> />
<ProjectCard <ProjectCard
cardUrl="https://downloads.scratch.mit.edu/ev3/ev3-space-tacos.sb3" cardUrl="https://beta.scratch.mit.edu/#239076044"
description={this.props.intl.formatMessage({id: 'ev3.spaceTacosDescription'})} description={this.props.intl.formatMessage({id: 'ev3.spaceTacosDescription'})}
imageAlt={this.props.intl.formatMessage({id: 'ev3.imgAltSpaceTacos'})}
imageSrc="/images/ev3/starter-flying-game.png" imageSrc="/images/ev3/starter-flying-game.png"
title={this.props.intl.formatMessage({id: 'ev3.spaceTacosTitle'})} title={this.props.intl.formatMessage({id: 'ev3.spaceTacosTitle'})}
/> />

View file

@ -2,7 +2,7 @@
.ev3 { .ev3 {
.extension-header { .extension-header {
background-color: $ui-aqua; background-color: $ui-orange;
background-image: url("/images/ev3/ev3-pattern.svg"); background-image: url("/images/ev3/ev3-pattern.svg");
} }
} }

View file

@ -33,6 +33,15 @@
"ev3.otherComputerConnectedTitle": "Make sure no other computer is connected to your EV3", "ev3.otherComputerConnectedTitle": "Make sure no other computer is connected to your EV3",
"ev3.otherComputerConnectedText": "Only one computer can be connected to an EV3 at a time. If you have another computer connected to your EV3, disconnect the EV3 or close Scratch on that computer and try again.", "ev3.otherComputerConnectedText": "Only one computer can be connected to an EV3 at a time. If you have another computer connected to your EV3, disconnect the EV3 or close Scratch on that computer and try again.",
"ev3.updateFirmwareTitle": "Try updating your EV3 firmware", "ev3.updateFirmwareTitle": "Try updating your EV3 firmware",
"ev3.updateFirmwareText": "We recommend updating to EV3 firmware version 1.10E or above. See {firmwareUpdateLink}. We recommend following the instructions for \"Manual Firmware Update\".", "ev3.updateFirmwareText": "We recommend updating to EV3 firmware version 1.10E or above. See {firmwareUpdateLink}.",
"ev3.firmwareUpdateText": "firmware update instructions from LEGO" "ev3.firmwareUpdateText": "firmware update instructions from LEGO",
"ev3.imgAltEv3Illustration": "Illustration of an EV3 hub, featuring some examples of interacting with it.",
"ev3.imgAltAcceptConnection": "Use the buttons on your EV3 to accept the connection.",
"ev3.imgAltAcceptPasscode": "Use the center button on your EV3 to accept the passcode.",
"ev3.imgAltWaitForWindows": "Windows will notify you when the EV3 is ready.",
"ev3.imgAltEnterPasscodeMac": "Enter the passcode into the connection request window opening on your Mac.",
"ev3.imgAltPlugInMotor": "To find port A: hold the EV3 with the screen and buttons facing you, with the screen above the buttons. Port A is on top, and it is the left-most one",
"ev3.imgAltWaveHello": "A Scratch project with a waving fairy.",
"ev3.imgAltDistanceInstrument": "A Scratch project with a guitar.",
"ev3.imgAltSpaceTacos": "A Scratch project with Scratch Cat and a taco in space."
} }

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

@ -35,14 +35,6 @@ const Jobs = () => (
MIT Media Lab, Cambridge, MA MIT Media Lab, Cambridge, MA
</span> </span>
</li> </li>
<li>
<a href="https://scratch.mit.edu/jobs/moderator">
Community Moderator
</a>
<span>
Remote
</span>
</li>
</ul> </ul>
</div> </div>
</div> </div>

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

@ -28,5 +28,11 @@
"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 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.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.imgAltDragDropHex": "Drag and drop the HEX file from the folder you downloaded it to to the micro:bit.",
"microbit.imgAltDisplayH": "A micro:bit displaying an H.",
"microbit.imgAltHeartBeat" : "A Scratch project with a heart.",
"microbit.imgAltTiltGuitar": "A Scratch project with a guitar.",
"microbit.imgAltOceanAdventure": "A Scratch project with a clown fish and a saxophone under water."
} }

View file

@ -31,9 +31,15 @@ class MicroBit extends ExtensionLanding {
render () { render () {
return ( return (
<div className="extension-landing microbit"> <div className="extension-landing microbit">
<ExtensionHeader imageSrc="/images/microbit/microbit-heart.png"> <ExtensionHeader
imageAlt={this.props.intl.formatMessage({id: 'microbit.imgAltMicrobitIllustration'})}
imageSrc="/images/microbit/microbit-heart.png"
>
<FlexRow className="column extension-copy"> <FlexRow className="column extension-copy">
<h2><img src="/images/microbit/microbit.svg" />micro:bit</h2> <h1><img
alt=""
src="/images/microbit/microbit.svg"
/>micro:bit</h1>
<FormattedMessage <FormattedMessage
id="microbit.headerText" id="microbit.headerText"
values={{ values={{
@ -51,19 +57,31 @@ class MicroBit extends ExtensionLanding {
</FlexRow> </FlexRow>
<ExtensionRequirements> <ExtensionRequirements>
<span> <span>
<img src="/svgs/extensions/windows.svg" /> <img
alt=""
src="/svgs/extensions/windows.svg"
/>
Windows 10+ Windows 10+
</span> </span>
<span> <span>
<img src="/svgs/extensions/mac.svg" /> <img
alt=""
src="/svgs/extensions/mac.svg"
/>
macOS 10.13+ macOS 10.13+
</span> </span>
<span> <span>
<img src="/svgs/extensions/bluetooth.svg" /> <img
alt=""
src="/svgs/extensions/bluetooth.svg"
/>
Bluetooth 4.0 Bluetooth 4.0
</span> </span>
<span> <span>
<img src="/svgs/extensions/scratch-link.svg" /> <img
alt=""
src="/svgs/extensions/scratch-link.svg"
/>
Scratch Link Scratch Link
</span> </span>
</ExtensionRequirements> </ExtensionRequirements>
@ -82,7 +100,10 @@ class MicroBit extends ExtensionLanding {
<Steps> <Steps>
<Step number={1}> <Step number={1}>
<div className="step-image"> <div className="step-image">
<img src="/images/microbit/mbit-usb.png" /> <img
alt=""
src="/images/microbit/mbit-usb.png"
/>
</div> </div>
<p> <p>
<FormattedMessage id="microbit.connectUSB" /> <FormattedMessage id="microbit.connectUSB" />
@ -90,7 +111,10 @@ class MicroBit extends ExtensionLanding {
</Step> </Step>
<Step number={2}> <Step number={2}>
<div className="step-image"> <div className="step-image">
<img src="/images/microbit/mbit-hex-download.png" /> <img
alt=""
src="/images/microbit/mbit-hex-download.png"
/>
</div> </div>
<a <a
download download
@ -103,6 +127,7 @@ class MicroBit extends ExtensionLanding {
<Step number={3}> <Step number={3}>
<div className="step-image"> <div className="step-image">
<img <img
alt={this.props.intl.formatMessage({id: 'microbit.imgAltDragDropHex'})}
src={`/images/microbit/${ src={`/images/microbit/${
this.state.OS === OS_ENUM.WINDOWS ? 'win' : 'mac' this.state.OS === OS_ENUM.WINDOWS ? 'win' : 'mac'
}-copy-hex.png`} }-copy-hex.png`}
@ -120,13 +145,20 @@ class MicroBit extends ExtensionLanding {
<Steps> <Steps>
<Step number={1}> <Step number={1}>
<div className="step-image"> <div className="step-image">
<img src="/images/microbit/mbit-connect-1.png" /> <img
alt=""
src="/images/microbit/mbit-connect-1.png"
/>
</div> </div>
<p><FormattedMessage id="microbit.powerMicrobit" /></p> <p><FormattedMessage id="microbit.powerMicrobit" /></p>
</Step> </Step>
<Step number={2}> <Step number={2}>
<div className="step-image"> <div className="step-image">
<img src="/images/microbit/mbit-connect-2.png" /> <img
alt=""
className="screenshot"
src="/images/microbit/mbit-connect-2.png"
/>
</div> </div>
<p> <p>
<FormattedMessage <FormattedMessage
@ -147,7 +179,11 @@ class MicroBit extends ExtensionLanding {
</Step> </Step>
<Step number={3}> <Step number={3}>
<div className="step-image"> <div className="step-image">
<img src="/images/microbit/mbit-connect-3.png" /> <img
alt={this.props.intl.formatMessage({id: 'extensionInstallation.addExtension'})}
className="screenshot"
src="/images/microbit/mbit-connect-3.png"
/>
</div> </div>
<p><FormattedMessage id="microbit.addExtension" /></p> <p><FormattedMessage id="microbit.addExtension" /></p>
</Step> </Step>
@ -175,7 +211,10 @@ class MicroBit extends ExtensionLanding {
/> />
</span> </span>
<div className="step-image"> <div className="step-image">
<img src="/images/microbit/display-hello-block.png" /> <img
alt=""
src="/images/microbit/display-hello-block.png"
/>
</div> </div>
</Step> </Step>
<Step <Step
@ -193,7 +232,10 @@ class MicroBit extends ExtensionLanding {
/> />
</span> </span>
<div className="step-image"> <div className="step-image">
<img src="/images/microbit/mbit-display-h.png" /> <img
alt={this.props.intl.formatMessage({id: 'microbit.imgAltDisplayH'})}
src="/images/microbit/mbit-display-h.png"
/>
</div> </div>
</Step> </Step>
</Steps> </Steps>
@ -201,20 +243,23 @@ class MicroBit extends ExtensionLanding {
<h3><FormattedMessage id="microbit.starterProjects" /></h3> <h3><FormattedMessage id="microbit.starterProjects" /></h3>
<Steps> <Steps>
<ProjectCard <ProjectCard
cardUrl="https://downloads.scratch.mit.edu/microbit/microbit-heartbeat.sb3" cardUrl="https://beta.scratch.mit.edu/#239075756"
description={this.props.intl.formatMessage({id: 'microbit.heartBeatDescription'})} description={this.props.intl.formatMessage({id: 'microbit.heartBeatDescription'})}
imageAlt={this.props.intl.formatMessage({id: 'microbit.imgAltHeartBeat'})}
imageSrc="/images/microbit/starter-heart.png" imageSrc="/images/microbit/starter-heart.png"
title={this.props.intl.formatMessage({id: 'microbit.heartBeat'})} title={this.props.intl.formatMessage({id: 'microbit.heartBeat'})}
/> />
<ProjectCard <ProjectCard
cardUrl="https://downloads.scratch.mit.edu/microbit/microbit-guitar.sb3" cardUrl="https://beta.scratch.mit.edu/#239075950"
description={this.props.intl.formatMessage({id: 'microbit.tiltGuitarDescription'})} description={this.props.intl.formatMessage({id: 'microbit.tiltGuitarDescription'})}
imageAlt={this.props.intl.formatMessage({id: 'microbit.imgAltTiltGuitar'})}
imageSrc="/images/microbit/starter-guitar.png" imageSrc="/images/microbit/starter-guitar.png"
title={this.props.intl.formatMessage({id: 'microbit.tiltGuitar'})} title={this.props.intl.formatMessage({id: 'microbit.tiltGuitar'})}
/> />
<ProjectCard <ProjectCard
cardUrl="https://downloads.scratch.mit.edu/microbit/microbit-fish.sb3" cardUrl="https://beta.scratch.mit.edu/#239075973"
description={this.props.intl.formatMessage({id: 'microbit.oceanAdventureDescription'})} description={this.props.intl.formatMessage({id: 'microbit.oceanAdventureDescription'})}
imageAlt={this.props.intl.formatMessage({id: 'microbit.imgAltOceanAdventure'})}
imageSrc="/images/microbit/starter-fish.png" imageSrc="/images/microbit/starter-fish.png"
title={this.props.intl.formatMessage({id: 'microbit.oceanAdventure'})} title={this.props.intl.formatMessage({id: 'microbit.oceanAdventure'})}
/> />

View file

@ -3,7 +3,7 @@
.microbit { .microbit {
.extension-header { .extension-header {
background-color: $ui-purple; background-color: $ui-mint-green;
background-image: url("/images/microbit/mbit-pattern.svg"); background-image: url("/images/microbit/mbit-pattern.svg");
.extension-info { .extension-info {

View file

@ -0,0 +1,246 @@
const React = require('react');
const PropTypes = require('prop-types');
const bindAll = require('lodash.bindall');
const classNames = require('classnames');
const FlexRow = require('../../../components/flex-row/flex-row.jsx');
const Avatar = require('../../../components/avatar/avatar.jsx');
const EmojiText = require('../../../components/emoji-text/emoji-text.jsx');
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');
require('./comment.scss');
class Comment extends React.Component {
constructor (props) {
super(props);
bindAll(this, [
'handleDelete',
'handleCancelDelete',
'handleConfirmDelete',
'handleReport',
'handleConfirmReport',
'handleCancelReport',
'handlePostReply',
'handleToggleReplying',
'handleRestore'
]);
this.state = {
deleting: false,
reporting: false,
reportConfirmed: false,
replying: false
};
}
handlePostReply (comment) {
this.setState({replying: false});
this.props.onAddComment(comment);
}
handleToggleReplying () {
this.setState({replying: !this.state.replying});
}
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
});
}
render () {
const {
author,
canDelete,
canReply,
canReport,
canRestore,
content,
datetimeCreated,
id,
projectId,
replyUsername,
visibility
} = this.props;
const visible = visibility === 'visible';
return (
<div
className="flex-row comment"
id={`comments-${id}`}
>
<a href={`/users/${author.username}`}>
<Avatar src={author.image} />
</a>
<FlexRow className="comment-body column">
<FlexRow className="comment-top-row">
<a
className="username"
href={`/users/${author.username}`}
>{author.username}</a>
<div className="action-list">
{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
})}
>
{/* TODO: at the moment, comment content does not properly display
* emojis/easter eggs
* @user links in replies
* links to scratch.mit.edu pages
*/}
<span className="comment-content">
{replyUsername && (
<a href={`/users/${replyUsername}`}>@{replyUsername}&nbsp;</a>
)}
<EmojiText
as="span"
text={content}
/>
</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
parentId={id}
projectId={projectId}
onAddComment={this.handlePostReply}
onCancel={this.handleToggleReplying}
/>
</FlexRow>
) : null}
</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>
);
}
}
Comment.propTypes = {
author: PropTypes.shape({
id: PropTypes.number,
image: PropTypes.string,
username: PropTypes.string
}),
canDelete: PropTypes.bool,
canReply: PropTypes.bool,
canReport: PropTypes.bool,
canRestore: PropTypes.bool,
content: PropTypes.string,
datetimeCreated: PropTypes.string,
id: PropTypes.number,
onAddComment: PropTypes.func,
onDelete: PropTypes.func,
onReport: PropTypes.func,
onRestore: PropTypes.func,
projectId: PropTypes.string,
replyUsername: PropTypes.string,
visibility: PropTypes.string
};
module.exports = Comment;

View file

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

View file

@ -0,0 +1,182 @@
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 FlexRow = require('../../../components/flex-row/flex-row.jsx');
const Avatar = require('../../../components/avatar/avatar.jsx');
const InplaceInput = require('../../../components/forms/inplace-input.jsx');
const Button = require('../../../components/forms/button.jsx');
const connect = require('react-redux').connect;
const api = require('../../../lib/api');
require('./comment.scss');
const onUpdate = update => update;
const MAX_COMMENT_LENGTH = 500;
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
};
}
handleInput (event) {
this.setState({
message: event.target.value,
status: ComposeStatus.EDITING,
error: 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 || '',
comentee_id: this.props.comenteeId || ''
}
}, (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
});
return;
}
// Clear the text field and reset status on successful submission
this.setState({
message: '',
status: ComposeStatus.EDITING,
error: 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
});
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}`} />
</div>
</FlexRow>
) : null}
<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>
</FlexRow>
</div>
);
}
}
ComposeComment.propTypes = {
comenteeId: 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

@ -0,0 +1,182 @@
const React = require('react');
const PropTypes = require('prop-types');
const bindAll = require('lodash.bindall');
const classNames = require('classnames');
const FormattedMessage = require('react-intl').FormattedMessage;
const FlexRow = require('../../../components/flex-row/flex-row.jsx');
const Comment = require('./comment.jsx');
require('./comment.scss');
class TopLevelComment extends React.Component {
constructor (props) {
super(props);
bindAll(this, [
'handleExpandThread',
'handleAddComment',
'handleDeleteReply',
'handleReportReply',
'handleRestoreReply'
]);
this.state = {
expanded: false
};
// A cache of {commentId: username, ...} in order to show reply usernames
this.commentUsernameCache = {};
}
handleExpandThread () {
this.setState({
expanded: true
});
}
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);
}
commentUsername (parentId) {
if (this.commentUsernameCache[parentId]) return this.commentUsernameCache[parentId];
// 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.commentUsernameCache[this.props.id] = this.props.author.username;
const replies = this.props.replies;
for (let i = 0; i < replies.length; i++) {
this.commentUsernameCache[replies[i].id] = replies[i].author.username;
}
return this.commentUsernameCache[parentId];
}
render () {
const {
author,
canDelete,
canReply,
canReport,
canRestore,
content,
datetimeCreated,
id,
onDelete,
onReport,
onRestore,
replies,
projectId,
visibility
} = this.props;
const parentVisible = visibility === 'visible';
return (
<FlexRow className="comment-container">
<Comment
projectId={projectId}
onAddComment={this.handleAddComment}
{...{
author,
content,
datetimeCreated,
canDelete,
canReply,
canReport,
canRestore,
id,
onDelete,
onReport,
onRestore,
visibility
}}
/>
{replies.length > 0 &&
<FlexRow
className={classNames(
'replies',
'column',
{collapsed: !this.state.expanded && replies.length > 3}
)}
key={id}
>
{(this.state.expanded ? replies : replies.slice(0, 3)).map(reply => (
<Comment
author={reply.author}
canDelete={canDelete}
canReply={canReply}
canReport={canReport}
canRestore={canRestore && parentVisible}
content={reply.content}
datetimeCreated={reply.datetime_created}
id={reply.id}
key={reply.id}
projectId={projectId}
replyUsername={this.commentUsername(reply.parent_id)}
visibility={reply.visibility}
onAddComment={this.handleAddComment}
onDelete={this.handleDeleteReply}
onReport={this.handleReportReply}
onRestore={this.handleRestoreReply}
/>
))}
</FlexRow>
}
{!this.state.expanded && replies.length > 3 &&
<a
className="expand-thread"
onClick={this.handleExpandThread}
>
<FormattedMessage
id="comments.seeMoreReplies"
values={{
repliesCount: replies.length
}}
/>
</a>
}
</FlexRow>
);
}
}
TopLevelComment.propTypes = {
author: PropTypes.shape({
id: PropTypes.number,
image: PropTypes.string,
username: PropTypes.string
}),
canDelete: PropTypes.bool,
canReply: PropTypes.bool,
canReport: PropTypes.bool,
canRestore: PropTypes.bool,
content: PropTypes.string,
datetimeCreated: PropTypes.string,
deletable: PropTypes.bool,
id: PropTypes.number,
onAddComment: PropTypes.func,
onDelete: PropTypes.func,
onReport: PropTypes.func,
onRestore: PropTypes.func,
parentId: PropTypes.number,
projectId: PropTypes.string,
replies: PropTypes.arrayOf(PropTypes.object),
visibility: PropTypes.string
};
module.exports = TopLevelComment;

View file

@ -26,6 +26,7 @@
font-size: .875rem; font-size: .875rem;
justify-content: center; justify-content: center;
flex-flow: column; flex-flow: column;
align-items: flex-start;
} }
.extension-status { .extension-status {

View file

@ -1,9 +1,15 @@
{ {
"addToStudio.title": "Add to Studio", "addToStudio.title": "Add to Studio",
"addToStudio.finishing": "Finishing up...", "addToStudio.finishing": "Finishing up...",
"preview.titleMaxLength": "Title is too long",
"preview.musicExtensionChip": "Music", "preview.musicExtensionChip": "Music",
"preview.penExtensionChip": "Pen", "preview.penExtensionChip": "Pen",
"preview.speechExtensionChip": "Google Speech", "preview.speechExtensionChip": "Google Speech",
"preview.translateExtensionChip": "Google Translate", "preview.translateExtensionChip": "Google Translate",
"preview.videoMotionChip": "Video Motion" "preview.videoMotionChip": "Video Motion",
"preview.comments.header": "Comments",
"preview.comments.turnOff": "Turn off commenting",
"preview.comments.turnedOff": "Sorry, comment posting has been turned off for this project.",
"preview.share.notShared": "This project is not shared — so only you can see it. Click share to let everyone see it!",
"preview.share.shareButton": "Share"
} }

Some files were not shown because too many files have changed in this diff Show more