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

View file

@ -1,6 +1,6 @@
[main]
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]
file_filter = localizations/explore/<lang>.json
@ -156,6 +156,23 @@ source_file = src/views/microbit/l10n.json
source_lang = en
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]
source_file = src/views/parents/l10n.json
source_lang = en

View file

@ -1,11 +1,25 @@
ESLINT=./node_modules/.bin/eslint
NODE= NODE_OPTIONS=--max_old_space_size=8000 node
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
TAP=./node_modules/.bin/tap
WATCH= NODE_OPTIONS=--max_old_space_size=8000 ./node_modules/.bin/watch
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:

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
| Variable | Default | Description |
| ------------- | ----------------------------- | ---------------------------------------------- |
| `API_HOST` | `https://api.scratch.mit.edu` | Hostname for API requests |
| `SENTRY_DSN` | `''` | DSN for Sentry |
| `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) |
| Variable | Default | Description |
| --------------- | ---------------------------------- | ---------------------------------------------- |
| `API_HOST` | `https://api.scratch.mit.edu` | Hostname for API requests |
| `ASSETS_HOST` | `https://assets.scratch.mit.edu` | Hostname for asset requests |
| `BACKPACK_HOST` | `https://backpack.scratch.mit.edu` | Hostname for backpack requests |
| `PROJECTS_HOST` | `https://projects.scratch.mit.edu` | Hostname for project requests |
| `SENTRY_DSN` | `''` | DSN for Sentry |
| `FALLBACK` | `''` | Pass-through location for old site |
| `GA_TRACKER` | `''` | Where to log Google Analytics data |
| `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.

View file

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

View file

@ -31,6 +31,7 @@
"lodash.defaults": "4.0.1",
"newrelic": "1.25.4",
"raven": "0.10.0",
"scratch-docker": "^1.0.2",
"scratch-parser": "^4.2.0",
"scratch-storage": "^0.5.1"
},
@ -77,7 +78,6 @@
"lodash.merge": "3.3.2",
"lodash.omit": "3.1.0",
"lodash.range": "3.0.1",
"lodash.truncate": "4.4.2",
"minilog": "2.0.8",
"node-dir": "0.1.16",
"node-sass": "4.6.1",
@ -100,7 +100,7 @@
"redux-thunk": "2.0.1",
"sass-lint": "1.5.1",
"sass-loader": "6.0.6",
"scratch-gui": "latest",
"scratch-gui": "develop",
"scratchr2_translations": "git://github.com/LLK/scratchr2_translations.git#master",
"slick-carousel": "1.6.0",
"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-15percent: hsla(0, 100%, 100%, .15); //#FFF
$ui-light-primary: hsl(215, 100, 95);
$ui-light-primary-transparent: hsla(215, 100, 95, 0);
$ui-border: hsla(0, 0, 85, 1); //#D9D9D9

View file

@ -38,6 +38,31 @@ $desktop: 942px;
$tablet: 640px;
$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
//
@ -48,7 +73,7 @@ $mobile: 480px;
//4 columns
@mixin submobile ($parent-selector, $child-selector) {
@media only screen and (max-width: $mobile - 1) {
@media #{$small} {
#{$parent-selector} {
text-align: center;
}
@ -64,7 +89,7 @@ $mobile: 480px;
//6 columns
@mixin mobile ($parent-selector, $child-selector) {
@media only screen and (min-width: $mobile) and (max-width: $tablet - 1) {
@media #{$medium} {
#{$parent-selector} {
text-align: center;
}
@ -80,7 +105,7 @@ $mobile: 480px;
//8 columns
@mixin tablet ($parent-selector, $child-selector) {
@media only screen and (min-width: $tablet) and (max-width: $desktop - 1) {
@media #{$intermediate} {
#{$parent-selector} {
text-align: center;
}
@ -94,7 +119,7 @@ $mobile: 480px;
//12 columns
@mixin desktop ($parent-selector, $child-selector) {
@media only screen and (min-width: $desktop) {
@media #{$big} {
#{$child-selector} {
margin: 0 auto;
width: $desktop;

View file

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

View file

@ -65,7 +65,7 @@
margin: 0 0 -3rem -4rem;
}
.row {
.row {
margin-bottom: 1.2rem;
&.has-error {
@ -81,7 +81,7 @@
}
}
@media only screen and (max-width: $mobile - 1) {
@media #{$small} {
.card {
width: 22.5rem;
@ -95,7 +95,7 @@
}
}
@media only screen and (max-width: $tablet - 1) {
@media #{$medium-and-smaller} {
.card {
.input {
width: 90%;
@ -103,7 +103,7 @@
}
}
@media only screen and (max-width: $desktop - 1) {
@media #{$intermediate-and-smaller} {
.card {
.validation-message {
position: relative;

View file

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

View file

@ -10,8 +10,16 @@ class ExtensionLanding extends React.Component {
'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 = {
OS: OS_ENUM.WINDOWS
OS: detectedOS
};
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -7,7 +7,7 @@
$thumbnail-width: 220px;
$thumbnail-inner-width: 204px;
$project-height: 208px;
$gallery-height: 164px;
@ -94,21 +94,21 @@
}
//4 columns
@media only screen and (max-width: $mobile - 1) {
@media #{$small} {
.flex-row {
width: $cols4;
}
}
//6 columns
@media only screen and (min-width: $mobile) and (max-width: $tablet - 1) {
@media #{$medium} {
.flex-row {
width: $cols6;
}
}
// 8 columns
@media only screen and (min-width: $tablet) and (max-width: $desktop - 1) {
@media #{$intermediate} {
.flex-row {
width: $cols9;
}

View file

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

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

View file

@ -2,6 +2,26 @@
.login {
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 {
padding-top: 5px;
@ -15,7 +35,7 @@
.spinner {
margin: 0 .8rem;
width: 1rem;
height: 1rem;
vertical-align: middle;
}
.submit-button {
@ -24,13 +44,19 @@
a {
margin-top: 15px;
color: $ui-white;
&:link,
&:visited,
&:active {
color: $ui-white;
}
&:hover {
background-color: transparent;
}
}
.error {
border: 1px solid $active-dark-gray;
border-radius: 5px;

View file

@ -9,14 +9,14 @@
// column-count required for Firefox, IE and Edge
//4 columns
@media only screen and (max-width: $mobile - 1) {
@media #{$small} {
.masonry {
column-count: 1;
}
}
//6 columns
@media only screen and (min-width: $mobile) and (max-width: $tablet - 1) {
@media #{$medium} {
.masonry {
column-count: 1;
}
@ -24,14 +24,14 @@
//8 columns
@media only screen and (min-width: $tablet) and (max-width: $desktop - 1) {
@media #{$intermediate} {
.masonry {
column-count: 2;
}
}
// 12 columns
@media only screen and (min-width: $desktop) {
@media #{$big} {
.masonry {
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 "../../../frameless";
.mod-addToStudio * {
box-sizing: border-box;
}
.mod-addToStudio {
margin: 100px auto;
outline: none;
padding: 0;
width: 36.25rem; /* 580px; */
height: 388px; /* 24.25rem; */
overflow: hidden;
user-select: none;
@media #{$small}, #{$small-height} {
overflow: hidden;
}
}
.addToStudio-modal-header {
box-shadow: inset 0 -1px 0 0 $ui-blue-dark;
background-color: $ui-blue;
padding-top: .75rem;
width: 100%;
height: 3rem;
box-sizing: border-box;
}
.addToStudio-content-label {
text-align: center;
color: $type-white;
font-family: "Helvetica Neue", Helvetica, Arial, sans-serif;
font-size: 1rem;
font-weight: bold;
}
.addToStudio-modal-content {
margin: 0 auto;
box-shadow: none;
width: 100%;
line-height: 1.5rem;
font-size: .875rem;
}
.studio-list-outer-scrollbox {
position: relative;
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 {
margin-right: .5rem;
padding-right: .5rem;
height: 16.9375rem;
height: 100%;
overflow: scroll;
overflow-x: hidden;
@ -90,34 +78,36 @@
pointer-events: none; /* pass clicks through to buttons underneath */
}
.studio-selector-button {
display: flex;
position: relative;
margin: .21875rem .21875rem;
transition: all .5s;
margin: .21875rem;
border-radius: .5rem;
background-color: $ui-white;
cursor: pointer;
padding: 0;
width: 16.1875rem; /* 259px */
width: 48%;
height: 2.5rem;
box-sizing: border-box;
justify-content: space-between;
align-items: center;
@media #{$small} {
min-width: 98%;
flex-shrink: 1;
}
}
.studio-selector-button-text {
position: absolute;
/* per spec, should be:
margin: .375rem 2.18375rem .375rem .6875rem
but in practice, our css seems to vertically align text to top, where
invision spec aligned to middle.
*/
margin: .575rem 2.18375rem .175rem .6875rem;
width: 13.3125rem;
height: 1rem; /* diff from spec, in case we ever do valign to middle */
line-height: 1.25rem;
margin: auto 2.18375rem auto .6875rem;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-family: "Helvetica Neue";
font-size: .875rem;
font-weight: regular;
flex-shrink: 1;
}
.studio-selector-button-selected {
@ -140,7 +130,7 @@
.studio-status-icon {
position: absolute;
margin: .5rem .625rem .5rem 14.0625rem;
right: .625rem;
border-radius: .75rem;
padding: .0625rem .075rem;
width: 1.5rem;
@ -161,30 +151,30 @@
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;
height: 1.4rem;
transform-origin: center;
}
.studio-status-icon--img {
width: 1.4rem;
height: 1.4rem;
.studio-status-icon-with-animation {
animation-name: bump;
animation-duration: .25s;
animation-timing-function: cubic-bezier(.3, -3, .6, 3);
animation-iteration-count: 1;
}
.action-button-text .spinner-smooth {
margin: .2125rem auto;
width: 1.875rem;
height: 1rem;
}
.studio-status-icon .spinner-smooth {
position: unset; /* don't understand why neither relative nor absolute work */
}
.studio-status-icon .spinner-smooth .circle {
/* overlay spinner on circle */
position: absolute;
margin: .1875rem; /* stay within boundaries of circle */
width: 75%; /* stay within boundaries of circle */
height: 75%; /* stay within boundaries of circle */
@keyframes bump {
0% {
transform: scale(0);
opacity: 0;
-webkit-transform: scale(0);
}
100% {
transform: scale(1);
opacity: 1;
-webkit-transform: scale(1);
}
}

View file

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

View file

@ -1,29 +1,36 @@
const truncateAtWordBoundary = require('../../../lib/truncate').truncateAtWordBoundary;
const PropTypes = require('prop-types');
const React = require('react');
const classNames = require('classnames');
const Spinner = require('../../spinner/spinner.jsx');
const AnimateHOC = require('./animate-hoc.jsx');
require('./modal.scss');
const StudioButton = ({
hasRequestOutstanding,
id,
includesProject,
title,
onToggleStudio
onClick,
wasClicked
}) => {
const checkmark = (
<img
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"
/>
);
const plus = (
<img
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"
/>
);
@ -35,8 +42,7 @@ const StudioButton = ({
{'studio-selector-button-selected':
includesProject && !hasRequestOutstanding}
)}
data-id={id}
onClick={onToggleStudio}
onClick={onClick}
>
<div
className={classNames(
@ -44,17 +50,18 @@ const StudioButton = ({
{'studio-selector-button-text-selected': includesProject || hasRequestOutstanding},
{'studio-selector-button-text-unselected': !includesProject && !hasRequestOutstanding}
)}
title={title}
>
{truncateAtWordBoundary(title, 25)}
{title}
</div>
<div
className={classNames(
'studio-status-icon',
{'studio-status-icon-unselected': !includesProject}
{'studio-status-icon-unselected': !includesProject && !hasRequestOutstanding}
)}
>
{(hasRequestOutstanding ?
(<Spinner mode="smooth" />) :
<Spinner /> :
(includesProject ? checkmark : plus))}
</div>
</div>
@ -63,10 +70,10 @@ const StudioButton = ({
StudioButton.propTypes = {
hasRequestOutstanding: PropTypes.bool,
id: PropTypes.number,
includesProject: PropTypes.bool,
onToggleStudio: PropTypes.func,
title: PropTypes.string
onClick: PropTypes.func,
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');
ReactModal.setAppElement(document.getElementById('view'));
ReactModal.setAppElement(document.getElementById('app'));
/**
* Container for pop up windows (See: registration window)
@ -23,11 +23,19 @@ class Modal extends React.Component {
return this.modal.portal.requestClose();
}
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 (
<ReactModal
appElement={document.getElementById('view')}
appElement={document.getElementById('app')}
{...bodyOpenClassNameProp}
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),
beforeClose: classNames('modal-content', this.props.className)
}}
@ -60,7 +68,8 @@ class Modal extends React.Component {
Modal.propTypes = {
children: PropTypes.node,
className: PropTypes.string,
overlayClassName: PropTypes.string
overlayClassName: PropTypes.string,
useStandardSizes: PropTypes.bool
};
module.exports = Modal;

View file

@ -1,6 +1,12 @@
@import "../../../colors";
@import "../../../frameless";
.overflow-hidden {
/* to avoid double scroll bars this
gets added to body while modal is open */
overflow: hidden;
}
.modal-content {
position: relative;
margin: 3.75rem auto;
@ -10,9 +16,27 @@
padding: 0;
width: 48.75rem;
.modal-content { /* content inside of content */
display: flex;
border-radius: 0;
flex-direction: column;
}
&:focus {
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 {
@ -43,30 +67,27 @@ $modal-close-size: 1rem;
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. */
.action-buttons {
display: flex;
margin: 1.125rem .8275rem .9375rem .8275rem;
line-height: 1.5rem;
justify-content: flex-end !important;
align-items: flex-start;
flex-wrap: nowrap;
@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 {
@ -83,3 +104,62 @@ $modal-close-size: 1rem;
.action-button-text {
display: flex;
}
.action-button.disabled {
background-color: $active-dark-gray;
}
.error-text
{
display: block;
border: 1px solid $active-gray;
border-radius: 5px;
background-color: $ui-orange;
padding: 1rem;
min-height: 1rem;
overflow: visible;
color: $type-white;
}
.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 PropTypes = require('prop-types');
const React = require('react');
const connect = require('react-redux').connect;
const FormattedMessage = require('react-intl').FormattedMessage;
const injectIntl = require('react-intl').injectIntl;
const intlShape = require('react-intl').intlShape;
const Modal = require('../base/modal.jsx');
const classNames = require('classnames');
const Form = require('../../forms/form.jsx');
const Button = require('../../forms/button.jsx');
@ -12,6 +14,7 @@ const Select = require('../../forms/select.jsx');
const Spinner = require('../../spinner/spinner.jsx');
const TextArea = require('../../forms/textarea.jsx');
const FlexRow = require('../../flex-row/flex-row.jsx');
const previewActions = require('../../../redux/preview.js');
require('../../forms/button.scss');
require('./modal.scss');
@ -68,12 +71,24 @@ class ReportModal extends React.Component {
constructor (props) {
super(props);
bindAll(this, [
'handleReportCategorySelect'
'handleCategorySelect',
'handleValid',
'handleInvalid'
]);
this.state = {reportCategory: this.props.report.category};
this.state = {
category: '',
notes: '',
valid: false
};
}
handleReportCategorySelect (name, value) {
this.setState({reportCategory: value});
handleCategorySelect (name, value) {
this.setState({category: value});
}
handleValid () {
this.setState({valid: true});
}
handleInvalid () {
this.setState({valid: false});
}
lookupPrompt (value) {
const prompt = REPORT_OPTIONS.find(item => item.value === value).prompt;
@ -82,90 +97,145 @@ class ReportModal extends React.Component {
render () {
const {
intl,
isConfirmed,
isError,
isOpen,
isWaiting,
onReport, // eslint-disable-line no-unused-vars
report,
onRequestClose,
type,
...modalProps
} = this.props;
const submitEnabled = this.state.valid && !isWaiting;
const submitDisabledParam = submitEnabled ? {} : {disabled: 'disabled'};
const contentLabel = intl.formatMessage({id: `report.${type}`});
return (
<Modal
useStandardSizes
className="mod-report"
contentLabel={contentLabel}
isOpen={report.open}
isOpen={isOpen}
onRequestClose={onRequestClose}
{...modalProps}
>
<div>
<div className="report-modal-header">
<div className="report-content-label">
<div className="report-modal-header modal-header">
<div className="report-content-label content-label">
{contentLabel}
</div>
</div>
<Form
className="report"
onSubmit={onReport}
onInvalid={this.handleInvalid}
onValid={this.handleValid}
onValidSubmit={onReport}
>
<div className="report-modal-content">
<FormattedMessage
id={`report.${type}Instructions`}
values={{
CommunityGuidelinesLink: (
<a href="/community_guidelines">
<FormattedMessage id="report.CommunityGuidelinesLinkText" />
</a>
)
}}
/>
<Select
required
elementWrapperClassName="report-modal-field"
label={null}
name="report_category"
options={REPORT_OPTIONS.map(option => ({
value: option.value,
label: this.props.intl.formatMessage(option.label)
}))}
value={this.state.reportCategory}
onChange={this.handleReportCategorySelect}
/>
<TextArea
required
className="report-text"
elementWrapperClassName="report-modal-field"
label={null}
name="notes"
placeholder={this.lookupPrompt(this.state.reportCategory)}
validationErrors={{
maxLength: this.props.intl.formatMessage({id: 'report.tooLongError'}),
minLength: this.props.intl.formatMessage({id: 'report.tooShortError'})
}}
validations={{
maxLength: 500,
minLength: 20
}}
value={report.notes}
/>
<div className="report-modal-content modal-content">
{isConfirmed ? (
<div className="received">
<div className="received-header">
<FormattedMessage id="report.receivedHeader" />
</div>
<FormattedMessage id="report.receivedBody" />
</div>
) : (
<div>
<div className="instructions">
<FormattedMessage
id={`report.${type}Instructions`}
key={`report.${type}Instructions`}
values={{
CommunityGuidelinesLink: (
<a href="/community_guidelines">
<FormattedMessage id="report.CommunityGuidelinesLinkText" />
</a>
)
}}
/>
</div>
<Select
required
elementWrapperClassName="report-modal-field"
label={null}
name="report_category"
options={REPORT_OPTIONS.map(option => ({
value: option.value,
label: this.props.intl.formatMessage(option.label),
key: option.value
}))}
validationErrors={{
isDefaultRequiredValue: this.props.intl.formatMessage({
id: 'report.reasonMissing'
})
}}
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>
<FlexRow className="action-buttons">
{report.waiting ? [
<Button
className="submit-button"
disabled="disabled"
key="submitButton"
type="submit"
>
<Spinner />
</Button>
] : [
<Button
className="submit-button"
key="submitButton"
type="submit"
>
<FormattedMessage id="report.send" />
</Button>
]}
<div className="action-buttons-overflow-fix">
{isConfirmed ? (
<Button
className="action-button submit-button"
type="button"
onClick={onRequestClose}
>
<div className="action-button-text">
<FormattedMessage id="general.close" />
</div>
</Button>
) : (
<Button
className={classNames(
'action-button',
'submit-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>
</Form>
</div>
@ -176,15 +246,26 @@ class ReportModal extends React.Component {
ReportModal.propTypes = {
intl: intlShape,
isConfirmed: PropTypes.bool,
isError: PropTypes.bool,
isOpen: PropTypes.bool,
isWaiting: PropTypes.bool,
onReport: PropTypes.func,
onRequestClose: PropTypes.func,
report: PropTypes.shape({
category: PropTypes.string,
notes: PropTypes.string,
open: PropTypes.bool,
waiting: PropTypes.bool
}),
type: PropTypes.string
};
module.exports = injectIntl(ReportModal);
const mapStateToProps = state => ({
isConfirmed: state.preview.status.report === previewActions.Status.FETCHED,
isError: state.preview.status.report === previewActions.Status.ERROR,
isWaiting: state.preview.status.report === previewActions.Status.FETCHING
});
const mapDispatchToProps = () => ({});
const ConnectedReportModal = connect(
mapStateToProps,
mapDispatchToProps
)(ReportModal);
module.exports = injectIntl(ConnectedReportModal);

View file

@ -1,66 +1,65 @@
@import "../../../colors";
@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 {
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%;
line-height: 1.5rem;
font-size: .875rem;
.instructions {
line-height: 1.5rem;
}
.received {
margin: 0 auto;
width: 90%;
text-align: center;
line-height: 1.65rem;
.received-header {
font-weight: bold;
}
}
.error-text {
margin-top: .9375rem;
}
.validation-message {
$arrow-border-width: 1rem;
display: block;
position: absolute;
top: 0;
left: 0;
transform: translate(23.5rem, 0);
margin-left: $arrow-border-width;
position: relative;
z-index: 1;
margin-top: $arrow-border-width;
border: 1px solid $active-gray;
border-radius: 5px;
background-color: $ui-orange;
padding: 1rem;
max-width: 18.75rem;
min-width: 12rem;
min-height: 1rem;
overflow: visible;
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 {
display: block;
position: absolute;
top: 1rem;
left: -$arrow-border-width / 2;
top: -.5rem;
left: calc(50% - calc(#{$arrow-border-width} / 2));
transform: rotate(45deg);
transform: rotate(135deg);
border-bottom: 1px solid $active-gray;
border-left: 1px solid $active-gray;
@ -71,6 +70,10 @@
height: $arrow-border-width;
content: "";
@media #{$medium-and-smaller} {
display: none;
}
}
}
}
@ -78,3 +81,16 @@
.report-modal-field {
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;
}
@media only screen and (max-width: $mobile - 1) {
@media #{$small} {
.modal-content.mod-ttt {
overflow: scroll;
}
@ -76,7 +76,7 @@
}
}
@media only screen and (min-width: $mobile) and (max-width: $tablet - 1) {
@media #{$medium} {
.modal-content.mod-ttt {
overflow: scroll;
}
@ -90,7 +90,7 @@
}
}
@media only screen and (min-width: $tablet) and (max-width: $desktop - 1) {
@media #{$intermediate} {
.modal-content.mod-ttt {
overflow: scroll;
}

View file

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

View file

@ -11,7 +11,7 @@
align-items: center;
list-style-type: none;
}
.li-left-ul.mod-2018 {
padding-left: 0;
}
@ -45,7 +45,7 @@
font-weight: bold;
}
@media only screen and (max-width: $mobile - 1) {
@media #{$small} {
.li-right-ul.mod-2018 {
flex-flow: row nowrap;
}
@ -60,7 +60,7 @@
}
}
@media only screen and (max-width: $tablet - 1) {
@media #{$medium-and-smaller} {
height: 100px;
.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 messageCountActions = require('../../../redux/message-count.js');
const navigationActions = require('../../../redux/navigation.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 Dropdown = require('../../dropdown/dropdown.jsx');
const Form = require('../../forms/form.jsx');
const Input = require('../../forms/input.jsx');
const log = require('../../../lib/log.js');
const Login = require('../../login/login.jsx');
const Modal = require('../../modal/base/modal.jsx');
const LoginDropdown = require('../../login/login-dropdown.jsx');
const CanceledDeletionModal = require('../../login/canceled-deletion-modal.jsx');
const NavigationBox = require('../base/navigation.jsx');
const Registration = require('../../registration/registration.jsx');
const AccountNav = require('./accountnav.jsx');
require('./navigation.scss');
@ -29,34 +27,16 @@ class Navigation extends React.Component {
super(props);
bindAll(this, [
'getProfileUrl',
'handleJoinClick',
'handleLoginClick',
'handleCloseLogin',
'handleLogIn',
'handleLogOut',
'handleAccountNavClick',
'handleCloseAccountNav',
'showCanceledDeletion',
'handleCloseCanceledDeletion',
'handleCloseRegistration',
'handleCompleteRegistration',
'handleSearchSubmit'
]);
this.state = {
accountNavOpen: false,
canceledDeletionOpen: false,
loginOpen: false,
loginError: null,
registrationOpen: false,
messageCountIntervalId: -1 // javascript method interval id for getting messsage count.
};
}
componentDidMount () {
if (this.props.session.session.user) {
if (this.props.user) {
const intervalId = setInterval(() => {
this.props.dispatch(
messageCountActions.getCount(this.props.session.session.user.username)
);
this.props.getMessageCount(this.props.user.username);
}, 120000); // check for new messages every 2 mins.
this.setState({ // eslint-disable-line react/no-did-mount-set-state
messageCountIntervalId: intervalId
@ -64,16 +44,11 @@ class Navigation extends React.Component {
}
}
componentDidUpdate (prevProps) {
if (prevProps.session.session.user !== this.props.session.session.user) {
this.setState({ // eslint-disable-line react/no-did-update-set-state
loginOpen: false,
accountNavOpen: false
});
if (this.props.session.session.user) {
if (prevProps.user !== this.props.user) {
this.props.closeAccountMenus();
if (this.props.user) {
const intervalId = setInterval(() => {
this.props.dispatch(
messageCountActions.getCount(this.props.session.session.user.username)
);
this.props.getMessageCount(this.props.user.username);
}, 120000); // check for new messages every 2 mins.
this.setState({ // eslint-disable-line react/no-did-update-set-state
messageCountIntervalId: intervalId
@ -81,7 +56,7 @@ class Navigation extends React.Component {
} else {
// clear message count check, and set to default id.
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
messageCountIntervalId: -1
});
@ -92,102 +67,25 @@ class Navigation extends React.Component {
// clear message interval if it exists
if (this.state.messageCountIntervalId !== -1) {
clearInterval(this.state.messageCountIntervalId);
this.props.dispatch(messageCountActions.setCount(0));
this.props.setMessageCount(0);
this.setState({
messageCountIntervalId: -1
});
}
}
getProfileUrl () {
if (!this.props.session.session.user) return;
return `/users/${this.props.session.session.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();
if (!this.props.user) return;
return `/users/${this.props.user.username}/`;
}
handleSearchSubmit (formData) {
window.location.href = `/search/projects?q=${encodeURIComponent(formData.q)}`;
}
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 (
<NavigationBox
className={classNames({
'logged-in': this.props.session.session.user
'logged-in': this.props.user
})}
>
<ul>
@ -235,7 +133,7 @@ class Navigation extends React.Component {
</Form>
</li>
{this.props.session.status === sessionActions.Status.FETCHED ? (
this.props.session.session.user ? [
this.props.user ? [
<li
className="link right messages"
key="messages"
@ -249,7 +147,7 @@ class Navigation extends React.Component {
'message-count': true,
'show': this.props.unreadMessageCount > 0
})}
>{this.props.unreadMessageCount}</span>
>{this.props.unreadMessageCount} </span>
<FormattedMessage id="general.messages" />
</a>
</li>,
@ -268,66 +166,18 @@ class Navigation extends React.Component {
className="link right account-nav"
key="account-nav"
>
<a
className={classNames({
'user-info': true,
'open': this.state.accountNavOpen
})}
href="#"
onClick={this.handleAccountNavClick}
>
<Avatar
alt=""
src={this.props.session.session.user.thumbnailUrl}
/>
<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>
<AccountNav
classroomId={this.props.user.classroomId}
isEducator={this.props.permissions.educator}
isOpen={this.props.accountNavOpen}
isStudent={this.props.permissions.student}
profileUrl={this.getProfileUrl()}
thumbnailUrl={this.props.user.thumbnailUrl}
username={this.props.user.username}
onClick={this.props.handleToggleAccountNav}
onClickLogout={this.props.handleLogOut}
onClose={this.props.handleCloseAccountNav}
/>
</li>
] : [
<li
@ -336,16 +186,13 @@ class Navigation extends React.Component {
>
<a
href="#"
onClick={this.handleJoinClick}
onClick={this.props.handleOpenRegistration}
>
<FormattedMessage id="general.joinScratch" />
</a>
</li>,
<Registration
isOpen={this.state.registrationOpen}
key="registration"
onRegistrationDone={this.handleCompleteRegistration}
onRequestClose={this.handleCloseRegistration}
/>,
<li
className="link right login-item"
@ -355,53 +202,31 @@ class Navigation extends React.Component {
className="ignore-react-onclickoutside"
href="#"
key="login-link"
onClick={this.handleLoginClick}
onClick={this.props.handleToggleLoginOpen}
>
<FormattedMessage id="general.signIn" />
</a>
<Dropdown
className="login-dropdown with-arrow"
isOpen={this.state.loginOpen}
<LoginDropdown
key="login-dropdown"
onRequestClose={this.handleCloseLogin}
>
<Login
error={this.state.loginError}
onLogIn={this.handleLogIn}
/>
</Dropdown>
/>
</li>
]) : []}
</ul>
<Modal
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>
<CanceledDeletionModal />
</NavigationBox>
);
}
}
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,
permissions: PropTypes.shape({
admin: PropTypes.bool,
@ -412,16 +237,15 @@ Navigation.propTypes = {
}),
searchTerm: PropTypes.string,
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
}),
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 = {
@ -431,12 +255,48 @@ Navigation.defaultProps = {
};
const mapStateToProps = state => ({
accountNavOpen: state.navigation && state.navigation.accountNavOpen,
session: state.session,
permissions: state.permissions,
searchTerm: state.navigation.searchTerm,
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);

View file

@ -163,78 +163,10 @@
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
@media only screen and (max-width: $mobile - 1) {
@media #{$small} {
#navigation .inner {
width: $cols4;
@ -242,20 +174,6 @@
&.login-item {
margin-left: 0;
}
&.account-nav {
margin-left: 0;
> a {
.avatar {
margin-right: 0;
}
&:after {
display: none;
}
}
}
}
.create,
@ -272,7 +190,7 @@
//6 columns
@media only screen and (min-width: $mobile) and (max-width: $tablet - 1) {
@media #{$medium} {
#navigation .inner {
width: $cols6;
@ -280,20 +198,6 @@
&.login-item {
margin-left: 0;
}
&.account-nav {
margin-left: 0;
> a {
.avatar {
margin-right: 0;
}
&:after {
display: none;
}
}
}
}
.discuss,
@ -308,13 +212,12 @@
}
//8 columns
@media only screen and (min-width: $tablet) and (max-width: $desktop - 1) {
@media #{$intermediate} {
#navigation .inner {
width: $cols8;
> ul > li {
&.login-item,
&.account-nav {
&.login-item {
margin-left: 0;
}
}

View file

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

View file

@ -1,8 +1,10 @@
const bindAll = require('lodash.bindall');
const PropTypes = require('prop-types');
const React = require('react');
const connect = require('react-redux').connect;
const IframeModal = require('../modal/iframe/modal.jsx');
const navigationActions = require('../../redux/navigation.js');
require('./registration.scss');
@ -26,7 +28,7 @@ class Registration extends React.Component {
handleMessage (e) {
if (e.origin !== window.location.origin) 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') {
this.registrationIframe.contentWindow.location.reload();
}
@ -47,16 +49,32 @@ class Registration extends React.Component {
}}
isOpen={this.props.isOpen}
src="/accounts/standalone-registration/"
onRequestClose={this.props.onRequestClose}
onRequestClose={this.props.handleCloseRegistration}
/>
);
}
}
Registration.propTypes = {
isOpen: PropTypes.bool,
onRegistrationDone: PropTypes.func,
onRequestClose: PropTypes.func
handleCloseRegistration: PropTypes.func,
handleCompleteRegistration: 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;
}
@media only screen and (max-width: $desktop - 1) {
@media #{$intermediate-and-smaller} {
.modal-content.mod-registration {
width: 100%;
overflow: scroll;

View file

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

View file

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

View file

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

View file

@ -53,7 +53,7 @@ a.social-messages-profile-link {
margin-left: 1.5rem;
}
@media only screen and (max-width: $mobile - 1) {
@media #{$small} {
.social-message {
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 {
text-align: left;
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,6 +1,6 @@
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.
@ -14,8 +14,8 @@ class Storage extends ScratchStorage {
projectAsset => {
const [projectId, revision] = projectAsset.assetId.split('.');
return revision ?
`${PROJECT_SERVER}/internalapi/project/${projectId}/get/${revision}` :
`${PROJECT_SERVER}/internalapi/project/${projectId}/get/`;
`${PROJECT_HOST}/internalapi/project/${projectId}/get/${revision}` :
`${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 defaults = require('lodash.defaults');
const api = require('../lib/api');
const log = require('../lib/log.js');
const sessionActions = require('./session.js');
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) => {
if (typeof state === 'undefined') {
state = '';
state = module.exports.getInitialState();
}
switch (action.type) {
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:
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 => ({
type: Types.SET_SEARCH_TERM,
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 async = require('async');
const merge = require('lodash.merge');
const api = require('../lib/api');
const log = require('../lib/log');
@ -19,6 +22,7 @@ module.exports.getInitialState = () => ({
original: module.exports.Status.NOT_FETCHED,
parent: module.exports.Status.NOT_FETCHED,
remixes: module.exports.Status.NOT_FETCHED,
report: module.exports.Status.NOT_FETCHED,
projectStudios: module.exports.Status.NOT_FETCHED,
curatedStudios: module.exports.Status.NOT_FETCHED,
studioRequests: {}
@ -26,6 +30,7 @@ module.exports.getInitialState = () => ({
projectInfo: {},
remixes: [],
comments: [],
replies: {},
faved: false,
loved: false,
original: {},
@ -79,7 +84,56 @@ module.exports.previewReducer = (state, action) => {
});
case 'SET_COMMENTS':
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':
return Object.assign({}, state, {
@ -145,6 +199,16 @@ module.exports.setProjectStudios = 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 => ({
type: 'SET_CURATED_STUDIOS',
items: items
@ -172,6 +236,55 @@ module.exports.setStudioFetchStatus = (studioId, 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 => {
const opts = {
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 => {
dispatch(module.exports.setFetchStatus('faved', module.exports.Status.FETCHING));
if (faved) {
api({
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 => {
dispatch(module.exports.setFetchStatus('loved', module.exports.Status.FETCHING));
if (loved) {
api({
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 => {
dispatch(module.exports.setFetchStatus('project', module.exports.Status.FETCHING));
api({
uri: `/projects/${id}`,
authentication: token,
@ -490,3 +657,84 @@ module.exports.updateProject = (id, jsonData, username, token) => (dispatch => {
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 permissionsReducer = require('./permissions.js').permissionsReducer;
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`.
@ -18,8 +19,9 @@ const sessionReducer = require('./session.js').sessionReducer;
module.exports = opts => {
opts = opts || {};
return combineReducers(defaults(opts, {
session: sessionReducer,
messageCount: messageCountReducer,
navigation: navigationReducer,
permissions: permissionsReducer,
messageCount: messageCountReducer
session: sessionReducer
}));
};

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -79,7 +79,7 @@ td {
color: $type-white;
}
@media only screen and (max-width: $mobile - 1) {
@media #{$small} {
.index.mod-2017 {
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 {
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 {
text-align: left;
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -29,7 +29,7 @@ const Credits = () => (
/>
<span className="name">Carl Bowman</span>
</li>
<li>
<img
alt="Karishma Avatar"
@ -86,6 +86,14 @@ const Credits = () => (
<span className="name">DD Liu</span>
</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>
<img
alt="Shruti Avatar"
@ -446,6 +454,6 @@ const Credits = () => (
</p>
</div>
);
render(<Page><Credits /></Page>, document.getElementById('app'));

View file

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

View file

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

View file

@ -31,19 +31,25 @@ class EV3 extends ExtensionLanding {
render () {
return (
<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">
<h2><img src="/images/ev3/ev3.svg" />LEGO MINDSTORMS EV3</h2>
<h1><img
alt=""
src="/images/ev3/ev3.svg"
/>LEGO MINDSTORMS EV3</h1>
<FormattedMessage
id="ev3.headerText"
values={{
ev3Link: (
<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"
target="_blank"
>
LEGO MINDSTORMS EV3
LEGO MINDSTORMS Education EV3
</a>
)
}}
@ -51,11 +57,17 @@ class EV3 extends ExtensionLanding {
</FlexRow>
<ExtensionRequirements>
<span>
<img src="/svgs/extensions/windows.svg" />
<img
alt=""
src="/svgs/extensions/windows.svg"
/>
Windows 10+
</span>
<span>
<img src="/svgs/extensions/mac.svg" />
<img
alt=""
src="/svgs/extensions/mac.svg"
/>
macOS 10.13+
</span>
<span>
@ -63,7 +75,10 @@ class EV3 extends ExtensionLanding {
Bluetooth
</span>
<span>
<img src="/svgs/extensions/scratch-link.svg" />
<img
alt=""
src="/svgs/extensions/scratch-link.svg"
/>
Scratch Link
</span>
</ExtensionRequirements>
@ -82,13 +97,20 @@ class EV3 extends ExtensionLanding {
<Steps>
<Step number={1}>
<div className="step-image">
<img src="/images/ev3/ev3-connect-1.png" />
<img
alt=""
src="/images/ev3/ev3-connect-1.png"
/>
</div>
<p><FormattedMessage id="ev3.turnOnEV3" /></p>
</Step>
<Step number={2}>
<div className="step-image">
<img src="/images/ev3/ev3-connect-2.png" />
<img
alt=""
className="screenshot"
src="/images/ev3/ev3-connect-2.png"
/>
</div>
<p>
<FormattedMessage
@ -109,7 +131,11 @@ class EV3 extends ExtensionLanding {
</Step>
<Step number={3}>
<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>
<p><FormattedMessage id="ev3.addExtension" /></p>
</Step>
@ -119,19 +145,30 @@ class EV3 extends ExtensionLanding {
<Steps>
<Step>
<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>
<p><FormattedMessage id="ev3.acceptConnection" /></p>
</Step>
<Step>
<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>
<p><FormattedMessage id="ev3.acceptPasscode" /></p>
</Step>
<Step>
<div className="step-image">
<img
alt={this.props.intl.formatMessage({id: `ev3.imgAlt${
this.state.OS === OS_ENUM.WINDOWS ?
'WaitForWindows' :
'EnterPasscodeMac'
}`})}
className="screenshot"
src={`/images/ev3/${
this.state.OS === OS_ENUM.WINDOWS ?
@ -170,7 +207,10 @@ class EV3 extends ExtensionLanding {
/>
</span>
<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>
</Step>
<Step
@ -188,7 +228,10 @@ class EV3 extends ExtensionLanding {
/>
</span>
<div className="step-image">
<img src="/images/ev3/motor-turn-block.png" />
<img
alt=""
src="/images/ev3/motor-turn-block.png"
/>
</div>
</Step>
</Steps>
@ -196,20 +239,23 @@ class EV3 extends ExtensionLanding {
<h3><FormattedMessage id="ev3.starterProjects" /></h3>
<Steps>
<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'})}
imageAlt={this.props.intl.formatMessage({id: 'ev3.imgAltWaveHello'})}
imageSrc="/images/ev3/starter-wave-hello.png"
title={this.props.intl.formatMessage({id: 'ev3.waveHelloTitle'})}
/>
<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'})}
imageAlt={this.props.intl.formatMessage({id: 'ev3.imgAltDistanceInstrument'})}
imageSrc="/images/ev3/starter-distance-instrument.png"
title={this.props.intl.formatMessage({id: 'ev3.distanceInstrumentTitle'})}
/>
<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'})}
imageAlt={this.props.intl.formatMessage({id: 'ev3.imgAltSpaceTacos'})}
imageSrc="/images/ev3/starter-flying-game.png"
title={this.props.intl.formatMessage({id: 'ev3.spaceTacosTitle'})}
/>

View file

@ -2,7 +2,7 @@
.ev3 {
.extension-header {
background-color: $ui-aqua;
background-color: $ui-orange;
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.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.updateFirmwareText": "We recommend updating to EV3 firmware version 1.10E or above. See {firmwareUpdateLink}. We recommend following the instructions for \"Manual Firmware Update\".",
"ev3.firmwareUpdateText": "firmware update instructions from LEGO"
"ev3.updateFirmwareText": "We recommend updating to EV3 firmware version 1.10E or above. See {firmwareUpdateLink}.",
"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
@media only screen and (max-width: $mobile - 1) {
@media #{$small} {
.outer {
.tabs {
width: $cols4;
@ -139,7 +139,7 @@ $base-bg: $ui-white;
}
//6 columns
@media only screen and (min-width: $mobile) and (max-width: $tablet - 1) {
@media #{$medium} {
.outer {
.tabs {
width: $cols6;
@ -158,7 +158,7 @@ $base-bg: $ui-white;
}
// 8 columns
@media only screen and (min-width: $tablet) and (max-width: $desktop - 1) {
@media #{$intermediate} {
.outer {
.tabs {
width: $cols8;

View file

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

View file

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

View file

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

View file

@ -28,5 +28,11 @@
"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.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 () {
return (
<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">
<h2><img src="/images/microbit/microbit.svg" />micro:bit</h2>
<h1><img
alt=""
src="/images/microbit/microbit.svg"
/>micro:bit</h1>
<FormattedMessage
id="microbit.headerText"
values={{
@ -51,19 +57,31 @@ class MicroBit extends ExtensionLanding {
</FlexRow>
<ExtensionRequirements>
<span>
<img src="/svgs/extensions/windows.svg" />
<img
alt=""
src="/svgs/extensions/windows.svg"
/>
Windows 10+
</span>
<span>
<img src="/svgs/extensions/mac.svg" />
<img
alt=""
src="/svgs/extensions/mac.svg"
/>
macOS 10.13+
</span>
<span>
<img src="/svgs/extensions/bluetooth.svg" />
<img
alt=""
src="/svgs/extensions/bluetooth.svg"
/>
Bluetooth 4.0
</span>
<span>
<img src="/svgs/extensions/scratch-link.svg" />
<img
alt=""
src="/svgs/extensions/scratch-link.svg"
/>
Scratch Link
</span>
</ExtensionRequirements>
@ -82,7 +100,10 @@ class MicroBit extends ExtensionLanding {
<Steps>
<Step number={1}>
<div className="step-image">
<img src="/images/microbit/mbit-usb.png" />
<img
alt=""
src="/images/microbit/mbit-usb.png"
/>
</div>
<p>
<FormattedMessage id="microbit.connectUSB" />
@ -90,7 +111,10 @@ class MicroBit extends ExtensionLanding {
</Step>
<Step number={2}>
<div className="step-image">
<img src="/images/microbit/mbit-hex-download.png" />
<img
alt=""
src="/images/microbit/mbit-hex-download.png"
/>
</div>
<a
download
@ -103,6 +127,7 @@ class MicroBit extends ExtensionLanding {
<Step number={3}>
<div className="step-image">
<img
alt={this.props.intl.formatMessage({id: 'microbit.imgAltDragDropHex'})}
src={`/images/microbit/${
this.state.OS === OS_ENUM.WINDOWS ? 'win' : 'mac'
}-copy-hex.png`}
@ -120,13 +145,20 @@ class MicroBit extends ExtensionLanding {
<Steps>
<Step number={1}>
<div className="step-image">
<img src="/images/microbit/mbit-connect-1.png" />
<img
alt=""
src="/images/microbit/mbit-connect-1.png"
/>
</div>
<p><FormattedMessage id="microbit.powerMicrobit" /></p>
</Step>
<Step number={2}>
<div className="step-image">
<img src="/images/microbit/mbit-connect-2.png" />
<img
alt=""
className="screenshot"
src="/images/microbit/mbit-connect-2.png"
/>
</div>
<p>
<FormattedMessage
@ -147,7 +179,11 @@ class MicroBit extends ExtensionLanding {
</Step>
<Step number={3}>
<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>
<p><FormattedMessage id="microbit.addExtension" /></p>
</Step>
@ -175,7 +211,10 @@ class MicroBit extends ExtensionLanding {
/>
</span>
<div className="step-image">
<img src="/images/microbit/display-hello-block.png" />
<img
alt=""
src="/images/microbit/display-hello-block.png"
/>
</div>
</Step>
<Step
@ -193,7 +232,10 @@ class MicroBit extends ExtensionLanding {
/>
</span>
<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>
</Step>
</Steps>
@ -201,20 +243,23 @@ class MicroBit extends ExtensionLanding {
<h3><FormattedMessage id="microbit.starterProjects" /></h3>
<Steps>
<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'})}
imageAlt={this.props.intl.formatMessage({id: 'microbit.imgAltHeartBeat'})}
imageSrc="/images/microbit/starter-heart.png"
title={this.props.intl.formatMessage({id: 'microbit.heartBeat'})}
/>
<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'})}
imageAlt={this.props.intl.formatMessage({id: 'microbit.imgAltTiltGuitar'})}
imageSrc="/images/microbit/starter-guitar.png"
title={this.props.intl.formatMessage({id: 'microbit.tiltGuitar'})}
/>
<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'})}
imageAlt={this.props.intl.formatMessage({id: 'microbit.imgAltOceanAdventure'})}
imageSrc="/images/microbit/starter-fish.png"
title={this.props.intl.formatMessage({id: 'microbit.oceanAdventure'})}
/>

View file

@ -3,7 +3,7 @@
.microbit {
.extension-header {
background-color: $ui-purple;
background-color: $ui-mint-green;
background-image: url("/images/microbit/mbit-pattern.svg");
.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;
justify-content: center;
flex-flow: column;
align-items: flex-start;
}
.extension-status {

View file

@ -1,9 +1,15 @@
{
"addToStudio.title": "Add to Studio",
"addToStudio.finishing": "Finishing up...",
"preview.titleMaxLength": "Title is too long",
"preview.musicExtensionChip": "Music",
"preview.penExtensionChip": "Pen",
"preview.speechExtensionChip": "Google Speech",
"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