星晴 před 5 roky
revize
e8f897b2f7
100 změnil soubory, kde provedl 21174 přidání a 0 odebrání
  1. 13 0
      .babelrc
  2. 3 0
      .browserslistrc
  3. 11 0
      .editorconfig
  4. 3 0
      .eslintignore
  5. 3 0
      .eslintrc.js
  6. 39 0
      .gitattributes
  7. 24 0
      .gitignore
  8. 21 0
      .npmignore
  9. 66 0
      .travis.yml
  10. 12 0
      LICENSE
  11. 253 0
      README.md
  12. 1 0
      TRADEMARK
  13. 3 0
      docs/project_state_diagram.svg
  14. binární
      docs/project_state_example.png
  15. 15131 0
      package-lock.json
  16. 145 0
      package.json
  17. 64 0
      prune-gh-pages.sh
  18. 31 0
      src/.eslintrc.js
  19. 200 0
      src/components/action-menu/action-menu.css
  20. 210 0
      src/components/action-menu/action-menu.jsx
  21. 21 0
      src/components/action-menu/icon--backdrop.svg
  22. 12 0
      src/components/action-menu/icon--camera.svg
  23. 12 0
      src/components/action-menu/icon--file-upload.svg
  24. 12 0
      src/components/action-menu/icon--paint.svg
  25. 10 0
      src/components/action-menu/icon--search.svg
  26. 12 0
      src/components/action-menu/icon--sprite.svg
  27. 12 0
      src/components/action-menu/icon--surprise.svg
  28. 103 0
      src/components/alerts/alert.css
  29. 140 0
      src/components/alerts/alert.jsx
  30. 4 0
      src/components/alerts/alerts.css
  31. 47 0
      src/components/alerts/alerts.jsx
  32. 27 0
      src/components/alerts/inline-message.css
  33. 40 0
      src/components/alerts/inline-message.jsx
  34. 36 0
      src/components/asset-panel/asset-panel.css
  35. 23 0
      src/components/asset-panel/asset-panel.jsx
  36. 21 0
      src/components/asset-panel/icon--add-backdrop-lib.svg
  37. 12 0
      src/components/asset-panel/icon--add-blank-costume.svg
  38. 12 0
      src/components/asset-panel/icon--add-costume-lib.svg
  39. 12 0
      src/components/asset-panel/icon--add-sound-lib.svg
  40. 12 0
      src/components/asset-panel/icon--add-sound-record.svg
  41. 20 0
      src/components/asset-panel/icon--sound-rtl.svg
  42. 10 0
      src/components/asset-panel/icon--sound.svg
  43. 85 0
      src/components/asset-panel/selector.css
  44. 118 0
      src/components/asset-panel/selector.jsx
  45. 45 0
      src/components/asset-panel/sortable-asset.jsx
  46. 53 0
      src/components/audio-trimmer/audio-selector.jsx
  47. 148 0
      src/components/audio-trimmer/audio-trimmer.css
  48. 62 0
      src/components/audio-trimmer/audio-trimmer.jsx
  49. 16 0
      src/components/audio-trimmer/icon--handle.svg
  50. 21 0
      src/components/audio-trimmer/playhead.jsx
  51. 28 0
      src/components/audio-trimmer/selection-handle.jsx
  52. 101 0
      src/components/backpack/backpack.css
  53. 164 0
      src/components/backpack/backpack.jsx
  54. 99 0
      src/components/blocks/blocks.css
  55. 27 0
      src/components/blocks/blocks.jsx
  56. 2 0
      src/components/box/box.css
  57. 139 0
      src/components/box/box.jsx
  58. 82 0
      src/components/browser-modal/browser-modal.css
  59. 113 0
      src/components/browser-modal/browser-modal.jsx
  60. 54 0
      src/components/browser-modal/unsupported-browser.svg
  61. 29 0
      src/components/button/button.css
  62. 54 0
      src/components/button/button.jsx
  63. 157 0
      src/components/camera-modal/camera-modal.css
  64. 141 0
      src/components/camera-modal/camera-modal.jsx
  65. 16 0
      src/components/camera-modal/icon--back.svg
  66. 306 0
      src/components/cards/card.css
  67. 440 0
      src/components/cards/cards.jsx
  68. 18 0
      src/components/cards/icon--close.svg
  69. 18 0
      src/components/cards/icon--expand.svg
  70. 10 0
      src/components/cards/icon--next.svg
  71. 10 0
      src/components/cards/icon--prev.svg
  72. 18 0
      src/components/cards/icon--shrink.svg
  73. 81 0
      src/components/close-button/close-button.css
  74. 76 0
      src/components/close-button/close-button.jsx
  75. 20 0
      src/components/close-button/icon--close-orange.svg
  76. 1 0
      src/components/close-button/icon--close.svg
  77. binární
      src/components/coming-soon/aww-cat.png
  78. 77 0
      src/components/coming-soon/coming-soon.css
  79. 143 0
      src/components/coming-soon/coming-soon.jsx
  80. binární
      src/components/coming-soon/cool-cat.png
  81. 158 0
      src/components/connection-modal/auto-scanning-step.jsx
  82. 72 0
      src/components/connection-modal/connected-step.jsx
  83. 70 0
      src/components/connection-modal/connecting-step.jsx
  84. 425 0
      src/components/connection-modal/connection-modal.css
  85. 65 0
      src/components/connection-modal/connection-modal.jsx
  86. 65 0
      src/components/connection-modal/dots.jsx
  87. 78 0
      src/components/connection-modal/error-step.jsx
  88. 10 0
      src/components/connection-modal/icons/back.svg
  89. 10 0
      src/components/connection-modal/icons/bluetooth-white.svg
  90. 31 0
      src/components/connection-modal/icons/bluetooth.svg
  91. 10 0
      src/components/connection-modal/icons/cancel.svg
  92. 10 0
      src/components/connection-modal/icons/close.svg
  93. 10 0
      src/components/connection-modal/icons/help.svg
  94. 10 0
      src/components/connection-modal/icons/refresh.svg
  95. 41 0
      src/components/connection-modal/icons/scratchlink.svg
  96. binární
      src/components/connection-modal/icons/searching.png
  97. 87 0
      src/components/connection-modal/peripheral-tile.jsx
  98. 105 0
      src/components/connection-modal/scanning-step.jsx
  99. 102 0
      src/components/connection-modal/unavailable-step.jsx
  100. 0 0
      src/components/context-menu/context-menu.css

+ 13 - 0
.babelrc

@@ -0,0 +1,13 @@
+{
+    "plugins": [
+        "@babel/plugin-syntax-dynamic-import",
+        "@babel/plugin-transform-async-to-generator",
+        "@babel/plugin-proposal-object-rest-spread",
+        ["react-intl", {
+            "messagesDir": "./translations/messages/"
+        }]],
+    "presets": [
+        ["@babel/preset-env", {"targets": {"browsers": ["last 3 versions", "Safari >= 8", "iOS >= 8"]}}],
+        "@babel/preset-react"
+    ]
+}

+ 3 - 0
.browserslistrc

@@ -0,0 +1,3 @@
+last 3 versions
+Safari >= 8
+iOS >= 8

+ 11 - 0
.editorconfig

@@ -0,0 +1,11 @@
+root = true
+
+[*]
+end_of_line = lf
+insert_final_newline = true
+charset = utf-8
+indent_size = 4
+trim_trailing_whitespace = true
+
+[*.{js,html}]
+indent_style = space

+ 3 - 0
.eslintignore

@@ -0,0 +1,3 @@
+node_modules/*
+build/*
+dist/*

+ 3 - 0
.eslintrc.js

@@ -0,0 +1,3 @@
+module.exports = {
+    extends: ['scratch', 'scratch/node']
+};

+ 39 - 0
.gitattributes

@@ -0,0 +1,39 @@
+# Set the default behavior, in case people don't have core.autocrlf set.
+* text=auto
+
+# Explicitly specify line endings for as many files as possible.
+# People who (for example) rsync between Windows and Linux need this.
+
+# File types which we know are binary
+
+# Treat SVG files as binary so that their contents don't change due to line
+# endings. The contents of SVGs must not change from the way they're stored
+# on assets.scratch.mit.edu so that MD5 calculations don't change.
+*.svg binary
+
+# Prefer LF for most file types
+*.frag text eol=lf
+*.htm text eol=lf
+*.html text eol=lf
+*.iml text eol=lf
+*.js text eol=lf
+*.js.map text eol=lf
+*.json text eol=lf
+*.jsx text eol=lf
+*.md text eol=lf
+*.vert text eol=lf
+*.xml text eol=lf
+*.yml text eol=lf
+
+# Prefer LF for these files
+.editorconfig text eol=lf
+.eslintrc text eol=lf
+.gitattributes text eol=lf
+.gitignore text eol=lf
+.gitmodules text eol=lf
+LICENSE text eol=lf
+Makefile text eol=lf
+README text eol=lf
+TRADEMARK text eol=lf
+
+# Use CRLF for Windows-specific file types

+ 24 - 0
.gitignore

@@ -0,0 +1,24 @@
+# Mac OS
+.DS_Store
+
+# NPM
+/node_modules
+npm-*
+
+# Testing
+/.nyc_output
+/coverage
+
+# Build
+/build
+/dist
+/.opt-in
+
+# Generated translation files
+/translations
+/locale
+
+/.idea
+/.circleci
+/.github
+/.tx

+ 21 - 0
.npmignore

@@ -0,0 +1,21 @@
+# Mac OS
+.DS_Store
+
+# NPM
+/node_modules
+npm-*
+
+# Double copies of all the static assets and tutorial gifs
+/src
+
+# Testing
+/.nyc_output
+/coverage
+/test
+
+# Build
+/.opt-in
+/build
+
+# generated translation files
+/translations

+ 66 - 0
.travis.yml

@@ -0,0 +1,66 @@
+language: node_js
+sudo: required
+dist: xenial
+addons:
+    chrome: stable
+node_js:
+- 8
+env:
+  global:
+  - CHROMEDRIVER_VERSION=LATEST
+  - NODE_ENV=production
+  - NODE_OPTIONS=--max-old-space-size=7250
+  - NPM_TAG=latest
+  - RELEASE_VERSION="0.1.0-prerelease.$(date +'%Y%m%d%H%M%S')"
+cache:
+  directories:
+  - node_modules
+install:
+- npm --production=false install
+script:
+- npm test
+before_deploy:
+- >
+  if [ -z "$BEFORE_DEPLOY_RAN" ]; then
+    npm --no-git-tag-version version $RELEASE_VERSION
+    if [ "$TRAVIS_BRANCH" == "master" ]; then export NPM_TAG=stable; fi
+    if [[ "$TRAVIS_BRANCH" == hotfix/* ]]; then export NPM_TAG=hotfix; fi # double brackets are important for matching the wildcard
+    git config --global user.email $(git log --pretty=format:"%ae" -n1)
+    git config --global user.name $(git log --pretty=format:"%an" -n1)
+    export BEFORE_DEPLOY_RAN=true
+  fi
+deploy:
+- provider: npm
+  on:
+    branch:
+    - master
+    - develop
+    - hotfix/*
+    condition: $TRAVIS_EVENT_TYPE != cron
+  skip_cleanup: true
+  email: $NPM_EMAIL
+  api_key: $NPM_TOKEN
+  tag: $NPM_TAG
+- provider: script
+  on:
+    branch:
+    - master
+    - develop
+    - smoke
+    - hotfix/*
+    condition: $TRAVIS_EVENT_TYPE != cron
+  skip_cleanup: true
+  script: if npm info scratch-gui | grep -q $RELEASE_VERSION; then git tag $RELEASE_VERSION && git push https://${GH_TOKEN}@github.com/${TRAVIS_REPO_SLUG}.git $RELEASE_VERSION; fi
+- provider: script
+  on:
+    all_branches: true
+    condition: $TRAVIS_EVENT_TYPE != cron && ! $TRAVIS_BRANCH =~ ^greenkeeper/
+    tags: false # Don't push tags to gh-pages
+  skip_cleanup: true
+  script: npm run deploy -- -x -e $TRAVIS_BRANCH -r https://${GH_TOKEN}@github.com/${TRAVIS_REPO_SLUG}.git
+- provider: script
+  on:
+    branch: develop
+    condition: $TRAVIS_EVENT_TYPE == cron
+  skip_cleanup: true
+  script: npm run i18n:src && npm run i18n:push

Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 12 - 0
LICENSE


+ 253 - 0
README.md

@@ -0,0 +1,253 @@
+# scratch-gui
+#### Scratch GUI is a set of React components that comprise the interface for creating and running Scratch 3.0 projects
+
+[![Build Status](https://travis-ci.com/LLK/scratch-gui.svg?token=Yfq2ryN1BwaxDME69Lnc&branch=master)](https://travis-ci.com/LLK/scratch-gui)
+[![Greenkeeper badge](https://badges.greenkeeper.io/LLK/scratch-gui.svg)](https://greenkeeper.io/)
+
+## Installation
+This requires you to have Git and Node.js installed.
+
+In your own node environment/application:
+```bash
+npm install https://github.com/LLK/scratch-gui.git
+```
+If you want to edit/play yourself:
+```bash
+git clone https://github.com/LLK/scratch-gui.git
+cd scratch-gui
+npm install
+```
+
+## Getting started
+Running the project requires Node.js to be installed.
+
+## Running
+Open a Command Prompt or Terminal in the repository and run:
+```bash
+npm start
+```
+Then go to [http://localhost:8601/](http://localhost:8601/) - the playground outputs the default GUI component
+
+## Developing alongside other Scratch repositories
+
+### Getting another repo to point to this code
+
+
+If you wish to develop `scratch-gui` alongside other scratch repositories that depend on it, you may wish
+to have the other repositories use your local `scratch-gui` build instead of fetching the current production
+version of the scratch-gui that is found by default using `npm install`.
+
+Here's how to link your local `scratch-gui` code to another project's `node_modules/scratch-gui`.
+
+#### Configuration
+
+1. In your local `scratch-gui` repository's top level:
+    1. Make sure you have run `npm install`
+    2. Build the `dist` directory by running `BUILD_MODE=dist npm run build`
+    3. Establish a link to this repository by running `npm link`
+
+2. From the top level of each repository (such as `scratch-www`) that depends on `scratch-gui`:
+    1. Make sure you have run `npm install`
+    2. Run `npm link scratch-gui`
+    3. Build or run the repositoriy
+
+#### Using `npm run watch`
+
+Instead of `BUILD_MODE=dist npm run build`, you can use `BUILD_MODE=dist npm run watch` instead. This will watch for changes to your `scratch-gui` code, and automatically rebuild when there are changes. Sometimes this has been unreliable; if you are having problems, try going back to `BUILD_MODE=dist npm run build` until you resolve them.
+
+#### Oh no! It didn't work!
+
+If you can't get linking to work right, try:
+* Follow the recipe above step by step and don't change the order. It is especially important to run `npm install` _before_ `npm link`, because installing after the linking will reset the linking.
+* Make sure the repositories are siblings on your machine's file tree, like `.../.../MY_SCRATCH_DEV_DIRECTORY/scratch-gui/` and `.../.../MY_SCRATCH_DEV_DIRECTORY/scratch-www/`.
+* Consistent node.js version: If you have multiple Terminal tabs or windows open for the different Scratch repositories, make sure to use the same node version in all of them.
+* If nothing else works, unlink the repositories by running `npm unlink` in both, and start over.
+
+## Testing
+### Documentation
+
+You may want to review the documentation for [Jest](https://facebook.github.io/jest/docs/en/api.html) and [Enzyme](http://airbnb.io/enzyme/docs/api/) as you write your tests.
+
+See [jest cli docs](https://facebook.github.io/jest/docs/en/cli.html#content) for more options.
+
+### Running tests
+
+*NOTE: If you're a windows user, please run these scripts in Windows `cmd.exe`  instead of Git Bash/MINGW64.*
+
+Before running any tests, make sure you have run `npm install` from this (scratch-gui) repository's top level.
+
+#### Main testing command
+
+To run linter, unit tests, build, and integration tests, all at once:
+```bash
+npm test
+```
+
+#### Running unit tests
+
+To run unit tests in isolation:
+```bash
+npm run test:unit
+```
+
+To run unit tests in watch mode (watches for code changes and continuously runs tests):
+```bash
+npm run test:unit -- --watch
+```
+
+You can run a single file of integration tests (in this example, the `button` tests):
+
+```bash
+$(npm bin)/jest --runInBand test/unit/components/button.test.jsx
+```
+
+#### Running integration tests
+
+Integration tests use a headless browser to manipulate the actual html and javascript that the repo
+produces. You will not see this activity (though you can hear it when sounds are played!).
+
+Note that integration tests require you to first create a build that can be loaded in a browser:
+
+```bash
+npm run build
+```
+
+Then, you can run all integration tests:
+
+```bash
+npm run test:integration
+```
+
+Or, you can run a single file of integration tests (in this example, the `backpack` tests):
+
+```bash
+$(npm bin)/jest --runInBand test/integration/backpack.test.js
+```
+
+If you want to watch the browser as it runs the test, rather than running headless, use:
+
+```bash
+USE_HEADLESS=no $(npm bin)/jest --runInBand test/integration/backpack.test.js
+```
+
+## Troubleshooting
+
+### Ignoring optional dependencies
+
+When running `npm install`, you can get warnings about optionsl dependencies:
+
+```
+npm WARN optional Skipping failed optional dependency /chokidar/fsevents:
+npm WARN notsup Not compatible with your operating system or architecture: fsevents@1.2.7
+```
+
+You can suppress them by adding the `no-optional` switch:
+
+```
+npm install --no-optional
+```
+
+Further reading: [Stack Overflow](https://stackoverflow.com/questions/36725181/not-compatible-with-your-operating-system-or-architecture-fsevents1-0-11)
+
+### Resolving dependencies
+
+When installing for the first time, you can get warnings which need to be resolved:
+
+```
+npm WARN eslint-config-scratch@5.0.0 requires a peer of babel-eslint@^8.0.1 but none was installed.
+npm WARN eslint-config-scratch@5.0.0 requires a peer of eslint@^4.0 but none was installed.
+npm WARN scratch-paint@0.2.0-prerelease.20190318170811 requires a peer of react-intl-redux@^0.7 but none was installed.
+npm WARN scratch-paint@0.2.0-prerelease.20190318170811 requires a peer of react-responsive@^4 but none was installed.
+```
+
+You can check which versions are available:
+
+```
+npm view react-intl-redux@0.* version
+```
+
+You will neet do install the required version:
+
+```
+npm install  --no-optional --save-dev react-intl-redux@^0.7
+```
+
+The dependency itself might have more missing dependencies, which will show up like this:
+
+```
+user@machine:~/sources/scratch/scratch-gui (491-translatable-library-objects)$ npm install  --no-optional --save-dev react-intl-redux@^0.7
+scratch-gui@0.1.0 /media/cuideigin/Linux/sources/scratch/scratch-gui
+├── react-intl-redux@0.7.0
+└── UNMET PEER DEPENDENCY react-responsive@5.0.0
+```
+
+You will need to install those as well:
+
+```
+npm install  --no-optional --save-dev react-responsive@^5.0.0
+```
+
+Further reading: [Stack Overflow](https://stackoverflow.com/questions/46602286/npm-requires-a-peer-of-but-all-peers-are-in-package-json-and-node-modules)
+
+
+## Publishing to GitHub Pages
+You can publish the GUI to github.io so that others on the Internet can view it.
+[Read the wiki for a step-by-step guide.](https://github.com/LLK/scratch-gui/wiki/Publishing-to-GitHub-Pages)
+
+## Understanding the project state machine
+
+Since so much code throughout scratch-gui depends on the state of the project, which goes through many different phases of loading, displaying and saving, we created a "finite state machine" to make it clear which state it is in at any moment. This is contained in the file src/reducers/project-state.js .
+
+It can be hard to understand the code in src/reducers/project-state.js . There are several types of data and functions used, which relate to each other:
+
+### Loading states
+
+These include state constant strings like:
+
+* `NOT_LOADED` (the default state),
+* `ERROR`,
+* `FETCHING_WITH_ID`,
+* `LOADING_VM_WITH_ID`,
+* `REMIXING`,
+* `SHOWING_WITH_ID`,
+* `SHOWING_WITHOUT_ID`,
+* etc.
+
+### Transitions
+
+These are names for the action which causes a state change. Some examples are:
+
+* `START_FETCHING_NEW`,
+* `DONE_FETCHING_WITH_ID`,
+* `DONE_LOADING_VM_WITH_ID`,
+* `SET_PROJECT_ID`,
+* `START_AUTO_UPDATING`,
+
+### How transitions relate to loading states
+
+As this diagram of the project state machine shows, various transition actions can move us from one loading state to another:
+
+![Project state diagram](docs/project_state_diagram.svg)
+
+_Note: for clarity, the diagram above excludes states and transitions relating to error handling._
+
+#### Example
+
+Here's an example of how states transition.
+
+Suppose a user clicks on a project, and the page starts to load with url https://scratch.mit.edu/projects/123456 .
+
+Here's what will happen in the project state machine:
+
+![Project state example](docs/project_state_example.png)
+
+1. When the app first mounts, the project state is `NOT_LOADED`.
+2. The `SET_PROJECT_ID` redux action is dispatched (from src/lib/project-fetcher-hoc.jsx), with `projectId` set to `123456`. This transitions the state from `NOT_LOADED` to `FETCHING_WITH_ID`.
+3. The `FETCHING_WITH_ID` state. In src/lib/project-fetcher-hoc.jsx, the `projectId` value `123456` is used to request the data for that project from the server.
+4. When the server responds with the data, src/lib/project-fetcher-hoc.jsx dispatches the `DONE_FETCHING_WITH_ID` action, with `projectData` set. This transitions the state from `FETCHING_WITH_ID` to `LOADING_VM_WITH_ID`.
+5. The `LOADING_VM_WITH_ID` state. In src/lib/vm-manager-hoc.jsx, we load the `projectData` into Scratch's virtual machine ("the vm").
+6. When loading is done, src/lib/vm-manager-hoc.jsx dispatches the `DONE_LOADING_VM_WITH_ID` action. This transitions the state from `LOADING_VM_WITH_ID` to `SHOWING_WITH_ID`
+7. The `SHOWING_WITH_ID` state. Now the project appears normally and is playable and editable.
+
+## Donate
+We provide [Scratch](https://scratch.mit.edu) free of charge, and want to keep it that way! Please consider making a [donation](https://secure.donationpay.org/scratchfoundation/) to support our continued engineering, design, community, and resource development efforts. Donations of any size are appreciated. Thank you!

+ 1 - 0
TRADEMARK

@@ -0,0 +1 @@
+The Scratch trademarks, including the Scratch name, logo, the Scratch Cat, Gobo, Pico, Nano, Tera and Giga graphics (the "Marks"), are property of the Massachusetts Institute of Technology (MIT). Marks may not be used to endorse or promote products derived from this software without specific prior written permission.

Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 3 - 0
docs/project_state_diagram.svg


binární
docs/project_state_example.png


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 15131 - 0
package-lock.json


+ 145 - 0
package.json

@@ -0,0 +1,145 @@
+{
+  "name": "scratch-gui",
+  "version": "0.1.0",
+  "description": "GraphicaL User Interface for creating and running Scratch 3.0 projects",
+  "main": "./dist/scratch-gui.js",
+  "scripts": {
+    "build": "npm run clean && webpack --progress --colors --bail",
+    "clean": "rimraf ./build && mkdirp build && rimraf ./dist && mkdirp dist",
+    "deploy": "touch build/.nojekyll && gh-pages -t -d build -m \"Build for $(git log --pretty=format:%H -n1)\"",
+    "prune": "./prune-gh-pages.sh",
+    "i18n:push": "tx-push-src scratch-editor interface translations/en.json",
+    "i18n:src": "rimraf ./translations/messages/src && babel src > tmp.js && rimraf tmp.js && build-i18n-src ./translations/messages/src ./translations/ && npm run i18n:push",
+    "start": "webpack-dev-server",
+    "test": "npm run test:lint && npm run test:unit && npm run build && npm run test:integration",
+    "test:integration": "jest --runInBand test[\\\\/]integration",
+    "test:lint": "eslint . --ext .js,.jsx",
+    "test:unit": "jest test[\\\\/]unit",
+    "test:smoke": "jest --runInBand test[\\\\/]smoke",
+    "watch": "webpack --progress --colors --watch"
+  },
+  "author": "Massachusetts Institute of Technology",
+  "license": "BSD-3-Clause",
+  "homepage": "https://github.com/LLK/scratch-gui#readme",
+  "repository": {
+    "type": "git",
+    "url": "git+ssh://git@github.com/LLK/scratch-gui.git"
+  },
+  "peerDependencies": {
+    "react": "^16.0.0",
+    "react-dom": "^16.0.0"
+  },
+  "devDependencies": {
+    "@babel/cli": "^7.1.2",
+    "@babel/core": "^7.1.2",
+    "@babel/plugin-proposal-object-rest-spread": "^7.0.0",
+    "@babel/plugin-syntax-dynamic-import": "^7.0.0",
+    "@babel/plugin-transform-async-to-generator": "^7.1.0",
+    "@babel/preset-env": "^7.1.0",
+    "@babel/preset-react": "^7.0.0",
+    "arraybuffer-loader": "^1.0.6",
+    "autoprefixer": "^9.0.1",
+    "babel-core": "7.0.0-bridge.0",
+    "babel-eslint": "^10.0.1",
+    "babel-loader": "^8.0.4",
+    "base64-loader": "1.0.0",
+    "bowser": "1.9.4",
+    "chromedriver": "80.0.0",
+    "classnames": "2.2.6",
+    "computed-style-to-inline-style": "3.0.0",
+    "copy-webpack-plugin": "^4.5.1",
+    "core-js": "2.5.7",
+    "css-loader": "^1.0.0",
+    "enzyme": "^3.5.0",
+    "enzyme-adapter-react-16": "1.3.0",
+    "es6-object-assign": "1.1.0",
+    "eslint": "^5.0.1",
+    "eslint-config-scratch": "^5.0.0",
+    "eslint-import-resolver-webpack": "^0.11.1",
+    "eslint-plugin-import": "^2.18.2",
+    "eslint-plugin-jest": "^22.14.1",
+    "eslint-plugin-react": "^7.12.4",
+    "file-loader": "2.0.0",
+    "get-float-time-domain-data": "0.1.0",
+    "get-user-media-promise": "1.1.4",
+    "gh-pages": "github:rschamp/gh-pages#publish-branch-to-subfolder",
+    "html-webpack-plugin": "^3.2.0",
+    "immutable": "3.8.2",
+    "intl": "1.2.5",
+    "jest": "^21.0.0",
+    "jest-junit": "^7.0.0",
+    "js-base64": "2.4.9",
+    "keymirror": "0.1.1",
+    "lodash.bindall": "4.4.0",
+    "lodash.debounce": "4.0.8",
+    "lodash.defaultsdeep": "4.6.0",
+    "lodash.isequal": "4.5.0",
+    "lodash.omit": "4.5.0",
+    "lodash.pick": "4.4.0",
+    "lodash.throttle": "4.0.1",
+    "minilog": "3.1.0",
+    "mkdirp": "^1.0.3",
+    "omggif": "1.0.9",
+    "papaparse": "5.1.1",
+    "postcss-import": "^12.0.0",
+    "postcss-loader": "^3.0.0",
+    "postcss-simple-vars": "^5.0.1",
+    "prop-types": "^15.5.10",
+    "query-string": "^5.1.1",
+    "raf": "^3.4.0",
+    "raw-loader": "^0.5.1",
+    "react": "16.2.0",
+    "react-contextmenu": "2.9.4",
+    "react-dom": "16.2.0",
+    "react-draggable": "3.0.5",
+    "react-ga": "2.5.3",
+    "react-intl": "2.9.0",
+    "react-modal": "3.9.1",
+    "react-popover": "0.5.10",
+    "react-redux": "5.0.7",
+    "react-responsive": "5.0.0",
+    "react-style-proptype": "3.2.2",
+    "react-tabs": "2.3.0",
+    "react-test-renderer": "16.2.0",
+    "react-tooltip": "3.8.0",
+    "react-virtualized": "9.20.1",
+    "redux": "3.7.2",
+    "redux-mock-store": "^1.2.3",
+    "redux-throttle": "0.1.1",
+    "rimraf": "^2.6.1",
+    "scratch-audio": "0.1.0-prerelease.20190925183642",
+    "scratch-l10n": "3.7.20200205213636",
+    "scratch-blocks": "0.1.0-prerelease.1580911107",
+    "scratch-paint": "0.2.0-prerelease.20200204235639",
+    "scratch-render": "0.1.0-prerelease.20200128205403",
+    "scratch-storage": "1.3.2",
+    "scratch-svg-renderer": "0.2.0-prerelease.20200109070519",
+    "scratch-vm": "0.2.0-prerelease.20191227164934",
+    "selenium-webdriver": "3.6.0",
+    "startaudiocontext": "1.2.1",
+    "style-loader": "^0.23.0",
+    "svg-to-image": "1.1.3",
+    "text-encoding": "0.7.0",
+    "to-style": "1.3.3",
+    "uglifyjs-webpack-plugin": "^1.2.5",
+    "wav-encoder": "1.3.0",
+    "web-audio-test-api": "^0.5.2",
+    "webpack": "^4.6.0",
+    "webpack-cli": "^3.1.0",
+    "webpack-dev-server": "^3.1.3",
+    "xhr": "2.5.0"
+  },
+  "jest": {
+    "setupFiles": [
+      "raf/polyfill",
+      "<rootDir>/test/helpers/enzyme-setup.js"
+    ],
+    "testPathIgnorePatterns": [
+      "src/test.js"
+    ],
+    "moduleNameMapper": {
+      "\\.(jpg|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$": "<rootDir>/test/__mocks__/fileMock.js",
+      "\\.(css|less)$": "<rootDir>/test/__mocks__/styleMock.js"
+    }
+  }
+}

+ 64 - 0
prune-gh-pages.sh

@@ -0,0 +1,64 @@
+#!/bin/bash
+# gh-pages cleanup script: Switches to gh-pages branch, and removes all
+# directories that aren't listed as remote branches
+
+function deslash () {
+    # Recursively build a string of a directory's parents. E.g.,
+    # deslashed "feature/test/branch" returns feature/test feature
+    deslashed=$(dirname $1)
+    if [[ $deslashed =~ .*/.* ]]
+    then
+        echo $deslashed $(deslash $deslashed)
+    else
+        echo $deslashed
+    fi
+}
+
+repository=origin
+
+if [[ $1 != "" ]]
+then
+    repository=$1
+fi
+
+# Cache current branch
+current=$(git rev-parse --abbrev-ref HEAD)
+
+# Checkout most recent gh-pages
+git fetch --force $repository gh-pages:gh-pages
+git checkout gh-pages
+git clean -fdx
+
+# Make an array of directories to not delete, from the list of remote branches
+branches=$(git ls-remote --refs --quiet $repository | awk '{print $2}' | sed -e 's/refs\/heads\///')
+
+# Add parent directories of branches to the exclusion list (e.g. greenkeeper/)
+for branch in $branches; do
+    if [[ $branch =~ .*/.* ]]; then
+        branches+=" $(deslash $branch)"
+    fi
+done
+
+# Dedupe all the greenkeepers (or other duplicate parent directories)
+branches=$(echo "${branches[@]}" | tr ' ' '\n' | sort -u | tr '\n' ' ')
+
+# Remove all directories that don't have corresponding branches
+# It would be nice if we could exclude everything in .gitignore, but we're
+# not on the branch with the .gitignore anymore... so we can't.
+find . -type d \
+    \( \
+        -path ./.git -o \
+        -path ./node_modules \
+        $(printf " -o -path ./%s" $branches) \
+    \) -prune \
+    -o -mindepth 1 -type d \
+    -exec rm -rfv {} \;
+
+# Push
+git add -u
+git commit -m "Remove stale directories"
+git push $repository gh-pages
+
+# Return to where we were
+git checkout -f $current
+exit

