diff --git a/.eslintignore b/.eslintignore
index 941eaa3fb..322608d70 100644
--- a/.eslintignore
+++ b/.eslintignore
@@ -5,3 +5,4 @@ intl/*
locales/*
**/*.min.js
**/node_modules/*
+scratch-gui/*
diff --git a/.travis.yml b/.travis.yml
index 365e0ab47..6766f4c43 100644
--- a/.travis.yml
+++ b/.travis.yml
@@ -18,11 +18,26 @@ env:
- API_HOST_VAR=API_HOST_$TRAVIS_BRANCH
- API_HOST=${!API_HOST_VAR}
- API_HOST=${API_HOST:-$API_HOST_STAGING}
+ - ASSET_HOST_master=https://assets.scratch.mit.edu
+ - ASSET_HOST_STAGING=https://assets.scratch.ly
+ - ASSET_HOST_VAR=ASSET_HOST_$TRAVIS_BRANCH
+ - ASSET_HOST=${!ASSET_HOST_VAR}
+ - ASSET_HOST=${ASSET_HOST:-$ASSET_HOST_STAGING}
+ - BACKPACK_HOST_master=https://backpack.scratch.mit.edu
+ - BACKPACK_HOST_STAGING=https://backpack.scratch.ly
+ - BACKPACK_HOST_VAR=BACKPACK_HOST_$TRAVIS_BRANCH
+ - BACKPACK_HOST=${!BACKPACK_HOST_VAR}
+ - BACKPACK_HOST=${BACKPACK_HOST:-$BACKPACK_HOST_STAGING}
- ROOT_URL_master=https://scratch.mit.edu
- ROOT_URL_STAGING=https://scratch.ly
- ROOT_URL_VAR=ROOT_URL_$TRAVIS_BRANCH
- ROOT_URL=${!ROOT_URL_VAR}
- ROOT_URL=${ROOT_URL:-$ROOT_URL_STAGING}
+ - PROJECT_HOST_master=https://projects.scratch.mit.edu
+ - PROJECT_HOST_STAGING=https://projects.scratch.ly
+ - PROJECT_HOST_VAR=PROJECT_HOST_$TRAVIS_BRANCH
+ - PROJECT_HOST=${!PROJECT_HOST_VAR}
+ - PROJECT_HOST=${PROJECT_HOST:-$PROJECT_HOST_STAGING}
- PATH=$PATH:$PWD/test/integration/node_modules/chromedriver/bin
- AWS_ACCESS_KEY_ID=$EB_AWS_ACCESS_KEY_ID
- AWS_SECRET_ACCESS_KEY=$EB_AWS_SECRET_ACCESS_KEY
@@ -69,6 +84,7 @@ addons:
install:
- sudo -H pip install -r requirements.txt
- npm --production=false install
+ - npm --production=false update
jobs:
include:
- stage: test
@@ -94,8 +110,8 @@ jobs:
- cd test/integration
- npm install
- cd -
- script: npm run smoke
+ script: npm run smoke-sauce
stages:
- test
- name: smoke
- if: branch IN (travis) and type != pull_request
+ if: type != pull_request
diff --git a/Makefile b/Makefile
index 6fed25bde..21100b6f4 100644
--- a/Makefile
+++ b/Makefile
@@ -70,7 +70,7 @@ integration:
smoke:
$(TAP) ./test/integration/smoke-testing/*.js --timeout=3600
-
+
smoke-verbose:
$(TAP) ./test/integration/smoke-testing/*.js --timeout=3600 -R spec
diff --git a/README.md b/README.md
index 210e72876..25e52a316 100644
--- a/README.md
+++ b/README.md
@@ -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.
diff --git a/dev-server/index.js b/dev-server/index.js
index 61874bd15..5ab70abbd 100644
--- a/dev-server/index.js
+++ b/dev-server/index.js
@@ -21,7 +21,18 @@ routes.forEach(route => {
app.get(route.pattern, handler(route));
});
-app.use(webpackDevMiddleware(compiler));
+var middlewareOptions = {};
+if (process.env.USE_DOCKER_WATCHOPTIONS) {
+ middlewareOptions = {
+ watchOptions: {
+ aggregateTimeout: 500,
+ poll: 2500,
+ ignored: ['node_modules', 'build']
+ }
+ };
+}
+
+app.use(webpackDevMiddleware(compiler, middlewareOptions));
var proxyHost = process.env.FALLBACK || '';
if (proxyHost !== '') {
diff --git a/docker-compose.yml b/docker-compose.yml
index 8915d2b21..e7919d96f 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -2,7 +2,6 @@ version: '3.4'
volumes:
npm_data:
runtime_data:
- intl_data:
networks:
scratch-api_scratch_network:
@@ -15,7 +14,8 @@ services:
environment:
- API_HOST=http://localhost:8491
- FALLBACK=http://localhost:8080
- build:
+ - USE_DOCKER_WATCHOPTIONS=true
+ build:
context: ./
dockerfile: Dockerfile
image: scratch-www:latest
@@ -33,7 +33,6 @@ services:
nocopy: true
- npm_data:/var/app/current/node_modules
- runtime_data:/runtime
- - intl_data:/var/app/current/intl
ports:
- "8333:8333"
networks:
diff --git a/package.json b/package.json
index 6b1315452..e8bbee511 100644
--- a/package.json
+++ b/package.json
@@ -77,6 +77,7 @@
"lodash.merge": "3.3.2",
"lodash.omit": "3.1.0",
"lodash.range": "3.0.1",
+ "lodash.truncate": "4.4.2",
"minilog": "2.0.8",
"node-dir": "0.1.16",
"node-sass": "4.6.1",
diff --git a/src/_colors.scss b/src/_colors.scss
index 10f4a49ef..5ef54a7ce 100644
--- a/src/_colors.scss
+++ b/src/_colors.scss
@@ -5,6 +5,7 @@ $ui-blue-10percent: hsla(215, 100, 65, .1);
$ui-blue-25percent: hsla(215, 100, 65, .25);
$ui-orange: hsla(38, 100, 55, 1); // #FFAB19 Control Primary
+$ui-orange-10percent: hsla(35, 90, 55, .1);
$ui-orange-25percent: hsla(35, 90, 55, .25);
$ui-light-gray: hsla(0, 0, 98, 1); //#FAFAFA
@@ -13,7 +14,6 @@ $ui-dark-gray: hsla(0, 0, 70, 1); //#B3B3B3
$background-color: hsla(0, 0, 99, 1); //#FDFDFD
-
/* UI Secondary Colors */
/* 3.0 colors */
/* Using www naming convention for now, should be consistent with gui */
@@ -27,20 +27,25 @@ $ui-coral-dark: hsla(350, 100, 60, 1); // #FF3355 More Blocks tertiary
$ui-white: hsla(0, 100%, 100%, 1); //#FFF
$ui-white-15percent: hsla(0, 100%, 100%, .15); //#FFF
+$ui-light-primary: hsl(215, 100, 95);
$ui-border: hsla(0, 0, 85, 1); //#D9D9D9
+/* modals */
$ui-mint-green: hsl(163, 69, 44);
+$ui-light-mint: hsl(163, 53, 67);
/* Overlay UI Gray Colors */
$active-gray: hsla(0, 0, 0, .1);
$active-dark-gray: hsla(0, 0, 0, .2);
$box-shadow-gray: hsla(0, 0, 0, .25);
$overlay-gray: hsla(0, 0, 0, .75);
+$transparent-light-blue: rgba(229, 240, 254, 0);
/* Typography Colors */
$header-gray: hsla(225, 15, 40, 1); //#575E75
$type-gray: hsla(225, 15, 40, 1); //#575E75
+$type-gray-75percent: hsla(225, 15, 40, .75);
$type-white: hsla(0, 100, 100, 1); //#FFF
$link-blue: $ui-blue;
diff --git a/src/components/footer/www/footer.jsx b/src/components/footer/www/footer.jsx
index 523389516..74bd1add8 100644
--- a/src/components/footer/www/footer.jsx
+++ b/src/components/footer/www/footer.jsx
@@ -108,7 +108,7 @@ const Footer = props => (
-
+
diff --git a/src/components/forms/charcount.scss b/src/components/forms/charcount.scss
index ff5d85e94..05d1a4bee 100644
--- a/src/components/forms/charcount.scss
+++ b/src/components/forms/charcount.scss
@@ -2,7 +2,7 @@
.char-count {
letter-spacing: 1px;
- color: lighten($type-gray, 30%);
+ color: $type-gray-75percent;
font-weight: 500;
&.overmax {
diff --git a/src/components/forms/input.scss b/src/components/forms/input.scss
index 214365f28..3fd202c3e 100644
--- a/src/components/forms/input.scss
+++ b/src/components/forms/input.scss
@@ -2,7 +2,6 @@
@import "../../frameless";
$base-bg: $ui-light-gray;
-$pass-bg: lighten($ui-aqua, 35%);
.row {
label {
@@ -32,8 +31,7 @@ $pass-bg: lighten($ui-aqua, 35%);
}
&.pass {
- border: 1px solid $active-dark-gray;
- background-color: $pass-bg;
+ border: 1px solid $ui-aqua;
}
/* IE10/11-specific style resets */
diff --git a/src/components/forms/validations.jsx b/src/components/forms/validations.jsx
index c83ce63cb..c882aa40d 100644
--- a/src/components/forms/validations.jsx
+++ b/src/components/forms/validations.jsx
@@ -28,8 +28,8 @@ module.exports.validationHOCFactory = defaultValidationErrors => (Component => {
diff --git a/src/components/modal/addtostudio/container.jsx b/src/components/modal/addtostudio/container.jsx
new file mode 100644
index 000000000..582f194eb
--- /dev/null
+++ b/src/components/modal/addtostudio/container.jsx
@@ -0,0 +1,72 @@
+const bindAll = require('lodash.bindall');
+const PropTypes = require('prop-types');
+const React = require('react');
+const AddToStudioModalPresentation = require('./presentation.jsx');
+
+class AddToStudioModal extends React.Component {
+ constructor (props) {
+ super(props);
+ bindAll(this, [
+ 'handleRequestClose',
+ 'handleSubmit'
+ ]);
+
+ this.state = {
+ waitingToClose: false
+ };
+ }
+
+ componentWillUpdate () {
+ this.closeIfFinishedUpdating();
+ }
+
+ hasOutstandingUpdates () {
+ return (this.props.studios.some(studio => (studio.hasRequestOutstanding === true)));
+ }
+
+ closeIfFinishedUpdating () {
+ if (this.state.waitingToClose === true && this.hasOutstandingUpdates() === false) {
+ this.closeAndStopWaiting();
+ }
+ }
+
+ // before closing, set waitingToClose to false. That way, if user reopens
+ // modal, it won't unexpectedly close.
+ closeAndStopWaiting () {
+ this.setState({waitingToClose: false}, () => {
+ this.props.onRequestClose();
+ });
+ }
+
+ handleRequestClose () {
+ this.closeAndStopWaiting();
+ }
+
+ handleSubmit () {
+ this.setState({waitingToClose: true}, () => {
+ this.closeIfFinishedUpdating();
+ });
+ }
+
+ render () {
+ return (
+
+ );
+ }
+}
+
+AddToStudioModal.propTypes = {
+ isOpen: PropTypes.bool,
+ onRequestClose: PropTypes.func,
+ onToggleStudio: PropTypes.func,
+ studios: PropTypes.arrayOf(PropTypes.object)
+};
+
+module.exports = AddToStudioModal;
diff --git a/src/components/modal/addtostudio/modal.scss b/src/components/modal/addtostudio/modal.scss
new file mode 100644
index 000000000..08b4c1abc
--- /dev/null
+++ b/src/components/modal/addtostudio/modal.scss
@@ -0,0 +1,189 @@
+@import "../../../colors";
+@import "../../../frameless";
+
+.mod-addToStudio * {
+ box-sizing: border-box;
+}
+
+.mod-addToStudio {
+ margin: 100px auto;
+ outline: none;
+ padding: 0;
+ width: 36.25rem; /* 580px; */
+ height: 388px; /* 24.25rem; */
+ overflow: hidden;
+ user-select: none;
+}
+
+.addToStudio-modal-header {
+ box-shadow: inset 0 -1px 0 0 $ui-blue-dark;
+ background-color: $ui-blue;
+ padding-top: .75rem;
+ width: 100%;
+ height: 3rem;
+ box-sizing: border-box;
+}
+
+.addToStudio-content-label {
+ text-align: center;
+ color: $type-white;
+ font-family: "Helvetica Neue", Helvetica, Arial, sans-serif;
+ font-size: 1rem;
+ font-weight: bold;
+}
+
+.addToStudio-modal-content {
+ margin: 0 auto;
+ width: 100%;
+ font-size: .875rem;
+}
+
+.studio-list-outer-scrollbox {
+ position: relative;
+ background-color: $ui-blue-10percent;
+}
+
+.studio-list-inner-scrollbox {
+ margin-right: .5rem;
+ padding-right: .5rem;
+ height: 16.9375rem;
+ overflow: scroll;
+ overflow-x: hidden;
+
+ &::-webkit-scrollbar {
+ width: 8px;
+ }
+
+ &::-webkit-scrollbar-thumb {
+ border-radius: 4px;
+ background-color: $active-dark-gray;
+ height: 92px;
+ }
+
+ &::-webkit-scrollbar-track {
+ margin-top: 8px;
+ margin-bottom: 10px;
+ }
+}
+
+.studio-list-container {
+ display: flex;
+ padding: .40625rem 0 0 1.46875rem;
+ justify-content: flex-start;
+ flex-flow: row wrap;
+}
+/* NOTE: force scrolling: add to above:
+ min-height: 30rem;
+*/
+
+.studio-list-bottom-gradient {
+ position: absolute;
+ right: 1rem;
+ bottom: 0;
+ left: 0;
+ background: linear-gradient(
+ $transparent-light-blue,
+ $ui-light-primary
+ );
+ height: 32px;
+ pointer-events: none; /* pass clicks through to buttons underneath */
+}
+
+
+.studio-selector-button {
+ display: flex;
+ position: relative;
+ margin: .21875rem .21875rem;
+ border-radius: .5rem;
+ background-color: $ui-white;
+ padding: 0;
+ width: 16.1875rem; /* 259px */
+ height: 2.5rem;
+ box-sizing: border-box;
+ justify-content: space-between;
+}
+
+.studio-selector-button-text {
+ position: absolute;
+ /* per spec, should be:
+ margin: .375rem 2.18375rem .375rem .6875rem
+ but in practice, our css seems to vertically align text to top, where
+ invision spec aligned to middle.
+ */
+ margin: .575rem 2.18375rem .175rem .6875rem;
+ width: 13.3125rem;
+ height: 1rem; /* diff from spec, in case we ever do valign to middle */
+ line-height: 1.25rem;
+ font-family: "Helvetica Neue";
+ font-size: .875rem;
+ font-weight: regular;
+}
+
+.studio-selector-button-selected {
+ background-color: $ui-mint-green;
+ color: $ui-white;
+}
+
+.studio-selector-button-waiting {
+ background-color: $ui-light-mint;
+ color: $ui-white;
+}
+
+.studio-selector-button-text-selected {
+ color: $ui-white;
+}
+
+.studio-selector-button-text-unselected {
+ color: $type-gray;
+}
+
+.studio-status-icon {
+ position: absolute;
+ margin: .5rem .625rem .5rem 14.0625rem;
+ border-radius: .75rem;
+ padding: .0625rem .075rem;
+ width: 1.5rem;
+ height: 1.5rem;
+ color: $ui-white;
+ box-sizing: border-box;
+}
+
+.studio-status-icon-unselected {
+ background-color: $ui-blue;
+}
+
+.submit-button {
+ background-color: $ui-blue;
+}
+
+.submit-button-waiting {
+ background-color: $ui-blue;
+}
+
+.studio-status-icon-plus-img {
+ width: 1.4rem;
+ height: 1.4rem;
+}
+
+.studio-status-icon--img {
+ width: 1.4rem;
+ height: 1.4rem;
+}
+
+.action-button-text .spinner-smooth {
+ margin: .2125rem auto;
+ width: 1.875rem;
+ height: 1rem;
+}
+
+.studio-status-icon .spinner-smooth {
+ position: unset; /* don't understand why neither relative nor absolute work */
+}
+
+.studio-status-icon .spinner-smooth .circle {
+ /* overlay spinner on circle */
+ position: absolute;
+ margin: .1875rem; /* stay within boundaries of circle */
+ width: 75%; /* stay within boundaries of circle */
+ height: 75%; /* stay within boundaries of circle */
+}
diff --git a/src/components/modal/addtostudio/presentation.jsx b/src/components/modal/addtostudio/presentation.jsx
new file mode 100644
index 000000000..29e8eedcc
--- /dev/null
+++ b/src/components/modal/addtostudio/presentation.jsx
@@ -0,0 +1,119 @@
+const PropTypes = require('prop-types');
+const React = require('react');
+const FormattedMessage = require('react-intl').FormattedMessage;
+const injectIntl = require('react-intl').injectIntl;
+const intlShape = require('react-intl').intlShape;
+const Modal = require('../base/modal.jsx');
+
+const Form = require('../../forms/form.jsx');
+const Button = require('../../forms/button.jsx');
+const Spinner = require('../../spinner/spinner.jsx');
+const FlexRow = require('../../flex-row/flex-row.jsx');
+const StudioButton = require('./studio-button.jsx');
+
+require('../../forms/button.scss');
+require('./modal.scss');
+
+const AddToStudioModalPresentation = ({
+ intl,
+ isOpen,
+ studios,
+ waitingToClose,
+ onToggleStudio,
+ onRequestClose,
+ onSubmit
+}) => {
+ const contentLabel = intl.formatMessage({id: 'addToStudio.title'});
+ const studioButtons = studios.map(studio => (
+
+ ));
+
+ return (
+
+
+
+
+
+
+
+ {studioButtons}
+
+
+
+
+
+
+
+
+
+
+ );
+};
+
+AddToStudioModalPresentation.propTypes = {
+ intl: intlShape,
+ isOpen: PropTypes.bool,
+ onRequestClose: PropTypes.func,
+ onSubmit: PropTypes.func,
+ onToggleStudio: PropTypes.func,
+ studios: PropTypes.arrayOf(PropTypes.object),
+ waitingToClose: PropTypes.bool
+};
+
+module.exports = injectIntl(AddToStudioModalPresentation);
diff --git a/src/components/modal/addtostudio/studio-button.jsx b/src/components/modal/addtostudio/studio-button.jsx
new file mode 100644
index 000000000..137c4cc8a
--- /dev/null
+++ b/src/components/modal/addtostudio/studio-button.jsx
@@ -0,0 +1,72 @@
+const truncateAtWordBoundary = require('../../../lib/truncate').truncateAtWordBoundary;
+const PropTypes = require('prop-types');
+const React = require('react');
+const classNames = require('classnames');
+const Spinner = require('../../spinner/spinner.jsx');
+
+require('./modal.scss');
+
+const StudioButton = ({
+ hasRequestOutstanding,
+ id,
+ includesProject,
+ title,
+ onToggleStudio
+}) => {
+ const checkmark = (
+
+ );
+ const plus = (
+
+ );
+ return (
+
+
+ {truncateAtWordBoundary(title, 25)}
+
+
+ {(hasRequestOutstanding ?
+ ( ) :
+ (includesProject ? checkmark : plus))}
+
+
+ );
+};
+
+StudioButton.propTypes = {
+ hasRequestOutstanding: PropTypes.bool,
+ id: PropTypes.number,
+ includesProject: PropTypes.bool,
+ onToggleStudio: PropTypes.func,
+ title: PropTypes.string
+};
+
+module.exports = StudioButton;
diff --git a/src/components/modal/base/modal.scss b/src/components/modal/base/modal.scss
index 9fec2deef..cb878ae12 100644
--- a/src/components/modal/base/modal.scss
+++ b/src/components/modal/base/modal.scss
@@ -5,10 +5,14 @@
position: relative;
margin: 3.75rem auto;
border-radius: 1rem;
- box-shadow: 0 0 0 1px $active-gray;
+ box-shadow: 0 0 0 4px $ui-white-15percent;
background-color: $ui-white;
padding: 0;
width: 48.75rem;
+
+ &:focus {
+ outline: none;
+ }
}
.modal-overlay {
@@ -21,10 +25,6 @@
background-color: transparentize($ui-blue, .3);
}
-.modal-content:focus {
- outline: none;
-}
-
$modal-close-size: 1rem;
.modal-content-close {
position: absolute;
@@ -59,3 +59,52 @@ $modal-close-size: 1rem;
position: fixed;
}
}
+
+/* Close button, Submit button, etc. */
+.action-buttons {
+ display: flex;
+ margin: 1.125rem .8275rem .9375rem .8275rem;
+ line-height: 1.5rem;
+ justify-content: flex-end !important;
+ align-items: flex-start;
+ flex-wrap: nowrap;
+}
+
+/* setting overall modal to contain overflow looks good, but isn't
+compatible with elements (like validation popups) that need to bleed
+past modal boundary. This class can be used to force modal button
+row to appear to contain overflow. */
+.action-buttons-overflow-fix {
+ margin-bottom: .9375rem;
+}
+
+.action-button {
+ margin: 0 0 0 .54625rem;
+ border-radius: .25rem;
+ padding: 6px 1.25rem 14px 1.25rem;
+ height: 36px;
+}
+
+.action-button.close-button {
+ border: 1px solid $active-gray;
+}
+
+.action-button-text {
+ display: flex;
+}
+
+.action-button.disabled {
+ background-color: $active-dark-gray;
+}
+
+.error-text
+{
+ display: block;
+ border: 1px solid $active-gray;
+ border-radius: 5px;
+ background-color: $ui-orange;
+ padding: 1rem;
+ min-height: 1rem;
+ overflow: visible;
+ color: $type-white;
+}
diff --git a/src/components/modal/report/modal.jsx b/src/components/modal/report/modal.jsx
index 14be94544..7271458fd 100644
--- a/src/components/modal/report/modal.jsx
+++ b/src/components/modal/report/modal.jsx
@@ -1,16 +1,20 @@
const bindAll = require('lodash.bindall');
const PropTypes = require('prop-types');
const React = require('react');
+const connect = require('react-redux').connect;
const FormattedMessage = require('react-intl').FormattedMessage;
const injectIntl = require('react-intl').injectIntl;
const intlShape = require('react-intl').intlShape;
const Modal = require('../base/modal.jsx');
+const classNames = require('classnames');
const Form = require('../../forms/form.jsx');
const Button = require('../../forms/button.jsx');
const Select = require('../../forms/select.jsx');
const Spinner = require('../../spinner/spinner.jsx');
const TextArea = require('../../forms/textarea.jsx');
+const FlexRow = require('../../flex-row/flex-row.jsx');
+const previewActions = require('../../../redux/preview.js');
require('../../forms/button.scss');
require('./modal.scss');
@@ -67,12 +71,24 @@ class ReportModal extends React.Component {
constructor (props) {
super(props);
bindAll(this, [
- 'handleReportCategorySelect'
+ 'handleCategorySelect',
+ 'handleValid',
+ 'handleInvalid'
]);
- this.state = {reportCategory: this.props.report.category};
+ this.state = {
+ category: '',
+ notes: '',
+ valid: false
+ };
}
- handleReportCategorySelect (name, value) {
- this.setState({reportCategory: value});
+ handleCategorySelect (name, value) {
+ this.setState({category: value});
+ }
+ handleValid () {
+ this.setState({valid: true});
+ }
+ handleInvalid () {
+ this.setState({valid: false});
}
lookupPrompt (value) {
const prompt = REPORT_OPTIONS.find(item => item.value === value).prompt;
@@ -81,17 +97,24 @@ class ReportModal extends React.Component {
render () {
const {
intl,
+ isConfirmed,
+ isError,
+ isOpen,
+ isWaiting,
onReport, // eslint-disable-line no-unused-vars
- report,
+ onRequestClose,
type,
...modalProps
} = this.props;
+ const submitEnabled = this.state.valid && !isWaiting;
+ const submitDisabledParam = submitEnabled ? {} : {disabled: 'disabled'};
const contentLabel = intl.formatMessage({id: `report.${type}`});
return (
@@ -101,72 +124,120 @@ class ReportModal extends React.Component {
-
-
-
-
- )
- }}
- />
-
-
+
+
+ {isConfirmed ? (
+
+ ) : (
+
+
+
+
+
+ )
+ }}
+ />
+
+
({
+ 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}
+ />
+
+
+ )}
+ {isError && (
+
+
+
+ )}
+
+
+
+ {isConfirmed ? (
+
+
+
+
+
+ ) : (
+
+ {isWaiting ? (
+
+
+
+
+ ) : (
+
+
+
+ )}
+
+ )}
+
+
+
-
);
}
@@ -174,15 +245,26 @@ class ReportModal extends React.Component {
ReportModal.propTypes = {
intl: intlShape,
+ isConfirmed: PropTypes.bool,
+ isError: PropTypes.bool,
+ isOpen: PropTypes.bool,
+ isWaiting: PropTypes.bool,
onReport: PropTypes.func,
onRequestClose: PropTypes.func,
- report: PropTypes.shape({
- category: PropTypes.string,
- notes: PropTypes.string,
- open: PropTypes.bool,
- waiting: PropTypes.bool
- }),
type: PropTypes.string
};
-module.exports = injectIntl(ReportModal);
+const mapStateToProps = state => ({
+ isConfirmed: state.preview.status.report === previewActions.Status.FETCHED,
+ isError: state.preview.status.report === previewActions.Status.ERROR,
+ isWaiting: state.preview.status.report === previewActions.Status.FETCHING
+});
+
+const mapDispatchToProps = () => ({});
+
+const ConnectedReportModal = connect(
+ mapStateToProps,
+ mapDispatchToProps
+)(ReportModal);
+
+module.exports = injectIntl(ConnectedReportModal);
diff --git a/src/components/modal/report/modal.scss b/src/components/modal/report/modal.scss
index 3cd6355ac..375ac6227 100644
--- a/src/components/modal/report/modal.scss
+++ b/src/components/modal/report/modal.scss
@@ -9,7 +9,7 @@
margin: 100px auto;
outline: none;
padding: 0;
- width: 30rem;
+ width: 36.25rem; /* 580px; */
user-select: none;
}
@@ -34,26 +34,45 @@
.report-modal-content {
margin: 1rem auto;
width: 80%;
- line-height: 1.5rem;
font-size: .875rem;
-
+
+ .instructions {
+ line-height: 1.5rem;
+ }
+
+ .received {
+ margin: 0 auto;
+ width: 90%;
+ text-align: center;
+ line-height: 1.65rem;
+
+ .received-header {
+ font-weight: bold;
+ }
+ }
+
+ .error-text {
+ margin-top: .9375rem;
+ }
+
.validation-message {
$arrow-border-width: 1rem;
display: block;
position: absolute;
top: 0;
- left: 0;
- transform: translate(23.5rem, 0);
+ left: 100%; /* position to the right of parent */
margin-left: $arrow-border-width;
border: 1px solid $active-gray;
border-radius: 5px;
background-color: $ui-orange;
padding: 1rem;
+ min-width: 12rem;
max-width: 18.75rem;
min-height: 1rem;
overflow: visible;
color: $type-white;
+ /* arrow on box that points to the left */
&:before {
display: block;
position: absolute;
@@ -78,3 +97,13 @@
.report-modal-field {
position: relative;
}
+
+.form-group.has-error {
+ .textarea, select {
+ border: 1px solid $ui-orange;
+ }
+}
+
+.report-text .textarea {
+ margin-bottom: 0;
+}
diff --git a/src/components/navigation/www/navigation.jsx b/src/components/navigation/www/navigation.jsx
index fddfbafaf..1a0fedb9f 100644
--- a/src/components/navigation/www/navigation.jsx
+++ b/src/components/navigation/www/navigation.jsx
@@ -249,7 +249,7 @@ class Navigation extends React.Component {
'message-count': true,
'show': this.props.unreadMessageCount > 0
})}
- >{this.props.unreadMessageCount}
+ >{this.props.unreadMessageCount}
,
diff --git a/src/components/social-message/social-message.scss b/src/components/social-message/social-message.scss
index b6cdcc871..258fa713e 100644
--- a/src/components/social-message/social-message.scss
+++ b/src/components/social-message/social-message.scss
@@ -41,7 +41,7 @@ a.social-messages-profile-link {
color: $type-gray;
&:hover {
- color: darken($type-gray, 10);
+ color: $link-blue;
}
}
diff --git a/src/components/spinner/spinner.jsx b/src/components/spinner/spinner.jsx
index fa3af8050..694f5a18e 100644
--- a/src/components/spinner/spinner.jsx
+++ b/src/components/spinner/spinner.jsx
@@ -1,18 +1,29 @@
const range = require('lodash.range');
+const PropTypes = require('prop-types');
const React = require('react');
require('./spinner.scss');
// Adapted from http://tobiasahlin.com/spinkit/
-const Spinner = () => (
-
- {range(1, 13).map(id => (
-
- ))}
-
-);
+const Spinner = ({
+ mode
+}) => {
+ const spinnerClassName = (mode === 'smooth' ? 'spinner-smooth' : 'spinner');
+ const spinnerDivCount = (mode === 'smooth' ? 24 : 12);
+ return (
+
+ {range(1, spinnerDivCount + 1).map(id => (
+
+ ))}
+
+ );
+};
+
+Spinner.propTypes = {
+ mode: PropTypes.string
+};
module.exports = Spinner;
diff --git a/src/components/spinner/spinner.scss b/src/components/spinner/spinner.scss
index b335718ee..2707d02cf 100644
--- a/src/components/spinner/spinner.scss
+++ b/src/components/spinner/spinner.scss
@@ -18,13 +18,13 @@
animation: circleFadeDelay 1.2s infinite ease-in-out both;
margin: 0 auto;
border-radius: 100%;
- background-color: darken($ui-white, 8%);
+ background-color: $ui-gray;
width: 15%;
height: 15%;
content: "";
.white & {
- background-color: $ui-blue-dark
+ background-color: $ui-blue-dark;
}
}
}
@@ -37,7 +37,7 @@
transform: rotate($rotation);
&:before {
- animation-delay: $delay;
+ animation-delay: $delay;
}
}
@@ -54,5 +54,65 @@
40% {
opacity: 1;
- }
+ }
+}
+
+
+/*********************/
+/* type === "smooth" */
+/*********************/
+
+.spinner-smooth {
+ position: relative;
+ margin: 0 auto;
+ width: 20px;
+ height: 20px;
+
+ .circle {
+ position: absolute;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 100%;
+
+ &:before {
+ display: block;
+ animation: circleFadeDelaySmooth 1.8s infinite ease-in-out both;
+ margin: 0 auto;
+ border-radius: 100%;
+ background-color: $ui-white;
+ width: 30%;
+ height: 20%;
+ content: "";
+
+ .white & {
+ background-color: darken($ui-blue, 8%);
+ }
+ }
+ }
+
+ @for $i from 1 through 24 {
+ $rotation: 15deg * ($i - 1);
+ $delay: -1.9s + $i * .075;
+
+ .circle#{$i} {
+ transform: rotate($rotation);
+
+ &:before {
+ animation-delay: $delay;
+ }
+ }
+
+ }
+
+}
+
+@keyframes circleFadeDelaySmooth {
+ 0%,
+ 35% {
+ opacity: 0;
+ },
+ 40% {
+ opacity: 1;
+ }
}
diff --git a/src/components/ttt-tile/ttt-tile.scss b/src/components/ttt-tile/ttt-tile.scss
index 71bb685aa..148168675 100644
--- a/src/components/ttt-tile/ttt-tile.scss
+++ b/src/components/ttt-tile/ttt-tile.scss
@@ -106,7 +106,7 @@
font-weight: 500;
&:hover {
- background-color: lighten($link-blue, 40%);
+ background-color: $ui-blue-10percent;
}
}
diff --git a/src/l10n.json b/src/l10n.json
index 72d8967d3..a399ad6b2 100644
--- a/src/l10n.json
+++ b/src/l10n.json
@@ -6,6 +6,7 @@
"general.birthMonth": "Birth Month",
"general.birthYear": "Birth Year",
"general.donate": "Donate",
+ "general.close": "Close",
"general.collaborators": "Collaborators",
"general.community": "Community",
"general.confirmEmail": "Confirm Email",
@@ -52,6 +53,7 @@
"general.noDeletionDescription": "Your account was scheduled for deletion but you logged in. Your account has been reactivated. If you didn’t request for your account to be deleted, you should {resetLink} to make sure your account is secure.",
"general.noDeletionLink": "change your password",
"general.notRequired": "Not Required",
+ "general.okay": "Okay",
"general.other": "Other",
"general.offlineEditor": "Offline Editor",
"general.password": "Password",
@@ -170,6 +172,7 @@
"registration.welcomeStepTitle": "Hurray! Welcome to Scratch!",
"thumbnail.by": "by",
+ "report.error": "Something went wrong when trying to send your message. Please try again.",
"report.project": "Report Project",
"report.projectInstructions": "From the dropdown below, please select the reason why you feel this project is disrespectful or inappropriate or otherwise breaks the {CommunityGuidelinesLink}.",
"report.CommunityGuidelinesLinkText": "Scratch Community Guidelines",
@@ -179,8 +182,11 @@
"report.reasonScary": "Too Violent or Scary",
"report.reasonLanguage": "Inappropriate Language",
"report.reasonMusic": "Inappropriate Music",
+ "report.reasonMissing": "Please select a reason",
"report.reasonImage": "Inappropriate Images",
"report.reasonPersonal": "Sharing Personal Contact Information",
+ "report.receivedHeader": "We have received your report!",
+ "report.receivedBody": "The Scratch Team will review the project based on the Scratch community guidelines.",
"report.promptPlaceholder": "Select a reason why above.",
"report.promptCopy": "Please provide a link to the original project",
"report.promptUncredited": "Please provide links to the uncredited content",
@@ -192,5 +198,7 @@
"report.promptImage": "Please say the name of the sprite or the backdrop with the inappropriate image",
"report.tooLongError": "That's too long! Please find a way to shorten your text.",
"report.tooShortError": "That's too short. Please describe in detail what's inappropriate or disrespectful about the project.",
- "report.send": "Send"
-}
\ No newline at end of file
+ "report.send": "Send",
+ "report.sending": "Sending...",
+ "report.textMissing": "Please tell us why you are reporting this project"
+}
diff --git a/src/lib/truncate.js b/src/lib/truncate.js
new file mode 100644
index 000000000..36a7477f7
--- /dev/null
+++ b/src/lib/truncate.js
@@ -0,0 +1,9 @@
+const lodashTruncate = require('lodash.truncate');
+
+/*
+* Function that applies regex for word boundaries, replaces removed string
+* with indication of ellipsis (...)
+*/
+module.exports.truncateAtWordBoundary = (str, length) => (
+ lodashTruncate(str, {length: length, separator: /[.,:;]*\s+/})
+);
diff --git a/src/main.scss b/src/main.scss
index b7c074ec2..5a0f9cb64 100644
--- a/src/main.scss
+++ b/src/main.scss
@@ -74,7 +74,7 @@ p {
padding: 1.25em;
&.orange {
- background-color: lighten($ui-orange, 30);
+ background-color: $ui-orange-10percent;
}
}
@@ -138,7 +138,7 @@ p {
}
::selection {
- background-color: lighten($ui-blue, 30);
+ background-color: $ui-blue-25percent;
}
ol,
diff --git a/src/redux/preview.js b/src/redux/preview.js
index 25732af24..109635a12 100644
--- a/src/redux/preview.js
+++ b/src/redux/preview.js
@@ -1,4 +1,7 @@
+const defaults = require('lodash.defaults');
const keyMirror = require('keymirror');
+const async = require('async');
+const merge = require('lodash.merge');
const api = require('../lib/api');
const log = require('../lib/log');
@@ -19,16 +22,22 @@ module.exports.getInitialState = () => ({
original: module.exports.Status.NOT_FETCHED,
parent: module.exports.Status.NOT_FETCHED,
remixes: module.exports.Status.NOT_FETCHED,
- studios: module.exports.Status.NOT_FETCHED
+ report: module.exports.Status.NOT_FETCHED,
+ projectStudios: module.exports.Status.NOT_FETCHED,
+ curatedStudios: module.exports.Status.NOT_FETCHED,
+ studioRequests: {}
},
projectInfo: {},
remixes: [],
comments: [],
+ replies: {},
faved: false,
loved: false,
original: {},
parent: {},
- studios: []
+ projectStudios: [],
+ curatedStudios: [],
+ currentStudioIds: []
});
module.exports.previewReducer = (state, action) => {
@@ -53,13 +62,33 @@ module.exports.previewReducer = (state, action) => {
return Object.assign({}, state, {
parent: action.info
});
- case 'SET_STUDIOS':
+ case 'SET_PROJECT_STUDIOS':
+ // also initialize currentStudioIds, to keep track of which studios
+ // the project is currently in.
return Object.assign({}, state, {
- studios: action.items
+ projectStudios: action.items,
+ currentStudioIds: action.items.map(item => item.id)
+ });
+ case 'SET_CURATED_STUDIOS':
+ return Object.assign({}, state, {curatedStudios: action.items});
+ case 'ADD_PROJECT_TO_STUDIO':
+ // add studio id to our studios-that-this-project-belongs-to set.
+ return Object.assign({}, state, {
+ currentStudioIds: state.currentStudioIds.concat(action.studioId)
+ });
+ case 'REMOVE_PROJECT_FROM_STUDIO':
+ return Object.assign({}, state, {
+ currentStudioIds: state.currentStudioIds.filter(item => (
+ item !== action.studioId
+ ))
});
case 'SET_COMMENTS':
return Object.assign({}, state, {
- comments: action.items
+ comments: [...state.comments, ...action.items] // TODO: consider a different way of doing this?
+ });
+ case 'SET_REPLIES':
+ return Object.assign({}, state, {
+ replies: merge({}, state.replies, action.replies)
});
case 'SET_LOVED':
return Object.assign({}, state, {
@@ -73,6 +102,10 @@ module.exports.previewReducer = (state, action) => {
state = JSON.parse(JSON.stringify(state));
state.status[action.infoType] = action.status;
return state;
+ case 'SET_STUDIO_FETCH_STATUS':
+ state = JSON.parse(JSON.stringify(state));
+ state.status.studioRequests[action.studioId] = action.status;
+ return state;
case 'ERROR':
log.error(action.error);
return state;
@@ -116,17 +149,48 @@ module.exports.setRemixes = items => ({
items: items
});
-module.exports.setStudios = items => ({
- type: 'SET_STUDIOS',
+module.exports.setProjectStudios = items => ({
+ type: 'SET_PROJECT_STUDIOS',
items: items
});
+module.exports.setComments = items => ({
+ type: 'SET_COMMENTS',
+ items: items
+});
+
+module.exports.setReplies = replies => ({
+ type: 'SET_REPLIES',
+ replies: replies
+});
+
+module.exports.setCuratedStudios = items => ({
+ type: 'SET_CURATED_STUDIOS',
+ items: items
+});
+
+module.exports.addProjectToStudio = studioId => ({
+ type: 'ADD_PROJECT_TO_STUDIO',
+ studioId: studioId
+});
+
+module.exports.removeProjectFromStudio = studioId => ({
+ type: 'REMOVE_PROJECT_FROM_STUDIO',
+ studioId: studioId
+});
+
module.exports.setFetchStatus = (type, status) => ({
type: 'SET_FETCH_STATUS',
infoType: type,
status: status
});
+module.exports.setStudioFetchStatus = (studioId, status) => ({
+ type: 'SET_STUDIO_FETCH_STATUS',
+ studioId: studioId,
+ status: status
+});
+
module.exports.getProjectInfo = (id, token) => (dispatch => {
const opts = {
uri: `/projects/${id}`
@@ -212,7 +276,57 @@ module.exports.getFavedStatus = (id, username, token) => (dispatch => {
});
});
+module.exports.getTopLevelComments = (id, offset) => (dispatch => {
+ dispatch(module.exports.setFetchStatus('comments', module.exports.Status.FETCHING));
+ api({
+ uri: `/comments/project/${id}`,
+ params: {offset: offset || 0}
+ }, (err, body) => {
+ if (err) {
+ dispatch(module.exports.setFetchStatus('comments', module.exports.Status.ERROR));
+ dispatch(module.exports.setError(err));
+ return;
+ }
+ if (typeof body === 'undefined') {
+ dispatch(module.exports.setFetchStatus('comments', module.exports.Status.ERROR));
+ dispatch(module.exports.setError('No comment info'));
+ return;
+ }
+ dispatch(module.exports.setFetchStatus('comments', module.exports.Status.FETCHED));
+ dispatch(module.exports.setComments(body));
+ dispatch(module.exports.getReplies(id, body.map(comment => comment.id)));
+ });
+});
+
+module.exports.getReplies = (projectId, commentIds) => (dispatch => {
+ dispatch(module.exports.setFetchStatus('replies', module.exports.Status.FETCHING));
+ const fetchedReplies = {};
+ async.eachLimit(commentIds, 10, (parentId, callback) => {
+ api({
+ uri: `/comments/project/${projectId}/${parentId}`
+ }, (err, body) => {
+ if (err) {
+ return callback(`Error fetching comment replies: ${err}`);
+ }
+ if (typeof body === 'undefined') {
+ return callback('No comment reply information');
+ }
+ fetchedReplies[parentId] = body;
+ callback(null, body);
+ });
+ }, err => {
+ if (err) {
+ dispatch(module.exports.setFetchStatus('replies', module.exports.Status.ERROR));
+ dispatch(module.exports.setError(err));
+ return;
+ }
+ dispatch(module.exports.setFetchStatus('replies', module.exports.Status.FETCHED));
+ dispatch(module.exports.setReplies(fetchedReplies));
+ });
+});
+
module.exports.setFavedStatus = (faved, id, username, token) => (dispatch => {
+ dispatch(module.exports.setFetchStatus('faved', module.exports.Status.FETCHING));
if (faved) {
api({
uri: `/projects/${id}/favorites/user/${username}`,
@@ -272,6 +386,7 @@ module.exports.getLovedStatus = (id, username, token) => (dispatch => {
});
module.exports.setLovedStatus = (loved, id, username, token) => (dispatch => {
+ dispatch(module.exports.setFetchStatus('loved', module.exports.Status.FETCHING));
if (loved) {
api({
uri: `/projects/${id}/loves/user/${username}`,
@@ -333,30 +448,94 @@ module.exports.getRemixes = id => (dispatch => {
});
});
-module.exports.getStudios = id => (dispatch => {
- dispatch(module.exports.setFetchStatus('studios', module.exports.Status.FETCHING));
+module.exports.getProjectStudios = id => (dispatch => {
+ dispatch(module.exports.setFetchStatus('projectStudios', module.exports.Status.FETCHING));
api({
- uri: `/projects/${id}/studios?limit=5`
+ uri: `/projects/${id}/studios`
}, (err, body, res) => {
if (err) {
- dispatch(module.exports.setFetchStatus('studios', module.exports.Status.ERROR));
+ dispatch(module.exports.setFetchStatus('projectStudios', module.exports.Status.ERROR));
dispatch(module.exports.setError(err));
return;
}
if (typeof body === 'undefined') {
- dispatch(module.exports.setFetchStatus('studios', module.exports.Status.ERROR));
- dispatch(module.exports.setError('No studios info'));
+ dispatch(module.exports.setFetchStatus('projectStudios', module.exports.Status.ERROR));
+ dispatch(module.exports.setError('No projectStudios info'));
return;
}
if (res.statusCode === 404) { // NotFound
body = [];
}
- dispatch(module.exports.setFetchStatus('studios', module.exports.Status.FETCHED));
- dispatch(module.exports.setStudios(body));
+ dispatch(module.exports.setFetchStatus('projectStudios', module.exports.Status.FETCHED));
+ dispatch(module.exports.setProjectStudios(body));
+ });
+});
+
+module.exports.getCuratedStudios = username => (dispatch => {
+ dispatch(module.exports.setFetchStatus('curatedStudios', module.exports.Status.FETCHING));
+ api({
+ uri: `/users/${username}/studios/curate`
+ }, (err, body, res) => {
+ if (err) {
+ dispatch(module.exports.setFetchStatus('curatedStudios', module.exports.Status.ERROR));
+ dispatch(module.exports.setError(err));
+ return;
+ }
+ if (typeof body === 'undefined') {
+ dispatch(module.exports.setFetchStatus('curatedStudios', module.exports.Status.ERROR));
+ dispatch(module.exports.setError('No curated studios info'));
+ return;
+ }
+ if (res.statusCode === 404) { // NotFound
+ body = [];
+ }
+ dispatch(module.exports.setFetchStatus('curatedStudios', module.exports.Status.FETCHED));
+ dispatch(module.exports.setCuratedStudios(body));
+ });
+});
+
+module.exports.addToStudio = (studioId, projectId, token) => (dispatch => {
+ dispatch(module.exports.setStudioFetchStatus(studioId, module.exports.Status.FETCHING));
+ api({
+ uri: `/studios/${studioId}/project/${projectId}`,
+ authentication: token,
+ method: 'POST'
+ }, (err, body) => {
+ if (err) {
+ dispatch(module.exports.setError(err));
+ return;
+ }
+ if (typeof body === 'undefined') {
+ dispatch(module.exports.setError('Add to studio returned no data'));
+ return;
+ }
+ dispatch(module.exports.setStudioFetchStatus(studioId, module.exports.Status.FETCHED));
+ dispatch(module.exports.addProjectToStudio(studioId));
+ });
+});
+
+module.exports.leaveStudio = (studioId, projectId, token) => (dispatch => {
+ dispatch(module.exports.setStudioFetchStatus(studioId, module.exports.Status.FETCHING));
+ api({
+ uri: `/studios/${studioId}/project/${projectId}`,
+ authentication: token,
+ method: 'DELETE'
+ }, (err, body) => {
+ if (err) {
+ dispatch(module.exports.setError(err));
+ return;
+ }
+ if (typeof body === 'undefined') {
+ dispatch(module.exports.setError('Leave studio returned no data'));
+ return;
+ }
+ dispatch(module.exports.setStudioFetchStatus(studioId, module.exports.Status.FETCHED));
+ dispatch(module.exports.removeProjectFromStudio(studioId));
});
});
module.exports.updateProject = (id, jsonData, username, token) => (dispatch => {
+ dispatch(module.exports.setFetchStatus('project', module.exports.Status.FETCHING));
api({
uri: `/projects/${id}`,
authentication: token,
@@ -382,3 +561,27 @@ module.exports.updateProject = (id, jsonData, username, token) => (dispatch => {
dispatch(module.exports.setProjectInfo(body));
});
});
+
+module.exports.reportProject = (id, jsonData) => (dispatch => {
+ dispatch(module.exports.setFetchStatus('report', module.exports.Status.FETCHING));
+ // scratchr2 will fail if no thumbnail base64 string provided. We don't yet have
+ // a way to get the actual project thumbnail in www/gui, so for now just submit
+ // a minimal base64 png string.
+ defaults(jsonData, {
+ thumbnail: 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC' +
+ '0lEQVR42mP8/x8AAwMCAO+ip1sAAAAASUVORK5CYII='
+ });
+ api({
+ host: '',
+ uri: `/site-api/projects/all/${id}/report/`,
+ method: 'POST',
+ json: jsonData,
+ useCsrf: true
+ }, (err, body, res) => {
+ if (err || res.statusCode !== 200) {
+ dispatch(module.exports.setFetchStatus('report', module.exports.Status.ERROR));
+ return;
+ }
+ dispatch(module.exports.setFetchStatus('report', module.exports.Status.FETCHED));
+ });
+});
diff --git a/src/views/conference/2016/schedule/schedule.scss b/src/views/conference/2016/schedule/schedule.scss
index aa4dc891d..d65a921d0 100644
--- a/src/views/conference/2016/schedule/schedule.scss
+++ b/src/views/conference/2016/schedule/schedule.scss
@@ -67,7 +67,7 @@
display: block;
&:hover {
- background-color: lighten($ui-blue, 40);
+ background-color: $ui-blue-25percent;
}
}
}
diff --git a/src/views/conference/2017/index/index.scss b/src/views/conference/2017/index/index.scss
index 70695cb25..6c571b5fd 100644
--- a/src/views/conference/2017/index/index.scss
+++ b/src/views/conference/2017/index/index.scss
@@ -27,7 +27,7 @@
}
.conf2017-title-band {
- background-color: lighten($ui-blue, 10%);
+ background-color: $ui-blue-dark;
padding: 1.5rem;
text-align: center;
color: $type-white;
diff --git a/src/views/credits/credits.jsx b/src/views/credits/credits.jsx
index 2c00af01c..cdf43e0a5 100644
--- a/src/views/credits/credits.jsx
+++ b/src/views/credits/credits.jsx
@@ -174,6 +174,14 @@ const Credits = () => (
Tracy Tang
+
+
+ Bryce Taylor
+
+
(
Chris Willis-Ford
+
+
+ Kathy Wu
+
+
(
+
+);
+
+Comment.propTypes = {
+ author: PropTypes.shape({
+ id: PropTypes.number,
+ image: PropTypes.string,
+ username: PropTypes.string
+ }),
+ content: PropTypes.string,
+ datetimeCreated: PropTypes.string,
+ id: PropTypes.number
+};
+
+module.exports = Comment;
diff --git a/src/views/preview/comment/comment.scss b/src/views/preview/comment/comment.scss
new file mode 100644
index 000000000..7c9027d01
--- /dev/null
+++ b/src/views/preview/comment/comment.scss
@@ -0,0 +1,214 @@
+@import "../../../colors";
+
+.compose-comment {
+ width: 100%;
+
+ .textarea-row {
+ width: 100%;
+
+ textarea {
+ &:not(:focus) {
+ border: 1px solid $active-dark-gray;
+ }
+ }
+ }
+
+ .compose-bottom-row {
+ width: 100%;
+ justify-content: space-between;
+
+ .compose-post {
+ margin-right: .5rem;
+ }
+
+ .compose-cancel {
+ background-color: $ui-dark-gray;
+ }
+
+ .compose-limit {
+ margin-left: auto;
+ height: 100%;
+ font-size: .75rem;
+ }
+
+ .button {
+ margin-left: 0;
+ border-radius: .25rem;
+ }
+ }
+}
+
+.comment-container {
+ position: relative;
+ width: 100%;
+ justify-content: flex-end;
+}
+
+.comment {
+ position: relative;
+ width: 100%;
+ flex-wrap: nowrap;
+ justify-content: space-between;
+ align-items: flex-start;
+
+ .comment-top-row {
+ margin-bottom: 8px;
+ width: 100%;
+
+ .username {
+ margin-right: auto;
+ }
+
+ .comment-delete,
+ .comment-report {
+ opacity: .5;
+ font-size: .75rem;
+ font-weight: 500;
+
+ &:before {
+ display: inline-block;
+ margin-right: .5rem;
+ background-repeat: no-repeat;
+ background-position: center center;
+ background-size: contain;
+ content: "";
+ }
+ }
+
+ .comment-delete {
+ margin-right: 1rem;
+
+ &:before {
+ background-image: url("/svgs/project/delete-gray.svg");
+ width: 1rem;
+ height: 1rem;
+ vertical-align: -.25rem;
+ }
+ }
+
+ .comment-report {
+ &:before {
+ margin-right: .25rem;
+ background-image: url("/svgs/project/report-gray.svg");
+ width: .75rem;
+ height: .75rem;
+ vertical-align: -.125rem;
+ }
+ }
+ }
+
+ .avatar {
+ margin-right: .5rem;
+ }
+
+ .comment-body {
+ margin-bottom: 1.5rem;
+ min-width: 50%;
+ flex-grow: 1;
+ align-items: flex-start;
+
+ .comment-bubble {
+ position: relative;
+ margin-left: .5rem;
+ border: 1px solid $active-gray;
+ border-radius: 0 .5rem .5rem .5rem;
+ background-color: $ui-white;
+ padding: .75rem;
+ width: calc(100% - .5rem);
+ box-sizing: border-box;
+
+ &:before {
+ display: inline-block;
+ position: absolute;
+ top: -1px;
+ left: -11px; // width + 1px
+ border-width: 1px 0 1px 1px;
+ border-style: solid;
+ border-radius: 0 0 0 8px;
+ border-color: $active-gray transparent $active-gray $active-gray;
+ background: $ui-white;
+ width: 10px;
+ height: 9px;
+ content: "";
+ }
+ }
+
+ .comment-content {
+ overflow-wrap: break-word;
+ }
+
+ .comment-bottom-row {
+ padding-top: 1rem;
+ font-size: .75rem;
+ justify-content: space-between;
+
+ .comment-time {
+ color: $ui-dark-gray;
+ }
+
+ .comment-reply {
+ display: inline-flex;
+
+ &:after {
+ margin-left: .25rem;
+ background-image: url("/svgs/project/comment-reply.svg");
+ background-size: cover;
+ width: 1rem;
+ height: 1rem;
+ content: "";
+ }
+ }
+ }
+ }
+
+}
+
+.replies {
+ width: calc(100% - 4rem);
+
+ &.collapsed .comment {
+ &:last-child {
+ &:after {
+ position: absolute;
+ bottom: 0;
+ background: linear-gradient(
+ transparent,
+ $ui-light-primary
+ );
+ width: 100%;
+ height: 100%;
+ content: "";
+ pointer-events: none;
+ }
+ }
+ }
+}
+
+.expand-thread {
+ margin-bottom: 24px;
+ width: 100%;
+ overflow: hidden;
+ text-align: center;
+
+ &:before,
+ &:after {
+ display: inline-block;
+ position: relative;
+ background-color: $active-gray;
+ width: 50%;
+ height: 2px;
+ vertical-align: middle;
+ content: "";
+ }
+
+ &:before {
+ right: .5em;
+ margin-left: -50%;
+ }
+
+ &:after {
+ left: .5em;
+ margin-right: -50%;
+ }
+}
+
diff --git a/src/views/preview/comment/compose-comment.jsx b/src/views/preview/comment/compose-comment.jsx
new file mode 100644
index 000000000..d14537965
--- /dev/null
+++ b/src/views/preview/comment/compose-comment.jsx
@@ -0,0 +1,26 @@
+const React = require('react');
+
+const FlexRow = require('../../../components/flex-row/flex-row.jsx');
+const InplaceInput = require('../../../components/forms/inplace-input.jsx');
+const Button = require('../../../components/forms/button.jsx');
+
+require('./comment.scss');
+
+const onUpdate = update => update;
+
+const ComposeComment = () => (
+
+
+
+ Post
+ Cancel
+ 500 characters left
+
+
+);
+
+module.exports = ComposeComment;
diff --git a/src/views/preview/comment/top-level-comment.jsx b/src/views/preview/comment/top-level-comment.jsx
new file mode 100644
index 000000000..a1b96f4ec
--- /dev/null
+++ b/src/views/preview/comment/top-level-comment.jsx
@@ -0,0 +1,85 @@
+const React = require('react');
+const PropTypes = require('prop-types');
+const bindAll = require('lodash.bindall');
+const classNames = require('classnames');
+
+const FlexRow = require('../../../components/flex-row/flex-row.jsx');
+const Comment = require('./comment.jsx');
+
+require('./comment.scss');
+
+class TopLevelComment extends React.Component {
+ constructor (props) {
+ super(props);
+ bindAll(this, [
+ 'handleExpandThread'
+ ]);
+ this.state = {
+ expanded: false
+ };
+ }
+
+ handleExpandThread () {
+ this.setState({
+ expanded: true
+ });
+ }
+
+ render () {
+ const {
+ author,
+ content,
+ datetimeCreated,
+ id,
+ replies
+ } = this.props;
+
+ return (
+
+
+ {replies.length > 0 &&
+ 3}
+ )}
+ key={id}
+ >
+ {(this.state.expanded ? replies : replies.slice(0, 3)).map(reply => (
+
+ ))}
+
+ }
+ {!this.state.expanded && replies.length > 3 &&
+ See all {replies.length} replies
+ }
+
+ );
+ }
+}
+
+TopLevelComment.propTypes = {
+ author: PropTypes.shape({
+ id: PropTypes.number,
+ image: PropTypes.string,
+ username: PropTypes.string
+ }),
+ content: PropTypes.string,
+ datetimeCreated: PropTypes.string,
+ id: PropTypes.number,
+ parentId: PropTypes.number,
+ projectId: PropTypes.string,
+ replies: PropTypes.arrayOf(PropTypes.object)
+};
+
+module.exports = TopLevelComment;
diff --git a/src/views/preview/l10n.json b/src/views/preview/l10n.json
index e2f65dd8e..4dead1d4a 100644
--- a/src/views/preview/l10n.json
+++ b/src/views/preview/l10n.json
@@ -1,7 +1,9 @@
{
+ "addToStudio.title": "Add to Studio",
+ "addToStudio.finishing": "Finishing up...",
"preview.musicExtensionChip": "Music",
"preview.penExtensionChip": "Pen",
"preview.speechExtensionChip": "Google Speech",
"preview.translateExtensionChip": "Google Translate",
"preview.videoMotionChip": "Video Motion"
-}
\ No newline at end of file
+}
diff --git a/src/views/preview/presentation.jsx b/src/views/preview/presentation.jsx
index 530a985e1..bffa0f829 100644
--- a/src/views/preview/presentation.jsx
+++ b/src/views/preview/presentation.jsx
@@ -19,13 +19,18 @@ const RemixCredit = require('./remix-credit.jsx');
const RemixList = require('./remix-list.jsx');
const StudioList = require('./studio-list.jsx');
const InplaceInput = require('../../components/forms/inplace-input.jsx');
+const AddToStudioModal = require('../../components/modal/addtostudio/container.jsx');
const ReportModal = require('../../components/modal/report/modal.jsx');
+const TopLevelComment = require('./comment/top-level-comment.jsx');
const ExtensionChip = require('./extension-chip.jsx');
const projectShape = require('./projectshape.jsx').projectShape;
require('./preview.scss');
const PreviewPresentation = ({
+ assetHost,
+ backpackOptions,
+ comments,
editable,
extensions,
faved,
@@ -37,17 +42,25 @@ const PreviewPresentation = ({
loveCount,
originalInfo,
parentInfo,
+ projectHost,
projectId,
projectInfo,
remixes,
- report,
+ reportOpen,
+ replies,
+ addToStudioOpen,
+ projectStudios,
studios,
userOwnsProject,
onFavoriteClicked,
+ onLoadMore,
onLoveClicked,
onReportClicked,
onReportClose,
onReportSubmit,
+ onAddToStudioClicked,
+ onAddToStudioClosed,
+ onToggleStudio,
onSeeInside,
onUpdate
}) => {
@@ -55,21 +68,21 @@ const PreviewPresentation = ({
return (
-
+
{ projectInfo && projectInfo.author && projectInfo.author.id && (
-
-
+
+
{editable ?
-
+
@@ -237,9 +253,24 @@ const PreviewPresentation = ({
{/* eslint-enable react/jsx-sort-props */}
-
- Add to Studio
-
+ {(isLoggedIn && userOwnsProject) &&
+
+
+ Add to Studio
+ ,
+
+
+ }
Copy Link
@@ -253,8 +284,8 @@ const PreviewPresentation = ({
Report
,
-
-
-
-
-
+
+
+
+
+
+
+ Comments
+ {/* TODO: Add toggle comments component and logic*/}
+
+
+ {comments.map(comment => (
+
+ ))}
+ {comments.length < projectInfo.stats.comments &&
+
+ Load More
+
+ }
+
+
+
+
+
+
-
-
-
+
+
+
)}
);
};
PreviewPresentation.propTypes = {
+ addToStudioOpen: PropTypes.bool,
+ assetHost: PropTypes.string,
+ backpackOptions: PropTypes.shape({
+ host: PropTypes.string,
+ visible: PropTypes.bool
+ }),
+ comments: PropTypes.arrayOf(PropTypes.object),
editable: PropTypes.bool,
extensions: PropTypes.arrayOf(PropTypes.object),
faved: PropTypes.bool,
@@ -303,24 +370,26 @@ PreviewPresentation.propTypes = {
isShared: PropTypes.bool,
loveCount: PropTypes.number,
loved: PropTypes.bool,
+ onAddToStudioClicked: PropTypes.func,
+ onAddToStudioClosed: PropTypes.func,
onFavoriteClicked: PropTypes.func,
+ onLoadMore: PropTypes.func,
onLoveClicked: PropTypes.func,
onReportClicked: PropTypes.func.isRequired,
onReportClose: PropTypes.func.isRequired,
onReportSubmit: PropTypes.func.isRequired,
onSeeInside: PropTypes.func,
+ onToggleStudio: PropTypes.func,
onUpdate: PropTypes.func,
originalInfo: projectShape,
parentInfo: projectShape,
+ projectHost: PropTypes.string,
projectId: PropTypes.string,
projectInfo: projectShape,
+ projectStudios: PropTypes.arrayOf(PropTypes.object),
remixes: PropTypes.arrayOf(PropTypes.object),
- report: PropTypes.shape({
- category: PropTypes.string,
- notes: PropTypes.string,
- open: PropTypes.bool,
- waiting: PropTypes.bool
- }),
+ replies: PropTypes.objectOf(PropTypes.array),
+ reportOpen: PropTypes.bool,
studios: PropTypes.arrayOf(PropTypes.object),
userOwnsProject: PropTypes.bool
};
diff --git a/src/views/preview/preview.jsx b/src/views/preview/preview.jsx
index 2905b1925..d221c2513 100644
--- a/src/views/preview/preview.jsx
+++ b/src/views/preview/preview.jsx
@@ -1,3 +1,6 @@
+// preview view can show either project page or editor page;
+// idea is that we shouldn't require a page reload to switch back and forth
+
const bindAll = require('lodash.bindall');
const React = require('react');
const PropTypes = require('prop-types');
@@ -24,13 +27,17 @@ class Preview extends React.Component {
super(props);
bindAll(this, [
'addEventListeners',
+ 'handleToggleStudio',
'handleFavoriteToggle',
+ 'handleLoadMore',
'handleLoveToggle',
'handlePermissions',
'handlePopState',
'handleReportClick',
'handleReportClose',
'handleReportSubmit',
+ 'handleAddToStudioClick',
+ 'handleAddToStudioClose',
'handleSeeInside',
'handleUpdate',
'initCounts',
@@ -49,12 +56,8 @@ class Preview extends React.Component {
favoriteCount: 0,
loveCount: 0,
projectId: parts[1] === 'editor' ? 0 : parts[1],
- report: {
- category: '',
- notes: '',
- open: false,
- waiting: false
- }
+ addToStudioOpen: false,
+ reportOpen: false
};
this.getExtensions(this.state.projectId);
this.addEventListeners();
@@ -66,17 +69,19 @@ class Preview extends React.Component {
if (this.props.user) {
const username = this.props.user.username;
const token = this.props.user.token;
+ this.props.getTopLevelComments(this.state.projectId, this.props.comments.length);
this.props.getProjectInfo(this.state.projectId, token);
this.props.getRemixes(this.state.projectId, token);
- this.props.getStudios(this.state.projectId, token);
+ this.props.getProjectStudios(this.state.projectId, token);
+ this.props.getCuratedStudios(username);
this.props.getFavedStatus(this.state.projectId, username, token);
this.props.getLovedStatus(this.state.projectId, username, token);
} else {
+ this.props.getTopLevelComments(this.state.projectId, this.props.comments.length);
this.props.getProjectInfo(this.state.projectId);
this.props.getRemixes(this.state.projectId);
- this.props.getStudios(this.state.projectId);
+ this.props.getProjectStudios(this.state.projectId);
}
-
}
if (this.props.projectInfo.id !== prevProps.projectInfo.id) {
this.getExtensions(this.state.projectId);
@@ -107,7 +112,7 @@ class Preview extends React.Component {
getExtensions (projectId) {
storage
.load(storage.AssetType.Project, projectId, storage.DataFormat.JSON)
- .then(projectAsset => {
+ .then(projectAsset => { // NOTE: this is turning up null, breaking the line below.
let input = projectAsset.data;
if (typeof input === 'object' && !(input instanceof ArrayBuffer) &&
!ArrayBuffer.isView(input)) { // taken from scratch-vm
@@ -137,31 +142,19 @@ class Preview extends React.Component {
});
}
handleReportClick () {
- this.setState({report: {...this.state.report, open: true}});
+ this.setState({reportOpen: true});
}
handleReportClose () {
- this.setState({report: {...this.state.report, open: false}});
+ this.setState({reportOpen: false});
+ }
+ handleAddToStudioClick () {
+ this.setState({addToStudioOpen: true});
+ }
+ handleAddToStudioClose () {
+ this.setState({addToStudioOpen: false});
}
handleReportSubmit (formData) {
- this.setState({report: {
- category: formData.report_category,
- notes: formData.notes,
- open: this.state.report.open,
- waiting: true}
- });
-
- const data = {
- ...formData,
- id: this.state.projectId,
- user: this.props.user.username
- };
- console.log('submit report data', data); // eslint-disable-line no-console
- this.setState({report: {
- category: '',
- notes: '',
- open: false,
- waiting: false}
- });
+ this.props.reportProject(this.state.projectId, formData);
}
handlePopState () {
const path = window.location.pathname.toLowerCase();
@@ -196,6 +189,22 @@ class Preview extends React.Component {
);
}
}
+ handleToggleStudio (event) {
+ const studioId = parseInt(event.currentTarget.dataset.id, 10);
+ if (isNaN(studioId)) { // sanity check in case event had no integer data-id
+ return;
+ }
+ const studio = this.props.studios.find(thisStudio => (thisStudio.id === studioId));
+ // only send add or leave request to server if we know current status
+ if ((typeof studio !== 'undefined') && ('includesProject' in studio)) {
+ this.props.toggleStudio(
+ (studio.includesProject === false),
+ studioId,
+ this.props.projectInfo.id,
+ this.props.user.token
+ );
+ }
+ }
handleFavoriteToggle () {
this.props.setFavedStatus(
!this.props.faved,
@@ -213,6 +222,9 @@ class Preview extends React.Component {
}));
}
}
+ handleLoadMore () {
+ this.props.getTopLevelComments(this.state.projectId, this.props.comments.length);
+ }
handleLoveToggle () {
this.props.setLovedStatus(
!this.props.loved,
@@ -231,7 +243,7 @@ class Preview extends React.Component {
}
}
handlePermissions () {
- // TODO: handle admins and mods
+ // TODO: handle admins and mods
if (this.props.projectInfo.author.username === this.props.user.username) {
this.setState({editable: true});
}
@@ -280,6 +292,9 @@ class Preview extends React.Component {
this.props.playerMode ?
:
);
@@ -319,28 +345,40 @@ class Preview extends React.Component {
}
Preview.propTypes = {
+ assetHost: PropTypes.string.isRequired,
+ backpackOptions: PropTypes.shape({
+ host: PropTypes.string,
+ visible: PropTypes.bool
+ }),
comments: PropTypes.arrayOf(PropTypes.object),
faved: PropTypes.bool,
fullScreen: PropTypes.bool,
+ getCuratedStudios: PropTypes.func.isRequired,
getFavedStatus: PropTypes.func.isRequired,
getLovedStatus: PropTypes.func.isRequired,
getOriginalInfo: PropTypes.func.isRequired,
getParentInfo: PropTypes.func.isRequired,
getProjectInfo: PropTypes.func.isRequired,
+ getProjectStudios: PropTypes.func.isRequired,
getRemixes: PropTypes.func.isRequired,
- getStudios: PropTypes.func.isRequired,
+ getTopLevelComments: PropTypes.func.isRequired,
loved: PropTypes.bool,
original: projectShape,
parent: projectShape,
playerMode: PropTypes.bool,
+ projectHost: PropTypes.string.isRequired,
projectInfo: projectShape,
+ projectStudios: PropTypes.arrayOf(PropTypes.object),
remixes: PropTypes.arrayOf(PropTypes.object),
+ replies: PropTypes.objectOf(PropTypes.array),
+ reportProject: PropTypes.func,
sessionStatus: PropTypes.string,
setFavedStatus: PropTypes.func.isRequired,
setFullScreen: PropTypes.func.isRequired,
setLovedStatus: PropTypes.func.isRequired,
setPlayer: PropTypes.func.isRequired,
studios: PropTypes.arrayOf(PropTypes.object),
+ toggleStudio: PropTypes.func.isRequired,
updateProject: PropTypes.func.isRequired,
user: PropTypes.shape({
id: PropTypes.number,
@@ -355,10 +393,50 @@ Preview.propTypes = {
};
Preview.defaultProps = {
+ assetHost: process.env.ASSET_HOST,
+ backpackOptions: {
+ host: process.env.BACKPACK_HOST,
+ visible: true
+ },
+ projectHost: process.env.PROJECT_HOST,
sessionStatus: sessionActions.Status.NOT_FETCHED,
user: {}
};
+// Build consolidated curatedStudios object from all studio info.
+// We add flags to indicate whether the project is currently in the studio,
+// and the status of requests to join/leave studios.
+const consolidateStudiosInfo = (curatedStudios, projectStudios, currentStudioIds, studioRequests) => {
+ const consolidatedStudios = [];
+
+ projectStudios.forEach(projectStudio => {
+ const includesProject = (currentStudioIds.indexOf(projectStudio.id) !== -1);
+ const consolidatedStudio =
+ Object.assign({}, projectStudio, {includesProject: includesProject});
+ consolidatedStudios.push(consolidatedStudio);
+ });
+
+ // copy the curated studios that project is not in
+ curatedStudios.forEach(curatedStudio => {
+ if (!projectStudios.some(projectStudio => (projectStudio.id === curatedStudio.id))) {
+ const includesProject = (currentStudioIds.indexOf(curatedStudio.id) !== -1);
+ const consolidatedStudio =
+ Object.assign({}, curatedStudio, {includesProject: includesProject});
+ consolidatedStudios.push(consolidatedStudio);
+ }
+ });
+
+ // set studio state to hasRequestOutstanding==true if it's being fetched,
+ // false if it's not
+ consolidatedStudios.forEach(consolidatedStudio => {
+ const id = consolidatedStudio.id;
+ consolidatedStudio.hasRequestOutstanding =
+ ((id in studioRequests) &&
+ (studioRequests[id] === previewActions.Status.FETCHING));
+ });
+ return consolidatedStudios;
+};
+
const mapStateToProps = state => ({
projectInfo: state.preview.projectInfo,
comments: state.preview.comments,
@@ -367,14 +445,17 @@ const mapStateToProps = state => ({
original: state.preview.original,
parent: state.preview.parent,
remixes: state.preview.remixes,
+ replies: state.preview.replies,
sessionStatus: state.session.status,
- studios: state.preview.studios,
+ projectStudios: state.preview.projectStudios,
+ studios: consolidateStudiosInfo(state.preview.curatedStudios,
+ state.preview.projectStudios, state.preview.currentStudioIds,
+ state.preview.status.studioRequests),
user: state.session.session.user,
playerMode: state.scratchGui.mode.isPlayerOnly,
fullScreen: state.scratchGui.mode.isFullScreen
});
-
const mapDispatchToProps = dispatch => ({
getOriginalInfo: id => {
dispatch(previewActions.getOriginalInfo(id));
@@ -388,8 +469,21 @@ const mapDispatchToProps = dispatch => ({
getRemixes: id => {
dispatch(previewActions.getRemixes(id));
},
- getStudios: id => {
- dispatch(previewActions.getStudios(id));
+ getProjectStudios: id => {
+ dispatch(previewActions.getProjectStudios(id));
+ },
+ getCuratedStudios: (username, token) => {
+ dispatch(previewActions.getCuratedStudios(username, token));
+ },
+ toggleStudio: (isAdd, studioId, id, token) => {
+ if (isAdd === true) {
+ dispatch(previewActions.addToStudio(studioId, id, token));
+ } else {
+ dispatch(previewActions.leaveStudio(studioId, id, token));
+ }
+ },
+ getTopLevelComments: (id, offset) => {
+ dispatch(previewActions.getTopLevelComments(id, offset));
},
getFavedStatus: (id, username, token) => {
dispatch(previewActions.getFavedStatus(id, username, token));
@@ -406,6 +500,9 @@ const mapDispatchToProps = dispatch => ({
refreshSession: () => {
dispatch(sessionActions.refreshSession());
},
+ reportProject: (id, formData) => {
+ dispatch(previewActions.reportProject(id, formData));
+ },
setOriginalInfo: info => {
dispatch(previewActions.setOriginalInfo(info));
},
diff --git a/src/views/preview/preview.scss b/src/views/preview/preview.scss
index e318eef95..8aefd8ead 100644
--- a/src/views/preview/preview.scss
+++ b/src/views/preview/preview.scss
@@ -1,18 +1,14 @@
@import "../../colors";
@import "../../frameless";
-/* stage size constants
- * this is a hack right now - stage includes padding of .5rem (8px) for alignment in gui
- * in www the player is placed with margin -.5rem to align the edge.
- * the height is calculated from the actual height on the page (404)
-*/
-$gui-width: 496px;
+/* stage size constants */
+$player-width: 482px;
+$player-height: 406px;
$stage-width: 480px;
-$stage-height: 404px;
/* override view padding for share banner */
#view {
- padding: 0 0 20px 0;
+ padding: 0;
}
.gui {
@@ -29,34 +25,34 @@ $stage-height: 404px;
.project-title {
font-size: 1.75rem;
font-weight: 500;
-
+
&.has-error {
-
+
.validation-message {
transform: translate(22rem, 0);
}
}
}
-
+
.project-header {
margin-right: 2rem;
flex-grow: 1;
justify-content: flex-start;
align-items: flex-start;
-
+
.inplace-input {
height: calc(3rem - 4px);
}
}
-
+
img {
-
+
&.avatar {
border: 0;
border-radius: 5px;
width: 3rem;
height: 3rem;
-
+
&.remix {
margin-right: .5em;
width: 2rem;
@@ -64,14 +60,14 @@ $stage-height: 404px;
}
}
}
-
+
.title {
margin-left: 1rem;
text-align: left;
font-size: .8rem;
flex-grow: 1;
}
-
+
.validation-message {
$arrow-border-width: 1rem;
display: block;
@@ -108,8 +104,8 @@ $stage-height: 404px;
content: "";
}
}
-
- .row {
+
+ .row {
&.has-error {
.inplace-input,
@@ -122,21 +118,21 @@ $stage-height: 404px;
position: relative;
}
}
-
+
.button {
margin-left: 1rem;
}
-
+
.comments-container {
width: 60%;
}
-
+
.remix-button,
.see-inside-button {
margin-top: 0;
font-size: .875rem;
font-weight: normal;
-
+
&:before {
display: inline-block;
margin-right: .5rem;
@@ -152,19 +148,19 @@ $stage-height: 404px;
.remix-button {
background-color: $ui-aqua;
-
+
&:before {
background-image: url("/svgs/project/remix-white.svg");
}
}
.see-inside-button {
-
+
&:before {
background-image: url("/svgs/project/see-inside-white.svg");
}
}
-
+
.preview-row {
margin-top: 1rem;
justify-content: space-between;
@@ -174,14 +170,13 @@ $stage-height: 404px;
.guiPlayer {
display: inline-block;
- margin-left: -.5rem;
- width: $gui-width;
+ width: $player-width;
}
.project-notes {
// not 1.5rem because of stage padding
margin-left: 1rem;
- height: $stage-height;
+ height: $player-height;
align-items: flex-start;
flex: 1;
flex-flow: column;
@@ -194,7 +189,7 @@ $stage-height: 404px;
color: $type-gray;
font-size: .875rem;
}
-
+
.subactions {
margin-left: 1.5rem;
justify-content: flex-end;
@@ -216,7 +211,7 @@ $stage-height: 404px;
font-size: .875rem;
flex-shrink: 1;
}
-
+
.description-block {
display: flex;
width: 100%;
@@ -225,7 +220,7 @@ $stage-height: 404px;
align-items: flex-start;
flex: 1;
}
-
+
.project-textlabel {
margin: 0 0 .5rem 0;
font-size: 1rem;
@@ -240,12 +235,12 @@ $stage-height: 404px;
padding: .5rem;
width: calc(100% - (1rem + 2px));
overflow: auto;
- white-space: pre-line;
+ white-space: pre-line;
font-size: 1rem;
// flex-grow
flex: 1;
}
-
+
.project-description.last {
margin-bottom: 0;
}
@@ -261,29 +256,29 @@ $stage-height: 404px;
white-space: pre-line;
// flex-grow
flex: 1;
-
+
&.last {
margin-bottom: 0;
}
-
+
&.textarea-row {
border: 0;
background-color: inherit;
padding: 0;
}
-
+
&.has-error {
.validation-message {
transform: translate(26rem, 0);
}
}
-
+
& > .grow {
display: flex;
flex: 1;
}
}
-
+
.copyleft {
display: inline-block;
transform: scale(-1, 1);
@@ -320,9 +315,9 @@ $stage-height: 404px;
}
.project-loves {
-
+
cursor: pointer;
-
+
&:before {
opacity: .5;
background-image: url("/svgs/project/love-gray.svg");
@@ -330,7 +325,7 @@ $stage-height: 404px;
}
.project-loves.loved {
-
+
&:before {
opacity: 1;
background-image: url("/svgs/project/love-red.svg");
@@ -338,9 +333,9 @@ $stage-height: 404px;
}
.project-favorites {
-
+
cursor: pointer;
-
+
&:before {
opacity: .5;
background-image: url("/svgs/project/fav-gray.svg");
@@ -348,7 +343,7 @@ $stage-height: 404px;
}
.project-favorites.favorited {
-
+
&:before {
opacity: 1;
background-image: url("/svgs/project/fav-yellow.svg");
@@ -356,7 +351,7 @@ $stage-height: 404px;
}
.project-remixes {
-
+
&:before {
opacity: .5;
background-image: url("/svgs/project/remix-gray.svg");
@@ -364,7 +359,7 @@ $stage-height: 404px;
}
.project-views {
-
+
&:before {
opacity: .5;
background-image: url("/svgs/project/views-gray.svg");
@@ -396,24 +391,24 @@ $stage-height: 404px;
// border-color: transparent;
// background-color: $active-gray;
// }
- //
+ //
// &:active {
// border: 0 solid transparent;
// box-shadow: inset 0 0 5px $box-shadow-gray;
// background-color: $active-dark-gray;
// padding: calc(.75em + 1px) calc(1.5em + 1px);
// }
- //
+ //
// &.report {
// border: 1px solid $ui-coral;
// background-color: $ui-coral;
- //
+ //
// &:hover {
// transition: background-color .25s ease;
// border-color: transparent;
// background-color: $active-gray;
// }
- //
+ //
// &:active {
// border: 0 solid transparent;
// box-shadow: inset 0 0 5px $box-shadow-gray;
@@ -422,7 +417,16 @@ $stage-height: 404px;
// }
// }
}
-
+
+ .comments-header {
+ padding: 0 0 1rem 0;
+ justify-content: space-between;
+
+ h4 {
+ font-size: 1.25rem;
+ }
+ }
+
.studio-button,
.copy-link-button,
.report-button {
@@ -452,29 +456,45 @@ $stage-height: 404px;
.report-button {
background-color: $ui-coral;
-
+
&:before {
background-image: url("/svgs/project/report-white.svg");
}
}
+ .project-lower-container {
+ margin-top: 1rem;
+ background-color: $ui-blue-10percent;
+ padding: 1rem 0;
+ min-height: 12rem;
+ }
+
+ .create-comment {
+ margin-bottom: 2rem;
+ }
+
+ .load-more-button {
+ margin-left: 0;
+ width: 100%;
+ }
+
.extension-list {
justify-content: flex-start;
}
-
+
.remix-list,
.studio-list {
flex-direction: column;
-
+
.project {
margin-bottom: 1.5rem;
}
-
+
.creator-image img {
max-width: 2rem;
max-height: 2rem;
}
-
+
.thumbnail-column {
display: inline-block;
width: 100%;
diff --git a/src/views/search/l10n.json b/src/views/search/l10n.json
new file mode 100644
index 000000000..29de14190
--- /dev/null
+++ b/src/views/search/l10n.json
@@ -0,0 +1,4 @@
+{
+ "search.trending": "Trending",
+ "search.popular": "Popular"
+}
diff --git a/src/views/search/search.jsx b/src/views/search/search.jsx
index d9fd1655a..d35f2c937 100644
--- a/src/views/search/search.jsx
+++ b/src/views/search/search.jsx
@@ -8,14 +8,18 @@ const React = require('react');
const api = require('../../lib/api');
const Button = require('../../components/forms/button.jsx');
+const Form = require('../../components/forms/form.jsx');
const Grid = require('../../components/grid/grid.jsx');
const navigationActions = require('../../redux/navigation.js');
+const Select = require('../../components/forms/select.jsx');
const TitleBanner = require('../../components/title-banner/title-banner.jsx');
const Tabs = require('../../components/tabs/tabs.jsx');
const Page = require('../../components/page/www/page.jsx');
const render = require('../../lib/render.jsx');
+const ACCEPTABLE_MODES = ['trending', 'popular', ''];
+
require('./search.scss');
class Search extends React.Component {
@@ -23,14 +27,31 @@ class Search extends React.Component {
super(props);
bindAll(this, [
'getSearchState',
+ 'handleChangeSortMode',
'handleGetSearchMore',
'getTab'
]);
this.state = this.getSearchState();
this.state.loaded = [];
this.state.loadNumber = 16;
+ this.state.mode = '';
this.state.offset = 0;
this.state.loadMore = false;
+
+ let mode = '';
+ const query = window.location.search;
+ const m = query.lastIndexOf('mode=');
+ if (m !== -1) {
+ mode = query.substring(m + 5, query.length).toLowerCase();
+ }
+ while (mode.indexOf('/') > -1) {
+ mode = mode.substring(0, mode.indexOf('/'));
+ }
+ while (mode.indexOf('&') > -1) {
+ mode = mode.substring(0, mode.indexOf('&'));
+ }
+ mode = decodeURIComponent(mode.split('+').join(' '));
+ this.state.mode = mode;
}
componentDidMount () {
const query = window.location.search;
@@ -65,6 +86,13 @@ class Search extends React.Component {
loadNumber: 16
};
}
+ handleChangeSortMode (name, value) {
+ if (ACCEPTABLE_MODES.indexOf(value) !== -1) {
+ const term = this.props.searchTerm.split(' ').join('+');
+ window.location =
+ `${window.location.origin}/search/${this.state.tab}?q=${term}&mode=${value}`;
+ }
+ }
handleGetSearchMore () {
let termText = '';
if (this.props.searchTerm !== '') {
@@ -73,7 +101,8 @@ class Search extends React.Component {
const locale = this.props.intl.locale;
const loadNumber = this.state.loadNumber;
const offset = this.state.offset;
- const queryString = `limit=${loadNumber}&offset=${offset}&language=${locale}&mode=popular${termText}`;
+ const mode = this.state.mode;
+ const queryString = `limit=${loadNumber}&offset=${offset}&language=${locale}&mode=${mode}${termText}`;
api({
uri: `/search/${this.state.tab}?${queryString}`
@@ -167,6 +196,25 @@ class Search extends React.Component {
{this.getTab('projects')}
{this.getTab('studios')}
+
+
+
+
+
{this.getProjectBox()}
diff --git a/src/views/search/search.scss b/src/views/search/search.scss
index 2588ac6ef..93e3c3fc0 100644
--- a/src/views/search/search.scss
+++ b/src/views/search/search.scss
@@ -104,15 +104,40 @@ $base-bg: $ui-white;
}
}
}
+
+ /* HACK: sort controls are terrible. There's some sort of magic formula for height of formsy components that I can't control. */
- .select {
- select {
- margin-bottom: 0;
- color: $header-gray;
- }
+ .sort-controls {
+ display: flex;
+ margin: 0 auto;
+ border-bottom: 1px solid $ui-border;
+ padding: 8px 0;
+ width: 58.75rem;
+ justify-content: space-between;
+ }
- .help-block {
- display: none;
+ .sort-mode {
+ margin-top: -4px;
+ width: 13.75rem;
+
+ .select {
+
+ select {
+ margin-bottom: 0;
+ border: 0;
+ background-color: transparent;
+ height: 32px;
+ color: $header-gray;
+
+ &:focus,
+ &:active {
+ background-color: transparent;
+ }
+ }
+
+ .help-block {
+ display: none;
+ }
}
}
diff --git a/src/views/terms/terms.jsx b/src/views/terms/terms.jsx
index 63e223e1d..b372a6489 100644
--- a/src/views/terms/terms.jsx
+++ b/src/views/terms/terms.jsx
@@ -466,7 +466,7 @@ const Terms = () => (
This document, together with all appendices, constitutes the entire Terms
of Use and supersedes all previous agreements with the Scratch Team relating
- to the use of Scratch. Revision date: 4 March 2015.
+ to the use of Scratch. Revision date: April 2016.
diff --git a/src/views/tips/tips.scss b/src/views/tips/tips.scss
index 4dcbe1419..f9b4c62aa 100644
--- a/src/views/tips/tips.scss
+++ b/src/views/tips/tips.scss
@@ -31,25 +31,24 @@ $base-bg: $ui-white;
max-width: calc(100% - 2rem);
}
-$darken-button: rgba(0, 0, 0, .1);
-
.tips-button {
margin-right: .75rem;
background-color: $ui-blue;
color: $ui-white;
font-size: 1rem;
-
+
&.getting-started-button {
margin-right: 0;
- background-color: $darken-button;
+ background-color: $ui-white;
+ color: $link-blue;
}
-
+
img {
margin-right: 1rem;
height: 1.25rem;
vertical-align: middle;
}
-
+
a {
color: $ui-white;
}
@@ -95,26 +94,26 @@ img.tips-icon {
}
}
}
-
+
.ttt-head {
-
+
p {
max-width: $cols4;
}
}
-
+
//put the image first if in 4-column
.tips-info-body {
max-width: $cols4;
text-align: center;
-
+
&.tips-illustration {
order: -1;
img {
width: $cols4;
}
}
-
+
.button {
width: 100%;
}
@@ -131,13 +130,13 @@ img.tips-icon {
}
}
}
-
+
.ttt-head {
p {
max-width: $cols6;
}
}
-
+
.tips-info-body.tips-illustration {
order: -1;
img {
@@ -163,27 +162,27 @@ img.tips-icon {
}
}
}
-
+
.ttt-head {
p {
max-width: $cols6;
}
}
-
+
.tips-info-section {
&.mod-align-top {
align-items: flex-start;
}
}
-
+
.tips-info-body {
max-width: $cols4;
}
-
+
.tips-button {
width: 100%;
}
-
+
img.mod-flow-left {
transform: translate(-1*$cols2);
}
@@ -200,19 +199,19 @@ img.tips-icon {
}
}
}
-
+
.ttt-head {
p {
max-width: $cols8;
}
}
-
+
.tips-info-section {
&.mod-align-top {
align-items: flex-start;
}
}
-
+
.tips-info-body {
max-width: $cols6;
&.mod-narrow {
diff --git a/static/images/tips/blocks-icon.svg b/static/images/tips/blocks-icon.svg
index 2c9d7914f..4bd25574d 100644
--- a/static/images/tips/blocks-icon.svg
+++ b/static/images/tips/blocks-icon.svg
@@ -1,22 +1,34 @@
-
-
-
- blocks icon
- Created with Sketch.
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
+
+
+
+
+blocks icon
+Created with Sketch.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/static/svgs/modal/add.svg b/static/svgs/modal/add.svg
new file mode 100644
index 000000000..61e7b2fd7
--- /dev/null
+++ b/static/svgs/modal/add.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/static/svgs/modal/close-x.svg b/static/svgs/modal/close-x.svg
index ae4c8027e..b6c3e3c06 100644
--- a/static/svgs/modal/close-x.svg
+++ b/static/svgs/modal/close-x.svg
@@ -1,17 +1 @@
-
-
-
-
-
-
-
-
-
-
-
-
+
\ No newline at end of file
diff --git a/static/svgs/modal/confirm.svg b/static/svgs/modal/confirm.svg
new file mode 100644
index 000000000..e9eb14561
--- /dev/null
+++ b/static/svgs/modal/confirm.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/static/svgs/modal/open-blue.svg b/static/svgs/modal/open-blue.svg
index 96b6da932..4b9fe7dd7 100644
--- a/static/svgs/modal/open-blue.svg
+++ b/static/svgs/modal/open-blue.svg
@@ -1,12 +1 @@
-
-
-
- open-blue
- Created with Sketch.
-
-
-
-
-
-
-
\ No newline at end of file
+
\ No newline at end of file
diff --git a/static/svgs/modal/open-white.svg b/static/svgs/modal/open-white.svg
index e916103ff..ac1ce86a9 100644
--- a/static/svgs/modal/open-white.svg
+++ b/static/svgs/modal/open-white.svg
@@ -1,12 +1 @@
-
-
-
- open-modal-icon
- Created with Sketch.
-
-
-
-
-
-
-
\ No newline at end of file
+
\ No newline at end of file
diff --git a/static/svgs/modal/spinner.svg b/static/svgs/modal/spinner.svg
new file mode 100644
index 000000000..b6a2b26f1
--- /dev/null
+++ b/static/svgs/modal/spinner.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/static/svgs/project/comment-reply.svg b/static/svgs/project/comment-reply.svg
new file mode 100644
index 000000000..b0edaf79c
--- /dev/null
+++ b/static/svgs/project/comment-reply.svg
@@ -0,0 +1,10 @@
+
+
+
+ comment-reply
+ Created with Sketch.
+
+
+
\ No newline at end of file
diff --git a/test/integration/selenium-helpers.js b/test/integration/selenium-helpers.js
index dcd96476f..5b6e6e392 100644
--- a/test/integration/selenium-helpers.js
+++ b/test/integration/selenium-helpers.js
@@ -3,6 +3,8 @@ const bindAll = require('lodash.bindall');
const headless = process.env.SMOKE_HEADLESS || false;
const remote = process.env.SMOKE_REMOTE || false;
+const ci = process.env.CI || false;
+const buildID = process.env.TRAVIS_BUILD_NUMBER;
const {SAUCE_USERNAME, SAUCE_ACCESS_KEY} = process.env;
const {By, until} = webdriver;
@@ -24,7 +26,13 @@ class SeleniumHelper {
}
buildDriver (name) {
if (remote === 'true'){
- this.driver = this.getSauceDriver(SAUCE_USERNAME, SAUCE_ACCESS_KEY, name);
+ let nameToUse;
+ if (ci === 'true'){
+ nameToUse = 'travis ' + buildID + ' : ' + name;
+ } else {
+ nameToUse = name;
+ }
+ this.driver = this.getSauceDriver(SAUCE_USERNAME, SAUCE_ACCESS_KEY, nameToUse);
} else {
this.driver = this.getDriver();
}
diff --git a/webpack.config.js b/webpack.config.js
index 24b70598a..ca7377562 100644
--- a/webpack.config.js
+++ b/webpack.config.js
@@ -165,6 +165,9 @@ module.exports = {
'process.env.NODE_ENV': '"' + (process.env.NODE_ENV || 'development') + '"',
'process.env.SENTRY_DSN': '"' + (process.env.SENTRY_DSN || '') + '"',
'process.env.API_HOST': '"' + (process.env.API_HOST || 'https://api.scratch.mit.edu') + '"',
+ 'process.env.ASSET_HOST': '"' + (process.env.ASSET_HOST || 'https://assets.scratch.mit.edu') + '"',
+ 'process.env.BACKPACK_HOST': '"' + (process.env.BACKPACK_HOST || 'https://backpack.scratch.mit.edu') + '"',
+ 'process.env.PROJECT_HOST': '"' + (process.env.PROJECT_HOST || 'https://projects.scratch.mit.edu') + '"',
'process.env.SCRATCH_ENV': '"' + (process.env.SCRATCH_ENV || 'development') + '"'
}),
new webpack.optimize.CommonsChunkPlugin({