diff --git a/package.json b/package.json index 00718bd23e6230581e5146499ae66f6a78dcf544..7cadf34d5da3ce08ddf66e59dbcd30a86d785f0e 100644 --- a/package.json +++ b/package.json @@ -82,8 +82,8 @@ "react-draggable": "3.0.5", "react-ga": "2.5.3", "react-intl": "2.4.0", - "react-modal": "3.5.1", - "react-popover": "0.5.7", + "react-modal": "3.6.1", + "react-popover": "0.5.10", "react-redux": "5.0.7", "react-responsive": "5.0.0", "react-style-proptype": "3.2.2", @@ -96,13 +96,13 @@ "redux-throttle": "0.1.1", "rimraf": "^2.6.1", "scratch-audio": "0.1.0-prerelease.20180625202813", - "scratch-blocks": "0.1.0-prerelease.1537303399", - "scratch-l10n": "3.0.20180918211645", - "scratch-paint": "0.2.0-prerelease.20180918203812", - "scratch-render": "0.1.0-prerelease.20180918201144", + "scratch-blocks": "0.1.0-prerelease.1537975589", + "scratch-l10n": "3.0.20180926203705", + "scratch-paint": "0.2.0-prerelease.20180926191006", + "scratch-render": "0.1.0-prerelease.20180926153819", "scratch-storage": "1.0.2", - "scratch-svg-renderer": "0.2.0-prerelease.20180907141232", - "scratch-vm": "0.2.0-prerelease.20180918201814", + "scratch-svg-renderer": "0.2.0-prerelease.20180926143036", + "scratch-vm": "0.2.0-prerelease.20180925190229", "selenium-webdriver": "3.6.0", "startaudiocontext": "1.2.1", "style-loader": "^0.23.0", diff --git a/src/components/alerts/alert.css b/src/components/alerts/alert.css new file mode 100644 index 0000000000000000000000000000000000000000..250c79d2a9f77fd6f0872244dc25b2dc3eefbb15 --- /dev/null +++ b/src/components/alerts/alert.css @@ -0,0 +1,34 @@ +@import "../../css/units.css"; +@import "../../css/colors.css"; +@import "../../css/z-index.css"; + +.alert { + width: 100%; + background: #FFF0DF; + display: flex; + flex-direction: row; + overflow: hidden; + align-items: left; + border: 1px solid #FF8C1A; + border-radius: 8px; + padding: 12px; + box-shadow: 2px 2px 2px 2px rgba(255, 140, 26, 0.25); + margin-bottom: 7px; +} + +.alert-icon { + vertical-align: middle; + margin-right: 5px; +} + +.alert-message { + color: #555; + font-weight: bold; + font-size: 12px; + line-height: 22pt; + width: 100%; +} + +.alert-remove-button { + color: #FF8C1A; +} diff --git a/src/components/alerts/alert.jsx b/src/components/alerts/alert.jsx new file mode 100644 index 0000000000000000000000000000000000000000..63ac06f5123945c8fbbc54b0faf8b48b05d372b2 --- /dev/null +++ b/src/components/alerts/alert.jsx @@ -0,0 +1,41 @@ +import React from 'react'; +import PropTypes from 'prop-types'; + +import Box from '../box/box.jsx'; +import Button from '../button/button.jsx'; + +import styles from './alert.css'; + +const AlertComponent = ({ + iconURL, + message, + onCloseAlert +}) => ( + <Box + className={styles.alert} + > + <div className={styles.alertMessage}> + {iconURL ? ( + <img + className={styles.alertIcon} + src={iconURL} + /> + ) : null} + {message} + </div> + <Button + className={styles.alertRemoveButton} + onClick={onCloseAlert} + > + {'x'} + </Button> + </Box> +); + +AlertComponent.propTypes = { + iconURL: PropTypes.string, + message: PropTypes.string, + onCloseAlert: PropTypes.func.isRequired +}; + +export default AlertComponent; diff --git a/src/components/gui/gui.css b/src/components/gui/gui.css index 086011f4ba0a975499f0f7892fcc2eaa3bcee5b5..16f79ea4d684308cf8a6095bb4246b7966eb87af 100644 --- a/src/components/gui/gui.css +++ b/src/components/gui/gui.css @@ -278,3 +278,15 @@ $fade-out-distance: 15px; .extension-button > div { margin-top: 0; } + +/* Alerts */ + +.alerts-container { + width: 448px; + z-index: $z-index-alerts; + left: 0; + right: 0; + margin: auto; + position: absolute; + margin-top: 53px; +} diff --git a/src/components/gui/gui.jsx b/src/components/gui/gui.jsx index aa1dfc0393388111d80c4179e21b7a0d62fa95f1..9cdafaa8cfb31143e74cf879ffcfaef8a0d7e6ca 100644 --- a/src/components/gui/gui.jsx +++ b/src/components/gui/gui.jsx @@ -27,6 +27,7 @@ import ImportModal from '../../containers/import-modal.jsx'; import WebGlModal from '../../containers/webgl-modal.jsx'; import TipsLibrary from '../../containers/tips-library.jsx'; import Cards from '../../containers/cards.jsx'; +import Alerts from '../../containers/alerts.jsx'; import DragLayer from '../../containers/drag-layer.jsx'; import layout, {STAGE_SIZE_MODES} from '../../lib/layout-constants'; @@ -52,7 +53,9 @@ let isRendererSupported = null; const GUIComponent = props => { const { + accountNavOpen, activeTabIndex, + alertsVisible, basePath, backdropLibraryVisible, backpackOptions, @@ -67,14 +70,21 @@ const GUIComponent = props => { isPlayerOnly, isRtl, loading, - onExtensionButtonClick, + renderLogin, + onClickAccountNav, + onCloseAccountNav, + onLogOut, + onOpenRegistration, + onToggleLoginOpen, + onUpdateProjectTitle, onActivateCostumesTab, onActivateSoundsTab, onActivateTab, + onExtensionButtonClick, onRequestCloseBackdropLibrary, onRequestCloseCostumeLibrary, onSeeCommunity, - onUpdateProjectTitle, + onShare, previewInfoVisible, targetIsStage, soundsTabVisible, @@ -108,7 +118,11 @@ const GUIComponent = props => { isRendererSupported={isRendererSupported} stageSize={stageSize} vm={vm} - /> + > + {alertsVisible ? ( + <Alerts className={styles.alertsContainer} /> + ) : null} + </StageWrapper> ) : ( <Box className={styles.pageWrapper} @@ -133,6 +147,9 @@ const GUIComponent = props => { {cardsVisible ? ( <Cards /> ) : null} + {alertsVisible ? ( + <Alerts className={styles.alertsContainer} /> + ) : null} {costumeLibraryVisible ? ( <CostumeLibrary vm={vm} @@ -146,8 +163,16 @@ const GUIComponent = props => { /> ) : null} <MenuBar + accountNavOpen={accountNavOpen} enableCommunity={enableCommunity} + renderLogin={renderLogin} + onClickAccountNav={onClickAccountNav} + onCloseAccountNav={onCloseAccountNav} + onLogOut={onLogOut} + onOpenRegistration={onOpenRegistration} onSeeCommunity={onSeeCommunity} + onShare={onShare} + onToggleLoginOpen={onToggleLoginOpen} onUpdateProjectTitle={onUpdateProjectTitle} /> <Box className={styles.bodyWrapper}> @@ -270,6 +295,7 @@ const GUIComponent = props => { }; GUIComponent.propTypes = { + accountNavOpen: PropTypes.bool, activeTabIndex: PropTypes.number, backdropLibraryVisible: PropTypes.bool, backpackOptions: PropTypes.shape({ @@ -291,13 +317,20 @@ GUIComponent.propTypes = { onActivateCostumesTab: PropTypes.func, onActivateSoundsTab: PropTypes.func, onActivateTab: PropTypes.func, + onClickAccountNav: PropTypes.func, + onCloseAccountNav: PropTypes.func, onExtensionButtonClick: PropTypes.func, + onLogOut: PropTypes.func, + onOpenRegistration: PropTypes.func, onRequestCloseBackdropLibrary: PropTypes.func, onRequestCloseCostumeLibrary: PropTypes.func, onSeeCommunity: PropTypes.func, + onShare: PropTypes.func, onTabSelect: PropTypes.func, + onToggleLoginOpen: PropTypes.func, onUpdateProjectTitle: PropTypes.func, previewInfoVisible: PropTypes.bool, + renderLogin: PropTypes.func, soundsTabVisible: PropTypes.bool, stageSizeMode: PropTypes.oneOf(Object.keys(STAGE_SIZE_MODES)), targetIsStage: PropTypes.bool, diff --git a/src/components/library/library.jsx b/src/components/library/library.jsx index 9d63fc3bfad63cab11cf3f8df27bec40d7f9c13e..9ff4ead42b382166655a48335ced57026dee7ee3 100644 --- a/src/components/library/library.jsx +++ b/src/components/library/library.jsx @@ -13,17 +13,22 @@ import analytics from '../../lib/analytics'; import styles from './library.css'; -const ALL_TAG_TITLE = 'All'; -const tagListPrefix = [{title: ALL_TAG_TITLE}]; - const messages = defineMessages({ filterPlaceholder: { id: 'gui.library.filterPlaceholder', defaultMessage: 'Search', description: 'Placeholder text for library search field' + }, + allTag: { + id: 'gui.library.allTag', + defaultMessage: 'All', + description: 'Label for library tag to revert to all items after filtering by tag.' } }); +const ALL_TAG = {tag: 'all', intlLabel: messages.allTag}; +const tagListPrefix = [ALL_TAG]; + class LibraryComponent extends React.Component { constructor (props) { super(props); @@ -42,7 +47,7 @@ class LibraryComponent extends React.Component { this.state = { selectedItem: null, filterQuery: '', - selectedTag: ALL_TAG_TITLE.toLowerCase() + selectedTag: ALL_TAG.tag }; } componentDidUpdate (prevProps, prevState) { @@ -80,7 +85,7 @@ class LibraryComponent extends React.Component { handleFilterChange (event) { this.setState({ filterQuery: event.target.value, - selectedTag: ALL_TAG_TITLE.toLowerCase() + selectedTag: ALL_TAG.tag }); } handleFilterClear () { @@ -141,7 +146,7 @@ class LibraryComponent extends React.Component { <div className={styles.tagWrapper}> {tagListPrefix.concat(this.props.tags).map((tagProps, id) => ( <TagButton - active={this.state.selectedTag === tagProps.title.toLowerCase()} + active={this.state.selectedTag === tagProps.tag.toLowerCase()} className={classNames( styles.filterBarItem, styles.tagButton, diff --git a/src/components/menu-bar/account-nav.css b/src/components/menu-bar/account-nav.css new file mode 100644 index 0000000000000000000000000000000000000000..c04f0709ee8fc463535399a5149e3a443aecf42e --- /dev/null +++ b/src/components/menu-bar/account-nav.css @@ -0,0 +1,50 @@ +@import "../../css/colors.css"; +@import "../../css/units.css"; + +.user-info { + display: inline-flex; + flex-wrap: nowrap; + justify-content: center; + align-items: center; + align-content: center; + padding: 0 0.95rem; + max-width: 260px; + height: $menu-bar-height; + overflow: hidden; + text-decoration: none; + text-overflow: ellipsis; + white-space: nowrap; + color: $ui-white; + font-size: .75rem; + font-weight: normal; +} + +[dir="ltr"] .user-info .avatar { + margin-right: calc($space * .8125); +} + +[dir="rtl"] .user-info .avatar { + margin-left: calc($space * .8125); +} + +.user-info .avatar { + margin-right: calc($space * .8125); + border-radius: $form-radius; + width: 2rem; + height: 2rem; + vertical-align: middle; +} + +[dir="ltr"] .user-info .dropdown-caret-position { + margin-left: calc($space * .8125); +} + +[dir="rtl"] .user-info .dropdown-caret-position { + margin-right: calc($space * .8125); +} + +.user-info .dropdown-caret-position { + display: inline-block; + padding-bottom: .125rem; + vertical-align: middle; +} diff --git a/src/components/menu-bar/account-nav.jsx b/src/components/menu-bar/account-nav.jsx new file mode 100644 index 0000000000000000000000000000000000000000..351ae2136557e41da84df8f9d52ee1e3c9534144 --- /dev/null +++ b/src/components/menu-bar/account-nav.jsx @@ -0,0 +1,110 @@ +/* +NOTE: this file only temporarily resides in scratch-gui. +Nearly identical code appears in scratch-www, and the two should +eventually be consolidated. +*/ + +import classNames from 'classnames'; +import {FormattedMessage} from 'react-intl'; +import PropTypes from 'prop-types'; +import React from 'react'; + +import MenuBarMenu from './menu-bar-menu.jsx'; +import {MenuSection} from '../menu/menu.jsx'; +import MenuItemContainer from '../../containers/menu-item.jsx'; +import dropdownCaret from './dropdown-caret.svg'; + +import styles from './account-nav.css'; + +const AccountNavComponent = ({ + className, + classroomId, + isEducator, + isOpen, + isRtl, + isStudent, + menuBarMenuClassName, + onClick, + onClose, + onLogOut, + profileUrl, + thumbnailUrl, + username +}) => ( + <React.Fragment> + <div + className={classNames( + styles.userInfo, + className + )} + onMouseUp={onClick} + > + {thumbnailUrl ? ( + <img + className={styles.avatar} + src={thumbnailUrl} + /> + ) : null} + <span className={styles.profileName}> + {username} + </span> + <div className={styles.dropdownCaretPosition}> + <img + className={styles.dropdownCaretIcon} + src={dropdownCaret} + /> + </div> + </div> + <MenuBarMenu + className={menuBarMenuClassName} + open={isOpen} + // note: the Rtl styles are switched here, because this menu is justified + // opposite all the others + place={isRtl ? 'right' : 'left'} + onRequestClose={onClose} + > + <MenuItemContainer href={profileUrl}> + <FormattedMessage id="general.profile" /> + </MenuItemContainer> + <MenuItemContainer href="/mystuff/"> + <FormattedMessage id="general.myStuff" /> + </MenuItemContainer> + {isEducator ? ( + <MenuItemContainer href="/educators/classes/"> + <FormattedMessage id="general.myClasses" /> + </MenuItemContainer> + ) : null} + {isStudent ? ( + <MenuItemContainer href={`/classes/${classroomId}/`}> + <FormattedMessage id="general.myClass" /> + </MenuItemContainer> + ) : null} + <MenuItemContainer href="/accounts/settings/"> + <FormattedMessage id="general.accountSettings" /> + </MenuItemContainer> + <MenuSection> + <MenuItemContainer onClick={onLogOut}> + <FormattedMessage id="navigation.signOut" /> + </MenuItemContainer> + </MenuSection> + </MenuBarMenu> + </React.Fragment> +); + +AccountNavComponent.propTypes = { + className: PropTypes.string, + classroomId: PropTypes.string, + isEducator: PropTypes.bool, + isOpen: PropTypes.bool, + isRtl: PropTypes.bool, + isStudent: PropTypes.bool, + menuBarMenuClassName: PropTypes.string, + onClick: PropTypes.func, + onClose: PropTypes.func, + onLogOut: PropTypes.func, + profileUrl: PropTypes.string, + thumbnailUrl: PropTypes.string, + username: PropTypes.string +}; + +export default AccountNavComponent; diff --git a/src/components/language-selector/dropdown-caret.svg b/src/components/menu-bar/dropdown-caret.svg similarity index 100% rename from src/components/language-selector/dropdown-caret.svg rename to src/components/menu-bar/dropdown-caret.svg diff --git a/src/components/menu-bar/login-dropdown.css b/src/components/menu-bar/login-dropdown.css new file mode 100644 index 0000000000000000000000000000000000000000..8b16b7fdbfe18a0ad05de7438b72ab6cbff242d5 --- /dev/null +++ b/src/components/menu-bar/login-dropdown.css @@ -0,0 +1,4 @@ + +.login { + padding: .625rem; +} diff --git a/src/components/menu-bar/login-dropdown.jsx b/src/components/menu-bar/login-dropdown.jsx new file mode 100644 index 0000000000000000000000000000000000000000..11d23856c8f35edbec895c3b311073b8cc7ec743 --- /dev/null +++ b/src/components/menu-bar/login-dropdown.jsx @@ -0,0 +1,50 @@ +/* +NOTE: this file only temporarily resides in scratch-gui. +Nearly identical code appears in scratch-www, and the two should +eventually be consolidated. +*/ + +import classNames from 'classnames'; +import PropTypes from 'prop-types'; +import React from 'react'; + +import MenuBarMenu from './menu-bar-menu.jsx'; + +import styles from './login-dropdown.css'; + +const LoginDropdown = ({ + className, + isOpen, + isRtl, + onClose, + renderLogin +}) => ( + <MenuBarMenu + className={className} + open={isOpen} + // note: the Rtl styles are switched here, because this menu is justified + // opposite all the others + place={isRtl ? 'right' : 'left'} + onRequestClose={onClose} + > + <div + className={classNames( + styles.login + )} + > + {renderLogin({ + onClose: onClose + })} + </div> + </MenuBarMenu> +); + +LoginDropdown.propTypes = { + className: PropTypes.string, + isOpen: PropTypes.bool, + isRtl: PropTypes.bool, + onClose: PropTypes.func, + renderLogin: PropTypes.func +}; + +export default LoginDropdown; diff --git a/src/components/menu-bar/menu-bar-menu.jsx b/src/components/menu-bar/menu-bar-menu.jsx new file mode 100644 index 0000000000000000000000000000000000000000..d71d9e04930c95ab79222881d5d87253597472c9 --- /dev/null +++ b/src/components/menu-bar/menu-bar-menu.jsx @@ -0,0 +1,31 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import Menu from '../../containers/menu.jsx'; + +const MenuBarMenu = ({ + children, + className, + onRequestClose, + open, + place = 'right' +}) => ( + <div className={className}> + <Menu + open={open} + place={place} + onRequestClose={onRequestClose} + > + {children} + </Menu> + </div> +); + +MenuBarMenu.propTypes = { + children: PropTypes.node, + className: PropTypes.string, + onRequestClose: PropTypes.func, + open: PropTypes.bool, + place: PropTypes.oneOf(['left', 'right']) +}; + +export default MenuBarMenu; diff --git a/src/components/menu-bar/menu-bar.css b/src/components/menu-bar/menu-bar.css index 46fd7313c16c6a12c55799979d43a876f6e33eff..dcc230672499634335229e1e3295bd626319970a 100644 --- a/src/components/menu-bar/menu-bar.css +++ b/src/components/menu-bar/menu-bar.css @@ -58,11 +58,6 @@ width: $language-selector-width; } -.menu { - z-index: $z-index-menu-bar; - top: $menu-bar-height; -} - .menu-bar-item { display: flex; padding: 0 0.25rem; @@ -105,6 +100,11 @@ padding: 0 0.75rem; } +.menu-bar-menu { + margin-top: $menu-bar-height; + z-index: $z-index-menu-bar; +} + .feedback-link { color: $motion-primary; text-decoration: none; @@ -139,13 +139,16 @@ opacity: 0.5; } -.account-info-wrapper { +.account-info-group { display: flex; flex-direction: row; - padding: 0 .5rem; align-items: center; } +.account-info-group .menu-bar-item { + padding: 0 0.75rem; +} + .mystuff-icon { margin: 0 .25rem; height: 1rem; @@ -197,3 +200,22 @@ filter: hue-rotate(360deg); } } + +.mystuff > a { + background-repeat: no-repeat; + background-position: center center; + background-size: 45%; + padding-right: 10px; + padding-left: 10px; + width: 30px; + overflow: hidden; + text-indent: 50px; + white-space: nowrap; +} +.mystuff > a:hover { + background-size: 50%; +} + +.mystuff > a { + background-image: url("/images/mystuff.png"); +} diff --git a/src/components/menu-bar/menu-bar.jsx b/src/components/menu-bar/menu-bar.jsx index 897fedc3f3166da91678c56be805566d0200b35d..7d321ede9ecb4c068e1fc2c8a0de36af2ff6cc9a 100644 --- a/src/components/menu-bar/menu-bar.jsx +++ b/src/components/menu-bar/menu-bar.jsx @@ -11,9 +11,11 @@ import {ComingSoonTooltip} from '../coming-soon/coming-soon.jsx'; import Divider from '../divider/divider.jsx'; import LanguageSelector from '../../containers/language-selector.jsx'; import ProjectLoader from '../../containers/project-loader.jsx'; -import Menu from '../../containers/menu.jsx'; +import MenuBarMenu from './menu-bar-menu.jsx'; import {MenuItem, MenuSection} from '../menu/menu.jsx'; import ProjectTitleInput from './project-title-input.jsx'; +import AccountNav from '../../containers/account-nav.jsx'; +import LoginDropdown from './login-dropdown.jsx'; import ProjectSaver from '../../containers/project-saver.jsx'; import DeletionRestorer from '../../containers/deletion-restorer.jsx'; import TurboMode from '../../containers/turbo-mode.jsx'; @@ -21,6 +23,9 @@ import TurboMode from '../../containers/turbo-mode.jsx'; import {openTipsLibrary} from '../../reducers/modals'; import {setPlayer} from '../../reducers/mode'; import { + openAccountMenu, + closeAccountMenu, + accountMenuOpen, openFileMenu, closeFileMenu, fileMenuOpen, @@ -29,7 +34,10 @@ import { editMenuOpen, openLanguageMenu, closeLanguageMenu, - languageMenuOpen + languageMenuOpen, + openLoginMenu, + closeLoginMenu, + loginMenuOpen } from '../../reducers/menus'; import styles from './menu-bar.css'; @@ -39,7 +47,7 @@ import mystuffIcon from './icon--mystuff.png'; import feedbackIcon from './icon--feedback.svg'; import profileIcon from './icon--profile.png'; import communityIcon from './icon--see-community.svg'; -import dropdownCaret from '../language-selector/dropdown-caret.svg'; +import dropdownCaret from './dropdown-caret.svg'; import languageIcon from '../language-selector/language-icon.svg'; import scratchLogo from './scratch-logo.svg'; @@ -111,28 +119,6 @@ MenuItemTooltip.propTypes = { isRtl: PropTypes.bool }; -const MenuBarMenu = ({ - children, - onRequestClose, - open, - place = 'right' -}) => ( - <Menu - className={styles.menu} - open={open} - place={place} - onRequestClose={onRequestClose} - > - {children} - </Menu> -); - -MenuBarMenu.propTypes = { - children: PropTypes.node, - onRequestClose: PropTypes.func, - open: PropTypes.bool, - place: PropTypes.oneOf(['left', 'right']) -}; class MenuBar extends React.Component { constructor (props) { super(props); @@ -210,6 +196,18 @@ class MenuBar extends React.Component { id="gui.menuBar.saveNow" /> ); + const shareButton = ( + <Button + className={classNames(styles.shareButton)} + onClick={this.props.onShare} + > + <FormattedMessage + defaultMessage="Share" + description="Label for project share button" + id="gui.menuBar.share" + /> + </Button> + ); return ( <Box className={classNames(styles.menuBar, { @@ -253,14 +251,13 @@ class MenuBar extends React.Component { })} onMouseUp={this.props.onClickFile} > - <div className={classNames(styles.fileMenu)}> - <FormattedMessage - defaultMessage="File" - description="Text for file dropdown menu" - id="gui.menuBar.file" - /> - </div> + <FormattedMessage + defaultMessage="File" + description="Text for file dropdown menu" + id="gui.menuBar.file" + /> <MenuBarMenu + className={classNames(styles.menuBarMenu)} open={this.props.fileMenuOpen} place={this.props.isRtl ? 'left' : 'right'} onRequestClose={this.props.onRequestCloseFile} @@ -301,7 +298,8 @@ class MenuBar extends React.Component { defaultMessage="Save as a copy" description="Menu bar item for saving as a copy" id="gui.menuBar.saveAsCopy" - /></MenuItem> + /> + </MenuItem> </MenuItemTooltip> </MenuSection> <MenuSection> @@ -346,6 +344,7 @@ class MenuBar extends React.Component { /> </div> <MenuBarMenu + className={classNames(styles.menuBarMenu)} open={this.props.editMenuOpen} place={this.props.isRtl ? 'left' : 'right'} onRequestClose={this.props.onRequestCloseEdit} @@ -405,15 +404,11 @@ class MenuBar extends React.Component { </MenuBarItemTooltip> </div> <div className={classNames(styles.menuBarItem)}> - <MenuBarItemTooltip id="share-button"> - <Button className={classNames(styles.shareButton)}> - <FormattedMessage - defaultMessage="Share" - description="Label for project share button" - id="gui.menuBar.share" - /> - </Button> - </MenuBarItemTooltip> + {this.props.onShare ? shareButton : ( + <MenuBarItemTooltip id="share-button"> + {shareButton} + </MenuBarItemTooltip> + )} </div> <div className={classNames(styles.menuBarItem, styles.communityButtonWrapper)}> {this.props.enableCommunity ? @@ -445,64 +440,145 @@ class MenuBar extends React.Component { } </div> </div> - <div className={classNames(styles.menuBarItem, styles.feedbackButtonWrapper)}> - <a - className={styles.feedbackLink} - href="https://scratch.mit.edu/discuss/topic/312261/" - rel="noopener noreferrer" - target="_blank" - > - <Button - className={styles.feedbackButton} - iconSrc={feedbackIcon} - > - <FormattedMessage - defaultMessage="Give Feedback" - description="Label for feedback form modal button" - id="gui.menuBar.giveFeedback" - /> - </Button> - </a> - </div> - <div className={styles.accountInfoWrapper}> - <MenuBarItemTooltip id="mystuff"> - <div - className={classNames( - styles.menuBarItem, - styles.hoverable, - styles.mystuffButton - )} - > - <img - className={styles.mystuffIcon} - src={mystuffIcon} - /> - </div> - </MenuBarItemTooltip> - <MenuBarItemTooltip - id="account-nav" - place={this.props.isRtl ? 'right' : 'left'} - > - <div - className={classNames( - styles.menuBarItem, - styles.hoverable, - styles.accountNavMenu - )} - > - <img - className={styles.profileIcon} - src={profileIcon} - /> - <span> - {'scratch-cat' /* @todo username */} - </span> - <img - className={styles.dropdownCaretIcon} - src={dropdownCaret} - /> - </div> - </MenuBarItemTooltip> + + {/* show the proper UI in the account menu, given whether the user is + logged in, and whether a session is available to log in with */} + <div className={styles.accountInfoGroup}> + {this.props.sessionExists ? ( + this.props.username ? ( + // ************ user is logged in ************ + <React.Fragment> + <a href="/mystuff/"> + <div + className={classNames( + styles.menuBarItem, + styles.hoverable, + styles.mystuffButton + )} + > + <img + className={styles.mystuffIcon} + src={mystuffIcon} + /> + </div> + </a> + <AccountNav + className={classNames( + styles.menuBarItem, + styles.hoverable, + {[styles.active]: this.props.accountMenuOpen} + )} + isOpen={this.props.accountMenuOpen} + isRtl={this.props.isRtl} + menuBarMenuClassName={classNames(styles.menuBarMenu)} + onClick={this.props.onClickAccount} + onClose={this.props.onRequestCloseAccount} + onLogOut={this.props.onLogOut} + /> + </React.Fragment> + ) : ( + // ********* user not logged in, but a session exists + // ********* so they can choose to log in + <React.Fragment> + <div + className={classNames( + styles.menuBarItem, + styles.hoverable + )} + key="join" + onMouseUp={this.props.onOpenRegistration} + > + <FormattedMessage + defaultMessage="Join Scratch" + description="Link for creating a Scratch account" + id="gui.menuBar.joinScratch" + /> + </div> + <div + className={classNames( + styles.menuBarItem, + styles.hoverable + )} + key="login" + onMouseUp={this.props.onClickLogin} + > + <FormattedMessage + defaultMessage="Sign in" + description="Link for signing in to your Scratch account" + id="gui.menuBar.signIn" + /> + <LoginDropdown + className={classNames(styles.menuBarMenu)} + isOpen={this.props.loginMenuOpen} + isRtl={this.props.isRtl} + renderLogin={this.props.renderLogin} + onClose={this.props.onRequestCloseLogin} + /> + </div> + </React.Fragment> + ) + ) : ( + // ******** no login session is available, so don't show login stuff + <React.Fragment> + <div className={classNames(styles.menuBarItem, styles.feedbackButtonWrapper)}> + <a + className={styles.feedbackLink} + href="https://scratch.mit.edu/discuss/topic/312261/" + rel="noopener noreferrer" + target="_blank" + > + <Button + className={styles.feedbackButton} + iconSrc={feedbackIcon} + > + <FormattedMessage + defaultMessage="Give Feedback" + description="Label for feedback form modal button" + id="gui.menuBar.giveFeedback" + /> + </Button> + </a> + </div> + <MenuBarItemTooltip id="mystuff"> + <div + className={classNames( + styles.menuBarItem, + styles.hoverable, + styles.mystuffButton + )} + > + <img + className={styles.mystuffIcon} + src={mystuffIcon} + /> + </div> + </MenuBarItemTooltip> + <MenuBarItemTooltip + id="account-nav" + place={this.props.isRtl ? 'right' : 'left'} + > + <div + className={classNames( + styles.menuBarItem, + styles.hoverable, + styles.accountNavMenu + )} + > + <img + className={styles.profileIcon} + src={profileIcon} + /> + <span> + {'scratch-cat'} + </span> + <img + className={styles.dropdownCaretIcon} + src={dropdownCaret} + /> + </div> + </MenuBarItemTooltip> + </React.Fragment> + )} </div> </Box> ); @@ -510,6 +586,7 @@ class MenuBar extends React.Component { } MenuBar.propTypes = { + accountMenuOpen: PropTypes.bool, canUpdateProject: PropTypes.bool, editMenuOpen: PropTypes.bool, enableCommunity: PropTypes.bool, @@ -517,33 +594,53 @@ MenuBar.propTypes = { intl: intlShape, isRtl: PropTypes.bool, languageMenuOpen: PropTypes.bool, + loginMenuOpen: PropTypes.bool, + onClickAccount: PropTypes.func, onClickEdit: PropTypes.func, onClickFile: PropTypes.func, onClickLanguage: PropTypes.func, + onClickLogin: PropTypes.func, + onLogOut: PropTypes.func, + onOpenRegistration: PropTypes.func, onOpenTipLibrary: PropTypes.func, + onRequestCloseAccount: PropTypes.func, onRequestCloseEdit: PropTypes.func, onRequestCloseFile: PropTypes.func, onRequestCloseLanguage: PropTypes.func, + onRequestCloseLogin: PropTypes.func, onSeeCommunity: PropTypes.func, - onUpdateProjectTitle: PropTypes.func + onToggleLoginOpen: PropTypes.func, + onUpdateProjectTitle: PropTypes.func, + renderLogin: PropTypes.func, + sessionExists: PropTypes.bool, + username: PropTypes.string }; const mapStateToProps = state => ({ canUpdateProject: typeof (state.session && state.session.session && state.session.session.user) !== 'undefined', + accountMenuOpen: accountMenuOpen(state), fileMenuOpen: fileMenuOpen(state), editMenuOpen: editMenuOpen(state), isRtl: state.locales.isRtl, - languageMenuOpen: languageMenuOpen(state) + languageMenuOpen: languageMenuOpen(state), + loginMenuOpen: loginMenuOpen(state), + sessionExists: state.session && typeof state.session.session !== 'undefined', + username: state.session && state.session.session && state.session.session.user ? + state.session.session.user.username : null }); const mapDispatchToProps = dispatch => ({ onOpenTipLibrary: () => dispatch(openTipsLibrary()), + onClickAccount: () => dispatch(openAccountMenu()), + onRequestCloseAccount: () => dispatch(closeAccountMenu()), onClickFile: () => dispatch(openFileMenu()), onRequestCloseFile: () => dispatch(closeFileMenu()), onClickEdit: () => dispatch(openEditMenu()), onRequestCloseEdit: () => dispatch(closeEditMenu()), onClickLanguage: () => dispatch(openLanguageMenu()), onRequestCloseLanguage: () => dispatch(closeLanguageMenu()), + onClickLogin: () => dispatch(openLoginMenu()), + onRequestCloseLogin: () => dispatch(closeLoginMenu()), onSeeCommunity: () => dispatch(setPlayer(true)) }); diff --git a/src/components/menu/menu.jsx b/src/components/menu/menu.jsx index ce443db55cd0bc63a5a867b97ebd6c0760967cc8..4e6a6b229170d5d8f0a7844c3fa58ff3425972bc 100644 --- a/src/components/menu/menu.jsx +++ b/src/components/menu/menu.jsx @@ -32,6 +32,7 @@ MenuComponent.propTypes = { place: PropTypes.oneOf(['left', 'right']) }; + const MenuItem = ({ children, className, @@ -51,6 +52,7 @@ MenuItem.propTypes = { onClick: PropTypes.func }; + const addDividerClassToFirstChild = (child, id) => ( React.cloneElement(child, { className: classNames(child.className, { diff --git a/src/components/mic-indicator/mic-indicator.css b/src/components/mic-indicator/mic-indicator.css new file mode 100644 index 0000000000000000000000000000000000000000..9fb3970297b19fbc9c4db523bc9bbe42674ed682 --- /dev/null +++ b/src/components/mic-indicator/mic-indicator.css @@ -0,0 +1,10 @@ +@keyframes popIn { + from {transform: scale(0.5)} + to {transform: scale(1)} +} + +.mic-img { + margin: 10px; + transform-origin: center; + animation: popIn 0.1s ease-in-out; +} diff --git a/src/components/mic-indicator/mic-indicator.jsx b/src/components/mic-indicator/mic-indicator.jsx new file mode 100644 index 0000000000000000000000000000000000000000..fe0eb602b531e5c33de0cd2a696ab5a5dc907962 --- /dev/null +++ b/src/components/mic-indicator/mic-indicator.jsx @@ -0,0 +1,29 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import styles from './mic-indicator.css'; +import micIcon from './mic-indicator.svg'; +import {stageSizeToTransform} from '../../lib/screen-utils'; + +const MicIndicatorComponent = props => ( + <div + className={props.className} + style={stageSizeToTransform(props.stageSize)} + > + <img + className={styles.micImg} + src={micIcon} + /> + </div> +); + +MicIndicatorComponent.propTypes = { + className: PropTypes.string, + stageSize: PropTypes.shape({ + width: PropTypes.number, + height: PropTypes.number, + widthDefault: PropTypes.number, + heightDefault: PropTypes.number + }).isRequired +}; + +export default MicIndicatorComponent; diff --git a/src/components/mic-indicator/mic-indicator.svg b/src/components/mic-indicator/mic-indicator.svg new file mode 100644 index 0000000000000000000000000000000000000000..78726389d13b811a8b7f31bbfa32a98df118a932 Binary files /dev/null and b/src/components/mic-indicator/mic-indicator.svg differ diff --git a/src/components/monitor-list/monitor-list.jsx b/src/components/monitor-list/monitor-list.jsx index 5cb11424113808e07f9c8573a9b2626ec3a838d5..f7b02080fdf71aeadbf673bf53a9fa23990d6760 100644 --- a/src/components/monitor-list/monitor-list.jsx +++ b/src/components/monitor-list/monitor-list.jsx @@ -4,20 +4,10 @@ import Box from '../box/box.jsx'; import Monitor from '../../containers/monitor.jsx'; import PropTypes from 'prop-types'; import {OrderedMap} from 'immutable'; +import {stageSizeToTransform} from '../../lib/screen-utils'; import styles from './monitor-list.css'; -const stageSizeToTransform = ({width, height, widthDefault, heightDefault}) => { - const scaleX = width / widthDefault; - const scaleY = height / heightDefault; - if (scaleX === 1 && scaleY === 1) { - // Do not set a transform if the scale is 1 because - // it messes up `position: fixed` elements like the context menu. - return; - } - return {transform: `scale(${scaleX},${scaleY})`}; -}; - const MonitorList = props => ( <Box // Use static `monitor-overlay` class for bounds of draggables diff --git a/src/components/question/question.css b/src/components/question/question.css index 4550df75cdbe1b3ad51b2e5c45e54f47d1de7627..a0f8116ed1a1d9c85957d1167ee792f1ee8515f4 100644 --- a/src/components/question/question.css +++ b/src/components/question/question.css @@ -1,13 +1,6 @@ @import "../../css/units.css"; @import "../../css/colors.css"; -.question-wrapper { - position: absolute; - bottom: 0; - left: 0; - width: 100%; -} - .question-container { margin: $space; border: 1px solid $ui-black-transparent; diff --git a/src/components/question/question.jsx b/src/components/question/question.jsx index c57fb085d37de1f42a75f3a709c05d0f313f76ac..373f7cb919cd86fdbab96665457fdec29cb5fb4e 100644 --- a/src/components/question/question.jsx +++ b/src/components/question/question.jsx @@ -7,13 +7,14 @@ import enterIcon from './icon--enter.svg'; const QuestionComponent = props => { const { answer, + className, question, onChange, onClick, onKeyPress } = props; return ( - <div className={styles.questionWrapper}> + <div className={className}> <div className={styles.questionContainer}> {question ? ( <div className={styles.questionLabel}>{question}</div> @@ -43,6 +44,7 @@ const QuestionComponent = props => { QuestionComponent.propTypes = { answer: PropTypes.string, + className: PropTypes.string, onChange: PropTypes.func.isRequired, onClick: PropTypes.func.isRequired, onKeyPress: PropTypes.func.isRequired, diff --git a/src/components/stage/stage.css b/src/components/stage/stage.css index 7ce4d55d9f6a953541564e6e12b51fc1fed9be78..98a0c42a715d8aace5fc8294dfc5d9f895226dd2 100644 --- a/src/components/stage/stage.css +++ b/src/components/stage/stage.css @@ -74,10 +74,6 @@ border: none; } -.question-wrapper { - position: absolute; -} - /* adjust monitors when stage is standard size: shift them down and right to compensate for the stage's border */ .stage-wrapper .monitor-wrapper { @@ -93,7 +89,7 @@ to adjust for the border using a different method */ padding-bottom: calc($stage-full-screen-stage-padding + $stage-full-screen-border-width); } -.monitor-wrapper, .color-picker-wrapper, .queston-wrapper { +.monitor-wrapper, .color-picker-wrapper { position: absolute; top: 0; left: 0; @@ -110,3 +106,24 @@ to adjust for the border using a different method */ z-index: $z-index-dragging-sprite; filter: drop-shadow(5px 5px 5px $ui-black-transparent); } + +.stage-bottom-wrapper { + position: absolute; + display: flex; + flex-direction: column; + justify-content: flex-end; + top: 0; + overflow: hidden; + pointer-events: none; +} + +.mic-indicator { + transform-origin: bottom right; + z-index: $z-index-stage-indicator; + pointer-events: none; + align-self: flex-end; +} + +.question-wrapper { + pointer-events: auto; +} diff --git a/src/components/stage/stage.jsx b/src/components/stage/stage.jsx index d8c0d07749bec8eb360820988b5e360318513e71..9e1f3454dd1686be6e745dd5fb8035ec97b16b41 100644 --- a/src/components/stage/stage.jsx +++ b/src/components/stage/stage.jsx @@ -7,6 +7,7 @@ import DOMElementRenderer from '../../containers/dom-element-renderer.jsx'; import Loupe from '../loupe/loupe.jsx'; import MonitorList from '../../containers/monitor-list.jsx'; import Question from '../../containers/question.jsx'; +import MicIndicator from '../mic-indicator/mic-indicator.jsx'; import {STAGE_DISPLAY_SIZES} from '../../lib/layout-constants.js'; import {getStageDimensions} from '../../lib/screen-utils.js'; import styles from './stage.css'; @@ -18,6 +19,7 @@ const StageComponent = props => { isColorPicking, isFullScreen, colorInfo, + micIndicator, question, stageSize, useEditorDragStyle, @@ -66,13 +68,22 @@ const StageComponent = props => { <Loupe colorInfo={colorInfo} /> </Box> ) : null} - {question === null ? null : ( - <div - className={classNames( - styles.stageOverlayContent, - styles.stageOverlayContentBorderOverride - )} - > + <div + className={styles.stageBottomWrapper} + style={{ + width: stageDimensions.width, + height: stageDimensions.height, + left: '50%', + marginLeft: stageDimensions.width * -0.5 + }} + > + {micIndicator ? ( + <MicIndicator + className={styles.micIndicator} + stageSize={stageDimensions} + /> + ) : null} + {question === null ? null : ( <div className={styles.questionWrapper} style={{width: stageDimensions.width}} @@ -82,8 +93,8 @@ const StageComponent = props => { onQuestionAnswered={onQuestionAnswered} /> </div> - </div> - )} + )} + </div> <canvas className={styles.draggingSprite} height={0} diff --git a/src/components/tag-button/tag-button.jsx b/src/components/tag-button/tag-button.jsx index 5e1318f90d993ea74d3ac59faf5719a522fe7ef6..e36a2ed19867de20a09cd6d98580b43b876b13c0 100644 --- a/src/components/tag-button/tag-button.jsx +++ b/src/components/tag-button/tag-button.jsx @@ -1,6 +1,7 @@ import classNames from 'classnames'; import PropTypes from 'prop-types'; import React from 'react'; +import {FormattedMessage} from 'react-intl'; import Button from '../button/button.jsx'; @@ -10,7 +11,8 @@ const TagButtonComponent = ({ active, iconClassName, className, - title, + tag, // eslint-disable-line no-unused-vars + intlLabel, ...props }) => ( <Button @@ -26,17 +28,19 @@ const TagButtonComponent = ({ )} {...props} > - {title} + <FormattedMessage {...intlLabel} /> </Button> ); TagButtonComponent.propTypes = { ...Button.propTypes, active: PropTypes.bool, - title: PropTypes.oneOfType([ - PropTypes.string, - PropTypes.object // FormattedMessage - ]).isRequired + intlLabel: PropTypes.shape({ + defaultMessage: PropTypes.string, + description: PropTypes.string, + id: PropTypes.string + }).isRequired, + tag: PropTypes.string.isRequired }; TagButtonComponent.defaultProps = { diff --git a/src/containers/account-nav.jsx b/src/containers/account-nav.jsx new file mode 100644 index 0000000000000000000000000000000000000000..d0ce3b0628b078612bb83b368a5bcd93f9d3eb1f --- /dev/null +++ b/src/containers/account-nav.jsx @@ -0,0 +1,53 @@ +/* +NOTE: this file only temporarily resides in scratch-gui. +Nearly identical code appears in scratch-www, and the two should +eventually be consolidated. +*/ + +import {injectIntl} from 'react-intl'; +import PropTypes from 'prop-types'; +import React from 'react'; +import {connect} from 'react-redux'; + +import AccountNavComponent from '../components/menu-bar/account-nav.jsx'; + +const AccountNav = function (props) { + const { + ...componentProps + } = props; + return ( + <AccountNavComponent + {...componentProps} + /> + ); +}; + +AccountNav.propTypes = { + classroomId: PropTypes.string, + isEducator: PropTypes.bool, + isRtl: PropTypes.bool, + isStudent: PropTypes.bool, + profileUrl: PropTypes.string, + thumbnailUrl: PropTypes.string, + username: PropTypes.string +}; + +const mapStateToProps = state => ({ + classroomId: state.session && state.session.session && state.session.session.user ? + state.session.session.user.classroomId : '', + isEducator: state.session && state.session.permissions && state.session.permissions.educator, + isStudent: state.session && state.session.permissions && state.session.permissions.student, + profileUrl: state.session && state.session.session && state.session.session.user ? + `/users/${state.session.session.user.username}` : '', + thumbnailUrl: state.session && state.session.session && state.session.session.user ? + state.session.session.user.thumbnailUrl : null, + username: state.session && state.session.session && state.session.session.user ? + state.session.session.user.username : '' +}); + +const mapDispatchToProps = () => ({}); + +export default injectIntl(connect( + mapStateToProps, + mapDispatchToProps +)(AccountNav)); diff --git a/src/containers/alert.jsx b/src/containers/alert.jsx new file mode 100644 index 0000000000000000000000000000000000000000..90f2505dc16d8c4d62354ca83fd9cdebcb044ddb --- /dev/null +++ b/src/containers/alert.jsx @@ -0,0 +1,35 @@ +import React from 'react'; +import bindAll from 'lodash.bindall'; +import PropTypes from 'prop-types'; + +import AlertComponent from '../components/alerts/alert.jsx'; + +class Alert extends React.Component { + constructor (props) { + super(props); + bindAll(this, [ + 'handleOnCloseAlert' + ]); + } + handleOnCloseAlert () { + this.props.onCloseAlert(this.props.index); + } + render () { + return ( + <AlertComponent + iconURL={this.props.iconURL} + message={this.props.message} + onCloseAlert={this.handleOnCloseAlert} + /> + ); + } +} + +Alert.propTypes = { + iconURL: PropTypes.string, + index: PropTypes.number, + message: PropTypes.string, + onCloseAlert: PropTypes.func.isRequired +}; + +export default Alert; diff --git a/src/containers/alerts.jsx b/src/containers/alerts.jsx new file mode 100644 index 0000000000000000000000000000000000000000..c6d7e668dee8f44f0dd878deadc43300941fa9c9 --- /dev/null +++ b/src/containers/alerts.jsx @@ -0,0 +1,51 @@ +import classNames from 'classnames'; +import React from 'react'; +import PropTypes from 'prop-types'; +import {connect} from 'react-redux'; + +import { + closeAlert +} from '../reducers/alerts'; + +import Box from '../components/box/box.jsx'; +import Alert from '../containers/alert.jsx'; + +const Alerts = ({ + alertsList, + className, + onCloseAlert +}) => ( + <Box + bounds="parent" + className={classNames(className)} + > + {alertsList.map((a, index) => ( + <Alert + iconURL={a.iconURL} + index={index} + key={index} + message={a.message} + onCloseAlert={onCloseAlert} + /> + ))} + </Box> +); + +Alerts.propTypes = { + alertsList: PropTypes.arrayOf(PropTypes.object), + className: PropTypes.string, + onCloseAlert: PropTypes.func +}; + +const mapStateToProps = state => ({ + alertsList: state.scratchGui.alerts.alertsList +}); + +const mapDispatchToProps = dispatch => ({ + onCloseAlert: index => dispatch(closeAlert(index)) +}); + +export default connect( + mapStateToProps, + mapDispatchToProps +)(Alerts); diff --git a/src/containers/blocks.jsx b/src/containers/blocks.jsx index 499d77636116b287d0acb3de60c939a0d31f0bd3..bf759bb1b89eade4eb1f4b01446faeecfe075c81 100644 --- a/src/containers/blocks.jsx +++ b/src/containers/blocks.jsx @@ -8,6 +8,7 @@ import VMScratchBlocks from '../lib/blocks'; import VM from 'scratch-vm'; import analytics from '../lib/analytics'; +import log from '../lib/log.js'; import Prompt from './prompt.jsx'; import ConnectionModal from './connection-modal.jsx'; import BlocksComponent from '../components/blocks/blocks.jsx'; @@ -300,7 +301,21 @@ class Blocks extends React.Component { // Remove and reattach the workspace listener (but allow flyout events) this.workspace.removeChangeListener(this.props.vm.blockListener); const dom = this.ScratchBlocks.Xml.textToDom(data.xml); - this.ScratchBlocks.Xml.clearWorkspaceAndLoadFromXml(dom, this.workspace); + try { + this.ScratchBlocks.Xml.clearWorkspaceAndLoadFromXml(dom, this.workspace); + } catch (error) { + // The workspace is likely incomplete. What did update should be + // functional. + // + // Instead of throwing the error, by logging it and continuing as + // normal lets the other workspace update processes complete in the + // gui and vm, which lets the vm run even if the workspace is + // incomplete. Throwing the error would keep things like setting the + // correct editing target from happening which can interfere with + // some blocks and processes in the vm. + error.message = `Workspace Update Error: ${error.message}`; + log.error(error); + } this.workspace.addChangeListener(this.props.vm.blockListener); if (this.props.vm.editingTarget && this.state.workspaceMetrics[this.props.vm.editingTarget.id]) { diff --git a/src/containers/gui.jsx b/src/containers/gui.jsx index 04c6142dce25e073c4f0a0676cd2ab3e5c37590d..6ef14439ba7c19b2f39301e15e3154fc59dd25b8 100644 --- a/src/containers/gui.jsx +++ b/src/containers/gui.jsx @@ -125,6 +125,7 @@ GUI.propTypes = { const mapStateToProps = (state, ownProps) => ({ activeTabIndex: state.scratchGui.editorTab.activeTabIndex, + alertsVisible: state.scratchGui.alerts.visible, backdropLibraryVisible: state.scratchGui.modals.backdropLibrary, blocksTabVisible: state.scratchGui.editorTab.activeTabIndex === BLOCKS_TAB_INDEX, cardsVisible: state.scratchGui.cards.visible, diff --git a/src/containers/language-selector.jsx b/src/containers/language-selector.jsx index c3f108efbe3315dd732a3b450479efddc7bec00d..8eb621bb1b79b441627a49245245ab80549bf109 100644 --- a/src/containers/language-selector.jsx +++ b/src/containers/language-selector.jsx @@ -13,11 +13,13 @@ class LanguageSelector extends React.Component { bindAll(this, [ 'handleChange' ]); + document.documentElement.lang = props.currentLocale; } handleChange (e) { const newLocale = e.target.value; if (this.props.supportedLocales.includes(newLocale)) { this.props.onChangeLanguage(newLocale); + document.documentElement.lang = newLocale; } } render () { diff --git a/src/containers/menu-item.jsx b/src/containers/menu-item.jsx new file mode 100644 index 0000000000000000000000000000000000000000..8755c19f7343db268ef10afd4976dfa4a14bffb9 --- /dev/null +++ b/src/containers/menu-item.jsx @@ -0,0 +1,43 @@ +import bindAll from 'lodash.bindall'; +import PropTypes from 'prop-types'; +import React from 'react'; + +import {MenuItem as MenuItemComponent} from '../components/menu/menu.jsx'; + +class MenuItem extends React.Component { + constructor (props) { + super(props); + bindAll(this, [ + 'navigateToHref' + ]); + } + navigateToHref () { + if (this.props.href) window.location.href = this.props.href; + } + render () { + const { + children, + className, + onClick + } = this.props; + const clickAction = onClick ? onClick : this.navigateToHref; + return ( + <MenuItemComponent + className={className} + onClick={clickAction} + > + {children} + </MenuItemComponent> + ); + } +} + +MenuItem.propTypes = { + children: PropTypes.node, + className: PropTypes.string, + // can take an onClick prop, or take an href and build an onClick handler + href: PropTypes.string, + onClick: PropTypes.func +}; + +export default MenuItem; diff --git a/src/containers/stage.jsx b/src/containers/stage.jsx index 0aa3f2a4b64ef883e29cf0b47ff0a4f1d8d93cd3..dc2b87043956be682e4dcb68dc4e6a9657f9bcf3 100644 --- a/src/containers/stage.jsx +++ b/src/containers/stage.jsx @@ -75,7 +75,8 @@ class Stage extends React.Component { this.props.isColorPicking !== nextProps.isColorPicking || this.state.colorInfo !== nextState.colorInfo || this.props.isFullScreen !== nextProps.isFullScreen || - this.state.question !== nextState.question; + this.state.question !== nextState.question || + this.props.micIndicator !== nextProps.micIndicator; } componentDidUpdate (prevProps) { if (this.props.isColorPicking && !prevProps.isColorPicking) { @@ -402,6 +403,7 @@ Stage.defaultProps = { const mapStateToProps = state => ({ isColorPicking: state.scratchGui.colorPicker.active, isFullScreen: state.scratchGui.mode.isFullScreen, + micIndicator: state.scratchGui.micIndicator, // Do not use editor drag style in fullscreen or player mode. useEditorDragStyle: !(state.scratchGui.mode.isFullScreen || state.scratchGui.mode.isPlayerOnly) }); diff --git a/src/containers/tag-button.jsx b/src/containers/tag-button.jsx index e5ad61f4080abbaced07fe2c6f7ab45bcca7f086..3e7dd84b1bdb105aab8588cd8b9a9ee85e799a91 100644 --- a/src/containers/tag-button.jsx +++ b/src/containers/tag-button.jsx @@ -12,7 +12,7 @@ class TagButton extends React.Component { ]); } handleClick () { - this.props.onClick(this.props.title); + this.props.onClick(this.props.tag); } render () { return ( @@ -26,11 +26,7 @@ class TagButton extends React.Component { TagButton.propTypes = { ...TagButtonComponent.propTypes, - onClick: PropTypes.func, - title: PropTypes.oneOfType([ - PropTypes.string, - PropTypes.object - ]).isRequired + onClick: PropTypes.func }; export default TagButton; diff --git a/src/css/z-index.css b/src/css/z-index.css index 26893f08fa5147930328992359d709777bef0ec7..06e2015658b563f88c84a901a35f36cad91c8fcb 100644 --- a/src/css/z-index.css +++ b/src/css/z-index.css @@ -8,6 +8,7 @@ $z-index-extension-button: 50; /* Force extension button above the ScratchBlocks $z-index-menu-bar: 50; /* blocklyToolboxDiv is 40 */ $z-index-monitor: 100; +$z-index-stage-indicator: 110; $z-index-add-button: 120; $z-index-tooltip: 130; /* tooltips should go over add buttons if they overlap */ @@ -23,6 +24,7 @@ $z-index-stage-color-picker-background: 2000; $z-index-stage-with-color-picker: 2010; $z-index-stage-header: 5000; $z-index-stage-wrapper-overlay: 5000; +$z-index-alerts: 5010; /* in most interfaces, the context menu is always on top */ $z-index-context-menu: 10000; diff --git a/src/index.js b/src/index.js index dfd32a399de9c132135e2cb0f0596bd8eecc74a2..237c1093d65b545b595a75574a19eae69ad5e57c 100644 --- a/src/index.js +++ b/src/index.js @@ -1,4 +1,5 @@ import GUI from './containers/gui.jsx'; +import AppStateHOC from './lib/app-state-hoc.jsx'; import GuiReducer, {guiInitialState, guiMiddleware, initFullScreen, initPlayer} from './reducers/gui'; import LocalesReducer, {localesInitialState, initLocale} from './reducers/locales'; import {ScratchPaintReducer} from 'scratch-paint'; @@ -13,6 +14,7 @@ const guiReducers = { export { GUI as default, + AppStateHOC, setAppElement, guiReducers, guiInitialState, diff --git a/src/lib/libraries/backdrop-tags.js b/src/lib/libraries/backdrop-tags.js index 7bafa3410aa3afe94692d0e4d495a9f8ecaeb256..77374719d2b20eebde64ea3d6b3dc40296818eb8 100644 --- a/src/lib/libraries/backdrop-tags.js +++ b/src/lib/libraries/backdrop-tags.js @@ -1,10 +1,11 @@ +import messages from './tag-messages.js'; export default [ - {title: 'Fantasy'}, - {title: 'Music'}, - {title: 'Sports'}, - {title: 'Outdoors'}, - {title: 'Indoors'}, - {title: 'Space'}, - {title: 'Underwater'}, - {title: 'Patterns'} + {tag: 'fantasy', intlLabel: messages.fantasy}, + {tag: 'music', intlLabel: messages.music}, + {tag: 'sports', intlLabel: messages.sports}, + {tag: 'outdoors', intlLabel: messages.outdoors}, + {tag: 'indoors', intlLabel: messages.indoors}, + {tag: 'space', intlLabel: messages.space}, + {tag: 'underwater', intlLabel: messages.underwater}, + {tag: 'patterns', intlLabel: messages.patterns} ]; diff --git a/src/lib/libraries/sound-tags.js b/src/lib/libraries/sound-tags.js index 61a37002e6279ff0e24fe9a9208bb4549e9c686b..4cba1d57b34441b9ed7ab3f22575c964bc5356de 100644 --- a/src/lib/libraries/sound-tags.js +++ b/src/lib/libraries/sound-tags.js @@ -1,11 +1,12 @@ +import messages from './tag-messages.js'; export default [ - {title: 'Animals'}, - {title: 'Effects'}, - {title: 'Loops'}, - {title: 'Notes'}, - {title: 'Percussion'}, - {title: 'Space'}, - {title: 'Sports'}, - {title: 'Voice'}, - {title: 'Wacky'} + {tag: 'animals', intlLabel: messages.animals}, + {tag: 'effects', intlLabel: messages.effects}, + {tag: 'loops', intlLabel: messages.loops}, + {tag: 'notes', intlLabel: messages.notes}, + {tag: 'percussion', intlLabel: messages.percussion}, + {tag: 'space', intlLabel: messages.space}, + {tag: 'sports', intlLabel: messages.sports}, + {tag: 'voice', intlLabel: messages.voice}, + {tag: 'wacky', intlLabel: messages.wacky} ]; diff --git a/src/lib/libraries/sprite-tags.js b/src/lib/libraries/sprite-tags.js index f1fbe3d5647910980f1133dd57aa05569ee05334..8a12e1e41e54fbbee6945361b429435c07b67dad 100644 --- a/src/lib/libraries/sprite-tags.js +++ b/src/lib/libraries/sprite-tags.js @@ -1,10 +1,11 @@ +import messages from './tag-messages.js'; export default [ - {title: 'Animals'}, - {title: 'People'}, - {title: 'Fantasy'}, - {title: 'Dance'}, - {title: 'Music'}, - {title: 'Sports'}, - {title: 'Food'}, - {title: 'Fashion'} + {tag: 'animals', intlLabel: messages.animals}, + {tag: 'people', intlLabel: messages.people}, + {tag: 'fantasy', intlLabel: messages.fantasy}, + {tag: 'dance', intlLabel: messages.dance}, + {tag: 'music', intlLabel: messages.music}, + {tag: 'sports', intlLabel: messages.sports}, + {tag: 'food', intlLabel: messages.food}, + {tag: 'fashion', intlLabel: messages.fashion} ]; diff --git a/src/lib/libraries/tag-messages.js b/src/lib/libraries/tag-messages.js new file mode 100644 index 0000000000000000000000000000000000000000..60a6d13fc504d8745205db7be798c8ffd4f526b3 --- /dev/null +++ b/src/lib/libraries/tag-messages.js @@ -0,0 +1,104 @@ +import {defineMessages} from 'react-intl'; + +export default defineMessages({ + all: { + defaultMessage: 'All', + description: 'Tag for filtering a library for everything', + id: 'gui.libraryTags.all' + }, + animals: { + defaultMessage: 'Animals', + description: 'Tag for filtering a library for animals', + id: 'gui.libraryTags.animals' + }, + dance: { + defaultMessage: 'Dance', + description: 'Tag for filtering a library for dance', + id: 'gui.libraryTags.dance' + }, + effects: { + defaultMessage: 'Effects', + description: 'Tag for filtering a library for effects', + id: 'gui.libraryTags.effects' + }, + fantasy: { + defaultMessage: 'Fantasy', + description: 'Tag for filtering a library for fantasy', + id: 'gui.libraryTags.fantasy' + }, + fashion: { + defaultMessage: 'Fashion', + description: 'Tag for filtering a library for fashion', + id: 'gui.libraryTags.fashion' + }, + food: { + defaultMessage: 'Food', + description: 'Tag for filtering a library for food', + id: 'gui.libraryTags.food' + }, + indoors: { + defaultMessage: 'Indoors', + description: 'Tag for filtering a library for indoors', + id: 'gui.libraryTags.indoors' + }, + loops: { + defaultMessage: 'Loops', + description: 'Tag for filtering a library for loops', + id: 'gui.libraryTags.loops' + }, + music: { + defaultMessage: 'Music', + description: 'Tag for filtering a library for music', + id: 'gui.libraryTags.music' + }, + notes: { + defaultMessage: 'Notes', + description: 'Tag for filtering a library for notes', + id: 'gui.libraryTags.notes' + }, + outdoors: { + defaultMessage: 'Outdoors', + description: 'Tag for filtering a library for outdoors', + id: 'gui.libraryTags.outdoors' + }, + patterns: { + defaultMessage: 'Patterns', + description: 'Tag for filtering a library for patterns', + id: 'gui.libraryTags.patterns' + }, + people: { + defaultMessage: 'People', + description: 'Tag for filtering a library for people', + id: 'gui.libraryTags.people' + }, + percussion: { + defaultMessage: 'Percussion', + description: 'Tag for filtering a library for percussion', + id: 'gui.libraryTags.percussion' + }, + space: { + defaultMessage: 'Space', + description: 'Tag for filtering a library for space', + id: 'gui.libraryTags.space' + }, + sports: { + defaultMessage: 'Sports', + description: 'Tag for filtering a library for sports', + id: 'gui.libraryTags.sports' + }, + underwater: { + defaultMessage: 'Underwater', + description: 'Tag for filtering a library for underwater', + id: 'gui.libraryTags.underwater' + }, + voice: { + defaultMessage: 'Voice', + description: 'Tag for filtering a library for voice', + id: 'gui.libraryTags.voice' + }, + wacky: { + defaultMessage: 'Wacky', + description: 'Tag for filtering a library for wacky', + id: 'gui.libraryTags.wacky' + } +}); diff --git a/src/lib/screen-utils.js b/src/lib/screen-utils.js index afef90d84c845119d881c11eef4e27c4f3a0b2ae..d612017841291b990e3fb1fa6af539f99afee1f5 100644 --- a/src/lib/screen-utils.js +++ b/src/lib/screen-utils.js @@ -72,7 +72,29 @@ const getStageDimensions = (stageSize, isFullScreen) => { return stageDimensions; }; +/** + * Take a pair of sizes for the stage (a target height and width and a default height and width), + * calculate the ratio between them, and return a CSS transform to scale to that ratio. + * @param {object} sizeInfo An object containing dimensions of the target and default stage sizes. + * @param {number} sizeInfo.width The target width + * @param {number} sizeInfo.height The target height + * @param {number} sizeInfo.widthDefault The default width + * @param {number} sizeInfo.heightDefault The default height + * @returns {object} the CSS transform + */ +const stageSizeToTransform = ({width, height, widthDefault, heightDefault}) => { + const scaleX = width / widthDefault; + const scaleY = height / heightDefault; + if (scaleX === 1 && scaleY === 1) { + // Do not set a transform if the scale is 1 because + // it messes up `position: fixed` elements like the context menu. + return; + } + return {transform: `scale(${scaleX},${scaleY})`}; +}; + export { getStageDimensions, - resolveStageSize + resolveStageSize, + stageSizeToTransform }; diff --git a/src/lib/vm-listener-hoc.jsx b/src/lib/vm-listener-hoc.jsx index 67e70f90019e90b92c262fdef5067c12c9c927ff..6b8d1777792c8e68790a6f5b5d0859e21952088c 100644 --- a/src/lib/vm-listener-hoc.jsx +++ b/src/lib/vm-listener-hoc.jsx @@ -9,6 +9,8 @@ import {updateTargets} from '../reducers/targets'; import {updateBlockDrag} from '../reducers/block-drag'; import {updateMonitors} from '../reducers/monitors'; import {setRunningState, setTurboState} from '../reducers/vm-status'; +import {showAlert} from '../reducers/alerts'; +import {updateMicIndicator} from '../reducers/mic-indicator'; /* * Higher Order Component to manage events emitted by the VM @@ -36,6 +38,9 @@ const vmListenerHOC = function (WrappedComponent) { this.props.vm.on('TURBO_MODE_OFF', this.props.onTurboModeOff); this.props.vm.on('PROJECT_RUN_START', this.props.onProjectRunStart); this.props.vm.on('PROJECT_RUN_STOP', this.props.onProjectRunStop); + this.props.vm.on('PERIPHERAL_ERROR', this.props.onShowAlert); + this.props.vm.on('MIC_LISTENING', this.props.onMicListeningUpdate); + } componentDidMount () { if (this.props.attachKeyboardEvents) { @@ -87,12 +92,14 @@ const vmListenerHOC = function (WrappedComponent) { onBlockDragUpdate, onKeyDown, onKeyUp, + onMicListeningUpdate, onMonitorsUpdate, onTargetsUpdate, onProjectRunStart, onProjectRunStop, onTurboModeOff, onTurboModeOn, + onShowAlert, /* eslint-enable no-unused-vars */ ...props } = this.props; @@ -104,9 +111,11 @@ const vmListenerHOC = function (WrappedComponent) { onBlockDragUpdate: PropTypes.func.isRequired, onKeyDown: PropTypes.func, onKeyUp: PropTypes.func, + onMicListeningUpdate: PropTypes.func.isRequired, onMonitorsUpdate: PropTypes.func.isRequired, onProjectRunStart: PropTypes.func.isRequired, onProjectRunStop: PropTypes.func.isRequired, + onShowAlert: PropTypes.func.isRequired, onTargetsUpdate: PropTypes.func.isRequired, onTurboModeOff: PropTypes.func.isRequired, onTurboModeOn: PropTypes.func.isRequired, @@ -118,8 +127,8 @@ const vmListenerHOC = function (WrappedComponent) { }; const mapStateToProps = state => ({ vm: state.scratchGui.vm, - username: state.session && state.session.session ? - state.session.session.username : '' + username: state.session && state.session.session && state.session.session.user ? + state.session.session.user.username : '' }); const mapDispatchToProps = dispatch => ({ onTargetsUpdate: data => { @@ -134,7 +143,13 @@ const vmListenerHOC = function (WrappedComponent) { onProjectRunStart: () => dispatch(setRunningState(true)), onProjectRunStop: () => dispatch(setRunningState(false)), onTurboModeOn: () => dispatch(setTurboState(true)), - onTurboModeOff: () => dispatch(setTurboState(false)) + onTurboModeOff: () => dispatch(setTurboState(false)), + onShowAlert: data => { + dispatch(showAlert(data)); + }, + onMicListeningUpdate: listening => { + dispatch(updateMicIndicator(listening)); + } }); return connect( mapStateToProps, diff --git a/src/reducers/alerts.js b/src/reducers/alerts.js new file mode 100644 index 0000000000000000000000000000000000000000..e0fdd72a968e5ff0fc5c7f5459da22a41017f254 --- /dev/null +++ b/src/reducers/alerts.js @@ -0,0 +1,77 @@ +import extensionData from '../lib/libraries/extensions/index.jsx'; + +const CLOSE_ALERT = 'scratch-gui/alerts/CLOSE_ALERT'; +const SHOW_ALERT = 'scratch-gui/alerts/SHOW_ALERT'; + +const initialState = { + visible: true, + alertsList: [] +}; + +const reducer = function (state, action) { + if (typeof state === 'undefined') state = initialState; + switch (action.type) { + case SHOW_ALERT: { + const newList = state.alertsList.slice(); + const newAlert = {message: action.data.message}; + const extensionId = action.data.extensionId; + if (extensionId) { // if it's an extension + const extension = extensionData.find(ext => ext.extensionId === extensionId); + if (extension && extension.name) { + // TODO: is this the right place to assemble this message? + newAlert.message = `${newAlert.message} ${extension.name}.`; + } + if (extension && extension.smallPeripheralImage) { + newAlert.iconURL = extension.smallPeripheralImage; + } + } + // TODO: add cases for other kinds of alerts here? + newList.push(newAlert); + return Object.assign({}, state, { + alertsList: newList + }); + } + case CLOSE_ALERT: { + const newList = state.alertsList.slice(); + newList.splice(action.index, 1); + return Object.assign({}, state, { + alertsList: newList + }); + } + default: + return state; + } +}; + +/** + * Function to close an alert with the given index. + * + * @param {object} index - the index of the alert to close. + * @return {object} - an object to be passed to the reducer. + */ +const closeAlert = function (index) { + return { + type: CLOSE_ALERT, + index + }; +}; + +/** + * Function to show an alert with the given input data. + * + * @param {object} data - data with the following props: {message, extensionId=null} + * @return {object} - an object to be passed to the reducer. + */ +const showAlert = function (data) { + return { + type: SHOW_ALERT, + data + }; +}; + +export { + reducer as default, + initialState as alertsInitialState, + closeAlert, + showAlert +}; diff --git a/src/reducers/gui.js b/src/reducers/gui.js index 8869c1a35031e1d46b8dd28f4f408d2500946840..bce112a1c327c6e6a2761cbbeb6e63f29dbf87da 100644 --- a/src/reducers/gui.js +++ b/src/reducers/gui.js @@ -1,4 +1,5 @@ import {applyMiddleware, compose, combineReducers} from 'redux'; +import alertsReducer, {alertsInitialState} from './alerts'; import assetDragReducer, {assetDragInitialState} from './asset-drag'; import cardsReducer, {cardsInitialState} from './cards'; import colorPickerReducer, {colorPickerInitialState} from './color-picker'; @@ -7,6 +8,7 @@ import blockDragReducer, {blockDragInitialState} from './block-drag'; import editorTabReducer, {editorTabInitialState} from './editor-tab'; import hoveredTargetReducer, {hoveredTargetInitialState} from './hovered-target'; import menuReducer, {menuInitialState} from './menus'; +import micIndicatorReducer, {micIndicatorInitialState} from './mic-indicator'; import modalReducer, {modalsInitialState} from './modals'; import modeReducer, {modeInitialState} from './mode'; import monitorReducer, {monitorsInitialState} from './monitors'; @@ -26,6 +28,7 @@ import decks from '../lib/libraries/decks/index.jsx'; const guiMiddleware = compose(applyMiddleware(throttle(300, {leading: true, trailing: true}))); const guiInitialState = { + alerts: alertsInitialState, assetDrag: assetDragInitialState, blockDrag: blockDragInitialState, cards: cardsInitialState, @@ -36,6 +39,7 @@ const guiInitialState = { hoveredTarget: hoveredTargetInitialState, stageSize: stageSizeInitialState, menus: menuInitialState, + micIndicator: micIndicatorInitialState, modals: modalsInitialState, monitors: monitorsInitialState, monitorLayout: monitorLayoutInitialState, @@ -91,6 +95,7 @@ const initTutorialCard = function (currentState, deckId) { }; const guiReducer = combineReducers({ + alerts: alertsReducer, assetDrag: assetDragReducer, blockDrag: blockDragReducer, cards: cardsReducer, @@ -101,6 +106,7 @@ const guiReducer = combineReducers({ hoveredTarget: hoveredTargetReducer, stageSize: stageSizeReducer, menus: menuReducer, + micIndicator: micIndicatorReducer, modals: modalReducer, monitors: monitorReducer, monitorLayout: monitorLayoutReducer, diff --git a/src/reducers/menus.js b/src/reducers/menus.js index 6693160c177fc988232fbd505f5a6c1e5af8d134..4cc494c60b860d324e4fa49e96a748f17a7233ca 100644 --- a/src/reducers/menus.js +++ b/src/reducers/menus.js @@ -1,15 +1,19 @@ const OPEN_MENU = 'scratch-gui/menus/OPEN_MENU'; const CLOSE_MENU = 'scratch-gui/menus/CLOSE_MENU'; +const MENU_ACCOUNT = 'accountMenu'; const MENU_FILE = 'fileMenu'; const MENU_EDIT = 'editMenu'; const MENU_LANGUAGE = 'languageMenu'; +const MENU_LOGIN = 'loginMenu'; const initialState = { + [MENU_ACCOUNT]: false, [MENU_FILE]: false, [MENU_EDIT]: false, - [MENU_LANGUAGE]: false + [MENU_LANGUAGE]: false, + [MENU_LOGIN]: false }; const reducer = function (state, action) { @@ -35,6 +39,9 @@ const closeMenu = menu => ({ type: CLOSE_MENU, menu: menu }); +const openAccountMenu = () => openMenu(MENU_ACCOUNT); +const closeAccountMenu = () => closeMenu(MENU_ACCOUNT); +const accountMenuOpen = state => state.scratchGui.menus[MENU_ACCOUNT]; const openFileMenu = () => openMenu(MENU_FILE); const closeFileMenu = () => closeMenu(MENU_FILE); const fileMenuOpen = state => state.scratchGui.menus[MENU_FILE]; @@ -44,17 +51,26 @@ const editMenuOpen = state => state.scratchGui.menus[MENU_EDIT]; const openLanguageMenu = () => openMenu(MENU_LANGUAGE); const closeLanguageMenu = () => closeMenu(MENU_LANGUAGE); const languageMenuOpen = state => state.scratchGui.menus[MENU_LANGUAGE]; +const openLoginMenu = () => openMenu(MENU_LOGIN); +const closeLoginMenu = () => closeMenu(MENU_LOGIN); +const loginMenuOpen = state => state.scratchGui.menus[MENU_LOGIN]; export { reducer as default, initialState as menuInitialState, + openAccountMenu, + closeAccountMenu, + accountMenuOpen, openFileMenu, closeFileMenu, + fileMenuOpen, openEditMenu, closeEditMenu, + editMenuOpen, openLanguageMenu, closeLanguageMenu, - fileMenuOpen, - editMenuOpen, - languageMenuOpen + languageMenuOpen, + openLoginMenu, + closeLoginMenu, + loginMenuOpen }; diff --git a/src/reducers/mic-indicator.js b/src/reducers/mic-indicator.js new file mode 100644 index 0000000000000000000000000000000000000000..2548abbb41e785c086036b7e18e48b18f9e3542f --- /dev/null +++ b/src/reducers/mic-indicator.js @@ -0,0 +1,26 @@ +const UPDATE = 'scratch-gui/mic-indicator/UPDATE'; + +const initialState = false; + +const reducer = function (state, action) { + if (typeof state === 'undefined') state = initialState; + switch (action.type) { + case UPDATE: + return action.visible; + default: + return state; + } +}; + +const updateMicIndicator = function (visible) { + return { + type: UPDATE, + visible: visible + }; +}; + +export { + reducer as default, + initialState as micIndicatorInitialState, + updateMicIndicator +};