+ 31 - 0
src/.eslintrc.js

@@ -0,0 +1,31 @@
+const path = require('path');
+module.exports = {
+    root: true,
+    extends: ['scratch', 'scratch/es6', 'scratch/react', 'plugin:import/errors'],
+    env: {
+        browser: true
+    },
+    globals: {
+        process: true
+    },
+    rules: {
+        'import/no-mutable-exports': 'error',
+        'import/no-commonjs': 'error',
+        'import/no-amd': 'error',
+        'import/no-nodejs-modules': 'error',
+        'react/jsx-no-literals': 'error',
+        'no-confusing-arrow': ['error', {
+            'allowParens': true
+        }]
+    },
+    settings: {
+        react: {
+            version: '16.2' // Prevent 16.3 lifecycle method errors
+        },
+        'import/resolver': {
+            webpack: {
+                config: path.resolve(__dirname, '../webpack.config.js')
+            }
+        }
+    }
+};

+ 200 - 0
src/components/action-menu/action-menu.css

@@ -0,0 +1,200 @@
+@import "../../css/colors.css";
+@import "../../css/units.css";
+@import "../../css/z-index.css";
+
+$main-button-size: 2.75rem;
+$more-button-size: 2.25rem;
+
+.menu-container {
+    display: flex;
+    flex-direction: column-reverse;
+    transition: 0.2s;
+    position: relative;
+}
+
+.button {
+    display: flex;
+    flex-direction: column;
+    align-items: center;
+    justify-content: center;
+    cursor: pointer;
+    font-family: "Helvetica Neue", Helvetica, Arial, sans-serif;
+    background: $motion-primary;
+    outline: none;
+    border: none;
+    transition: background-color 0.2s;
+}
+
+button::-moz-focus-inner {
+    border: 0;
+}
+
+.button:hover {
+    background: $extensions-primary;
+}
+
+.button:active {
+    padding: inherit;
+}
+
+.button.coming-soon:hover {
+    background: $data-primary;
+}
+
+.main-button {
+    border-radius: 100%;
+    width: $main-button-size;
+    height: $main-button-size;
+    box-shadow: 0 0 0 4px $motion-transparent;
+    z-index: $z-index-add-button;
+    transition: transform, box-shadow 0.5s;
+}
+
+.main-button:hover {
+  transform: scale(1.1);
+  box-shadow: 0 0 0 6px $motion-transparent;
+}
+
+.main-icon {
+    width: calc($main-button-size - 1rem);
+    height: calc($main-button-size - 1rem);
+}
+
+[dir="rtl"] .main-icon {
+    transform: scaleX(-1);
+}
+
+.more-buttons-outer {
+    /*
+        Need to use two divs to set different overflow x/y
+        which is needed to get animation to look right while
+        allowing the tooltips to be visible.
+    */
+    overflow-y: hidden;
+
+    background: $motion-tertiary;
+    border-top-left-radius: $more-button-size;
+    border-top-right-radius: $more-button-size;
+    width: $more-button-size;
+    margin-left: calc(($main-button-size - $more-button-size) / 2);
+    margin-right: calc(($main-button-size - $more-button-size) / 2);
+
+    position: absolute;
+    bottom: calc($main-button-size);
+
+    margin-bottom: calc($main-button-size / -2);
+    padding-bottom: calc($main-button-size / 2);
+}
+
+.more-buttons {
+    max-height: 0;
+    transition: max-height 1s;
+    overflow-x: visible;
+    display: flex;
+    flex-direction: column;
+    z-index: 10; /* @todo justify */
+}
+
+.file-input {
+    display: none;
+}
+
+.expanded .more-buttons {
+    max-height: 1000px; /* Arbitrary, needs to be a value in order for animation to run */
+}
+
+.force-hidden .more-buttons {
+    display: none; /* This property does not animate */
+}
+
+.more-buttons:first-child { /* Round off top button */
+    border-top-right-radius: $more-button-size;
+    border-top-left-radius: $more-button-size;
+}
+
+.more-button {
+    width: $more-button-size;
+    height: $more-button-size;
+    background: $motion-tertiary;
+}
+
+.more-icon {
+    width: calc($more-button-size - 1rem);
+    height: calc($more-button-size - 1rem);
+}
+
+.coming-soon .more-icon {
+    opacity: 0.5;
+}
+
+/*
+    @todo needs to be refactored with coming soon tooltip overrides.
+    The "!important"s are for the same reason as with coming soon, the library
+    is not very easy to style.
+*/
+.tooltip {
+    background-color: $extensions-primary !important;
+    opacity: 1 !important;
+    border: 1px solid hsla(0, 0%, 0%, .1) !important;
+    box-shadow: 0 0 .5rem hsla(0, 0%, 0%, .25) !important;
+    font-family: "Helvetica Neue", Helvetica, Arial, sans-serif !important;
+}
+
+.tooltip:after {
+    background-color: $extensions-primary;
+}
+
+.coming-soon-tooltip {
+    background-color: $data-primary !important;
+}
+
+.coming-soon-tooltip:after {
+    background-color: $data-primary !important;
+}
+
+.tooltip {
+    border: 1px solid hsla(0, 0%, 0%, .1) !important;
+    border-radius: $form-radius !important;
+    box-shadow: 0 0 .5rem hsla(0, 0%, 0%, .25) !important;
+    font-family: "Helvetica Neue", Helvetica, Arial, sans-serif !important;
+    z-index: $z-index-tooltip !important;
+}
+
+$arrow-size: 0.5rem;
+$arrow-inset: -0.25rem;
+$arrow-rounding: 0.125rem;
+
+.tooltip:after {
+    content: "";
+    border-top: 1px solid hsla(0, 0%, 0%, .1) !important;
+    border-left: 0 !important;
+    border-bottom: 0 !important;
+    border-right: 1px solid hsla(0, 0%, 0%, .1) !important;
+    border-radius: $arrow-rounding;
+    height: $arrow-size !important;
+    width: $arrow-size !important;
+}
+
+.tooltip:global(.place-left):after {
+    margin-top: $arrow-inset !important;
+    right: $arrow-inset !important;
+    transform: rotate(45deg) !important;
+}
+
+.tooltip:global(.place-right):after {
+    margin-top: $arrow-inset !important;
+    left: $arrow-inset !important;
+    transform: rotate(-135deg) !important;
+}
+
+.tooltip:global(.place-top):after {
+    margin-right: $arrow-inset !important;
+    bottom: $arrow-inset !important;
+    transform: rotate(135deg) !important;
+}
+
+.tooltip:global(.place-bottom):after {
+    margin-left: $arrow-inset !important;
+    top: $arrow-inset !important;
+    transform: rotate(-45deg) !important;
+}

+ 210 - 0
src/components/action-menu/action-menu.jsx

@@ -0,0 +1,210 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import classNames from 'classnames';
+import bindAll from 'lodash.bindall';
+import ReactTooltip from 'react-tooltip';
+
+import styles from './action-menu.css';
+
+const CLOSE_DELAY = 300; // ms
+
+class ActionMenu extends React.Component {
+    constructor (props) {
+        super(props);
+        bindAll(this, [
+            'clickDelayer',
+            'handleClosePopover',
+            'handleToggleOpenState',
+            'handleTouchStart',
+            'handleTouchOutside',
+            'setButtonRef',
+            'setContainerRef'
+        ]);
+        this.state = {
+            isOpen: false,
+            forceHide: false
+        };
+        this.mainTooltipId = `tooltip-${Math.random()}`;
+    }
+    componentDidMount () {
+        // Touch start on the main button is caught to trigger open and not click
+        this.buttonRef.addEventListener('touchstart', this.handleTouchStart);
+        // Touch start on document is used to trigger close if it is outside
+        document.addEventListener('touchstart', this.handleTouchOutside);
+    }
+    shouldComponentUpdate (newProps, newState) {
+        // This check prevents re-rendering while the project is updating.
+        // @todo check only the state and the title because it is enough to know
+        //  if anything substantial has changed
+        // This is needed because of the sloppy way the props are passed as a new object,
+        //  which should be refactored.
+        return newState.isOpen !== this.state.isOpen ||
+            newState.forceHide !== this.state.forceHide ||
+            newProps.title !== this.props.title;
+    }
+    componentWillUnmount () {
+        this.buttonRef.removeEventListener('touchstart', this.handleTouchStart);
+        document.removeEventListener('touchstart', this.handleTouchOutside);
+    }
+    handleClosePopover () {
+        this.closeTimeoutId = setTimeout(() => {
+            this.setState({isOpen: false});
+            this.closeTimeoutId = null;
+        }, CLOSE_DELAY);
+    }
+    handleToggleOpenState () {
+        // Mouse enter back in after timeout was started prevents it from closing.
+        if (this.closeTimeoutId) {
+            clearTimeout(this.closeTimeoutId);
+            this.closeTimeoutId = null;
+        } else if (!this.state.isOpen) {
+            this.setState({
+                isOpen: true,
+                forceHide: false
+            });
+        }
+    }
+    handleTouchOutside (e) {
+        if (this.state.isOpen && !this.containerRef.contains(e.target)) {
+            this.setState({isOpen: false});
+            ReactTooltip.hide();
+        }
+    }
+    clickDelayer (fn) {
+        // Return a wrapped action that manages the menu closing.
+        // @todo we may be able to use react-transition for this in the future
+        // for now all this work is to ensure the menu closes BEFORE the
+        // (possibly slow) action is started.
+        return event => {
+            ReactTooltip.hide();
+            if (fn) fn(event);
+            // Blur the button so it does not keep focus after being clicked
+            // This prevents keyboard events from triggering the button
+            this.buttonRef.blur();
+            this.setState({forceHide: true, isOpen: false}, () => {
+                setTimeout(() => this.setState({forceHide: false}));
+            });
+        };
+    }
+    handleTouchStart (e) {
+        // Prevent this touch from becoming a click if menu is closed
+        if (!this.state.isOpen) {
+            e.preventDefault();
+            this.handleToggleOpenState();
+        }
+    }
+    setButtonRef (ref) {
+        this.buttonRef = ref;
+    }
+    setContainerRef (ref) {
+        this.containerRef = ref;
+    }
+    render () {
+        const {
+            className,
+            img: mainImg,
+            title: mainTitle,
+            moreButtons,
+            tooltipPlace,
+            onClick
+        } = this.props;
+
+        return (
+            <div
+                className={classNames(styles.menuContainer, className, {
+                    [styles.expanded]: this.state.isOpen,
+                    [styles.forceHidden]: this.state.forceHide
+                })}
+                ref={this.setContainerRef}
+                onMouseEnter={this.handleToggleOpenState}
+                onMouseLeave={this.handleClosePopover}
+            >
+                <button
+                    aria-label={mainTitle}
+                    className={classNames(styles.button, styles.mainButton)}
+                    data-for={this.mainTooltipId}
+                    data-tip={mainTitle}
+                    ref={this.setButtonRef}
+                    onClick={this.clickDelayer(onClick)}
+                >
+                    <img
+                        className={styles.mainIcon}
+                        draggable={false}
+                        src={mainImg}
+                    />
+                </button>
+                <ReactTooltip
+                    className={styles.tooltip}
+                    effect="solid"
+                    id={this.mainTooltipId}
+                    place={tooltipPlace || 'left'}
+                />
+                <div className={styles.moreButtonsOuter}>
+                    <div className={styles.moreButtons}>
+                        {(moreButtons || []).map(({img, title, onClick: handleClick,
+                            fileAccept, fileChange, fileInput, fileMultiple}, keyId) => {
+                            const isComingSoon = !handleClick;
+                            const hasFileInput = fileInput;
+                            const tooltipId = `${this.mainTooltipId}-${title}`;
+                            return (
+                                <div key={`${tooltipId}-${keyId}`}>
+                                    <button
+                                        aria-label={title}
+                                        className={classNames(styles.button, styles.moreButton, {
+                                            [styles.comingSoon]: isComingSoon
+                                        })}
+                                        data-for={tooltipId}
+                                        data-tip={title}
+                                        onClick={hasFileInput ? handleClick : this.clickDelayer(handleClick)}
+                                    >
+                                        <img
+                                            className={styles.moreIcon}
+                                            draggable={false}
+                                            src={img}
+                                        />
+                                        {hasFileInput ? (
+                                            <input
+                                                accept={fileAccept}
+                                                className={styles.fileInput}
+                                                multiple={fileMultiple}
+                                                ref={fileInput}
+                                                type="file"
+                                                onChange={fileChange}
+                                            />) : null}
+                                    </button>
+                                    <ReactTooltip
+                                        className={classNames(styles.tooltip, {
+                                            [styles.comingSoonTooltip]: isComingSoon
+                                        })}
+                                        effect="solid"
+                                        id={tooltipId}
+                                        place={tooltipPlace || 'left'}
+                                    />
+                                </div>
+                            );
+                        })}
+                    </div>
+                </div>
+            </div>
+        );
+    }
+}
+
+ActionMenu.propTypes = {
+    className: PropTypes.string,
+    img: PropTypes.string,
+    moreButtons: PropTypes.arrayOf(PropTypes.shape({
+        img: PropTypes.string,
+        title: PropTypes.node.isRequired,
+        onClick: PropTypes.func, // Optional, "coming soon" if no callback provided
+        fileAccept: PropTypes.string, // Optional, only for file upload
+        fileChange: PropTypes.func, // Optional, only for file upload
+        fileInput: PropTypes.func, // Optional, only for file upload
+        fileMultiple: PropTypes.bool // Optional, only for file upload
+    })),
+    onClick: PropTypes.func.isRequired,
+    title: PropTypes.node.isRequired,
+    tooltipPlace: PropTypes.string
+};
+
+export default ActionMenu;

Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 21 - 0
src/components/action-menu/icon--backdrop.svg


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 12 - 0
src/components/action-menu/icon--camera.svg


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 12 - 0
src/components/action-menu/icon--file-upload.svg


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 12 - 0
src/components/action-menu/icon--paint.svg


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 10 - 0
src/components/action-menu/icon--search.svg


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 12 - 0
src/components/action-menu/icon--sprite.svg


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 12 - 0
src/components/action-menu/icon--surprise.svg


+ 103 - 0
src/components/alerts/alert.css

