diff --git a/package-lock.json b/package-lock.json index 318914c9753..afb21dd180e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -28942,7 +28942,7 @@ }, "node_modules/scratch-vm": { "version": "5.0.300", - "resolved": "git+ssh://git@github.com/smalruby/scratch-vm.git#bcb0cce40b7c5a7935e38fca611539b750b34781", + "resolved": "git+ssh://git@github.com/smalruby/scratch-vm.git#68dda6d1523e0e26610180ba23907bf16685ad03", "license": "AGPL-3.0-only", "dependencies": { "@vernier/godirect": "^1.5.0", diff --git a/src/components/gui/gui.jsx b/src/components/gui/gui.jsx index fd438fc0a73..d749044ac63 100644 --- a/src/components/gui/gui.jsx +++ b/src/components/gui/gui.jsx @@ -30,6 +30,7 @@ import DragLayer from '../../containers/drag-layer.jsx'; import ConnectionModal from '../../containers/connection-modal.jsx'; import TelemetryModal from '../telemetry-modal/telemetry-modal.jsx'; import BlockDisplayModal from '../../containers/block-display-modal.jsx'; +import MeshDomainModal from '../../containers/mesh-domain-modal.jsx'; import URLLoaderModal from '../url-loader-modal/url-loader-modal.jsx'; import KoshienTestModal from '../koshien-test-modal/koshien-test-modal.jsx'; @@ -98,6 +99,7 @@ const GUIComponent = props => { isTotallyNormal, loading, logo, + meshDomainModalVisible, renderLogin, onClickAbout, onClickAccountNav, @@ -140,6 +142,8 @@ const GUIComponent = props => { // Exclude Redux-related props from being passed to DOM setSelectedBlocks: _setSelectedBlocks, openUrlLoaderModal: _openUrlLoaderModal, + openKoshienTestModal: _openKoshienTestModal, + openMeshDomainModal: _openMeshDomainModal, ...componentProps } = omit(props, 'dispatch'); if (children) { @@ -198,6 +202,9 @@ const GUIComponent = props => { onLoadUrl={onUrlLoaderSubmit} /> ) : null} + {meshDomainModalVisible ? ( + + ) : null} {koshienTestModalVisible ? ( - - {meshV2Status.message} + + + {this.props.meshV2Domain || this.props.intl.formatMessage({ + id: 'mesh.domainNotSet', + defaultMessage: 'Not set' + })} + + ) + }} + /> + + + {meshV2Status.message} + + ); @@ -1342,6 +1403,7 @@ MenuBar.propTypes = { locale: PropTypes.string.isRequired, loginMenuOpen: PropTypes.bool, logo: PropTypes.string, + meshV2Domain: PropTypes.string, meshV2MenuOpen: PropTypes.bool, mode1920: PropTypes.bool, mode1990: PropTypes.bool, @@ -1376,6 +1438,7 @@ MenuBar.propTypes = { onOpenRegistration: PropTypes.func, onOpenBlockDisplayModal: PropTypes.func, onOpenConnectionModal: PropTypes.func, + onOpenMeshDomainModal: PropTypes.func, onOpenDebugModal: PropTypes.func, onOpenKoshienTestModal: PropTypes.func, onProjectTelemetryEvent: PropTypes.func, @@ -1399,6 +1462,7 @@ MenuBar.propTypes = { onStartSavingToGoogleDrive: PropTypes.func, onSaveDirectlyToGoogleDrive: PropTypes.func, onSetAiSaveStatus: PropTypes.func, + onSetMeshV2Domain: PropTypes.func, onClearAiSaveStatus: PropTypes.func, onStartSelectingUrlLoad: PropTypes.func, projectFilename: PropTypes.string, @@ -1431,6 +1495,7 @@ const mapStateToProps = (state, ownProps) => { fileMenuOpen: fileMenuOpen(state), editMenuOpen: editMenuOpen(state), koshienMenuOpen: koshienMenuOpen(state), + meshV2Domain: state.scratchGui.meshV2 ? state.scratchGui.meshV2.domain : null, meshV2MenuOpen: meshV2MenuOpen(state), extensionLoadCounter: state.scratchGui.koshienFile.extensionLoadCounter, aiSaveStatus: state.scratchGui.koshienFile.aiSaveStatus, @@ -1465,6 +1530,7 @@ const mapDispatchToProps = dispatch => ({ dispatch(setConnectionModalExtensionId(id)); dispatch(openConnectionModal()); }, + onOpenMeshDomainModal: () => dispatch(openMeshDomainModal()), onOpenBlockDisplayModal: () => dispatch(openBlockDisplayModal()), onOpenKoshienTestModal: () => dispatch(openKoshienTestModal()), onClickAccount: () => dispatch(openAccountMenu()), @@ -1493,6 +1559,7 @@ const mapDispatchToProps = dispatch => ({ onClickSave: () => dispatch(manualUpdateProject()), onClickSaveAsCopy: () => dispatch(saveProjectAsCopy()), onExtensionLoaded: () => dispatch(incrementExtensionLoad()), + onSetMeshV2Domain: domain => dispatch(setMeshV2Domain(domain)), onSetAiSaveStatus: status => dispatch(setAiSaveStatus(status)), onClearAiSaveStatus: () => dispatch(clearAiSaveStatus()), onSeeCommunity: () => dispatch(setPlayer(true)), diff --git a/src/components/mesh-domain-modal/mesh-domain-modal.css b/src/components/mesh-domain-modal/mesh-domain-modal.css new file mode 100644 index 00000000000..b7473335697 --- /dev/null +++ b/src/components/mesh-domain-modal/mesh-domain-modal.css @@ -0,0 +1,148 @@ +@import "../../css/colors.css"; +@import "../../css/units.css"; + +.modal-content { + width: 500px; + height: auto; + line-height: 1.75; +} + +.header { + background-color: $motion-primary; +} + +.body { + background: $ui-white; + padding: 1.5rem; + display: flex; + flex-direction: column; + gap: 1.5rem; +} + +.input-section { + display: flex; + flex-direction: column; +} + +.domain-input { + width: 100%; + padding: 0.75rem; + border: 2px solid $ui-black-transparent; + border-radius: 0.25rem; + font-size: 0.875rem; + font-family: inherit; + outline: none; + box-sizing: border-box; +} + +.domain-input:focus { + border-color: $motion-primary; + box-shadow: 0 0 0 1px $motion-primary; +} + +.domain-input.input-error { + border-color: $error-primary; + box-shadow: 0 0 0 1px $error-primary; +} + +.domain-input::placeholder { + color: $text-primary-transparent; +} + +.error-message { + margin-top: 0.5rem; + font-size: 0.75rem; + color: $error-primary; + line-height: 1.4; +} + +.description-section { + display: flex; + flex-direction: column; +} + +.description-text { + font-size: 0.75rem; + color: $text-primary; + line-height: 1.5; +} + +.example-section { + display: flex; + flex-direction: column; + margin-top: -0.5rem; +} + +.example-title { + font-size: 0.75rem; + color: $text-primary; + font-weight: bold; + margin-bottom: 0.25rem; +} + +.example-text { + font-size: 0.75rem; + color: $text-primary-transparent; + font-family: "Monaco", "Menlo", "Ubuntu Mono", monospace; + padding-left: 1rem; + position: relative; +} + +.example-text::before { + content: "•"; + position: absolute; + left: 0; + color: $text-primary-transparent; +} + +.button-section { + display: flex; + justify-content: flex-end; + gap: 0.75rem; + margin-top: 0.5rem; +} + +.cancel-button, +.save-button { + padding: 0.5rem 1rem; + border: none; + border-radius: 0.25rem; + font-size: 0.875rem; + font-weight: bold; + cursor: pointer; + transition: background-color 0.1s ease; + min-width: 80px; +} + +.cancel-button { + background: $ui-white; + color: $text-primary; + border: 1px solid $ui-black-transparent; +} + +.cancel-button:hover { + background: $ui-secondary; +} + +.cancel-button:active { + background: $ui-black-transparent; +} + +.save-button { + background: $motion-primary; + color: $ui-white; +} + +.save-button:hover { + background: $motion-tertiary; +} + +.save-button:active { + background: $motion-tertiary; +} + +.save-button.disabled { + background: $ui-black-transparent; + color: $text-primary-transparent; + cursor: not-allowed; +} \ No newline at end of file diff --git a/src/components/mesh-domain-modal/mesh-domain-modal.jsx b/src/components/mesh-domain-modal/mesh-domain-modal.jsx new file mode 100644 index 00000000000..fe893e1c4a9 --- /dev/null +++ b/src/components/mesh-domain-modal/mesh-domain-modal.jsx @@ -0,0 +1,208 @@ +import classNames from 'classnames'; +import PropTypes from 'prop-types'; +import React from 'react'; +import bindAll from 'lodash.bindall'; +import {defineMessages, FormattedMessage, injectIntl, intlShape} from 'react-intl'; + +import Box from '../box/box.jsx'; +import Modal from '../../containers/modal.jsx'; +import meshIcon from '../../lib/libraries/extensions/mesh/mesh-small.png'; + +import styles from './mesh-domain-modal.css'; + +const messages = defineMessages({ + title: { + defaultMessage: 'Mesh V2 Domain Settings', + description: 'Title for the Mesh V2 domain modal', + id: 'mesh.domainModalTitle' + }, + domainPlaceholder: { + defaultMessage: 'Enter domain name...', + description: 'Placeholder text for domain input field', + id: 'mesh.domainPlaceholder' + }, + saveButton: { + defaultMessage: 'Save', + description: 'Label for save button', + id: 'mesh.domainSaveButton' + }, + cancelButton: { + defaultMessage: 'Cancel', + description: 'Label for cancel button', + id: 'mesh.domainCancelButton' + }, + domainInvalidError: { + defaultMessage: 'Domain name contains invalid characters.', + description: 'Error message for invalid characters in domain name', + id: 'mesh.domainInvalidError' + }, + domainTooLongError: { + defaultMessage: 'Domain name is too long (max 256 characters).', + description: 'Error message for domain name exceeding 256 characters', + id: 'mesh.domainTooLongError' + }, + domainDescription: { + defaultMessage: 'If the host you want to join does not appear, ' + + 'everyone including the host should set the same domain (address on the internet). ' + + 'The address of a facility such as a school is ideal.', + description: 'Description for Mesh V2 domain setting', + id: 'mesh.domainDescription' + }, + domainExampleTitle: { + defaultMessage: 'Example', + description: 'Title for Mesh V2 domain example', + id: 'mesh.domainExampleTitle' + }, + domainExample: { + defaultMessage: '100-0014', + description: 'Example for Mesh V2 domain', + id: 'mesh.domainExample' + } +}); + +class MeshDomainModal extends React.Component { + constructor (props) { + super(props); + bindAll(this, [ + 'handleDomainChange', + 'handleSaveClick', + 'handleCancelClick', + 'handleKeyPress', + 'validate' + ]); + + this.state = { + domain: props.initialDomain || '', + error: null + }; + } + + validate (domain) { + if (!domain) return null; + + if (domain.length > 256) { + return 'tooLong'; + } + + // Allow alphanumeric, hyphen, underscore, dot. + const validPattern = /^[a-zA-Z0-9-._]+$/; + if (!validPattern.test(domain)) { + return 'invalid'; + } + + return null; + } + + handleDomainChange (event) { + const domain = event.target.value; + this.setState({ + domain: domain, + error: this.validate(domain) + }); + } + + handleSaveClick () { + const error = this.validate(this.state.domain); + if (!error) { + this.props.onSave(this.state.domain.trim()); + } + } + + handleCancelClick () { + this.props.onRequestClose(); + } + + handleKeyPress (event) { + if (event.key === 'Enter') { + this.handleSaveClick(); + } + } + + render () { + const {intl, onRequestClose} = this.props; + const {domain, error} = this.state; + + return ( + + + + + {error === 'tooLong' && ( +
+ +
+ )} + {error === 'invalid' && ( +
+ +
+ )} +
+ + +
+ +
+
+ + +
+ +
+
+ +
+
+ + + + + +
+
+ ); + } +} + +MeshDomainModal.propTypes = { + intl: intlShape.isRequired, + onRequestClose: PropTypes.func.isRequired, + onSave: PropTypes.func.isRequired, + initialDomain: PropTypes.string +}; + +export default injectIntl(MeshDomainModal); diff --git a/src/containers/gui.jsx b/src/containers/gui.jsx index 1e2bf6dfdc4..559093a9f67 100644 --- a/src/containers/gui.jsx +++ b/src/containers/gui.jsx @@ -181,6 +181,7 @@ const mapStateToProps = state => { telemetryModalVisible: state.scratchGui.modals.telemetryModal, rubyTabVisible: state.scratchGui.editorTab.activeTabIndex === RUBY_TAB_INDEX, urlLoaderModalVisible: state.scratchGui.modals.urlLoaderModal, + meshDomainModalVisible: state.scratchGui.modals.meshDomainModal, koshienTestModalVisible: state.scratchGui.modals.koshienTestModal, vm: state.scratchGui.vm }; diff --git a/src/containers/mesh-domain-modal.jsx b/src/containers/mesh-domain-modal.jsx new file mode 100644 index 00000000000..690540e56d9 --- /dev/null +++ b/src/containers/mesh-domain-modal.jsx @@ -0,0 +1,70 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import {connect} from 'react-redux'; +import bindAll from 'lodash.bindall'; + +import MeshDomainModalComponent from '../components/mesh-domain-modal/mesh-domain-modal.jsx'; +import {closeMeshDomainModal} from '../reducers/modals'; +import {setDomain} from '../reducers/mesh-v2'; + +class MeshDomainModal extends React.Component { + constructor (props) { + super(props); + bindAll(this, [ + 'handleSave' + ]); + } + + handleSave (domain) { + const extension = this.props.vm.runtime.peripheralExtensions.meshV2; + if (extension) { + const error = extension.setDomain(domain); + if (error) { + alert(error); // eslint-disable-line no-alert + return; + } + this.props.onSave(domain); + } + this.props.onRequestClose(); + } + + render () { + return ( + + ); + } +} + +MeshDomainModal.propTypes = { + domain: PropTypes.string, + onRequestClose: PropTypes.func.isRequired, + onSave: PropTypes.func.isRequired, + vm: PropTypes.shape({ + runtime: PropTypes.shape({ + peripheralExtensions: PropTypes.shape({ + meshV2: PropTypes.shape({ + setDomain: PropTypes.func + }) + }) + }) + }) +}; + +const mapStateToProps = state => ({ + domain: state.scratchGui.meshV2.domain, + vm: state.scratchGui.vm +}); + +const mapDispatchToProps = dispatch => ({ + onRequestClose: () => dispatch(closeMeshDomainModal()), + onSave: domain => dispatch(setDomain(domain)) +}); + +export default connect( + mapStateToProps, + mapDispatchToProps +)(MeshDomainModal); diff --git a/src/locales/en.js b/src/locales/en.js index 9c1c2376cfb..22d91e6d86d 100644 --- a/src/locales/en.js +++ b/src/locales/en.js @@ -35,6 +35,18 @@ export default { 'gui.smalruby3.extension.mesh.connectingMessage': 'Connecting', 'gui.smalruby3.extension.meshV2.name': 'Mesh V2', 'mesh.notConnected': 'Not connected (Mesh)', + 'mesh.domain': 'Domain: {domain}', + 'mesh.domainNotSet': 'Not set', + 'mesh.domainModalTitle': 'Mesh V2 Domain Settings', + 'mesh.domainPlaceholder': 'Enter domain name...', + 'mesh.domainSaveButton': 'Save', + 'mesh.domainCancelButton': 'Cancel', + 'mesh.domainConnectedAlert': 'Mesh V2 is connected. To change the domain, please disconnect first.', + 'mesh.domainInvalidError': 'Domain name contains invalid characters.', + 'mesh.domainTooLongError': 'Domain name is too long (max 256 characters).', + 'mesh.domainDescription': 'If the host is not displayed, please set the domain. The zip code of a facility such as a school is ideal.', + 'mesh.domainExampleTitle': 'Example', + 'mesh.domainExample': '100-0014', 'gui.smalruby3.extension.smalrubotS1.name': 'Smalrubot S1', 'gui.smalruby3.extension.smalrubotS1.description': 'Control the Smalrubot S1.', 'gui.smalruby3.extension.smalrubotS1.connectingMessage': 'Connecting the Smalrubot S1', diff --git a/src/locales/ja-Hira.js b/src/locales/ja-Hira.js index 5d34dc38247..ee4da18bb32 100644 --- a/src/locales/ja-Hira.js +++ b/src/locales/ja-Hira.js @@ -47,6 +47,18 @@ export default { 'mesh.notConnectedMenu': '!せつぞくしていません', 'mesh.registeredHostMenu': '✔【{ MESH_ID }】 ⏳️{ EXPIRES_AT }まで', 'mesh.joinedMeshMenu': '✔【{ MESH_ID }】 ⏳️{ EXPIRES_AT }まで', + 'mesh.domain': 'ドメイン: {domain}', + 'mesh.domainNotSet': 'みせってい', + 'mesh.domainModalTitle': 'meshV2 ドメインせってい', + 'mesh.domainPlaceholder': 'ドメインめいをにゅうりょく...', + 'mesh.domainSaveButton': 'ほぞん', + 'mesh.domainCancelButton': 'キャンセル', + 'mesh.domainConnectedAlert': 'meshV2がせつぞくちゅうです。ドメインをへんかんするには、まずせつぞくをきってください。', + 'mesh.domainInvalidError': 'ドメインめいに つかえない もじが ふくまれています。', + 'mesh.domainTooLongError': 'ドメインめいが ながすぎます(さいだい256もじ)。', + 'mesh.domainDescription': 'ホストが ひょうじされない ばあいは ドメインを せっていしてください。がっこうなどの しせつの ゆうびんばんごうが さいてきです。', + 'mesh.domainExampleTitle': 'れい', + 'mesh.domainExample': '100-0014', 'gui.smalruby3.extension.smalrubotS1.name': 'スモウルボットS1 (エス1)', 'gui.smalruby3.extension.smalrubotS1.description': 'スモウルボットS1 (エス1) をせいぎょする。', 'gui.smalruby3.extension.smalrubotS1.connectingMessage': 'スモウルボットS1 (エス1) にせつぞくしています。', diff --git a/src/locales/ja.js b/src/locales/ja.js index 175505f1189..d1019ce1dd4 100644 --- a/src/locales/ja.js +++ b/src/locales/ja.js @@ -82,8 +82,20 @@ export default { 'mesh.registeredHost': 'ホストとしてメッシュに登録しました 【{ MESH_ID }】', 'mesh.joinedMesh': 'メッシュに参加しました 【{ MESH_ID }】', 'mesh.notConnectedMenu': '!未接続', - 'mesh.registeredHostMenu': '✔【{ MESH_ID }】 ⏳️{ EXPIRES_AT }まで', - 'mesh.joinedMeshMenu': '✔【{ MESH_ID }】 ⏳️{ EXPIRES_AT }まで', + 'mesh.registeredHostMenu': '✔【{MESH_ID}】 ⏳️{EXPIRES_AT}まで', + 'mesh.joinedMeshMenu': '✔【{MESH_ID}】 ⏳️{EXPIRES_AT}まで', + 'mesh.domain': 'ドメイン: {domain}', + 'mesh.domainNotSet': '未設定', + 'mesh.domainModalTitle': 'meshV2 ドメイン設定', + 'mesh.domainPlaceholder': 'ドメイン名を入力...', + 'mesh.domainSaveButton': '保存', + 'mesh.domainCancelButton': 'キャンセル', + 'mesh.domainConnectedAlert': 'meshV2が接続中です。ドメインを変更するには、まず切断してください。', + 'mesh.domainInvalidError': 'ドメイン名に使用できない文字が含まれています。', + 'mesh.domainTooLongError': 'ドメイン名が長すぎます(最大256文字)。', + 'mesh.domainDescription': 'ホストが表示されない場合はドメインを設定してください。学校などの施設の郵便番号が最適です。', + 'mesh.domainExampleTitle': '例', + 'mesh.domainExample': '100-0014', 'gui.smalruby3.extension.smalrubotS1.name': 'スモウルボットS1', 'gui.smalruby3.extension.smalrubotS1.description': 'スモウルボットS1を制御する。', 'gui.smalruby3.extension.smalrubotS1.connectingMessage': 'スモウルボットS1に接続しています。', diff --git a/src/reducers/gui.js b/src/reducers/gui.js index 1ffb16f70f7..2307f1d0757 100644 --- a/src/reducers/gui.js +++ b/src/reducers/gui.js @@ -9,6 +9,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 meshV2Reducer, {meshV2InitialState} from './mesh-v2'; import micIndicatorReducer, {micIndicatorInitialState} from './mic-indicator'; import modalReducer, {modalsInitialState} from './modals'; import modeReducer, {modeInitialState} from './mode'; @@ -48,6 +49,7 @@ const guiInitialState = { hoveredTarget: hoveredTargetInitialState, stageSize: stageSizeInitialState, menus: menuInitialState, + meshV2: meshV2InitialState, micIndicator: micIndicatorInitialState, modals: modalsInitialState, monitors: monitorsInitialState, @@ -133,6 +135,7 @@ const guiReducer = combineReducers({ hoveredTarget: hoveredTargetReducer, stageSize: stageSizeReducer, menus: menuReducer, + meshV2: meshV2Reducer, micIndicator: micIndicatorReducer, modals: modalReducer, monitors: monitorReducer, diff --git a/src/reducers/mesh-v2.js b/src/reducers/mesh-v2.js new file mode 100644 index 00000000000..ac08d35dc8f --- /dev/null +++ b/src/reducers/mesh-v2.js @@ -0,0 +1,30 @@ +const SET_DOMAIN = 'scratch-gui/mesh-v2/SET_DOMAIN'; + +const initialState = { + domain: null +}; + +const reducer = function (state, action) { + if (typeof state === 'undefined') state = initialState; + switch (action.type) { + case SET_DOMAIN: + return Object.assign({}, state, { + domain: action.domain + }); + default: + return state; + } +}; + +const setDomain = function (domain) { + return { + type: SET_DOMAIN, + domain: domain + }; +}; + +export { + reducer as default, + initialState as meshV2InitialState, + setDomain +}; diff --git a/src/reducers/modals.js b/src/reducers/modals.js index c2ee6d0c214..8d3ecf01901 100644 --- a/src/reducers/modals.js +++ b/src/reducers/modals.js @@ -12,6 +12,7 @@ const MODAL_SPRITE_LIBRARY = 'spriteLibrary'; const MODAL_SOUND_RECORDER = 'soundRecorder'; const MODAL_CONNECTION = 'connectionModal'; const MODAL_URL_LOADER = 'urlLoaderModal'; +const MODAL_MESH_DOMAIN = 'meshDomainModal'; const MODAL_KOSHIEN_TEST = 'koshienTestModal'; const initialState = { @@ -26,6 +27,7 @@ const initialState = { [MODAL_SOUND_RECORDER]: false, [MODAL_CONNECTION]: false, [MODAL_URL_LOADER]: false, + [MODAL_MESH_DOMAIN]: false, [MODAL_KOSHIEN_TEST]: false }; @@ -89,6 +91,9 @@ const openConnectionModal = function () { const openUrlLoaderModal = function () { return openModal(MODAL_URL_LOADER); }; +const openMeshDomainModal = function () { + return openModal(MODAL_MESH_DOMAIN); +}; const openKoshienTestModal = function () { return openModal(MODAL_KOSHIEN_TEST); }; @@ -125,6 +130,9 @@ const closeConnectionModal = function () { const closeUrlLoaderModal = function () { return closeModal(MODAL_URL_LOADER); }; +const closeMeshDomainModal = function () { + return closeModal(MODAL_MESH_DOMAIN); +}; const closeKoshienTestModal = function () { return closeModal(MODAL_KOSHIEN_TEST); }; @@ -142,6 +150,7 @@ export { openTelemetryModal, openConnectionModal, openUrlLoaderModal, + openMeshDomainModal, openKoshienTestModal, closeBackdropLibrary, closeCostumeLibrary, @@ -154,5 +163,6 @@ export { closeTelemetryModal, closeConnectionModal, closeUrlLoaderModal, + closeMeshDomainModal, closeKoshienTestModal };