@@ -0,0 +1,103 @@
+@import "../../css/units.css";
+@import "../../css/colors.css";
+@import "../../css/z-index.css";
+
+.alert {
+    width: 100%;
+    display: flex;
+    flex-direction: row;
+    overflow: hidden;
+    justify-content: flex-start;
+    border-radius: $space;
+    padding-top: .875rem;
+    padding-bottom: .875rem;
+    padding-left: 1rem;
+    padding-right: 1rem;
+    margin-bottom: 7px;
+    min-height: 1.5rem;
+}
+
+.alert.warn {
+    background: #FFF0DF;
+    border: 1px solid #FF8C1A;
+    box-shadow: 0px 0px 0px 2px rgba(255, 140, 26, 0.25);
+}
+
+.alert.success {
+    background: $extensions-light;
+    border: 1px solid $extensions-tertiary;
+    box-shadow: 0px 0px 0px 2px $extensions-light;
+}
+
+.alert-spinner {
+    self-align: center;
+}
+
+.icon-section {
+    min-width: 1.25rem;
+    min-height: 1.25rem;
+    display: flex;
+    padding-right: 1rem;
+}
+
+.alert-icon {
+    vertical-align: middle;
+}
+
+.alert-message {
+    color: #555;
+    font-weight: bold;
+    font-size: .8125rem;
+    line-height: .875rem;
+    display: flex;
+    align-items: center;
+    padding-right: .5rem;
+}
+
+.alert-buttons {
+    display: flex;
+    flex-direction: row;
+}
+
+.alert-close-button {
+    outline-style:none;
+}
+
+.alert-close-button-container {
+    outline-style: none;
+    width: 30px;
+    height: 30px;
+    align-self: center;
+}
+
+.alert-connection-button {
+    min-height: 2rem;
+    width: 6.5rem;
+    padding: 0.55rem 0.9rem;
+    border-radius: 0.35rem;
+    background: #FF8C1A;
+    color: white;
+    font-weight: 700;
+    font-size: 0.77rem;
+    border: none;
+    cursor: pointer;
+    display: flex;
+    justify-content: center;
+    align-items: center;
+    align-self: stretch;
+    outline-style:none;
+}
+
+[dir="ltr"] .alert-connection-button {
+    margin-right: 13px;
+}
+
+[dir="rtl"] .alert-connection-button {
+    margin-left: 13px;
+}
+
+/* prevent last button in list from too much margin to edge of alert */
+.alert-buttons > :last-child {
+    margin-left: 0;
+    margin-right: 0;
+}

+ 140 - 0
src/components/alerts/alert.jsx

@@ -0,0 +1,140 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import classNames from 'classnames';
+import {FormattedMessage} from 'react-intl';
+
+import Box from '../box/box.jsx';
+import CloseButton from '../close-button/close-button.jsx';
+import Spinner from '../spinner/spinner.jsx';
+import {AlertLevels} from '../../lib/alerts/index.jsx';
+
+import styles from './alert.css';
+
+const closeButtonColors = {
+    [AlertLevels.SUCCESS]: CloseButton.COLOR_GREEN,
+    [AlertLevels.WARN]: CloseButton.COLOR_ORANGE
+};
+
+const AlertComponent = ({
+    content,
+    closeButton,
+    extensionName,
+    iconSpinner,
+    iconURL,
+    level,
+    showDownload,
+    showSaveNow,
+    onCloseAlert,
+    onDownload,
+    onSaveNow,
+    onReconnect,
+    showReconnect
+}) => (
+    <Box
+        className={classNames(styles.alert, styles[level])}
+    >
+        {/* TODO: implement Rtl handling */}
+        {(iconSpinner || iconURL) && (
+            <div className={styles.iconSection}>
+                {iconSpinner && (
+                    <Spinner
+                        className={styles.alertSpinner}
+                        level={level}
+                    />
+                )}
+                {iconURL && (
+                    <img
+                        className={styles.alertIcon}
+                        src={iconURL}
+                    />
+                )}
+            </div>
+        )}
+        <div className={styles.alertMessage}>
+            {extensionName ? (
+                <FormattedMessage
+                    defaultMessage="Scratch lost connection to {extensionName}."
+                    description="Message indicating that an extension peripheral has been disconnected"
+                    id="gui.alerts.lostPeripheralConnection"
+                    values={{
+                        extensionName: (
+                            `${extensionName}`
+                        )
+                    }}
+                />
+            ) : content}
+        </div>
+        <div className={styles.alertButtons}>
+            {showSaveNow && (
+                <button
+                    className={styles.alertConnectionButton}
+                    onClick={onSaveNow}
+                >
+                    <FormattedMessage
+                        defaultMessage="Try Again"
+                        description="Button to try saving again"
+                        id="gui.alerts.tryAgain"
+                    />
+                </button>
+            )}
+            {showDownload && (
+                <button
+                    className={styles.alertConnectionButton}
+                    onClick={onDownload}
+                >
+                    <FormattedMessage
+                        defaultMessage="Download"
+                        description="Button to download project locally"
+                        id="gui.alerts.download"
+                    />
+                </button>
+            )}
+            {showReconnect && (
+                <button
+                    className={styles.alertConnectionButton}
+                    onClick={onReconnect}
+                >
+                    <FormattedMessage
+                        defaultMessage="Reconnect"
+                        description="Button to reconnect the device"
+                        id="gui.connection.reconnect"
+                    />
+                </button>
+            )}
+            {closeButton && (
+                <Box
+                    className={styles.alertCloseButtonContainer}
+                >
+                    <CloseButton
+                        className={classNames(styles.alertCloseButton)}
+                        color={closeButtonColors[level]}
+                        size={CloseButton.SIZE_LARGE}
+                        onClick={onCloseAlert}
+                    />
+                </Box>
+            )}
+        </div>
+    </Box>
+);
+
+AlertComponent.propTypes = {
+    closeButton: PropTypes.bool,
+    content: PropTypes.oneOfType([PropTypes.element, PropTypes.string]),
+    extensionName: PropTypes.string,
+    iconSpinner: PropTypes.bool,
+    iconURL: PropTypes.string,
+    level: PropTypes.string,
+    onCloseAlert: PropTypes.func.isRequired,
+    onDownload: PropTypes.func,
+    onReconnect: PropTypes.func,
+    onSaveNow: PropTypes.func,
+    showDownload: PropTypes.func,
+    showReconnect: PropTypes.bool,
+    showSaveNow: PropTypes.bool
+};
+
+AlertComponent.defaultProps = {
+    level: AlertLevels.WARN
+};
+
+export default AlertComponent;

+ 4 - 0
src/components/alerts/alerts.css

@@ -0,0 +1,4 @@
+.alerts-inner-container {
+    min-width: 200px;
+    max-width: 548px;
+}

+ 47 - 0
src/components/alerts/alerts.jsx

@@ -0,0 +1,47 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+
+import Box from '../box/box.jsx';
+import Alert from '../../containers/alert.jsx';
+
+import styles from './alerts.css';
+
+const AlertsComponent = ({
+    alertsList,
+    className,
+    onCloseAlert
+}) => (
+    <Box
+        bounds="parent"
+        className={className}
+    >
+        <Box className={styles.alertsInnerContainer} >
+            {alertsList.map((a, index) => (
+                <Alert
+                    closeButton={a.closeButton}
+                    content={a.content}
+                    extensionId={a.extensionId}
+                    extensionName={a.extensionName}
+                    iconSpinner={a.iconSpinner}
+                    iconURL={a.iconURL}
+                    index={index}
+                    key={index}
+                    level={a.level}
+                    message={a.message}
+                    showDownload={a.showDownload}
+                    showReconnect={a.showReconnect}
+                    showSaveNow={a.showSaveNow}
+                    onCloseAlert={onCloseAlert}
+                />
+            ))}
+        </Box>
+    </Box>
+);
+
+AlertsComponent.propTypes = {
+    alertsList: PropTypes.arrayOf(PropTypes.object),
+    className: PropTypes.string,
+    onCloseAlert: PropTypes.func
+};
+
+export default AlertsComponent;

+ 27 - 0
src/components/alerts/inline-message.css

@@ -0,0 +1,27 @@
+@import "../../css/colors.css";
+@import "../../css/units.css";
+
+.inline-message {
+    color: $ui-white;
+    font-family: "Helvetica Neue", Helvetica, Arial, sans-serif;
+    display: flex;
+    justify-content: flex-end;
+    align-items: center;
+    font-size: .8125rem;
+}
+
+.success {
+    color: $ui-white-dim;
+}
+
+.info {
+    color: $ui-white;
+}
+
+.warn {
+    color: $error-light;
+}
+
+.spinner {
+    margin-right: $space;
+}

+ 40 - 0
src/components/alerts/inline-message.jsx

@@ -0,0 +1,40 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import classNames from 'classnames';
+
+import Spinner from '../spinner/spinner.jsx';
+import {AlertLevels} from '../../lib/alerts/index.jsx';
+
+import styles from './inline-message.css';
+
+const InlineMessageComponent = ({
+    content,
+    iconSpinner,
+    level
+}) => (
+    <div
+        className={classNames(styles.inlineMessage, styles[level])}
+    >
+        {/* TODO: implement Rtl handling */}
+        {iconSpinner && (
+            <Spinner
+                small
+                className={styles.spinner}
+                level={'info'}
+            />
+        )}
+        {content}
+    </div>
+);
+
+InlineMessageComponent.propTypes = {
+    content: PropTypes.element,
+    iconSpinner: PropTypes.bool,
+    level: PropTypes.string
+};
+
+InlineMessageComponent.defaultProps = {
+    level: AlertLevels.INFO
+};
+
+export default InlineMessageComponent;

+ 36 - 0
src/components/asset-panel/asset-panel.css

@@ -0,0 +1,36 @@
+@import "../../css/units.css";
+@import "../../css/colors.css";
+
+.wrapper {
+    display: flex;
+    flex-grow: 1;
+    border: 1px solid $ui-black-transparent;
+    background: white;
+    font-family: "Helvetica Neue", Helvetica, Arial, sans-serif;
+    font-size: 0.85rem;
+}
+
+[dir="ltr"] .wrapper {
+    border-top-right-radius: $space;
+    border-bottom-right-radius: $space;
+}
+
+[dir="rtl"] .wrapper {
+    border-top-left-radius: $space;
+    border-bottom-left-radius: $space;
+}
+
+.detail-area {
+    display: flex;
+    flex-grow: 1;
+    flex-shrink: 1;
+    overflow: visible;
+}
+
+[dir="ltr"] .detail-area {
+    border-left: 1px solid $ui-black-transparent;
+}
+
+[dir="rtl"] .detail-area {
+    border-right: 1px solid $ui-black-transparent;
+}

+ 23 - 0
src/components/asset-panel/asset-panel.jsx

@@ -0,0 +1,23 @@
+import React from 'react';
+
+import Box from '../box/box.jsx';
+import Selector from './selector.jsx';
+import styles from './asset-panel.css';
+
+const AssetPanel = props => (
+    <Box className={styles.wrapper}>
+        <Selector
+            className={styles.selector}
+            {...props}
+        />
+        <Box className={styles.detailArea}>
+            {props.children}
+        </Box>
+    </Box>
+);
+
+AssetPanel.propTypes = {
+    ...Selector.propTypes
+};
+
+export default AssetPanel;

Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 21 - 0
src/components/asset-panel/icon--add-backdrop-lib.svg


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 12 - 0
src/components/asset-panel/icon--add-blank-costume.svg


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 12 - 0
src/components/asset-panel/icon--add-costume-lib.svg


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 12 - 0
src/components/asset-panel/icon--add-sound-lib.svg


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 12 - 0
src/components/asset-panel/icon--add-sound-record.svg


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 20 - 0
src/components/asset-panel/icon--sound-rtl.svg


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 10 - 0
src/components/asset-panel/icon--sound.svg


+ 85 - 0
src/components/asset-panel/selector.css

@@ -0,0 +1,85 @@
+@import "../../css/colors.css";
+@import "../../css/units.css";
+
+.wrapper {
+    width: 150px;
+    position: relative;
+    display: flex;
+    flex-direction: column;
+    justify-content: space-between;
+    background: $ui-tertiary;
+}
+
+.new-buttons {
+    position: absolute;
+    bottom: 0;
+    width: 100%;
+
+    display: flex;
+    flex-direction: column;
+    align-items: center;
+    justify-content: space-around;
+    padding: 0.75rem 0;
+    color: $motion-primary;
+    text-align: center;
+    background: none;
+}
+
+$fade-out-distance: 100px;
+
+.new-buttons:before {
+    content: "";
+    position: absolute;
+    bottom: 0;
+    left: 0;
+    right:0;
+    background: linear-gradient(rgba(232,237,241, 0),rgba(232,237,241, 1));
+    height: $fade-out-distance;
+    width: 100%;
+    pointer-events: none;
+}
+
+.new-buttons > button + button {
+    margin-top: 0.75rem;
+}
+
+.list-area {
+    /* Must have some height (recalculated by flex-grow) in order to scroll */
+    height: 0;
+    flex-grow: 1;
+    overflow-y: scroll;
+    display: flex;
+    flex-direction: column;
+}
+
+.list-area:after {
+    /* Make sure there is room to scroll beyond the last tile */
+    content: '';
+    display: block;
+    height: 70px;
+    width: 100%;
+    flex-shrink: 0;
+    order: 99999999;
+}
+
+.list-item {
+    width: 5rem;
+    height: 5rem;
+    margin: 0.5rem auto;
+}
+
+@media only screen and (max-width: $full-size-paint) {
+    .wrapper {
+        width: 80px;
+    }
+
+    .list-item {
+        width: 4rem;
+    }
+}
+
+
+.list-item.placeholder {
+    background: white;
+    filter: opacity(15%) brightness(0%);
+}

+ 118 - 0
src/components/asset-panel/selector.jsx

@@ -0,0 +1,118 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import classNames from 'classnames';
+import SpriteSelectorItem from '../../containers/sprite-selector-item.jsx';
+import Box from '../box/box.jsx';
+import ActionMenu from '../action-menu/action-menu.jsx';
+import SortableAsset from './sortable-asset.jsx';
+import SortableHOC from '../../lib/sortable-hoc.jsx';
+import DragConstants from '../../lib/drag-constants';
+
+import styles from './selector.css';
+
+const Selector = props => {
+    const {
+        buttons,
+        containerRef,
+        dragType,
+        isRtl,
+        items,
+        selectedItemIndex,
+        draggingIndex,
+        draggingType,
+        ordering,
+        onAddSortable,
+        onRemoveSortable,
+        onDeleteClick,
+        onDuplicateClick,
+        onExportClick,
+        onItemClick
+    } = props;
+
+    const isRelevantDrag = draggingType === dragType;
+
+    let newButtonSection = null;
+
+    if (buttons.length > 0) {
+        const {img, title, onClick} = buttons[0];
+        const moreButtons = buttons.slice(1);
+        newButtonSection = (
+            <Box className={styles.newButtons}>
+                <ActionMenu
+                    img={img}
+                    moreButtons={moreButtons}
+                    title={title}
+                    tooltipPlace={isRtl ? 'left' : 'right'}
+                    onClick={onClick}
+                />
+            </Box>
+        );
+    }
+
+    return (
+        <Box
+            className={styles.wrapper}
+            componentRef={containerRef}
+        >
+            <Box className={styles.listArea}>
+                {items.map((item, index) => (
+                    <SortableAsset
+                        id={item.name}
+                        index={isRelevantDrag ? ordering.indexOf(index) : index}
+                        key={item.name}
+                        onAddSortable={onAddSortable}
+                        onRemoveSortable={onRemoveSortable}
+                    >
+                        <SpriteSelectorItem
+                            asset={item.asset}
+                            className={classNames(styles.listItem, {
+                                [styles.placeholder]: isRelevantDrag && index === draggingIndex
+                            })}
+                            costumeURL={item.url}
+                            details={item.details}
+                            dragPayload={item.dragPayload}
+                            dragType={dragType}
+                            id={index}
+                            index={index}
+                            name={item.name}
+                            number={index + 1 /* 1-indexed */}
+                            selected={index === selectedItemIndex}
+                            onClick={onItemClick}
+                            onDeleteButtonClick={onDeleteClick}
+                            onDuplicateButtonClick={onDuplicateClick}
+                            onExportButtonClick={onExportClick}
+                        />
+                    </SortableAsset>
+                ))}
+            </Box>
+            {newButtonSection}
+        </Box>
+    );
+};
+
+Selector.propTypes = {
+    buttons: PropTypes.arrayOf(PropTypes.shape({
+        title: PropTypes.string.isRequired,
+        img: PropTypes.string.isRequired,
+        onClick: PropTypes.func
+    })),
+    containerRef: PropTypes.func,
+    dragType: PropTypes.oneOf(Object.keys(DragConstants)),
+    draggingIndex: PropTypes.number,
+    draggingType: PropTypes.oneOf(Object.keys(DragConstants)),
+    isRtl: PropTypes.bool,
+    items: PropTypes.arrayOf(PropTypes.shape({
+        url: PropTypes.string,
+        name: PropTypes.string.isRequired
+    })),
+    onAddSortable: PropTypes.func,
+    onDeleteClick: PropTypes.func,
+    onDuplicateClick: PropTypes.func,
+    onExportClick: PropTypes.func,
+    onItemClick: PropTypes.func.isRequired,
+    onRemoveSortable: PropTypes.func,
+    ordering: PropTypes.arrayOf(PropTypes.number),
+    selectedItemIndex: PropTypes.number.isRequired
+};
+
+export default SortableHOC(Selector);

+ 45 - 0
src/components/asset-panel/sortable-asset.jsx

@@ -0,0 +1,45 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+
+import bindAll from 'lodash.bindall';
+
+class SortableAsset extends React.Component {
+    constructor (props) {
+        super(props);
+        bindAll(this, [
+            'setRef'
+        ]);
+    }
+    componentDidMount () {
+        this.props.onAddSortable(this.ref);
+    }
+    componentWillUnmount () {
+        this.props.onRemoveSortable(this.ref);
+    }
+    setRef (ref) {
+        this.ref = ref;
+    }
+    render () {
+        return (
+            <div
+                className={this.props.className}
+                ref={this.setRef}
+                style={{
+                    order: this.props.index
+                }}
+            >
+                {this.props.children}
+            </div>
+        );
+    }
+}
+
+SortableAsset.propTypes = {
+    children: PropTypes.node.isRequired,
+    className: PropTypes.string,
+    index: PropTypes.number.isRequired,
+    onAddSortable: PropTypes.func.isRequired,
+    onRemoveSortable: PropTypes.func.isRequired
+};
+
+export default SortableAsset;

+ 53 - 0
src/components/audio-trimmer/audio-selector.jsx

@@ -0,0 +1,53 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import classNames from 'classnames';
+import Box from '../box/box.jsx';
+import styles from './audio-trimmer.css';
+import SelectionHandle from './selection-handle.jsx';
+import Playhead from './playhead.jsx';
+
+const AudioSelector = props => (
+    <div
+        className={classNames(styles.absolute, styles.selector)}
+        ref={props.containerRef}
+        onMouseDown={props.onNewSelectionMouseDown}
+        onTouchStart={props.onNewSelectionMouseDown}
+    >
+        {props.trimStart === null ? null : (
+            <Box
+                className={classNames(styles.absolute)}
+                style={{
+                    left: `${props.trimStart * 100}%`,
+                    width: `${100 * (props.trimEnd - props.trimStart)}%`
+                }}
+            >
+                <Box className={classNames(styles.absolute, styles.selectionBackground)} />
+                <SelectionHandle
+                    handleStyle={styles.leftHandle}
+                    onMouseDown={props.onTrimStartMouseDown}
+                />
+                <SelectionHandle
+                    handleStyle={styles.rightHandle}
+                    onMouseDown={props.onTrimEndMouseDown}
+                />
+            </Box>
+        )}
+        {props.playhead ? (
+            <Playhead
+                playbackPosition={props.playhead}
+            />
+        ) : null}
+    </div>
+);
+
+AudioSelector.propTypes = {
+    containerRef: PropTypes.func,
+    onNewSelectionMouseDown: PropTypes.func.isRequired,
+    onTrimEndMouseDown: PropTypes.func.isRequired,
+    onTrimStartMouseDown: PropTypes.func.isRequired,
+    playhead: PropTypes.number,
+    trimEnd: PropTypes.number,
+    trimStart: PropTypes.number
+};
+
+export default AudioSelector;

+ 148 - 0
src/components/audio-trimmer/audio-trimmer.css

@@ -0,0 +1,148 @@
+@import "../../css/colors.css";
+
+$border-radius: 4px;
+$trim-handle-width: 30px;
+$trim-handle-height: 30px;
+$trim-handle-border: 3px;
+$stripe-size: 10px;
+$hover-scale: 1.25;
+
+.absolute {
+    position: absolute;
+    top: 0;
+    left: 0;
+    width: 100%;
+    height: 100%;
+
+    /* Force the browser to paint separately to avoid composite cost with waveform */
+    transform: translateZ(0);
+}
+
+.selector {
+    cursor: pointer;
+}
+
+.trim-background {
+    cursor: pointer;
+    touch-action: none;
+}
+
+.trim-background-mask {
+    border: 1px solid $red-tertiary;
+    opacity: 0.5;
+
+    background: repeating-linear-gradient(
+        45deg,
+        $red-primary,
+        $red-primary $stripe-size,
+        $red-tertiary $stripe-size,
+        $red-tertiary calc(2 * $stripe-size)
+    );
+}
+
+.selection-background {
+    background: $motion-primary;
+    opacity: 0.5;
+}
+
+.start-trim-background .trim-background-mask {
+    border-top-left-radius: $border-radius;
+    border-bottom-left-radius: $border-radius;
+}
+
+.end-trim-background .trim-background-mask {
+    border-top-right-radius: $border-radius;
+    border-bottom-right-radius: $border-radius;
+}
+
+.trim-line {
+    position: absolute;
+    top: 0;
+    width: 0px;
+    height: 100%;
+    border: 1px solid $red-tertiary;
+}
+
+.selector .trim-line {
+    border: 1px solid $motion-tertiary;
+}
+
+.playhead-container {
+    position: absolute;
+    top: 0;
+    left: 0;
+    height: 100%;
+    width: 100%;
+    overflow: hidden;
+}
+
+.playhead {
+    /*
+        Even though playhead is just a line, it is 100% width (the width of the waveform)
+        so that we can use transform: translateX() using percentages.
+    */
+    width: 100%;
+    height: 100%;
+    border-left: 1px solid $motion-primary;
+    border-top: none;
+    border-bottom: none;
+    border-right: none;
+}
+
+.right-handle {
+    transform: scaleX(-1);
+}
+
+.selector .left-handle {
+    left: -1px
+}
+
+.selector .right-handle {
+    right: -1px
+}
+
+.trimmer .left-handle {
+    right: -1px
+}
+
+.trimmer .right-handle {
+    left: -1px
+}
+
+.trim-handle {
+    position: absolute;
+    width: $trim-handle-width;
+    height: $trim-handle-height;
+    right: 0;
+    user-select: none;
+}
+
+.trimmer .trim-handle {
+    filter: hue-rotate(150deg);
+}
+
+.trim-handle img {
+    position: absolute;
+    width: $trim-handle-width;
+    height: $trim-handle-height;
+    left: calc($trim-handle-border * 1.5);
+
+    /* Make sure image dragging isn't triggered */
+    user-select: none;
+    user-drag: none;
+    -webkit-user-drag: none; /* Autoprefixer doesn't seem to work for this */
+
+    transition: 0.2s;
+}
+
+.top-trim-handle {
+    top: calc(-$trim-handle-height + $trim-handle-border);
+}
+
+.bottom-trim-handle {
+    bottom: calc(-$trim-handle-height + $trim-handle-border);
+}
+
+.top-trim-handle img {
+    transform: scaleY(-1);
+}

+ 62 - 0
src/components/audio-trimmer/audio-trimmer.jsx

@@ -0,0 +1,62 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import classNames from 'classnames';
+import Box from '../box/box.jsx';
+import styles from './audio-trimmer.css';
+import SelectionHandle from './selection-handle.jsx';
+import Playhead from './playhead.jsx';
+
+const AudioTrimmer = props => (
+    <div
+        className={classNames(styles.absolute, styles.trimmer)}
+        ref={props.containerRef}
+    >
+        {props.trimStart === null ? null : (
+            <Box
+                className={classNames(styles.absolute, styles.trimBackground, styles.startTrimBackground)}
+                style={{
+                    width: `${100 * props.trimStart}%`
+                }}
+                onMouseDown={props.onTrimStartMouseDown}
+                onTouchStart={props.onTrimStartMouseDown}
+            >
+                <Box className={classNames(styles.absolute, styles.trimBackgroundMask)} />
+                <SelectionHandle
+                    handleStyle={styles.leftHandle}
+                />
+            </Box>
+        )}
+        {props.playhead ? (
+            <Playhead
+                playbackPosition={props.playhead}
+            />
+        ) : null}
+        {props.trimEnd === null ? null : (
+            <Box
+                className={classNames(styles.absolute, styles.trimBackground, styles.endTrimBackground)}
+                style={{
+                    left: `${100 * props.trimEnd}%`,
+                    width: `${100 - (100 * props.trimEnd)}%`
+                }}
+                onMouseDown={props.onTrimEndMouseDown}
+                onTouchStart={props.onTrimEndMouseDown}
+            >
+                <Box className={classNames(styles.absolute, styles.trimBackgroundMask)} />
+                <SelectionHandle
+                    handleStyle={styles.rightHandle}
+                />
+            </Box>
+        )}
+    </div>
+);
+
+AudioTrimmer.propTypes = {
+    containerRef: PropTypes.func,
+    onTrimEndMouseDown: PropTypes.func.isRequired,
+    onTrimStartMouseDown: PropTypes.func.isRequired,
+    playhead: PropTypes.number,
+    trimEnd: PropTypes.number,
+    trimStart: PropTypes.number
+};
+
+export default AudioTrimmer;

+ 16 - 0
src/components/audio-trimmer/icon--handle.svg

@@ -0,0 +1,16 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<svg width="34px" height="34px" viewBox="1 1 33 33" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
+    <!-- Generator: Sketch 55.2 (78181) - https://sketchapp.com -->
+    <title>Bottom Left Handle</title>
+    <desc>Created with Sketch.</desc>
+    <defs>
+        <path d="M17,5 C23.627417,5 29,10.372583 29,17 L29,29 L17,29 C10.372583,29 5,23.627417 5,17 C5,10.372583 10.372583,5 17,5 Z" id="path-1"></path>
+    </defs>
+    <g id="Bottom-Left-Handle" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
+        <g id="Bottom-Left" transform="translate(17.000000, 17.000000) scale(1, -1) translate(-17.000000, -17.000000) ">
+            <use stroke="#4D97FF33" stroke-width="8" xlink:href="#path-1"></use>
+            <use fill="black" fill-opacity="1" filter="url(#filter-2)" xlink:href="#path-1"></use>
+            <use stroke="#3D73CC" stroke-width="1" fill="#4D97FF" fill-rule="evenodd" xlink:href="#path-1"></use>
+        </g>
+    </g>
+</svg>

+ 21 - 0
src/components/audio-trimmer/playhead.jsx

@@ -0,0 +1,21 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import classNames from 'classnames';
+import styles from './audio-trimmer.css';
+
+const Playhead = props => (
+    <div className={styles.playheadContainer}>
+        <div
+            className={classNames(styles.playhead)}
+            style={{
+                transform: `translateX(${100 * props.playbackPosition}%)`
+            }}
+        />
+    </div>
+);
+
+Playhead.propTypes = {
+    playbackPosition: PropTypes.number
+};
+
+export default Playhead;

+ 28 - 0
src/components/audio-trimmer/selection-handle.jsx

@@ -0,0 +1,28 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import classNames from 'classnames';
+import Box from '../box/box.jsx';
+import styles from './audio-trimmer.css';
+import handleIcon from './icon--handle.svg';
+
+const SelectionHandle = props => (
+    <Box
+        className={classNames(styles.trimLine, props.handleStyle)}
+        onMouseDown={props.onMouseDown}
+        onTouchStart={props.onMouseDown}
+    >
+        <Box className={classNames(styles.trimHandle, styles.topTrimHandle)}>
+            <img src={handleIcon} />
+        </Box>
+        <Box className={classNames(styles.trimHandle, styles.bottomTrimHandle)}>
+            <img src={handleIcon} />
+        </Box>
+    </Box>
+);
+
+SelectionHandle.propTypes = {
+    handleStyle: PropTypes.string,
+    onMouseDown: PropTypes.func
+};
+
+export default SelectionHandle;

+ 101 - 0
src/components/backpack/backpack.css

@@ -0,0 +1,101 @@
+@import "../../css/units.css";
+@import "../../css/colors.css";
+
+.backpack-container {
+    flex-shrink: 1;
+    position: relative;
+}
+
+.backpack-header {
+    margin-top: 0.5rem;
+    border: 1px solid $ui-black-transparent;
+    background: $ui-white;
+    padding: 0.25rem;
+    text-align: center;
+    font-size: 0.85rem;
+    color: $text-primary;
+    transition: 0.2s;
+    cursor: pointer;
+    user-select: none;
+}
+
+[dir="ltr"] .backpack-header {
+    border-top-right-radius: $space;
+}
+
+[dir="rtl"] .backpack-header {
+    border-top-left-radius: $space;
+}
+
+.backpack-list {
+    position: relative;
+    display: flex;
+    flex-direction: row;
+    align-items: center;
+    border-right: 1px solid $ui-black-transparent;
+    min-height: 5.5rem;
+}
+
+/* Absolute position the inner list to allow scrolling inside flex sized container */
+.backpack-list-inner {
+    position: absolute;
+    top: 0;
+    left: 0;
+    width: 100%;
+    height: 100%;
+    display: flex;
+    flex-direction: row;
+    align-items: center;
+    overflow-x: auto;
+}
+
+.drag-over:after {
+    content: '';
+    position: absolute;
+    top: 0;
+    left: 0;
+    width: 100%;
+    height: 100%;
+    opacity: 0.75;
+    background-color: $drop-highlight;
+    transition: all 0.25s ease;
+}
+
+.status-message {
+    width: 100%;
+    text-align: center;
+    font-size: 0.85rem;
+    color: $text-primary;
+}
+
+.backpack-item {
+    width: 4rem;
+    height: 4.5rem;
+    margin: 0 0.25rem;
+    flex-shrink: 0;
+
+    /* Need to hide overflow because of background setting below */
+    overflow: hidden;
+}
+
+.backpack-item > div {
+    /* Need to set the background to get blend-mode below to work */
+    background: $ui-primary;
+}
+
+.backpack-item img {
+    mix-blend-mode: multiply; /* Make white transparent for thumnbnails */
+}
+
+.more {
+    background: $motion-primary;
+    color: $ui-white;
+    border: none;
+    outline: none;
+    font-weight: bold;
+    border-radius: 0.5rem;
+    font-size: 0.85rem;
+    padding: 0.5rem;
+    margin: 0.5rem;
+    cursor: pointer;
+}

+ 164 - 0
src/components/backpack/backpack.jsx

@@ -0,0 +1,164 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import classNames from 'classnames';
+import {FormattedMessage} from 'react-intl';
+import DragConstants from '../../lib/drag-constants';
+import {ComingSoonTooltip} from '../coming-soon/coming-soon.jsx';
+import SpriteSelectorItem from '../../containers/sprite-selector-item.jsx';
+import styles from './backpack.css';
+
+// TODO make sprite selector item not require onClick
+const noop = () => {};
+
+const dragTypeMap = { // Keys correspond with the backpack-server item types
+    costume: DragConstants.BACKPACK_COSTUME,
+    sound: DragConstants.BACKPACK_SOUND,
+    script: DragConstants.BACKPACK_CODE,
+    sprite: DragConstants.BACKPACK_SPRITE
+};
+
+const Backpack = ({
+    blockDragOver,
+    containerRef,
+    contents,
+    dragOver,
+    error,
+    expanded,
+    loading,
+    showMore,
+    onToggle,
+    onDelete,
+    onMouseEnter,
+    onMouseLeave,
+    onMore
+}) => (
+    <div className={styles.backpackContainer}>
+        <div
+            className={styles.backpackHeader}
+            onClick={onToggle}
+        >
+            {onToggle ? (
+                <FormattedMessage
+                    defaultMessage="Backpack"
+                    description="Button to open the backpack"
+                    id="gui.backpack.header"
+                />
+            ) : (
+                <ComingSoonTooltip
+                    place="top"
+                    tooltipId="backpack-tooltip"
+                >
+                    <FormattedMessage
+                        defaultMessage="Backpack"
+                        description="Button to open the backpack"
+                        id="gui.backpack.header"
+                    />
+                </ComingSoonTooltip>
+            )}
+        </div>
+        {expanded ? (
+            <div
+                className={classNames(styles.backpackList, {
+                    [styles.dragOver]: dragOver || blockDragOver
+                })}
+                ref={containerRef}
+                onMouseEnter={onMouseEnter}
+                onMouseLeave={onMouseLeave}
+            >
+                {error ? (
+                    <div className={styles.statusMessage}>
+                        <FormattedMessage
+                            defaultMessage="Error loading backpack"
+                            description="Error backpack message"
+                            id="gui.backpack.errorBackpack"
+                        />
+                    </div>
+                ) : (
+                    loading ? (
+                        <div className={styles.statusMessage}>
+                            <FormattedMessage
+                                defaultMessage="Loading..."
+                                description="Loading backpack message"
+                                id="gui.backpack.loadingBackpack"
+                            />
+                        </div>
+                    ) : (
+                        contents.length > 0 ? (
+                            <div className={styles.backpackListInner}>
+                                {contents.map(item => (
+                                    <SpriteSelectorItem
+                                        className={styles.backpackItem}
+                                        costumeURL={item.thumbnailUrl}
+                                        details={item.name}
+                                        dragPayload={item}
+                                        dragType={dragTypeMap[item.type]}
+                                        id={item.id}
+                                        key={item.id}
+                                        name={item.type}
+                                        selected={false}
+                                        onClick={noop}
+                                        onDeleteButtonClick={onDelete}
+                                    />
+                                ))}
+                                {showMore && (
+                                    <button
+                                        className={styles.more}
+                                        onClick={onMore}
+                                    >
+                                        <FormattedMessage
+                                            defaultMessage="More"
+                                            description="Load more from backpack"
+                                            id="gui.backpack.more"
+                                        />
+                                    </button>
+                                )}
+                            </div>
+                        ) : (
+                            <div className={styles.statusMessage}>
+                                <FormattedMessage
+                                    defaultMessage="Backpack is empty"
+                                    description="Empty backpack message"
+                                    id="gui.backpack.emptyBackpack"
+                                />
+                            </div>
+                        )
+                    )
+                )}
+            </div>
+        ) : null}
+    </div>
+);
+
+Backpack.propTypes = {
+    blockDragOver: PropTypes.bool,
+    containerRef: PropTypes.func,
+    contents: PropTypes.arrayOf(PropTypes.shape({
+        id: PropTypes.string,
+        thumbnailUrl: PropTypes.string,
+        type: PropTypes.string,
+        name: PropTypes.string
+    })),
+    dragOver: PropTypes.bool,
+    error: PropTypes.bool,
+    expanded: PropTypes.bool,
+    loading: PropTypes.bool,
+    onDelete: PropTypes.func,
+    onMore: PropTypes.func,
+    onMouseEnter: PropTypes.func,
+    onMouseLeave: PropTypes.func,
+    onToggle: PropTypes.func,
+    showMore: PropTypes.bool
+};
+
+Backpack.defaultProps = {
+    blockDragOver: false,
+    contents: [],
+    dragOver: false,
+    expanded: false,
+    loading: false,
+    showMore: false,
+    onMore: null,
+    onToggle: null
+};
+
+export default Backpack;

+ 99 - 0
src/components/blocks/blocks.css

@@ -0,0 +1,99 @@
+@import "../../css/units.css";
+@import "../../css/colors.css";
+@import "../../css/z-index.css";
+
+.blocks {
+    height: 100%;
+}
+
+.drag-over:after {
+    content: '';
+    position: absolute;
+    top: 0;
+    left: 0;
+    width: 100%;
+    height: 100%;
+    opacity: 0.75;
+    background-color: $drop-highlight;
+    transition: all 0.25s ease;
+}
+
+.blocks :global(.injectionDiv){
+    position: absolute;
+    top: 0;
+    right: 0;
+    bottom: 0;
+    left: 0;
+    border: 1px solid $ui-black-transparent;
+    border-top-right-radius: $space;
+    border-bottom-right-radius: $space;
+}
+
+[dir="rtl"] .blocks :global(.injectionDiv) {
+    border-top-right-radius: 0;
+    border-bottom-right-radius: 0;
+    border-top-left-radius: $space;
+    border-bottom-left-radius: $space;
+}
+
+.blocks :global(.blocklyMainBackground) {
+    stroke: none;
+}
+
+.blocks :global(.blocklyToolboxDiv) {
+    border-right: 1px solid $ui-black-transparent;
+    border-bottom: 1px solid $ui-black-transparent;
+    box-sizing: content-box;
+    height: calc(100% - 3.25rem) !important;
+
+    /*
+        For now, the layout cannot support scrollbars in the category menu.
+        The line below works for Edge, the `::-webkit-scrollbar` line
+        below that is for webkit browsers. It isn't possible to do the
+        same for Firefox, so a different solution may be needed for them.
+    */
+    -ms-overflow-style: none;
+}
+
+[dir="rtl"] .blocks :global(.blocklyToolboxDiv) {
+    border-right: none;
+    border-left: 1px solid $ui-black-transparent;
+}
+
+.blocks :global(.blocklyToolboxDiv::-webkit-scrollbar) {
+    display: none;
+}
+
+.blocks :global(.blocklyFlyout) {
+    border-right: 1px solid $ui-black-transparent;
+    box-sizing: content-box;
+}
+
+[dir="rtl"] .blocks :global(.blocklyFlyout) {
+    border-right: none;
+    border-left: 1px solid $ui-black-transparent;
+}
+
+
+.blocks :global(.blocklyBlockDragSurface) {
+    /*
+        Fix an issue where the drag surface was preventing hover events for sharing blocks.
+        This does not prevent user interaction on the blocks themselves.
+    */
+    pointer-events: none;
+    z-index: $z-index-drag-layer; /* make blocks match gui drag layer */
+}
+
+/*
+    Shrink category font to fit "My Blocks" for now.
+    Probably will need different solutions for language support later, so
+    make the change here instead of in scratch-blocks.
+*/
+.blocks :global(.scratchCategoryMenuItemLabel) {
+    font-size: 0.65rem;
+}
+
+.blocks :global(.blocklyMinimalBody) {
+    min-width: auto;
+    min-height: auto;
+}

+ 27 - 0
src/components/blocks/blocks.jsx

@@ -0,0 +1,27 @@
+import PropTypes from 'prop-types';
+import classNames from 'classnames';
+import React from 'react';
+import Box from '../box/box.jsx';
+import styles from './blocks.css';
+
+const BlocksComponent = props => {
+    const {
+        containerRef,
+        dragOver,
+        ...componentProps
+    } = props;
+    return (
+        <Box
+            className={classNames(styles.blocks, {
+                [styles.dragOver]: dragOver
+            })}
+            {...componentProps}
+            componentRef={containerRef}
+        />
+    );
+};
+BlocksComponent.propTypes = {
+    containerRef: PropTypes.func,
+    dragOver: PropTypes.bool
+};
+export default BlocksComponent;

+ 2 - 0
src/components/box/box.css

@@ -0,0 +1,2 @@
+.box {
+}

+ 139 - 0
src/components/box/box.jsx

@@ -0,0 +1,139 @@
+import classNames from 'classnames';
+import PropTypes from 'prop-types';
+import React from 'react';
+import stylePropType from 'react-style-proptype';
+import styles from './box.css';
+
+const getRandomColor = (function () {
+    // In "DEBUG" mode this is used to output a random background color for each
+    // box. The function gives the same "random" set for each seed, allowing re-
+    // renders of the same content to give the same random display.
+    const random = (function (seed) {
+        let mW = seed;
+        let mZ = 987654321;
+        const mask = 0xffffffff;
+        return function () {
+            mZ = ((36969 * (mZ & 65535)) + (mZ >> 16)) & mask;
+            mW = ((18000 * (mW & 65535)) + (mW >> 16)) & mask;
+            let result = ((mZ << 16) + mW) & mask;
+            result /= 4294967296;
+            return result + 1;
+        };
+    }(601));
+    return function () {
+        const r = Math.max(parseInt(random() * 100, 10) % 256, 1);
+        const g = Math.max(parseInt(random() * 100, 10) % 256, 1);
+        const b = Math.max(parseInt(random() * 100, 10) % 256, 1);
+        return `rgb(${r},${g},${b})`;
+    };
+}());
+
+const Box = props => {
+    const {
+        alignContent,
+        alignItems,
+        alignSelf,
+        basis,
+        children,
+        className,
+        componentRef,
+        direction,
+        element,
+        grow,
+        height,
+        justifyContent,
+        width,
+        wrap,
+        shrink,
+        style,
+        ...componentProps
+    } = props;
+    return React.createElement(element, {
+        className: classNames(className, styles.box),
+        ref: componentRef,
+        style: Object.assign(
+            {
+                alignContent: alignContent,
+                alignItems: alignItems,
+                alignSelf: alignSelf,
+                flexBasis: basis,
+                flexDirection: direction,
+                flexGrow: grow,
+                flexShrink: shrink,
+                flexWrap: wrap,
+                justifyContent: justifyContent,
+                width: width,
+                height: height
+            },
+            process.env.DEBUG ? {
+                backgroundColor: getRandomColor(),
+                outline: `1px solid black`
+            } : {},
+            style
+        ),
+        ...componentProps
+    }, children);
+};
+Box.propTypes = {
+    /** Defines how the browser distributes space between and around content items vertically within this box. */
+    alignContent: PropTypes.oneOf([
+        'flex-start', 'flex-end', 'center', 'space-between', 'space-around', 'stretch'
+    ]),
+    /** Defines how the browser distributes space between and around flex items horizontally within this box. */
+    alignItems: PropTypes.oneOf([
+        'flex-start', 'flex-end', 'center', 'baseline', 'stretch'
+    ]),
+    /** Specifies how this box should be aligned inside of its container (requires the container to be flexable). */
+    alignSelf: PropTypes.oneOf([
+        'auto', 'flex-start', 'flex-end', 'center', 'baseline', 'stretch'
+    ]),
+    /** Specifies the initial length of this box */
+    basis: PropTypes.oneOfType([
+        PropTypes.number,
+        PropTypes.oneOf(['auto'])
+    ]),
+    /** Specifies the the HTML nodes which will be child elements of this box. */
+    children: PropTypes.node,
+    /** Specifies the class name that will be set on this box */
+    className: PropTypes.string,
+    /**
+     * A callback function whose first parameter is the underlying dom elements.
+     * This call back will be executed immediately after the component is mounted or unmounted
+     */
+    componentRef: PropTypes.func,
+    /** https://developer.mozilla.org/en-US/docs/Web/CSS/flex-direction */
+    direction: PropTypes.oneOf([
+        'row', 'row-reverse', 'column', 'column-reverse'
+    ]),
+    /** Specifies the type of HTML element of this box. Defaults to div. */
+    element: PropTypes.string,
+    /** Specifies the flex grow factor of a flex item. */
+    grow: PropTypes.number,
+    /** The height in pixels (if specified as a number) or a string if different units are required. */
+    height: PropTypes.oneOfType([
+        PropTypes.number,
+        PropTypes.string
+    ]),
+    /** https://developer.mozilla.org/en-US/docs/Web/CSS/justify-content */
+    justifyContent: PropTypes.oneOf([
+        'flex-start', 'flex-end', 'center', 'space-between', 'space-around'
+    ]),
+    /** Specifies the flex shrink factor of a flex item. */
+    shrink: PropTypes.number,
+    /** An object whose keys are css property names and whose values correspond the the css property. */
+    style: stylePropType,
+    /** The width in pixels (if specified as a number) or a string if different units are required. */
+    width: PropTypes.oneOfType([
+        PropTypes.number,
+        PropTypes.string
+    ]),
+    /** How whitespace should wrap within this block. */
+    wrap: PropTypes.oneOf([
+        'nowrap', 'wrap', 'wrap-reverse'
+    ])
+};
+Box.defaultProps = {
+    element: 'div',
+    style: {}
+};
+export default Box;

+ 82 - 0
src/components/browser-modal/browser-modal.css

@@ -0,0 +1,82 @@
+@import "../../css/colors.css";
+@import "../../css/units.css";
+@import "../../css/typography.css";
+@import "../../css/z-index.css";
+
+.modal-overlay {
+    position: fixed;
+    top: 0;
+    left: 0;
+    right: 0;
+    bottom: 0;
+    z-index: $z-index-modal;
+    background-color: $ui-modal-overlay;
+}
+
+.modal-content {
+    margin: 100px auto;
+    outline: none;
+    border: .25rem solid $ui-white-transparent;
+    padding: 0;
+    border-radius: $space;
+    user-select: none;
+    width: 500px;
+
+    color: $text-primary;
+    overflow: hidden;
+}
+
+.illustration {
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    width: 100%;
+    height: 100px;
+    background-color: $control-primary;
+}
+
+[dir="rtl"] .illustration {
+    transform: scaleX(-1);
+}
+
+.illustration img {
+    height: 80%;
+    width: auto;
+}
+
+.body {
+    background: $ui-white;
+    padding: 1.5rem 2.25rem;
+    text-align: center;
+}
+
+/* Confirmation buttons at the bottom of the modal */
+.button-row {
+    margin: 1.5rem 0;
+    font-weight: bolder;
+    text-align: right;
+    display: flex;
+    justify-content: center;
+}
+
+.button-row button {
+    border: 1px solid $motion-primary;
+    border-radius: 0.25rem;
+    padding: 0.5rem 2rem;
+    background: $motion-primary;
+    color: white;
+    font-weight: bold;
+    font-size: 0.875rem;
+    cursor: pointer;
+}
+
+.faq-link-text {
+    margin: 2rem 0 .5rem 0;
+    font-size: .875rem;
+    color: $text-primary;
+}
+
+.faq-link {
+    color: $motion-primary;
+    text-decoration: none;
+}

+ 113 - 0
src/components/browser-modal/browser-modal.jsx

@@ -0,0 +1,113 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import ReactModal from 'react-modal';
+import Box from '../box/box.jsx';
+import {defineMessages, injectIntl, intlShape, FormattedMessage} from 'react-intl';
+
+import styles from './browser-modal.css';
+import unhappyBrowser from './unsupported-browser.svg';
+
+const messages = defineMessages({
+    label: {
+        id: 'gui.unsupportedBrowser.label',
+        defaultMessage: 'Browser is not supported',
+        description: ''
+    },
+    error: {
+        id: 'gui.unsupportedBrowser.errorLabel',
+        defaultMessage: 'An Error Occurred',
+        description: 'Heading shown when there is an unhandled exception in an unsupported browser'
+    }
+});
+
+const BrowserModal = ({intl, ...props}) => {
+    const label = props.error ? messages.error : messages.label;
+    return (
+        <ReactModal
+            isOpen
+            className={styles.modalContent}
+            contentLabel={intl.formatMessage({...messages.label})}
+            overlayClassName={styles.modalOverlay}
+            onRequestClose={props.onBack}
+        >
+            <div dir={props.isRtl ? 'rtl' : 'ltr'} >
+                <Box className={styles.illustration}>
+                    <img src={unhappyBrowser} />
+                </Box>
+
+                <Box className={styles.body}>
+                    <h2>
+                        <FormattedMessage {...label} />
+                    </h2>
+                    <p>
+                        { /* eslint-disable max-len */ }
+                        {
+                            props.error ? <FormattedMessage
+                                defaultMessage="We are very sorry, but it looks like you are using a browser version that Scratch does not support. We recommend updating to the latest version of a supported browser such as Google Chrome, Mozilla Firefox, Microsoft Edge, or Apple Safari. "
+                                description="Error message when the browser does not meet our minimum requirements"
+                                id="gui.unsupportedBrowser.notRecommended"
+                            /> : <FormattedMessage
+                                defaultMessage="We are very sorry, but Scratch does not support this browser. We recommend updating to the latest version of a supported browser such as Google Chrome, Mozilla Firefox, Microsoft Edge, or Apple Safari."
+                                description="Error message when the browser does not work at all (IE)"
+                                id="gui.unsupportedBrowser.description"
+                            />
+                        }
+                        { /* eslint-enable max-len */ }
+                    </p>
+
+                    <Box className={styles.buttonRow}>
+                        <button
+                            className={styles.backButton}
+                            onClick={props.onBack}
+                        >
+                            <FormattedMessage
+                                defaultMessage="Back"
+                                description="Button to go back in unsupported browser modal"
+                                id="gui.unsupportedBrowser.back"
+                            />
+                        </button>
+
+                    </Box>
+                    <div className={styles.faqLinkText}>
+                        <FormattedMessage
+                            defaultMessage="To learn more, go to the {previewFaqLink}."
+                            description="Invitation to try 3.0 preview"
+                            id="gui.unsupportedBrowser.previewfaq"
+                            values={{
+                                previewFaqLink: (
+                                    <a
+                                        className={styles.faqLink}
+                                        href="//scratch.mit.edu/3faq"
+                                    >
+                                        <FormattedMessage
+                                            defaultMessage="FAQ"
+                                            description="link to Scratch 3.0 FAQ page"
+                                            id="gui.unsupportedBrowser.previewfaqlinktext"
+                                        />
+                                    </a>
+                                )
+                            }}
+                        />
+                    </div>
+                </Box>
+            </div>
+        </ReactModal>
+    );
+};
+
+BrowserModal.propTypes = {
+    error: PropTypes.bool,
+    intl: intlShape.isRequired,
+    isRtl: PropTypes.bool,
+    onBack: PropTypes.func.isRequired
+};
+
+BrowserModal.defaultProps = {
+    error: false
+};
+
+const WrappedBrowserModal = injectIntl(BrowserModal);
+
+WrappedBrowserModal.setAppElement = ReactModal.setAppElement;
+
+export default WrappedBrowserModal;

+ 54 - 0
src/components/browser-modal/unsupported-browser.svg

@@ -0,0 +1,54 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- Generator: Adobe Illustrator 23.0.1, SVG Export Plug-In . SVG Version: 6.00 Build 0)  -->
+<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" width="200px"
+	 height="150px" viewBox="0 0 200 150" style="enable-background:new 0 0 200 150;" xml:space="preserve">
+<style type="text/css">
+	.st0{opacity:0.1;fill:#231F20;stroke:#231F20;stroke-width:12;stroke-miterlimit:10;}
+	.st1{fill:#FFFFFF;stroke:#7F8CA5;stroke-width:2;stroke-miterlimit:10;}
+	.st2{fill:#BFC6D4;stroke:#7F8CA5;stroke-width:2;stroke-miterlimit:10;}
+	.st3{fill:#FFFFFF;}
+	.st4{opacity:0.25;}
+	.st5{fill:#231F20;}
+	.st6{opacity:0.15;}
+	.st7{fill:none;stroke:#231F20;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;}
+	.st8{fill:#7F9BD4;}
+</style>
+<g id="Layer_1">
+</g>
+<g id="Unsupported_Mask">
+	<g>
+		<g>
+			<path class="st0" d="M186,140H14c-2.21,0-4-1.79-4-4V14c0-2.21,1.79-4,4-4h172c2.21,0,4,1.79,4,4v122
+				C190,138.21,188.21,140,186,140z"/>
+			<path class="st1" d="M186,140H14c-2.21,0-4-1.79-4-4V14c0-2.21,1.79-4,4-4h172c2.21,0,4,1.79,4,4v122
+				C190,138.21,188.21,140,186,140z"/>
+			<path class="st2" d="M190,30H10V14c0-2.21,1.79-4,4-4h172c2.21,0,4,1.79,4,4V30z"/>
+			<path class="st3" d="M179.5,24h-128c-2.21,0-4-1.79-4-4v0c0-2.21,1.79-4,4-4h128c2.21,0,4,1.79,4,4v0
+				C183.5,22.21,181.71,24,179.5,24z"/>
+			<g class="st4">
+				<path class="st5" d="M24.09,20.22c-0.08,0.38-0.38,0.65-0.72,0.72l-2.87,0.66v1.77c0,0.44-0.55,0.66-0.87,0.36l-3.36-3.38
+					c-0.21-0.19-0.21-0.51,0-0.7l3.36-3.38c0.32-0.32,0.87-0.09,0.87,0.36v1.8l2.87,0.65C23.88,19.2,24.2,19.71,24.09,20.22z"/>
+			</g>
+			<g class="st4">
+				<path class="st5" d="M30.62,19.78c0.08-0.38,0.38-0.65,0.72-0.72l2.87-0.66v-1.77c0-0.44,0.55-0.66,0.87-0.36l3.36,3.38
+					c0.21,0.19,0.21,0.51,0,0.7l-3.36,3.38c-0.32,0.32-0.87,0.09-0.87-0.36v-1.8l-2.87-0.65C30.83,20.8,30.5,20.29,30.62,19.78z"/>
+			</g>
+			<g class="st6">
+				<line class="st7" x1="69.89" y1="20" x2="51.43" y2="20"/>
+				<line class="st7" x1="113.74" y1="20" x2="98.4" y2="20"/>
+				<line class="st7" x1="93.9" y1="20" x2="74.02" y2="20"/>
+			</g>
+		</g>
+		<g>
+			<circle class="st8" cx="89.61" cy="73.46" r="3.85"/>
+			<circle class="st8" cx="110.39" cy="73.46" r="3.85"/>
+			<g>
+				<path class="st8" d="M83.06,94.84c1.02-3.39,3.54-6.3,6.6-8.19c3.07-1.94,6.72-2.9,10.34-2.91c3.61,0.01,7.27,0.97,10.33,2.91
+					c3.06,1.89,5.58,4.8,6.6,8.19c0.16,0.53-0.14,1.1-0.68,1.26c-0.4,0.12-0.83-0.02-1.08-0.33l-0.02-0.03
+					c-3.85-4.75-9.5-7-15.17-7.01c-5.67,0.01-11.32,2.25-15.16,7.01l-0.02,0.02c-0.35,0.43-0.99,0.5-1.42,0.15
+					C83.06,95.66,82.95,95.23,83.06,94.84z"/>
+			</g>
+		</g>
+	</g>
+</g>
+</svg>

+ 29 - 0
src/components/button/button.css

@@ -0,0 +1,29 @@
+@import "../../css/units.css";
+
+.outlined-button {
+    cursor: pointer;
+    border-radius: $form-radius;
+    font-weight: bold;
+    display: flex;
+    flex-direction: row;
+    align-items: center;
+    padding-left: .75rem;
+    padding-right: .75rem;
+    user-select: none;
+}
+
+.icon {
+    height: 1.5rem;
+}
+
+[dir="ltr"] .icon {
+    margin-right: .5rem;
+}
+
+[dir="rtl"] .icon {
+    margin-left: .5rem;
+}
+
+.content {
+    white-space: nowrap;
+}

+ 54 - 0
src/components/button/button.jsx

@@ -0,0 +1,54 @@
+import classNames from 'classnames';
+import PropTypes from 'prop-types';
+import React from 'react';
+
+import styles from './button.css';
+
+const ButtonComponent = ({
+    className,
+    disabled,
+    iconClassName,
+    iconSrc,
+    onClick,
+    children,
+    ...props
+}) => {
+
+    if (disabled) {
+        onClick = function () {};
+    }
+
+    const icon = iconSrc && (
+        <img
+            className={classNames(iconClassName, styles.icon)}
+            draggable={false}
+            src={iconSrc}
+        />
+    );
+
+    return (
+        <span
+            className={classNames(
+                styles.outlinedButton,
+                className
+            )}
+            role="button"
+            onClick={onClick}
+            {...props}
+        >
+            {icon}
+            <div className={styles.content}>{children}</div>
+        </span>
+    );
+};
+
+ButtonComponent.propTypes = {
+    children: PropTypes.node,
+    className: PropTypes.string,
+    disabled: PropTypes.bool,
+    iconClassName: PropTypes.string,
+    iconSrc: PropTypes.string,
+    onClick: PropTypes.func
+};
+
+export default ButtonComponent;

+ 157 - 0
src/components/camera-modal/camera-modal.css

@@ -0,0 +1,157 @@
+@import "../../css/colors.css";
+@import "../../css/units.css";
+
+$main-button-size: 2.75rem;
+
+.modal-content {
+    width: 552px;
+}
+
+.body {
+    display: flex;
+    flex-direction: column;
+    align-items: center;
+    background: $ui-white;
+    padding: 1.5rem 2.25rem;
+}
+
+.camera-feed-container {
+    display: flex;
+    justify-content: space-around;
+    align-items: center;
+
+    background: $ui-primary;
+    border: 1px solid $ui-black-transparent;
+    border-radius: 4px;
+    padding: 3px;
+
+    width: 480px;
+    height: 360px;
+    position: relative;
+    overflow: hidden;
+}
+
+.canvas {
+    position: absolute;
+    width: 480px;
+    height: 360px;
+}
+
+.loading-text {
+    position: absolute;
+    font-family: "Helvetica Neue", Helvetica, Arial, sans-serif;
+    color: $text-primary-transparent;
+    font-size: 0.95rem;
+    font-weight: 500;
+    text-align: center;
+}
+
+.help-text {
+    margin: 10px auto 0;
+    font-family: "Helvetica Neue", Helvetica, Arial, sans-serif;
+    color: $text-primary-transparent;
+    font-size: 0.95rem;
+    font-weight: 500;
+    text-align: center;
+}
+
+.capture-text {
+    color: $motion-primary;
+}
+
+.disabled-text {
+    color: $text-primary;
+    opacity: 0.25;
+}
+
+.main-button-row {
+    display: flex;
+    flex-direction: column;
+    align-items: center;
+    justify-content: space-around;
+    margin-top: 15px;
+    width: 100%;
+}
+
+/* Action Menu */
+.main-button {
+    display: flex;
+    flex-direction: column;
+    align-items: center;
+    justify-content: center;
+    cursor: pointer;
+    font-family: "Helvetica Neue", Helvetica, Arial, sans-serif;
+    background: $motion-primary;
+    outline: none;
+    border: none;
+    transition: background-color 0.2s;
+
+    border-radius: 100%;
+    width: $main-button-size;
+    height: $main-button-size;
+    box-shadow: 0 0 0 4px $motion-transparent;
+}
+
+.main-button:hover {
+    background: $extensions-primary;
+    box-shadow: 0 0 0 6px $motion-transparent;
+}
+
+.main-button:disabled {
+    background: $text-primary;
+    border-color: $ui-black-transparent;
+    box-shadow: none;
+    opacity: 0.25;
+}
+
+.main-icon {
+    width: calc($main-button-size - 1rem);
+    height: calc($main-button-size - 1rem);
+}
+
+.button-row {
+    font-weight: bolder;
+    text-align: right;
+    display: flex;
+    justify-content: space-between;
+    margin-top: 20px;
+    width: 480px;
+}
+
+.button-row button {
+    padding: 0.75rem 1rem;
+    border-radius: 0.25rem;
+    background: $ui-white;
+    border: 1px solid $ui-black-transparent;
+    font-weight: 600;
+    font-size: 0.85rem;
+    color: $motion-primary;
+    cursor: pointer;
+}
+
+.button-row button.ok-button {
+    background: $motion-primary;
+    border: $motion-primary;
+    color: $ui-white;
+}
+
+[dir="rtl"] .retake-button img {
+    transform: scaleX(-1);
+}
+
+@keyframes flash {
+    0% { opacity: 1; }
+    100% { opacity: 0; }
+}
+
+.flash-overlay {
+    position: absolute;
+    top: 0;
+    left: 0;
+    width: 100%;
+    height: 100%;
+    background: $ui-white;
+    animation-name: flash;
+    animation-duration: 0.5s;
+    animation-fill-mode: forwards; /* Leave at 0 opacity after animation */
+}

+ 141 - 0
src/components/camera-modal/camera-modal.jsx

@@ -0,0 +1,141 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import {defineMessages, injectIntl, intlShape} from 'react-intl';
+import Box from '../box/box.jsx';
+import Modal from '../../containers/modal.jsx';
+import styles from './camera-modal.css';
+import backIcon from './icon--back.svg';
+import cameraIcon from '../action-menu/icon--camera.svg';
+
+const messages = defineMessages({
+    cameraModalTitle: {
+        defaultMessage: 'Take a Photo',
+        description: 'Title for prompt to take a picture (to add as a new costume).',
+        id: 'gui.cameraModal.cameraModalTitle'
+    },
+    loadingCameraMessage: {
+        defaultMessage: 'Loading Camera...',
+        description: 'Notification to the user that the camera is loading',
+        id: 'gui.cameraModal.loadingCameraMessage'
+    },
+    permissionRequest: {
+        defaultMessage: 'We need your permission to use your camera',
+        description: 'Notification to the user that the app needs camera access',
+        id: 'gui.cameraModal.permissionRequest'
+    },
+    retakePhoto: {
+        defaultMessage: 'Retake Photo',
+        description: 'A button that allows the user to take the picture again, replacing the old one',
+        id: 'gui.cameraModal.retakePhoto'
+    },
+    save: {
+        defaultMessage: 'Save',
+        description: 'A button that allows the user to save the photo they took as a costume',
+        id: 'gui.cameraModal.save'
+    },
+    takePhotoButton: {
+        defaultMessage: 'Take Photo',
+        description: 'A button to take a photo',
+        id: 'gui.cameraModal.takePhoto'
+    },
+    loadingCaption: {
+        defaultMessage: 'Loading...',
+        description: 'A caption for a disabled button while the video from the camera is still loading',
+        id: 'gui.cameraModal.loadingCaption'
+    },
+    enableCameraCaption: {
+        defaultMessage: 'Enable Camera',
+        description: 'A caption for a disabled button prompting the user to enable camera access',
+        id: 'gui.cameraModal.enableCameraCaption'
+    }
+});
+
+const CameraModal = ({intl, ...props}) => (
+    <Modal
+        className={styles.modalContent}
+        contentLabel={intl.formatMessage(messages.cameraModalTitle)}
+        onRequestClose={props.onCancel}
+    >
+        <Box className={styles.body}>
+            <Box className={styles.cameraFeedContainer}>
+                <div className={styles.loadingText}>
+                    {props.access ? intl.formatMessage(messages.loadingCameraMessage) :
+                        `↖️ \u00A0${intl.formatMessage(messages.permissionRequest)}`}
+                </div>
+                <canvas
+                    className={styles.canvas}
+                    // height and (below) width of the actual image
+                    // double stage dimensions to avoid the need for
+                    // resizing the captured image when importing costume
+                    // to accommodate double resolution bitmaps
+                    height="720"
+                    ref={props.canvasRef}
+                    width="960"
+                />
+                {props.capture ? (
+                    <div className={styles.flashOverlay} />
+                ) : null}
+            </Box>
+            {props.capture ?
+                <Box className={styles.buttonRow}>
+                    <button
+                        className={styles.retakeButton}
+                        key="retake-button"
+                        onClick={props.onBack}
+                    >
+                        <img
+                            draggable={false}
+                            src={backIcon}
+                        /> {intl.formatMessage(messages.retakePhoto)}
+                    </button>
+                    <button
+                        className={styles.okButton}
+                        onClick={props.onSubmit}
+                    > {intl.formatMessage(messages.save)}
+                    </button>
+                </Box> :
+                <Box className={styles.mainButtonRow}>
+                    <button
+                        className={styles.mainButton}
+                        disabled={!props.loaded}
+                        key="capture-button"
+                        onClick={props.onCapture}
+                    >
+                        <img
+                            className={styles.mainIcon}
+                            draggable={false}
+                            src={cameraIcon}
+                        />
+                    </button>
+                    <div className={styles.helpText}>
+                        {props.access ?
+                            <span className={props.loaded ? styles.captureText : styles.disabledText}>
+                                {props.loaded ?
+                                    intl.formatMessage(messages.takePhotoButton) :
+                                    intl.formatMessage(messages.loadingCaption)}
+                            </span> :
+                            <span className={styles.disabledText}>
+                                {intl.formatMessage(messages.enableCameraCaption)}
+                            </span>
+                        }
+                    </div>
+
+                </Box>
+            }
+        </Box>
+    </Modal>
+);
+
+CameraModal.propTypes = {
+    access: PropTypes.bool,
+    canvasRef: PropTypes.func.isRequired,
+    capture: PropTypes.string,
+    intl: intlShape.isRequired,
+    loaded: PropTypes.bool,
+    onBack: PropTypes.func.isRequired,
+    onCancel: PropTypes.func.isRequired,
+    onCapture: PropTypes.func.isRequired,
+    onSubmit: PropTypes.func.isRequired
+};
+
+export default injectIntl(CameraModal);

Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 16 - 0
src/components/camera-modal/icon--back.svg


+ 306 - 0
src/components/cards/card.css

@@ -0,0 +1,306 @@
+@import "../../css/units.css";
+@import "../../css/colors.css";
+@import "../../css/z-index.css";
+
+.card-container-overlay {
+    position: fixed;
+    pointer-events: none;
+    z-index: $z-index-card;
+}
+
+.card-container {
+    position:absolute;
+    pointer-events: auto;
+    z-index: $z-index-card;
+    margin: 0.5rem 2rem;
+    min-width: 468px;
+}
+
+.left-card, .right-card {
+    height: 90%;
+    position: absolute;
+    top: 5%;
+    background: $ui-white;
+    border: 1px solid $ui-tertiary;
+    width: .75rem;
+    z-index: 10;
+    opacity: 0.9;
+    overflow: hidden;
+}
+
+.left-card {
+    left: -.75rem;
+    border-right: 0;
+    border-top-left-radius: 0.75rem;
+    border-bottom-left-radius: 0.75rem;
+}
+
+.right-card {
+    right: -.75rem;
+    border-left: 0;
+    border-top-right-radius: 0.75rem;
+    border-bottom-right-radius: 0.75rem;
+}
+
+.left-card::after, .right-card::after {
+    content: "";
+    position: absolute;
+    top: 0;
+    left: 0;
+    height: 2.5rem;
+    width: 100%;
+    background: $extensions-primary;
+}
+
+.left-button, .right-button {
+    position: absolute;
+    top: 50%;
+    margin-top: -15px;
+    z-index: 20;
+    user-select: none;
+    cursor: pointer;
+    background: $extensions-primary;
+    box-shadow: 0 0 0 4px $extensions-transparent;
+    height: 44px;
+    width: 44px;
+    border-radius: 100%;
+    display: flex;
+    justify-content: center;
+    align-items: center;
+    transition: all 0.25s ease;
+}
+
+.left-button:hover, .right-button:hover {
+    box-shadow: 0 0 0 6px $extensions-transparent;
+    transform: scale(1.125);
+}
+
+.left-button img, .right-button img{
+    width: 1.75rem;
+}
+
+.left-button {
+    left: -27px;
+}
+
+.right-button {
+    right: -27px;
+}
+
+.card {
+    border: 1px solid $ui-tertiary;
+    border-radius: 0.75rem;
+    display: flex;
+    flex-direction: column;
+    cursor: move;
+    z-index: 20;
+    overflow: hidden;
+    box-shadow: 0px 5px 25px 5px $ui-black-transparent;
+    align-items: center;
+    font-family: "Helvetica Neue", Helvetica, Arial, sans-serif;
+}
+
+.header-buttons {
+    width: 100%;
+    display: flex;
+    flex-direction: row;
+    justify-content: space-between;
+    align-items: center;
+    background: $extensions-primary;
+    border-bottom: 1px solid $extensions-tertiary;
+    font-size: 0.625rem;
+    font-weight: bold;
+}
+
+.header-buttons-hidden {
+    border-bottom: 0px;
+}
+
+.header-buttons-right {
+    display: flex;
+    flex-direction: row;
+}
+
+.header-buttons img {
+    margin-bottom: 2px;
+}
+
+.shrink-expand-button {
+    cursor: pointer;
+    color: white;
+    display: flex;
+    flex-direction: column;
+    justify-content: center;
+    align-items: center;
+    padding: 0.75rem;
+}
+
+.shrink-expand-button:hover, .all-button:hover {
+    background-color: $ui-black-transparent;
+}
+
+.remove-button, .all-button {
+    cursor: pointer;
+    color: white;
+    display: flex;
+    flex-direction: column;
+    justify-content: center;
+    align-items: center;
+    padding: 0.75rem;
+}
+
+.remove-button:hover, .all-button:hover {
+    background-color: $ui-black-transparent;
+}
+
+.step-title {
+    font-size: 0.9rem;
+    margin: 0.9rem;
+    text-align: center;
+    font-weight: bold;
+    color: $text-primary;
+}
+
+.step-body {
+    width: 100%;
+    background: $ui-white;
+    display: flex;
+    flex-direction: column;
+    justify-content: center;
+    align-items: center;
+    position: relative;
+    text-align: center;
+
+    /* Min height prevents layout changing when images change */
+    min-height: 256px;
+}
+
+.step-video {
+    height: 256px;
+}
+
+.step-image {
+    max-width: 450px;
+    max-height: 200px;
+    object-fit: contain;
+    background: #F9F9F9;
+    border: 1px solid #ddd;
+    border-radius: 0.5rem;
+    overflow: hidden;
+    margin: 0 0.5rem 0.5rem;
+}
+
+.decks {
+    display: flex;
+    flex-direction: row;
+    justify-content: space-around;
+    padding: 0 1rem 0.5rem;
+}
+
+.deck {
+    display: flex;
+    flex-direction: column;
+    margin: 0 8px 8px;
+    cursor: pointer;
+    border: 1px solid $ui-black-transparent;
+    border-radius: 0.25rem;
+    overflow: hidden;
+}
+
+.deck-image {
+    width: 200px;
+    height: 100px;
+    object-fit: cover;
+}
+
+.deck-name {
+    color: $motion-primary;
+    font-weight: bold;
+    font-size: 0.85rem;
+    margin: .625rem 0px;
+    text-align: center;
+    font-weight: bold;
+    text-align: center;
+    max-width: 200px;
+}
+
+.help-icon {
+    height: 1.25rem;
+}
+
+.close-icon {
+    height: 1.25rem;
+    margin: .125rem 0; /* To offset the .25rem difference in icon size */
+}
+
+[dir="ltr"] .help-icon {
+    margin-right: 0.25rem;
+}
+
+[dir="rtl"] .help-icon {
+    margin-left: 0.25rem;
+}
+
+[dir="ltr"] .close-icon {
+    margin-left: 0.25rem;
+}
+
+[dir="rtl"] .close-icon {
+    margin-right: 0.25rem;
+}
+
+.see-all {
+    display: flex;
+    flex-direction: row;
+    justify-content: center;
+    width: 100%;
+    padding: 0.5rem;
+}
+
+.see-all-button {
+    cursor: pointer;
+    padding: 0.5rem 1rem;
+    background-color: $motion-primary;
+    color: white;
+    font-weight: bold;
+    border-radius: 0.25rem;
+    display: flex;
+    align-items: center;
+    color: $ui-white;
+    font-size: .75rem;
+    font-weight: bold;
+    line-height: 1rem;
+    text-align: center;
+}
+
+[dir="ltr"] .see-all-button img {
+    margin-left: 0.5rem;
+}
+
+[dir="rtl"] .see-all-button img {
+    margin-right: 0.5rem;
+}
+
+.steps-list {
+    display: flex;
+    flex-direction: row;
+    justify-content: center;
+    align-items: center;
+}
+
+.active-step-pip, .inactiveStepPip {
+    width: 0.5rem;
+    height: 0.5rem;
+    margin: 0 0.25rem;
+    border-radius: 100%;
+    background: $ui-white-transparent;
+}
+
+.active-step-pip {
+    background: $ui-white;
+    box-shadow: 0px 0px 0px 2px $ui-black-transparent;
+}
+
+.hidden {
+    display: none;
+}

+ 440 - 0
src/components/cards/cards.jsx

@@ -0,0 +1,440 @@
+import PropTypes from 'prop-types';
+import React, {Fragment} from 'react';
+import classNames from 'classnames';
+import {FormattedMessage} from 'react-intl';
+import Draggable from 'react-draggable';
+
+import styles from './card.css';
+
+import shrinkIcon from './icon--shrink.svg';
+import expandIcon from './icon--expand.svg';
+
+import rightArrow from './icon--next.svg';
+import leftArrow from './icon--prev.svg';
+
+import helpIcon from '../../lib/assets/icon--tutorials.svg';
+import closeIcon from './icon--close.svg';
+
+import {translateVideo} from '../../lib/libraries/decks/translate-video.js';
+import {translateImage} from '../../lib/libraries/decks/translate-image.js';
+
+const CardHeader = ({onCloseCards, onShrinkExpandCards, onShowAll, totalSteps, step, expanded}) => (
+    <div className={expanded ? styles.headerButtons : classNames(styles.headerButtons, styles.headerButtonsHidden)}>
+        <div
+            className={styles.allButton}
+            onClick={onShowAll}
+        >
+            <img
+                className={styles.helpIcon}
+                src={helpIcon}
+            />
+            <FormattedMessage
+                defaultMessage="Tutorials"
+                description="Title for button to return to tutorials library"
+                id="gui.cards.all-tutorials"
+            />
+        </div>
+        {totalSteps > 1 ? (
+            <div className={styles.stepsList}>
+                {Array(totalSteps).fill(0)
+                    .map((_, i) => (
+                        <div
+                            className={i === step ? styles.activeStepPip : styles.inactiveStepPip}
+                            key={`pip-step-${i}`}
+                        />
+                    ))}
+            </div>
+        ) : null}
+        <div className={styles.headerButtonsRight}>
+            <div
+                className={styles.shrinkExpandButton}
+                onClick={onShrinkExpandCards}
+            >
+                <img
+                    draggable={false}
+                    src={expanded ? shrinkIcon : expandIcon}
+                />
+                {expanded ?
+                    <FormattedMessage
+                        defaultMessage="Shrink"
+                        description="Title for button to shrink how-to card"
+                        id="gui.cards.shrink"
+                    /> :
+                    <FormattedMessage
+                        defaultMessage="Expand"
+                        description="Title for button to expand how-to card"
+                        id="gui.cards.expand"
+                    />
+                }
+            </div>
+            <div
+                className={styles.removeButton}
+                onClick={onCloseCards}
+            >
+                <img
+                    className={styles.closeIcon}
+                    src={closeIcon}
+                />
+                <FormattedMessage
+                    defaultMessage="Close"
+                    description="Title for button to close how-to card"
+                    id="gui.cards.close"
+                />
+            </div>
+        </div>
+    </div>
+);
+
+class VideoStep extends React.Component {
+
+    componentDidMount () {
+        const script = document.createElement('script');
+        script.src = `https://fast.wistia.com/embed/medias/${this.props.video}.jsonp`;
+        script.async = true;
+        script.setAttribute('id', 'wistia-video-content');
+        document.body.appendChild(script);
+
+        const script2 = document.createElement('script');
+        script2.src = 'https://fast.wistia.com/assets/external/E-v1.js';
+        script2.async = true;
+        script2.setAttribute('id', 'wistia-video-api');
+        document.body.appendChild(script2);
+    }
+
+    // We use the Wistia API here to update or pause the video dynamically:
+    // https://wistia.com/support/developers/player-api
+    componentDidUpdate (prevProps) {
+        // Ensure the wistia API is loaded and available
+        if (!(window.Wistia && window.Wistia.api)) return;
+
+        // Get a handle on the currently loaded video
+        const video = window.Wistia.api(prevProps.video);
+
+        // Reset the video source if a new video has been chosen from the library
+        if (prevProps.video !== this.props.video) {
+            video.replaceWith(this.props.video);
+        }
+
+        // Pause the video if the modal is being shrunken
+        if (!this.props.expanded) {
+            video.pause();
+        }
+    }
+
+    componentWillUnmount () {
+        const script = document.getElementById('wistia-video-content');
+        script.parentNode.removeChild(script);
+
+        const script2 = document.getElementById('wistia-video-api');
+        script2.parentNode.removeChild(script2);
+    }
+
+    render () {
+        return (
+            <div className={styles.stepVideo}>
+                <div
+                    className={`wistia_embed wistia_async_${this.props.video}`}
+                    id="video-div"
+                    style={{height: `257px`, width: `466px`}}
+                >
+                    &nbsp;
+                </div>
+            </div>
+        );
+    }
+}
+
+VideoStep.propTypes = {
+    expanded: PropTypes.bool.isRequired,
+    video: PropTypes.string.isRequired
+};
+
+const ImageStep = ({title, image}) => (
+    <Fragment>
+        <div className={styles.stepTitle}>
+            {title}
+        </div>
+        <div className={styles.stepImageContainer}>
+            <img
+                className={styles.stepImage}
+                draggable={false}
+                key={image} /* Use src as key to prevent hanging around on slow connections */
+                src={image}
+            />
+        </div>
+    </Fragment>
+);
+
+ImageStep.propTypes = {
+    image: PropTypes.string.isRequired,
+    title: PropTypes.node.isRequired
+};
+
+const NextPrevButtons = ({isRtl, onNextStep, onPrevStep, expanded}) => (
+    <Fragment>
+        {onNextStep ? (
+            <div>
+                <div className={expanded ? (isRtl ? styles.leftCard : styles.rightCard) : styles.hidden} />
+                <div
+                    className={expanded ? (isRtl ? styles.leftButton : styles.rightButton) : styles.hidden}
+                    onClick={onNextStep}
+                >
+                    <img
+                        draggable={false}
+                        src={isRtl ? leftArrow : rightArrow}
+                    />
+                </div>
+            </div>
+        ) : null}
+        {onPrevStep ? (
+            <div>
+                <div className={expanded ? (isRtl ? styles.rightCard : styles.leftCard) : styles.hidden} />
+                <div
+                    className={expanded ? (isRtl ? styles.rightButton : styles.leftButton) : styles.hidden}
+                    onClick={onPrevStep}
+                >
+                    <img
+                        draggable={false}
+                        src={isRtl ? rightArrow : leftArrow}
+                    />
+                </div>
+            </div>
+        ) : null}
+    </Fragment>
+);
+
+NextPrevButtons.propTypes = {
+    expanded: PropTypes.bool.isRequired,
+    isRtl: PropTypes.bool,
+    onNextStep: PropTypes.func,
+    onPrevStep: PropTypes.func
+};
+CardHeader.propTypes = {
+    expanded: PropTypes.bool.isRequired,
+    onCloseCards: PropTypes.func.isRequired,
+    onShowAll: PropTypes.func.isRequired,
+    onShrinkExpandCards: PropTypes.func.isRequired,
+    step: PropTypes.number,
+    totalSteps: PropTypes.number
+};
+
+const PreviewsStep = ({deckIds, content, onActivateDeckFactory, onShowAll}) => (
+    <Fragment>
+        <div className={styles.stepTitle}>
+            <FormattedMessage
+                defaultMessage="More things to try!"
+                description="Title card with more things to try"
+                id="gui.cards.more-things-to-try"
+            />
+        </div>
+        <div className={styles.decks}>
+            {deckIds.slice(0, 2).map(id => (
+                <div
+                    className={styles.deck}
+                    key={`deck-preview-${id}`}
+                    onClick={onActivateDeckFactory(id)}
+                >
+                    <img
+                        className={styles.deckImage}
+                        draggable={false}
+                        src={content[id].img}
+                    />
+                    <div className={styles.deckName}>{content[id].name}</div>
+                </div>
+            ))}
+        </div>
+        <div className={styles.seeAll}>
+            <div
+                className={styles.seeAllButton}
+                onClick={onShowAll}
+            >
+                <FormattedMessage
+                    defaultMessage="See more"
+                    description="Title for button to see more in how-to library"
+                    id="gui.cards.see-more"
+                />
+            </div>
+        </div>
+    </Fragment>
+);
+
+PreviewsStep.propTypes = {
+    content: PropTypes.shape({
+        id: PropTypes.shape({
+            name: PropTypes.node.isRequired,
+            img: PropTypes.string.isRequired,
+            steps: PropTypes.arrayOf(PropTypes.shape({
+                title: PropTypes.node,
+                image: PropTypes.string,
+                video: PropTypes.string,
+                deckIds: PropTypes.arrayOf(PropTypes.string)
+            }))
+        })
+    }).isRequired,
+    deckIds: PropTypes.arrayOf(PropTypes.string).isRequired,
+    onActivateDeckFactory: PropTypes.func.isRequired,
+    onShowAll: PropTypes.func.isRequired
+};
+
+const Cards = props => {
+    const {
+        activeDeckId,
+        content,
+        dragging,
+        isRtl,
+        locale,
+        onActivateDeckFactory,
+        onCloseCards,
+        onShrinkExpandCards,
+        onDrag,
+        onStartDrag,
+        onEndDrag,
+        onShowAll,
+        onNextStep,
+        onPrevStep,
+        showVideos,
+        step,
+        expanded,
+        ...posProps
+    } = props;
+    let {x, y} = posProps;
+
+    if (activeDeckId === null) return;
+
+    // Tutorial cards need to calculate their own dragging bounds
+    // to allow for dragging the cards off the left, right and bottom
+    // edges of the workspace.
+    const cardHorizontalDragOffset = 400; // ~80% of card width
+    const cardVerticalDragOffset = expanded ? 257 : 0; // ~80% of card height, if expanded
+    const menuBarHeight = 48; // TODO: get pre-calculated from elsewhere?
+    const wideCardWidth = 500;
+
+    if (x === 0 && y === 0) {
+        // initialize positions
+        x = isRtl ? (-190 - wideCardWidth - cardHorizontalDragOffset) : 292;
+        x += cardHorizontalDragOffset;
+        // The tallest cards are about 320px high, and the default position is pinned
+        // to near the bottom of the blocks palette to allow room to work above.
+        const tallCardHeight = 320;
+        const bottomMargin = 60; // To avoid overlapping the backpack region
+        y = window.innerHeight - tallCardHeight - bottomMargin - menuBarHeight;
+    }
+
+    const steps = content[activeDeckId].steps;
+
+    return (
+        // Custom overlay to act as the bounding parent for the draggable, using values from above
+        <div
+            className={styles.cardContainerOverlay}
+            style={{
+                width: `${window.innerWidth + (2 * cardHorizontalDragOffset)}px`,
+                height: `${window.innerHeight - menuBarHeight + cardVerticalDragOffset}px`,
+                top: `${menuBarHeight}px`,
+                left: `${-cardHorizontalDragOffset}px`
+            }}
+        >
+            <Draggable
+                bounds="parent"
+                cancel="#video-div" // disable dragging on video div
+                position={{x: x, y: y}}
+                onDrag={onDrag}
+                onStart={onStartDrag}
+                onStop={onEndDrag}
+            >
+                <div className={styles.cardContainer}>
+                    <div className={styles.card}>
+                        <CardHeader
+                            expanded={expanded}
+                            step={step}
+                            totalSteps={steps.length}
+                            onCloseCards={onCloseCards}
+                            onShowAll={onShowAll}
+                            onShrinkExpandCards={onShrinkExpandCards}
+                        />
+                        <div className={expanded ? styles.stepBody : styles.hidden}>
+                            {steps[step].deckIds ? (
+                                <PreviewsStep
+                                    content={content}
+                                    deckIds={steps[step].deckIds}
+                                    onActivateDeckFactory={onActivateDeckFactory}
+                                    onShowAll={onShowAll}
+                                />
+                            ) : (
+                                steps[step].video ? (
+                                    showVideos ? (
+                                        <VideoStep
+                                            dragging={dragging}
+                                            expanded={expanded}
+                                            video={translateVideo(steps[step].video, locale)}
+                                        />
+                                    ) : ( // Else show the deck image and title
+                                        <ImageStep
+                                            image={content[activeDeckId].img}
+                                            title={content[activeDeckId].name}
+                                        />
+                                    )
+                                ) : (
+                                    <ImageStep
+                                        image={translateImage(steps[step].image, locale)}
+                                        title={steps[step].title}
+                                    />
+                                )
+                            )}
+                            {steps[step].trackingPixel && steps[step].trackingPixel}
+                        </div>
+                        <NextPrevButtons
+                            expanded={expanded}
+                            isRtl={isRtl}
+                            onNextStep={step < steps.length - 1 ? onNextStep : null}
+                            onPrevStep={step > 0 ? onPrevStep : null}
+                        />
+                    </div>
+                </div>
+            </Draggable>
+        </div>
+    );
+};
+
+Cards.propTypes = {
+    activeDeckId: PropTypes.string.isRequired,
+    content: PropTypes.shape({
+        id: PropTypes.shape({
+            name: PropTypes.node.isRequired,
+            img: PropTypes.string.isRequired,
+            steps: PropTypes.arrayOf(PropTypes.shape({
+                title: PropTypes.node,
+                image: PropTypes.string,
+                video: PropTypes.string,
+                deckIds: PropTypes.arrayOf(PropTypes.string)
+            }))
+        })
+    }),
+    dragging: PropTypes.bool.isRequired,
+    expanded: PropTypes.bool.isRequired,
+    isRtl: PropTypes.bool.isRequired,
+    locale: PropTypes.string.isRequired,
+    onActivateDeckFactory: PropTypes.func.isRequired,
+    onCloseCards: PropTypes.func.isRequired,
+    onDrag: PropTypes.func,
+    onEndDrag: PropTypes.func,
+    onNextStep: PropTypes.func.isRequired,
+    onPrevStep: PropTypes.func.isRequired,
+    onShowAll: PropTypes.func,
+    onShrinkExpandCards: PropTypes.func.isRequired,
+    onStartDrag: PropTypes.func,
+    showVideos: PropTypes.bool,
+    step: PropTypes.number.isRequired,
+    x: PropTypes.number,
+    y: PropTypes.number
+};
+
+Cards.defaultProps = {
+    showVideos: true
+};
+
+export {
+    Cards as default,
+    // Others exported for testability
+    ImageStep,
+    VideoStep
+};

Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 18 - 0
src/components/cards/icon--close.svg


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 18 - 0
src/components/cards/icon--expand.svg


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 10 - 0
src/components/cards/icon--next.svg


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 10 - 0
src/components/cards/icon--prev.svg


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 18 - 0
src/components/cards/icon--shrink.svg


+ 81 - 0
src/components/close-button/close-button.css

@@ -0,0 +1,81 @@
+@import "../../css/colors.css";
+@import "../../css/units.css";
+
+.close-button {
+    display: flex;
+    align-items: center;
+    justify-content: center;
+
+    overflow: hidden;  /* Mask the icon animation */
+    background-color: $ui-black-transparent;
+    border-radius: 50%;
+    font-family: "Helvetica Neue", Helvetica, Arial, sans-serif;
+    user-select: none;
+    cursor: pointer;
+    transition: all 0.15s ease-out;
+}
+
+.close-button.large:hover {
+    transform: scale(1.1, 1.1);
+    box-shadow: 0 0 0 4px $ui-black-transparent;
+}
+
+.close-button.large.orange:hover {
+    transform: scale(1.1, 1.1);
+    box-shadow: 0px 0px 0px 4px hsla(29, 100%, 54%, 0.2);
+}
+
+.small {
+    width: 0.825rem;
+    height: 0.825rem;
+    background-color: $motion-primary;
+    color: $ui-white;
+}
+
+.large {
+    width: 1.75rem;
+    height: 1.75rem;
+    box-shadow: 0 0 0 2px $ui-black-transparent;
+}
+
+ .large.orange {
+    background-color: hsla(29, 100%, 54%, 0.2);
+    box-shadow: 0px 0px 0px 2px hsla(29, 100%, 54%, 0.2);
+}
+
+.close-icon {
+    position: relative;
+    margin: 0.25rem;
+    user-select: none;
+    transform-origin: 50%;
+    transform: rotate(45deg);
+}
+
+.close-icon.orange {
+    transform: rotate(45deg);
+    transform: scale(1.4);
+}
+
+.small .close-icon {
+    width: 50%;
+}
+
+.large .close-icon {
+    width: 0.75rem;
+    height: 0.75rem;
+}
+
+.back-icon {
+  position: relative;
+  margin: 0.25rem;
+  user-select: none;
+}
+
+.small .back-icon {
+    width: 50%;
+}
+
+.large .back-icon {
+    width: 2rem;
+    height: 2rem;
+}

+ 76 - 0
src/components/close-button/close-button.jsx

@@ -0,0 +1,76 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import classNames from 'classnames';
+
+import styles from './close-button.css';
+import closeIcon from './icon--close.svg';
+import closeIconOrange from './icon--close-orange.svg';
+import backIcon from '../../lib/assets/icon--back.svg';
+
+let closeIcons = {};
+
+const CloseButton = props => (
+    <div
+        aria-label="Close"
+        className={classNames(
+            styles.closeButton,
+            props.className,
+            {
+                [styles.small]: props.size === CloseButton.SIZE_SMALL,
+                [styles.large]: props.size === CloseButton.SIZE_LARGE,
+                [styles.orange]: props.color === CloseButton.COLOR_ORANGE
+            }
+        )}
+        role="button"
+        tabIndex="0"
+        onClick={props.onClick}
+    >
+        {props.buttonType === 'back' ?
+            <img
+                className={styles.backIcon}
+                src={backIcon}
+            /> :
+            <img
+                className={classNames(
+                    styles.closeIcon,
+                    {
+                        [styles[props.color]]: (props.color !== CloseButton.COLOR_NEUTRAL)
+                    }
+                )}
+                src={(props.color && closeIcons[props.color]) ?
+                    closeIcons[props.color] :
+                    closeIcon
+                }
+            />
+        }
+    </div>
+);
+
+CloseButton.SIZE_SMALL = 'small';
+CloseButton.SIZE_LARGE = 'large';
+
+CloseButton.COLOR_NEUTRAL = 'neutral';
+CloseButton.COLOR_GREEN = 'green';
+CloseButton.COLOR_ORANGE = 'orange';
+closeIcons = {
+    [CloseButton.COLOR_NEUTRAL]: closeIcon,
+    [CloseButton.COLOR_GREEN]: closeIcon, // TODO: temporary, need green icon
+    [CloseButton.COLOR_ORANGE]: closeIconOrange
+};
+
+
+CloseButton.propTypes = {
+    buttonType: PropTypes.oneOf(['back', 'close']),
+    className: PropTypes.string,
+    color: PropTypes.string,
+    onClick: PropTypes.func.isRequired,
+    size: PropTypes.oneOf([CloseButton.SIZE_SMALL, CloseButton.SIZE_LARGE])
+};
+
+CloseButton.defaultProps = {
+    color: CloseButton.COLOR_NEUTRAL,
+    size: CloseButton.SIZE_LARGE,
+    buttonType: 'close'
+};
+
+export default CloseButton;

Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 20 - 0
src/components/close-button/icon--close-orange.svg


+ 1 - 0
src/components/close-button/icon--close.svg

@@ -0,0 +1 @@
+<svg id="Layer_1" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 7.48 7.48"><defs><style>.cls-1{fill:none;stroke:#fff;stroke-linecap:round;stroke-linejoin:round;stroke-width:2px;}</style></defs><title>icon--add</title><line class="cls-1" x1="3.74" y1="6.48" x2="3.74" y2="1"/><line class="cls-1" x1="1" y1="3.74" x2="6.48" y2="3.74"/></svg>

binární
src/components/coming-soon/aww-cat.png


+ 77 - 0
src/components/coming-soon/coming-soon.css

@@ -0,0 +1,77 @@
+/*
+ * NOTE: the copious use of `important` is needed to overwrite
+ * the default tooltip styling, and is required by the 3rd party
+ * library being used, `react-tooltip`
+ */
+
+@import "../../css/colors.css";
+@import "../../css/units.css";
+@import "../../css/z-index.css";
+
+.coming-soon {
+    background-color: $data-primary !important;
+    border: 1px solid $ui-black-transparent !important;
+    border-radius: $form-radius !important;
+    box-shadow: 0 0 .5rem $ui-black-transparent !important;
+    padding: .75rem 1rem !important;
+    font-family: "Helvetica Neue", Helvetica, Arial, sans-serif !important;
+    font-size: 1rem !important;
+    line-height: 1.25rem !important;
+    z-index: $z-index-tooltip !important;
+}
+
+.coming-soon:after {
+    content: "";
+    border-top: 1px solid $ui-black-transparent !important;
+    border-left: 0 !important;
+    border-bottom: 0 !important;
+    border-right: 1px solid $ui-black-transparent !important;
+    border-radius: $form-radius;
+    background-color: $data-primary !important;
+    height: 1rem !important;
+    width: 1rem !important;
+}
+
+.show,
+.show:before,
+.show:after {
+    opacity: 1 !important;
+}
+
+.left:after {
+    margin-top: -.5rem !important;
+    right: -.5rem !important;
+    transform: rotate(45deg) !important;
+}
+
+.right:after {
+    margin-top: -.5rem !important;
+    left: -.5rem !important;
+    transform: rotate(-135deg) !important;
+}
+
+.top:after {
+    margin-right: -.5rem !important;
+    bottom: -.5rem !important;
+    transform: rotate(135deg) !important;
+}
+
+.bottom:after {
+    margin-left: -.5rem !important;
+    top: -.5rem !important;
+    transform: rotate(-45deg) !important;
+}
+
+.coming-soon-image {
+    width: 1.25rem;
+    height: 1.25rem;
+    vertical-align: middle;
+}
+
+[dir="ltr"] .coming-soon-image {
+    margin-left: .125rem;
+}
+
+[dir="rtl"] .coming-soon-image {
+    margin-right: .125rem;
+}

+ 143 - 0
src/components/coming-soon/coming-soon.jsx

@@ -0,0 +1,143 @@
+import bindAll from 'lodash.bindall';
+import classNames from 'classnames';
+import {defineMessages, injectIntl, intlShape, FormattedMessage} from 'react-intl';
+import PropTypes from 'prop-types';
+import React from 'react';
+import ReactTooltip from 'react-tooltip';
+
+import styles from './coming-soon.css';
+
+import awwCatIcon from './aww-cat.png';
+import coolCatIcon from './cool-cat.png';
+
+const messages = defineMessages({
+    message1: {
+        defaultMessage: 'Don\'t worry, we\'re on it {emoji}',
+        description: 'One of the "coming soon" random messages for yet-to-be-done features',
+        id: 'gui.comingSoon.message1'
+    },
+    message2: {
+        defaultMessage: 'Coming Soon...',
+        description: 'One of the "coming soon" random messages for yet-to-be-done features',
+        id: 'gui.comingSoon.message2'
+    },
+    message3: {
+        defaultMessage: 'We\'re working on it {emoji}',
+        description: 'One of the "coming soon" random messages for yet-to-be-done features',
+        id: 'gui.comingSoon.message3'
+    }
+});
+
+class ComingSoonContent extends React.Component {
+    constructor (props) {
+        super(props);
+        bindAll(this, [
+            'setHide',
+            'setShow',
+            'getRandomMessage'
+        ]);
+        this.state = {
+            isShowing: false
+        };
+    }
+    setShow () {
+        // needed to set the opacity to 1, since the default is .9 on show
+        this.setState({isShowing: true});
+    }
+    setHide () {
+        this.setState({isShowing: false});
+    }
+    getRandomMessage () {
+        // randomly chooses a messages from `messages` to display in the tooltip.
+        const images = [awwCatIcon, coolCatIcon];
+        const messageNumber = Math.floor(Math.random() * Object.keys(messages).length) + 1;
+        const imageNumber = Math.floor(Math.random() * Object.keys(images).length);
+        return (
+            <FormattedMessage
+                {...messages[`message${messageNumber}`]}
+                values={{
+                    emoji: (
+                        <img
+                            className={styles.comingSoonImage}
+                            src={images[imageNumber]}
+                        />
+                    )
+                }}
+            />
+        );
+    }
+    render () {
+        return (
+            <ReactTooltip
+                afterHide={this.setHide}
+                afterShow={this.setShow}
+                className={classNames(
+                    styles.comingSoon,
+                    this.props.className,
+                    {
+                        [styles.show]: (this.state.isShowing),
+                        [styles.left]: (this.props.place === 'left'),
+                        [styles.right]: (this.props.place === 'right'),
+                        [styles.top]: (this.props.place === 'top'),
+                        [styles.bottom]: (this.props.place === 'bottom')
+                    }
+                )}
+                getContent={this.getRandomMessage}
+                id={this.props.tooltipId}
+            />
+        );
+    }
+}
+
+ComingSoonContent.propTypes = {
+    className: PropTypes.string,
+    intl: intlShape,
+    place: PropTypes.oneOf(['top', 'right', 'bottom', 'left']),
+    tooltipId: PropTypes.string.isRequired
+};
+
+ComingSoonContent.defaultProps = {
+    place: 'bottom'
+};
+
+const ComingSoon = injectIntl(ComingSoonContent);
+
+const ComingSoonTooltip = props => (
+    <div className={props.className}>
+        <div
+            data-delay-hide={props.delayHide}
+            data-delay-show={props.delayShow}
+            data-effect="solid"
+            data-for={props.tooltipId}
+            data-place={props.place}
+            data-tip="tooltip"
+        >
+            {props.children}
+        </div>
+        <ComingSoon
+            className={props.tooltipClassName}
+            place={props.place}
+            tooltipId={props.tooltipId}
+        />
+    </div>
+);
+
+ComingSoonTooltip.propTypes = {
+    children: PropTypes.node.isRequired,
+    className: PropTypes.string,
+    delayHide: PropTypes.number,
+    delayShow: PropTypes.number,
+    place: PropTypes.oneOf(['top', 'right', 'bottom', 'left']),
+    tooltipClassName: PropTypes.string,
+    tooltipId: PropTypes.string.isRequired
+};
+
+ComingSoonTooltip.defaultProps = {
+    delayHide: 0,
+    delayShow: 0
+};
+
+export {
+    ComingSoon as ComingSoonComponent,
+    ComingSoonTooltip
+};

binární
src/components/coming-soon/cool-cat.png


+ 158 - 0
src/components/connection-modal/auto-scanning-step.jsx

@@ -0,0 +1,158 @@
+import {FormattedMessage} from 'react-intl';
+import PropTypes from 'prop-types';
+import React from 'react';
+import keyMirror from 'keymirror';
+import classNames from 'classnames';
+
+import Box from '../box/box.jsx';
+import Dots from './dots.jsx';
+
+import closeIcon from '../close-button/icon--close.svg';
+
+import radarIcon from './icons/searching.png';
+import bluetoothIcon from './icons/bluetooth-white.svg';
+import backIcon from './icons/back.svg';
+
+import styles from './connection-modal.css';
+
+const PHASES = keyMirror({
+    prescan: null,
+    pressbutton: null,
+    notfound: null
+});
+
+const AutoScanningStep = props => (
+    <Box className={styles.body}>
+        <Box className={styles.activityArea}>
+            <div className={styles.activityAreaInfo}>
+                <div className={styles.centeredRow}>
+                    {props.phase === PHASES.prescan && (
+                        <React.Fragment>
+                            <img
+                                className={styles.radarBig}
+                                src={radarIcon}
+                            />
+                            <img
+                                className={styles.bluetoothCenteredIcon}
+                                src={bluetoothIcon}
+                            />
+                        </React.Fragment>
+                    )}
+                    {props.phase === PHASES.pressbutton && (
+                        <React.Fragment>
+                            <img
+                                className={classNames(styles.radarBig, styles.radarSpin)}
+                                src={radarIcon}
+                            />
+                            <img
+                                className={styles.connectionTipIcon}
+                                src={props.connectionTipIconURL}
+                            />
+                        </React.Fragment>
+                    )}
+                    {props.phase === PHASES.notfound && (
+                        <Box className={styles.instructions}>
+                            <FormattedMessage
+                                defaultMessage="No devices found"
+                                description="Text shown when no devices could be found"
+                                id="gui.connection.auto-scanning.noPeripheralsFound"
+                            />
+                        </Box>
+                    )}
+                </div>
+            </div>
+        </Box>
+        <Box className={styles.bottomArea}>
+            <Box className={classNames(styles.bottomAreaItem, styles.instructions)}>
+                {props.phase === PHASES.prescan && (
+                    <FormattedMessage
+                        defaultMessage="Have your device nearby, then begin searching."
+                        description="Prompt for beginning the search"
+                        id="gui.connection.auto-scanning.prescan"
+                    />
+                )}
+                {props.phase === PHASES.pressbutton && (
+                    <FormattedMessage
+                        defaultMessage="Press the button on your device."
+                        description="Prompt for pushing the button on the device"
+                        id="gui.connection.auto-scanning.pressbutton"
+                    />
+                )}
+            </Box>
+            <Dots
+                className={styles.bottomAreaItem}
+                counter={0}
+                total={3}
+            />
+            <Box className={classNames(styles.bottomAreaItem, styles.buttonRow)}>
+                {props.phase === PHASES.prescan && (
+                    <button
+                        className={styles.connectionButton}
+                        onClick={props.onStartScan}
+                    >
+                        <FormattedMessage
+                            defaultMessage="Start Searching"
+                            description="Button in prompt for starting a search"
+                            id="gui.connection.auto-scanning.start-search"
+                        />
+                    </button>
+                )}
+                {props.phase === PHASES.pressbutton && (
+                    <div className={styles.segmentedButton}>
+                        <button
+                            disabled
+                            className={styles.connectionButton}
+                        >
+                            <FormattedMessage
+                                defaultMessage="Searching..."
+                                description="Label indicating that search is in progress"
+                                id="gui.connection.connecting-searchbutton"
+                            />
+                        </button>
+                        <button
+                            className={styles.connectionButton}
+                            onClick={props.onRefresh}
+                        >
+                            <img
+                                className={styles.abortConnectingIcon}
+                                src={closeIcon}
+                            />
+                        </button>
+                    </div>
+                )}
+                {props.phase === PHASES.notfound && (
+                    <button
+                        className={styles.connectionButton}
+                        onClick={props.onRefresh}
+                    >
+                        <img
+                            className={styles.buttonIconLeft}
+                            src={backIcon}
+                        />
+                        <FormattedMessage
+                            defaultMessage="Try again"
+                            description="Button in prompt for trying a device search again"
+                            id="gui.connection.auto-scanning.try-again"
+                        />
+                    </button>
+                )}
+            </Box>
+        </Box>
+    </Box>
+);
+
+AutoScanningStep.propTypes = {
+    connectionTipIconURL: PropTypes.string,
+    onRefresh: PropTypes.func,
+    onStartScan: PropTypes.func,
+    phase: PropTypes.oneOf(Object.keys(PHASES))
+};
+
+AutoScanningStep.defaultProps = {
+    phase: PHASES.prescan
+};
+
+export {
+    AutoScanningStep as default,
+    PHASES
+};

+ 72 - 0
src/components/connection-modal/connected-step.jsx

@@ -0,0 +1,72 @@
+import {FormattedMessage} from 'react-intl';
+import PropTypes from 'prop-types';
+import React from 'react';
+
+import Box from '../box/box.jsx';
+import Dots from './dots.jsx';
+import bluetoothIcon from './icons/bluetooth-white.svg';
+import styles from './connection-modal.css';
+import classNames from 'classnames';
+
+const ConnectedStep = props => (
+    <Box className={styles.body}>
+        <Box className={styles.activityArea}>
+            <Box className={styles.centeredRow}>
+                <div className={styles.peripheralActivity}>
+                    <img
+                        className={styles.peripheralActivityIcon}
+                        src={props.connectionIconURL}
+                    />
+                    <img
+                        className={styles.bluetoothConnectedIcon}
+                        src={bluetoothIcon}
+                    />
+                </div>
+            </Box>
+        </Box>
+        <Box className={styles.bottomArea}>
+            <Box className={classNames(styles.bottomAreaItem, styles.instructions)}>
+                <FormattedMessage
+                    defaultMessage="Connected"
+                    description="Message indicating that a device was connected"
+                    id="gui.connection.connected"
+                />
+            </Box>
+            <Dots
+                success
+                className={styles.bottomAreaItem}
+                total={3}
+            />
+            <div className={classNames(styles.bottomAreaItem, styles.cornerButtons)}>
+                <button
+                    className={classNames(styles.redButton, styles.connectionButton)}
+                    onClick={props.onDisconnect}
+                >
+                    <FormattedMessage
+                        defaultMessage="Disconnect"
+                        description="Button to disconnect the device"
+                        id="gui.connection.disconnect"
+                    />
+                </button>
+                <button
+                    className={styles.connectionButton}
+                    onClick={props.onCancel}
+                >
+                    <FormattedMessage
+                        defaultMessage="Go to Editor"
+                        description="Button to return to the editor"
+                        id="gui.connection.go-to-editor"
+                    />
+                </button>
+            </div>
+        </Box>
+    </Box>
+);
+
+ConnectedStep.propTypes = {
+    connectionIconURL: PropTypes.string.isRequired,
+    onCancel: PropTypes.func,
+    onDisconnect: PropTypes.func
+};
+
+export default ConnectedStep;

+ 70 - 0
src/components/connection-modal/connecting-step.jsx

@@ -0,0 +1,70 @@
+import {FormattedMessage} from 'react-intl';
+import PropTypes from 'prop-types';
+import React from 'react';
+import classNames from 'classnames';
+
+import Box from '../box/box.jsx';
+import Dots from './dots.jsx';
+
+import bluetoothIcon from './icons/bluetooth-white.svg';
+import closeIcon from '../close-button/icon--close.svg';
+
+import styles from './connection-modal.css';
+
+const ConnectingStep = props => (
+    <Box className={styles.body}>
+        <Box className={styles.activityArea}>
+            <Box className={styles.centeredRow}>
+                <div className={styles.peripheralActivity}>
+                    <img
+                        className={styles.peripheralActivityIcon}
+                        src={props.connectionIconURL}
+                    />
+                    <img
+                        className={styles.bluetoothConnectingIcon}
+                        src={bluetoothIcon}
+                    />
+                </div>
+            </Box>
+        </Box>
+        <Box className={styles.bottomArea}>
+            <Box className={classNames(styles.bottomAreaItem, styles.instructions)}>
+                {props.connectingMessage}
+            </Box>
+            <Dots
+                className={styles.bottomAreaItem}
+                counter={1}
+                total={3}
+            />
+            <div className={classNames(styles.bottomAreaItem, styles.segmentedButton)}>
+                <button
+                    disabled
+                    className={styles.connectionButton}
+                >
+                    <FormattedMessage
+                        defaultMessage="Connecting..."
+                        description="Label indicating that connection is in progress"
+                        id="gui.connection.connecting-cancelbutton"
+                    />
+                </button>
+                <button
+                    className={styles.connectionButton}
+                    onClick={props.onDisconnect}
+                >
+                    <img
+                        className={styles.abortConnectingIcon}
+                        src={closeIcon}
+                    />
+                </button>
+            </div>
+        </Box>
+    </Box>
+);
+
+ConnectingStep.propTypes = {
+    connectingMessage: PropTypes.node.isRequired,
+    connectionIconURL: PropTypes.string.isRequired,
+    onDisconnect: PropTypes.func
+};
+
+export default ConnectingStep;

+ 425 - 0
src/components/connection-modal/connection-modal.css

@@ -0,0 +1,425 @@
+@import "../../css/colors.css";
+@import "../../css/units.css";
+
+.modal-content {
+    width: 480px;
+}
+
+.header {
+    background-color: $pen-primary;
+}
+
+.body {
+    background: $ui-white;
+}
+
+.label {
+    font-weight: 500;
+    margin: 0 0 0.75rem;
+}
+
+.centered-row {
+    display: flex;
+    flex-direction: row;
+    justify-content: center;
+    align-items: center;
+}
+
+.peripheral-tile-pane {
+    overflow-y: auto;
+    width: 100%;
+    height: 100%;
+}
+
+.peripheral-tile {
+    display: flex;
+    flex-direction: row;
+    justify-content: space-between;
+    align-items: center;
+
+    background-color: $ui-white;
+    border-radius: 0.25rem;
+    padding: 10px;
+    width: 100%;
+    height: 55px;
+    margin-bottom: 0.5rem;
+}
+
+.peripheral-tile-name {
+    display: flex;
+    align-items: center;
+}
+
+[dir="ltr"] .peripheral-tile-image {
+    margin-right: 0.5rem;
+}
+
+[dir="rtl"] .peripheral-tile-image {
+    margin-left: 0.5rem;
+}
+
+.peripheral-tile-name-wrapper {
+    display: flex;
+    flex-direction: column;
+    justify-content: center;
+    align-items: flex-start;
+}
+
+.peripheral-tile-name-label {
+    font-weight: bold;
+    font-size: 0.625rem;
+}
+
+.peripheral-tile-name-text {
+    font-size: 0.875rem;
+}
+
+.peripheral-tile button {
+    padding: 0.6rem 0.75rem;
+    border: none;
+    border-radius: 0.25rem;
+    font-weight: 600;
+    font-size: 0.85rem;
+    background: $motion-primary;
+    border: $motion-primary;
+    color: white;
+    cursor: pointer;
+}
+
+.signal-strength-meter {
+    display: flex;
+    justify-content: space-between;
+    align-items: flex-end;
+    width: 22px;
+    height: 16px;
+}
+
+[dir="ltr"] .signal-strength-meter {
+    margin-right: 1rem;
+}
+
+[dir="rtl"] .signal-strength-meter {
+    margin-left: 1rem;
+}
+
+.signal-bar {
+    width: 4px;
+    border-radius: 4px;
+    background-color: #DBDBDB;
+}
+
+.signal-bar:nth-of-type(1) { height: 25%; }
+.signal-bar:nth-of-type(2) { height: 50%; }
+.signal-bar:nth-of-type(3) { height: 75%; }
+.signal-bar:nth-of-type(4) { height: 100%; }
+
+.green-bar {
+    background-color: $pen-primary;
+}
+
+.radar-small {
+    width: 40px;
+    height: 40px;
+}
+
+[dir="ltr"] .radar-small {
+    margin-right: 0.5rem;
+}
+
+[dir="rtl"] .radar-small {
+    margin-left: 0.5rem;
+}
+
+.radar-big {
+    width: 120px;
+    height: 120px;
+}
+
+.radar-spin {
+    animation: spin 4s linear infinite;
+}
+
+[dir="ltr"] .radar {
+    margin-right: .5rem;
+}
+
+[dir="rtl"] .radar {
+    margin-left: .5rem;
+}
+
+@keyframes spin {
+    100% {
+        transform: rotate(360deg);
+    }
+}
+
+.peripheral-activity {
+    position: relative;
+}
+
+.peripheral-activity-icon {
+    /* width: 80px;
+    height: 80px; */
+}
+
+.connection-tip-icon {
+    position: absolute;
+}
+
+.bluetooth-connecting-icon {
+    position: absolute;
+    top: -5px;
+    right: -15px;
+    left: -15px;
+    padding: 5px 5px;
+    background-color: $motion-primary;
+    border-radius: 100%;
+    box-shadow: 0px 0px 0px 4px $motion-transparent;
+    /* animation: pulse-blue-ring 1s infinite ease-in-out alternate; */
+    animation: wiggle 0.5s infinite ease-in-out alternate;
+
+}
+
+@keyframes pulse-blue-ring {
+    100% {
+        box-shadow: 0px 0px 0px 8px $motion-light-transparent;
+    }
+}
+
+.bluetooth-connected-icon {
+    position: absolute;
+    top: -5px;
+    right: -15px;
+    left: -15px;
+    padding: 5px 5px;
+    background-color: $pen-primary;
+    border-radius: 100%;
+    box-shadow: 0px 0px 0px 4px $pen-transparent;
+}
+
+@keyframes wiggle {
+    0% {transform: rotate(3deg) scale(1.05);}
+    25% {transform: rotate(-3deg) scale(1.05);}
+    50% {transform: rotate(5deg) scale(1.05);}
+    75% {transform: rotate(-2deg) scale(1.05);}
+    100% {transform: rotate(0deg) scale(1.05);}
+}
+
+.bluetooth-centered-icon {
+    position: absolute;
+    padding: 5px 5px;
+    background-color: $motion-primary;
+    border-radius: 100%;
+    box-shadow: 0px 0px 0px 2px $motion-transparent;
+}
+
+.peripheral-tile-widgets {
+    display: flex;
+    align-items: center;
+}
+
+.activityArea {
+    height: 165px;
+    background-color: $motion-light-transparent;
+    display: flex;
+    justify-content: center;
+    align-items: center;
+    padding: .5rem;
+}
+
+.scratch-link-help {
+    display: flex;
+    flex-direction: column;
+    justify-content: space-around;
+    height: 100%;
+    padding-top: .5rem;
+    padding-bottom: .5rem;
+}
+
+.scratch-link-help-step {
+    display: flex;
+    flex-direction: row;
+    justify-content: flex-start;
+    align-items: center;
+}
+
+[dir="ltr"] .scratch-link-help-step {
+    margin-left: 2.5rem;
+}
+
+[dir="rtl"] .scratch-link-help-step {
+    margin-right: 2.5rem;
+}
+
+.scratch-link-icon {
+    max-width: 50px;
+}
+
+[dir="ltr"] .help-step-image {
+    margin-right: 0.5rem;
+}
+
+[dir="rtl"] .help-step-image {
+    margin-left: 0.5rem;
+}
+
+.help-step-number {
+    background: $pen-primary;
+    border-radius: 100%;
+    display: flex;
+    justify-content: center;
+    align-items: center;
+    color: $ui-white;
+    font-weight: bold;
+    min-width: 2rem;
+    height: 2rem;
+}
+
+[dir="ltr"] .help-step-number {
+    margin-right: 0.5rem;
+}
+
+[dir="rtl"] .help-step-number {
+    margin-left: 0.5rem;
+}
+
+.button-row {
+    font-weight: bolder;
+    text-align: center;
+    display: flex;
+}
+
+.abort-connecting-icon {
+    width: 10px;
+    transform: rotate(45deg);
+}
+
+.connection-button {
+    padding: 0.6rem 0.75rem;
+    border-radius: 0.5rem;
+    background: $motion-primary;
+    color: white;
+    font-weight: 600;
+    font-size: 0.85rem;
+    margin: 0.25rem;
+    border: none;
+    cursor: pointer;
+    display: flex;
+    align-items: center;
+}
+
+.connection-button:disabled {
+    background: $motion-transparent;
+}
+
+.segmented-button {
+    display: flex;
+}
+
+.segmented-button .connection-button:first-of-type {
+    border-top-right-radius: 0;
+    border-bottom-right-radius: 0;
+    margin-right: 0;
+}
+
+.segmented-button .connection-button:last-of-type {
+    margin-left: 1px;
+    border-top-left-radius: 0;
+    border-bottom-left-radius: 0;
+}
+
+[dir="ltr"] .button-icon-right {
+    margin-left: 0.5rem;
+}
+[dir="rtl"] .button-icon-right {
+    margin-right: 0.5rem;
+}
+
+[dir="ltr"] .button-icon-left {
+    margin-right: 0.5rem;
+}
+
+[dir="rtl"] .button-icon-left {
+    margin-left: 0.5rem;
+}
+
+/* reverse back arrow icon for RTL, don't reverse other connection icons */
+[dir="rtl"] .button-icon-back {
+    transform: scaleX(-1);
+}
+
+.red-button {
+    background: $red-primary;
+}
+
+.corner-buttons {
+    display: flex;
+    justify-content: space-between;
+    width: 100%;
+    padding: 0 1rem;
+}
+
+.bottom-area {
+    background-color: $ui-white;
+    text-align: center;
+    display: flex;
+    flex-direction: column;
+    align-items: center;
+    padding-top: 1rem;
+    padding-bottom: .75rem;
+    padding-left: .75rem;
+    padding-right: .75rem;
+}
+
+.bottom-area .bottom-area-item+.bottom-area-item {
+    margin-top: 1rem;
+}
+
+.instructions {
+    text-align: center;
+}
+
+.dots-row {
+    display: flex;
+    flex-direction: row;
+    justify-content: center;
+    align-items: center;
+}
+
+.dots-holder {
+    display: flex;
+    padding: 0.25rem 0.1rem;
+    border-radius: 1rem;
+    background: $motion-light-transparent;
+}
+
+.dots-holder-success {
+    background: $pen-transparent;
+}
+
+.dots-holder-error {
+    background: $error-transparent;
+}
+
+.dot {
+    width: 0.5rem;
+    height: 0.5rem;
+    margin: 0 0.3rem;
+    border-radius: 100%;
+}
+
+.inactive-step-dot {
+    background: $motion-transparent;
+}
+
+.active-step-dot {
+    background: $motion-primary;
+}
+
+.success-dot {
+    background: $pen-primary;
+}
+
+.error-dot {
+    background: $error-primary;
+}

+ 65 - 0
src/components/connection-modal/connection-modal.jsx

@@ -0,0 +1,65 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import keyMirror from 'keymirror';
+
+import Box from '../box/box.jsx';
+import Modal from '../../containers/modal.jsx';
+
+import ScanningStep from '../../containers/scanning-step.jsx';
+import AutoScanningStep from '../../containers/auto-scanning-step.jsx';
+import ConnectingStep from './connecting-step.jsx';
+import ConnectedStep from './connected-step.jsx';
+import ErrorStep from './error-step.jsx';
+import UnavailableStep from './unavailable-step.jsx';
+
+import styles from './connection-modal.css';
+
+const PHASES = keyMirror({
+    scanning: null,
+    connecting: null,
+    connected: null,
+    error: null,
+    unavailable: null
+});
+
+const ConnectionModalComponent = props => (
+    <Modal
+        className={styles.modalContent}
+        contentLabel={props.name}
+        headerClassName={styles.header}
+        headerImage={props.connectionSmallIconURL}
+        id="connectionModal"
+        onHelp={props.onHelp}
+        onRequestClose={props.onCancel}
+    >
+        <Box className={styles.body}>
+            {props.phase === PHASES.scanning && !props.useAutoScan && <ScanningStep {...props} />}
+            {props.phase === PHASES.scanning && props.useAutoScan && <AutoScanningStep {...props} />}
+            {props.phase === PHASES.connecting && <ConnectingStep {...props} />}
+            {props.phase === PHASES.connected && <ConnectedStep {...props} />}
+            {props.phase === PHASES.error && <ErrorStep {...props} />}
+            {props.phase === PHASES.unavailable && <UnavailableStep {...props} />}
+        </Box>
+    </Modal>
+);
+
+ConnectionModalComponent.propTypes = {
+    connectingMessage: PropTypes.node.isRequired,
+    connectionSmallIconURL: PropTypes.string,
+    connectionTipIconURL: PropTypes.string,
+    name: PropTypes.node,
+    onCancel: PropTypes.func.isRequired,
+    onHelp: PropTypes.func.isRequired,
+    phase: PropTypes.oneOf(Object.keys(PHASES)).isRequired,
+    title: PropTypes.string.isRequired,
+    useAutoScan: PropTypes.bool.isRequired
+};
+
+ConnectionModalComponent.defaultProps = {
+    connectingMessage: 'Connecting'
+};
+
+export {
+    ConnectionModalComponent as default,
+    PHASES
+};

+ 65 - 0
src/components/connection-modal/dots.jsx

@@ -0,0 +1,65 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import classNames from 'classnames';
+
+import Box from '../box/box.jsx';
+import styles from './connection-modal.css';
+
+const Dots = props => (
+    <Box
+        className={classNames(
+            props.className,
+            styles.dotsRow
+        )}
+    >
+        <div
+            className={classNames(
+                styles.dotsHolder,
+                {
+                    [styles.dotsHolderError]: props.error,
+                    [styles.dotsHolderSuccess]: props.success
+                }
+            )}
+        >
+            {Array(props.total).fill(0)
+                .map((_, i) => {
+                    let type = 'inactive';
+                    if (props.counter === i) type = 'active';
+                    if (props.success) type = 'success';
+                    if (props.error) type = 'error';
+                    return (<Dot
+                        key={`dot-${i}`}
+                        type={type}
+                    />);
+                })}
+        </div>
+    </Box>
+);
+
+Dots.propTypes = {
+    className: PropTypes.string,
+    counter: PropTypes.number,
+    error: PropTypes.bool,
+    success: PropTypes.bool,
+    total: PropTypes.number
+};
+
+const Dot = props => (
+    <div
+        className={classNames(
+            styles.dot,
+            {
+                [styles.inactiveStepDot]: props.type === 'inactive',
+                [styles.activeStepDot]: props.type === 'active',
+                [styles.successDot]: props.type === 'success',
+                [styles.errorDot]: props.type === 'error'
+            }
+        )}
+    />
+);
+
+Dot.propTypes = {
+    type: PropTypes.string
+};
+
+export default Dots;

+ 78 - 0
src/components/connection-modal/error-step.jsx

@@ -0,0 +1,78 @@
+import {FormattedMessage} from 'react-intl';
+import PropTypes from 'prop-types';
+import classNames from 'classnames';
+import React from 'react';
+
+import Box from '../box/box.jsx';
+import Dots from './dots.jsx';
+import helpIcon from './icons/help.svg';
+import backIcon from './icons/back.svg';
+
+import styles from './connection-modal.css';
+
+const ErrorStep = props => (
+    <Box className={styles.body}>
+        <Box className={styles.activityArea}>
+            <Box className={styles.centeredRow}>
+                <div className={styles.peripheralActivity}>
+                    <img
+                        className={styles.peripheralActivityIcon}
+                        src={props.connectionIconURL}
+                    />
+                </div>
+            </Box>
+        </Box>
+        <Box className={styles.bottomArea}>
+            <div className={classNames(styles.bottomAreaItem, styles.instructions)}>
+                <FormattedMessage
+                    defaultMessage="Oops, looks like something went wrong."
+                    description="The device connection process has encountered an error."
+                    id="gui.connection.error.errorMessage"
+                />
+            </div>
+            <Dots
+                error
+                className={styles.bottomAreaItem}
+                total={3}
+            />
+            <Box className={classNames(styles.bottomAreaItem, styles.buttonRow)}>
+                <button
+                    className={styles.connectionButton}
+                    onClick={props.onScanning}
+                >
+                    <img
+                        className={classNames(styles.buttonIconLeft, styles.buttonIconBack)}
+                        src={backIcon}
+                    />
+                    <FormattedMessage
+                        defaultMessage="Try again"
+                        description="Button to initiate trying the device connection again after an error"
+                        id="gui.connection.error.tryagainbutton"
+                    />
+                </button>
+                <button
+                    className={styles.connectionButton}
+                    onClick={props.onHelp}
+                >
+                    <img
+                        className={styles.buttonIconLeft}
+                        src={helpIcon}
+                    />
+                    <FormattedMessage
+                        defaultMessage="Help"
+                        description="Button to view help content"
+                        id="gui.connection.error.helpbutton"
+                    />
+                </button>
+            </Box>
+        </Box>
+    </Box>
+);
+
+ErrorStep.propTypes = {
+    connectionIconURL: PropTypes.string.isRequired,
+    onHelp: PropTypes.func,
+    onScanning: PropTypes.func
+};
+
+export default ErrorStep;

Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 10 - 0
src/components/connection-modal/icons/back.svg


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 10 - 0
src/components/connection-modal/icons/bluetooth-white.svg


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 31 - 0
src/components/connection-modal/icons/bluetooth.svg


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 10 - 0
src/components/connection-modal/icons/cancel.svg


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 10 - 0
src/components/connection-modal/icons/close.svg


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 10 - 0
src/components/connection-modal/icons/help.svg


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 10 - 0
src/components/connection-modal/icons/refresh.svg


+ 41 - 0
src/components/connection-modal/icons/scratchlink.svg

@@ -0,0 +1,41 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- Generator: Adobe Illustrator 22.1.0, SVG Export Plug-In . SVG Version: 6.00 Build 0)  -->
+<svg width="52px" height="52px" viewBox="0 0 52 52" version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
+	 style="enable-background:new 0 0 52 52;" xml:space="preserve">
+<style type="text/css">
+	.st0{fill:#0FBD8C;}
+	.st1{fill:#FFFFFF;}
+	.st2{fill:#F9A83A;}
+</style>
+<title>Scratch Link</title>
+<desc>Created with Sketch.</desc>
+<g id="_x35_2x52-for-the-dialog">
+	<g id="Group">
+		<path id="bg" class="st0" d="M41.4,5c3.1,0,5.6,2.5,5.6,5.6v30.8c0,3.1-2.5,5.6-5.6,5.6H10.6C7.5,47,5,44.5,5,41.4V10.6
+			C5,7.5,7.5,5,10.6,5H41.4z"/>
+		<path id="scratch-outline-2" class="st1" d="M28.8,28.9c0,2.7-1.1,5.4-3.2,7.2c-1.7,1.5-3.8,2.3-5.9,2.4c-1,0.8-2.2,1.3-3.5,1.4
+			c-0.1,0-0.3,0-0.4,0c-3.3,0-6.1-2.7-6.4-6.2c0,0,0-0.1,0-0.2c0,0,0,0,0-0.1c-0.1-1.5,0-2.7,0-3.4c0-0.7,0.1-2,0.1-2.3v0
+			c0-1,0.3-2,0.7-2.9c-0.1-1-0.1-2.1,0-3.2l0-0.2c0,0,0,0,0-0.1c0,0,0,0,0-0.1c0.1-1,0.3-2.5,1.1-4.2c1.4-2.7,4-4.3,7-4.3h0.1
+			c0.9-0.5,2-0.8,3.1-0.8h0.1c3.5,0.1,6.3,3.1,6.3,6.7c0,0-0.1,4.4-0.3,5.9C28.4,25.9,28.7,27.4,28.8,28.9"/>
+		<path id="scratch-outline-1" class="st2" d="M25.8,29c0,1.8-0.8,3.6-2.2,4.8c-1.2,1.1-2.8,1.7-4.3,1.7c-0.3,0-0.5,0-0.8-0.1
+			c-0.1,0.1-0.1,0.2-0.2,0.2c-0.6,0.7-1.5,1.1-2.4,1.2c-0.1,0-0.1,0-0.2,0c-1.8,0-3.3-1.5-3.4-3.3c0,0,0-0.1,0-0.1l0,0
+			c-0.1-1.3,0-2.4,0-3.1c0-0.8,0.1-2.1,0.1-2.4c0-0.9,0.3-1.7,0.9-2.4c-0.3-1-0.4-2.1-0.2-3.5l0-0.2c0,0,0,0,0,0
+			c0.1-0.8,0.2-2,0.8-3.1c0.9-1.7,2.5-2.7,4.4-2.7c0.1,0,0.2,0,0.3,0c0.2,0,0.4,0,0.6,0.1c0.6-0.6,1.5-0.9,2.4-0.9
+			c1.9,0,3.4,1.6,3.4,3.6c0,0-0.2,5.2-0.2,5.7c0,0.3-0.1,0.5-0.2,0.8C25.3,26.2,25.8,27.5,25.8,29"/>
+		<path id="scratch-fill" class="st1" d="M18.3,25.3c-0.9-0.1-1.4-0.8-1.1-2.7l0-0.2c0.2-1.7,0.4-2,1.1-2c0.2,0,0.5,0.2,0.7,0.4
+			c0.2,0.3,0.8,0.7,1.1,1.4c0.2,0.5,0.3,0.9,0.3,1.3l0,0.5v0c0.1,0.3,0.3,0.6,0.6,0.6c0.4,0.1,0.8-0.2,0.9-0.6
+			c0-0.1,0.2-5.1,0.2-5.2c0-0.4-0.3-0.8-0.8-0.8c-0.4,0-0.8,0.4-0.8,0.8c0,0,0,0.7,0,1.4c-0.6-0.7-1.4-1.3-2.3-1.4
+			c-2.3-0.1-2.6,2.1-2.8,3.4l0,0.2c-0.3,2.5,0.5,4.2,2.4,4.5c2.1,0.3,3.5,0.8,3.5,2.2c0,0.5-0.3,1.1-0.7,1.5c-0.6,0.5-1.3,0.7-2,0.6
+			c-0.2,0-0.4-0.1-0.6-0.2c-0.3-0.2-1-0.6-1.3-1.1c-0.3-0.4-0.4-1.1-0.4-1.5c0-0.2,0-0.3,0-0.3c0-0.4-0.3-0.8-0.8-0.8
+			c-0.4,0-0.8,0.3-0.8,0.8c0,0,0,1.6-0.1,2.5c-0.1,1.5,0,2.8,0,2.9c0,0.4,0.4,0.8,0.8,0.8c0.4,0,0.8-0.4,0.7-0.9c0,0,0-0.6,0-1.5
+			c0.6,0.4,1.3,0.7,2.2,0.9c1.2,0.2,2.3-0.1,3.3-1c0.8-0.7,1.3-1.7,1.3-2.7C23.1,26,19.9,25.5,18.3,25.3"/>
+		<path id="signal" class="st1" d="M37.7,36.9c-0.2,0-0.4-0.1-0.5-0.2c-0.3-0.3-0.3-0.8,0-1.1c2.6-2.6,4-6,4-9.6
+			c0-3.6-1.4-7.1-4-9.7c-0.3-0.3-0.3-0.8,0-1.1c0.3-0.3,0.8-0.3,1,0c2.8,2.9,4.4,6.7,4.4,10.7c0,4-1.6,7.9-4.4,10.7
+			C38.1,36.9,37.9,36.9,37.7,36.9z M35,33.5c-0.2,0-0.4-0.1-0.5-0.2c-0.3-0.3-0.3-0.8,0-1.1c1.7-1.7,2.6-3.9,2.6-6.3
+			c0-2.4-0.9-4.6-2.6-6.3c-0.3-0.3-0.3-0.8,0-1.1c0.3-0.3,0.8-0.3,1,0c1.9,2,3,4.6,3,7.3c0,2.8-1.1,5.4-3,7.3
+			C35.4,33.5,35.2,33.5,35,33.5z M32.3,30.1c-0.2,0-0.4-0.1-0.5-0.2c-0.3-0.3-0.3-0.8,0-1.1c0.8-0.8,1.2-1.8,1.2-2.9
+			c0-1.1-0.4-2.1-1.2-2.9c-0.3-0.3-0.3-0.8,0-1.1c0.3-0.3,0.8-0.3,1,0c1,1,1.6,2.4,1.6,3.9c0,1.5-0.6,2.9-1.6,3.9
+			C32.7,30.1,32.5,30.1,32.3,30.1z"/>
+	</g>
+</g>
+</svg>

binární
src/components/connection-modal/icons/searching.png


+ 87 - 0
src/components/connection-modal/peripheral-tile.jsx

@@ -0,0 +1,87 @@
+import {FormattedMessage} from 'react-intl';
+import PropTypes from 'prop-types';
+import classNames from 'classnames';
+import React from 'react';
+import bindAll from 'lodash.bindall';
+import Box from '../box/box.jsx';
+
+import styles from './connection-modal.css';
+
+class PeripheralTile extends React.Component {
+    constructor (props) {
+        super(props);
+        bindAll(this, [
+            'handleConnecting'
+        ]);
+    }
+    handleConnecting () {
+        this.props.onConnecting(this.props.peripheralId);
+    }
+    render () {
+        return (
+            <Box className={styles.peripheralTile}>
+                <Box className={styles.peripheralTileName}>
+                    <img
+                        className={styles.peripheralTileImage}
+                        src={this.props.connectionSmallIconURL}
+                    />
+                    <Box className={styles.peripheralTileNameWrapper}>
+                        <Box className={styles.peripheralTileNameLabel}>
+                            <FormattedMessage
+                                defaultMessage="Device name"
+                                description="Label for field showing the device name"
+                                id="gui.connection.peripheral-name-label"
+                            />
+                        </Box>
+                        <Box className={styles.peripheralTileNameText}>
+                            {this.props.name}
+                        </Box>
+                    </Box>
+                </Box>
+                <Box className={styles.peripheralTileWidgets}>
+                    <Box className={styles.signalStrengthMeter}>
+                        <div
+                            className={classNames(styles.signalBar, {
+                                [styles.greenBar]: this.props.rssi > -80
+                            })}
+                        />
+                        <div
+                            className={classNames(styles.signalBar, {
+                                [styles.greenBar]: this.props.rssi > -60
+                            })}
+                        />
+                        <div
+                            className={classNames(styles.signalBar, {
+                                [styles.greenBar]: this.props.rssi > -40
+                            })}
+                        />
+                        <div
+                            className={classNames(styles.signalBar, {
+                                [styles.greenBar]: this.props.rssi > -20
+                            })}
+                        />
+                    </Box>
+                    <button
+                        onClick={this.handleConnecting}
+                    >
+                        <FormattedMessage
+                            defaultMessage="Connect"
+                            description="Button to start connecting to a specific device"
+                            id="gui.connection.connect"
+                        />
+                    </button>
+                </Box>
+            </Box>
+        );
+    }
+}
+
+PeripheralTile.propTypes = {
+    connectionSmallIconURL: PropTypes.string,
+    name: PropTypes.string,
+    onConnecting: PropTypes.func,
+    peripheralId: PropTypes.string,
+    rssi: PropTypes.number
+};
+
+export default PeripheralTile;

+ 105 - 0
src/components/connection-modal/scanning-step.jsx

@@ -0,0 +1,105 @@
+import {FormattedMessage} from 'react-intl';
+import PropTypes from 'prop-types';
+import React from 'react';
+import classNames from 'classnames';
+
+import Box from '../box/box.jsx';
+import PeripheralTile from './peripheral-tile.jsx';
+import Dots from './dots.jsx';
+
+import radarIcon from './icons/searching.png';
+import refreshIcon from './icons/refresh.svg';
+
+import styles from './connection-modal.css';
+
+const ScanningStep = props => (
+    <Box className={styles.body}>
+        <Box className={styles.activityArea}>
+            {props.scanning ? (
+                props.peripheralList.length === 0 ? (
+                    <div className={styles.activityAreaInfo}>
+                        <div className={styles.centeredRow}>
+                            <img
+                                className={classNames(styles.radarSmall, styles.radarSpin)}
+                                src={radarIcon}
+                            />
+                            <FormattedMessage
+                                defaultMessage="Looking for devices"
+                                description="Text shown while scanning for devices"
+                                id="gui.connection.scanning.lookingforperipherals"
+                            />
+                        </div>
+                    </div>
+                ) : (
+                    <div className={styles.peripheralTilePane}>
+                        {props.peripheralList.map(peripheral =>
+                            (<PeripheralTile
+                                connectionSmallIconURL={props.connectionSmallIconURL}
+                                key={peripheral.peripheralId}
+                                name={peripheral.name}
+                                peripheralId={peripheral.peripheralId}
+                                rssi={peripheral.rssi}
+                                onConnecting={props.onConnecting}
+                            />)
+                        )}
+                    </div>
+                )
+            ) : (
+                <Box className={styles.instructions}>
+                    <FormattedMessage
+                        defaultMessage="No devices found"
+                        description="Text shown when no devices could be found"
+                        id="gui.connection.scanning.noPeripheralsFound"
+                    />
+                </Box>
+            )}
+        </Box>
+        <Box className={styles.bottomArea}>
+            <Box className={classNames(styles.bottomAreaItem, styles.instructions)}>
+                <FormattedMessage
+                    defaultMessage="Select your device in the list above."
+                    description="Prompt for choosing a device to connect to"
+                    id="gui.connection.scanning.instructions"
+                />
+            </Box>
+            <Dots
+                className={styles.bottomAreaItem}
+                counter={0}
+                total={3}
+            />
+            <button
+                className={classNames(styles.bottomAreaItem, styles.connectionButton)}
+                onClick={props.onRefresh}
+            >
+                <FormattedMessage
+                    defaultMessage="Refresh"
+                    description="Button in prompt for starting a search"
+                    id="gui.connection.search"
+                />
+                <img
+                    className={styles.buttonIconRight}
+                    src={refreshIcon}
+                />
+            </button>
+        </Box>
+    </Box>
+);
+
+ScanningStep.propTypes = {
+    connectionSmallIconURL: PropTypes.string,
+    onConnecting: PropTypes.func,
+    onRefresh: PropTypes.func,
+    peripheralList: PropTypes.arrayOf(PropTypes.shape({
+        name: PropTypes.string,
+        rssi: PropTypes.number,
+        peripheralId: PropTypes.string
+    })),
+    scanning: PropTypes.bool.isRequired
+};
+
+ScanningStep.defaultProps = {
+    peripheralList: [],
+    scanning: true
+};
+
+export default ScanningStep;

+ 102 - 0
src/components/connection-modal/unavailable-step.jsx

@@ -0,0 +1,102 @@
+import {FormattedMessage} from 'react-intl';
+import PropTypes from 'prop-types';
+import classNames from 'classnames';
+import React from 'react';
+
+import Box from '../box/box.jsx';
+import Dots from './dots.jsx';
+import helpIcon from './icons/help.svg';
+import backIcon from './icons/back.svg';
+import bluetoothIcon from './icons/bluetooth.svg';
+import scratchLinkIcon from './icons/scratchlink.svg';
+
+import styles from './connection-modal.css';
+
+const UnavailableStep = props => (
+    <Box className={styles.body}>
+        <Box className={styles.activityArea}>
+            <div className={styles.scratchLinkHelp}>
+                <div className={styles.scratchLinkHelpStep}>
+                    <div className={styles.helpStepNumber}>
+                        {'1'}
+                    </div>
+                    <div className={styles.helpStepImage}>
+                        <img
+                            className={styles.scratchLinkIcon}
+                            src={scratchLinkIcon}
+                        />
+                    </div>
+                    <div className={styles.helpStepText}>
+                        <FormattedMessage
+                            defaultMessage="Make sure you have Scratch Link installed and running"
+                            description="Message for getting Scratch Link installed"
+                            id="gui.connection.unavailable.installscratchlink"
+                        />
+                    </div>
+                </div>
+                <div className={styles.scratchLinkHelpStep}>
+                    <div className={styles.helpStepNumber}>
+                        {'2'}
+                    </div>
+                    <div className={styles.helpStepImage}>
+                        <img
+                            className={styles.scratchLinkIcon}
+                            src={bluetoothIcon}
+                        />
+                    </div>
+                    <div className={styles.helpStepText}>
+                        <FormattedMessage
+                            defaultMessage="Check that Bluetooth is enabled"
+                            description="Message for making sure Bluetooth is enabled"
+                            id="gui.connection.unavailable.enablebluetooth"
+                        />
+                    </div>
+                </div>
+            </div>
+        </Box>
+        <Box className={styles.bottomArea}>
+            <Dots
+                error
+                className={styles.bottomAreaItem}
+                total={3}
+            />
+            <Box className={classNames(styles.bottomAreaItem, styles.buttonRow)}>
+                <button
+                    className={styles.connectionButton}
+                    onClick={props.onScanning}
+                >
+                    <img
+                        className={classNames(styles.buttonIconLeft, styles.buttonIconBack)}
+                        src={backIcon}
+                    />
+                    <FormattedMessage
+                        defaultMessage="Try again"
+                        description="Button to initiate trying the device connection again after an error"
+                        id="gui.connection.unavailable.tryagainbutton"
+                    />
+                </button>
+                <button
+                    className={styles.connectionButton}
+                    onClick={props.onHelp}
+                >
+                    <img
+                        className={styles.buttonIconLeft}
+                        src={helpIcon}
+                    />
+                    <FormattedMessage
+                        defaultMessage="Help"
+                        description="Button to view help content"
+                        id="gui.connection.unavailable.helpbutton"
+                    />
+                </button>
+            </Box>
+        </Box>
+    </Box>
+);
+
+UnavailableStep.propTypes = {
+    onHelp: PropTypes.func,
+    onScanning: PropTypes.func
+};
+
+export default UnavailableStep;

+ 0 - 0
src/components/context-menu/context-menu.css


Některé soubory nejsou zobrazeny, neboť je v těchto rozdílových datech změněno mnoho souborů