From 4273821354badd2095bdbb02c3bb64bc2178a5e9 Mon Sep 17 00:00:00 2001 From: Kouji Takao Date: Tue, 16 Sep 2025 13:37:53 +0900 Subject: [PATCH 01/15] feat: add Load from URL menu item to File menu MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implement "Load from URL" functionality for Scratch projects: - Add URL parsing utility to extract project IDs from Scratch URLs - Create URL loader HOC following the same pattern as sb-file-uploader-hoc - Add new menu item "Load from URL" in File menu between existing load/save options - Add Japanese translation "URLから読み込む" for i18n support - Integrate with existing project loading infrastructure via ProjectFetcherHOC - Support URLs like https://scratch.mit.edu/projects/1209008277/ The implementation uses prompt() for URL input and validates Scratch project URLs, extracting project IDs to load projects through the existing Smalruby API proxy. Fixes #222 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- src/components/menu-bar/menu-bar.jsx | 10 + src/containers/gui.jsx | 2 + src/lib/url-loader-hoc.jsx | 279 +++++++++++++++++++++++++++ src/lib/url-parser.js | 37 ++++ src/locales/ja.js | 1 + 5 files changed, 329 insertions(+) create mode 100644 src/lib/url-loader-hoc.jsx create mode 100644 src/lib/url-parser.js diff --git a/src/components/menu-bar/menu-bar.jsx b/src/components/menu-bar/menu-bar.jsx index a5cfacb4488..02aaf1635c0 100644 --- a/src/components/menu-bar/menu-bar.jsx +++ b/src/components/menu-bar/menu-bar.jsx @@ -492,6 +492,15 @@ class MenuBar extends React.Component { > {this.props.intl.formatMessage(sharedMessages.loadFromComputerTitle)} + + + {(className, downloadProjectCallback) => ( + * + * + */ +const URLLoaderHOC = function (WrappedComponent) { + class URLLoaderComponent extends React.Component { + constructor (props) { + super(props); + bindAll(this, [ + 'handleStartSelectingUrlLoad', + 'handleUrlInput', + 'loadProjectFromUrl', + 'handleFinishedLoadingUpload' + ]); + } + componentDidUpdate (prevProps) { + if (this.props.isLoadingUpload && !prevProps.isLoadingUpload) { + this.handleFinishedLoadingUpload(); + } + } + + // Step 1: Start the URL loading process + handleStartSelectingUrlLoad () { + this.handleUrlInput(); + } + + // Step 2: Prompt user for URL input + handleUrlInput () { + const { + intl, + isShowingWithoutId, + loadingState, + projectChanged, + userOwnsProject + } = this.props; + + const url = prompt(intl.formatMessage(messages.urlPrompt)); // eslint-disable-line no-alert + + if (!url) { + // User cancelled + this.props.closeFileMenu(); + return; + } + + const projectId = extractScratchProjectId(url); + if (!projectId) { + alert(intl.formatMessage(messages.invalidUrl)); // eslint-disable-line no-alert + this.props.closeFileMenu(); + return; + } + + this.projectIdToLoad = projectId; + this.projectUrlToLoad = url; + + // If user owns the project, or user has changed the project, + // we must confirm with the user that they really intend to + // replace it. + let uploadAllowed = true; + if (userOwnsProject || (projectChanged && isShowingWithoutId)) { + uploadAllowed = confirm( // eslint-disable-line no-alert + intl.formatMessage(sharedMessages.replaceProjectWarning) + ); + } + + if (uploadAllowed) { + // Start the loading process + this.props.requestProjectUpload(loadingState); + } + + this.props.closeFileMenu(); + } + + // Step 3: Load project from URL (called from componentDidUpdate) + handleFinishedLoadingUpload () { + if (this.projectIdToLoad) { + this.loadProjectFromUrl(this.projectIdToLoad); + return; + } + this.props.cancelFileUpload(this.props.loadingState); + } + + // Step 4: Actually load the project data + loadProjectFromUrl (projectId) { + this.props.onLoadingStarted(); + let loadingSuccess = false; + + // Use the same approach as project-fetcher-hoc.jsx + // First get the project token via the proxy API + const options = { + method: 'GET', + uri: `https://api.smalruby.app/scratch-api-proxy/projects/${projectId}`, + json: true + }; + + fetch(options.uri, { + method: options.method, + headers: { + 'Content-Type': 'application/json' + } + }) + .then(response => { + if (!response.ok) { + throw new Error(`HTTP ${response.status}`); + } + return response.json(); + }) + .then(data => { + const projectToken = data.project_token; + + // Now load the project using the VM's storage system + const storage = this.props.vm.runtime.storage; + storage.setProjectToken(projectToken); + + return storage.load(storage.AssetType.Project, projectId, storage.DataFormat.JSON); + }) + .then(projectAsset => { + if (projectAsset) { + return this.props.vm.loadProject(projectAsset.data); + } + throw new Error('Could not find project'); + }) + .then(() => { + // Set project title based on the project data or URL + const projectTitle = `Project ${this.projectIdToLoad}`; + this.props.onSetProjectTitle(projectTitle); + loadingSuccess = true; + }) + .catch(error => { + log.warn('URL loader error:', error); + alert(this.props.intl.formatMessage(messages.loadError)); // eslint-disable-line no-alert + }) + .then(() => { + this.props.onLoadingFinished(this.props.loadingState, loadingSuccess); + // Clear the project reference + this.projectIdToLoad = null; + this.projectUrlToLoad = null; + }); + } + + render () { + const { + /* eslint-disable no-unused-vars */ + cancelFileUpload, + closeFileMenu: closeFileMenuProp, + isLoadingUpload, + isShowingWithoutId, + loadingState, + onLoadingFinished, + onLoadingStarted, + onSetProjectTitle, + projectChanged, + requestProjectUpload: requestProjectUploadProp, + userOwnsProject, + /* eslint-enable no-unused-vars */ + ...componentProps + } = this.props; + return ( + + + + ); + } + } + + URLLoaderComponent.propTypes = { + canSave: PropTypes.bool, + cancelFileUpload: PropTypes.func, + closeFileMenu: PropTypes.func, + intl: intlShape.isRequired, + isLoadingUpload: PropTypes.bool, + isShowingWithoutId: PropTypes.bool, + loadingState: PropTypes.oneOf(LoadingStates), + onLoadingFinished: PropTypes.func, + onLoadingStarted: PropTypes.func, + onSetProjectTitle: PropTypes.func, + projectChanged: PropTypes.bool, + requestProjectUpload: PropTypes.func, + userOwnsProject: PropTypes.bool, + vm: PropTypes.shape({ + loadProject: PropTypes.func, + runtime: PropTypes.shape({ + storage: PropTypes.object + }) + }) + }; + + const mapStateToProps = (state, ownProps) => { + const loadingState = state.scratchGui.projectState.loadingState; + const user = state.session && state.session.session && state.session.session.user; + return { + isLoadingUpload: getIsLoadingUpload(loadingState), + isShowingWithoutId: getIsShowingWithoutId(loadingState), + loadingState: loadingState, + projectChanged: state.scratchGui.projectChanged, + userOwnsProject: ownProps.authorUsername && user && + (ownProps.authorUsername === user.username), + vm: state.scratchGui.vm + }; + }; + + const mapDispatchToProps = (dispatch, ownProps) => ({ + cancelFileUpload: loadingState => dispatch(onLoadedProject(loadingState, false, false)), + closeFileMenu: () => dispatch(closeFileMenu()), + onLoadingFinished: (loadingState, success) => { + dispatch(onLoadedProject(loadingState, ownProps.canSave, success)); + dispatch(closeLoadingProject()); + dispatch(closeFileMenu()); + }, + onLoadingStarted: () => dispatch(openLoadingProject()), + onSetProjectTitle: title => dispatch(setProjectTitle(title)), + requestProjectUpload: loadingState => dispatch(requestProjectUpload(loadingState)) + }); + + const mergeProps = (stateProps, dispatchProps, ownProps) => Object.assign( + {}, stateProps, dispatchProps, ownProps + ); + + return injectIntl(connect( + mapStateToProps, + mapDispatchToProps, + mergeProps + )(URLLoaderComponent)); +}; + +export { + URLLoaderHOC as default +}; diff --git a/src/lib/url-parser.js b/src/lib/url-parser.js new file mode 100644 index 00000000000..53529d01251 --- /dev/null +++ b/src/lib/url-parser.js @@ -0,0 +1,37 @@ +/** + * Utility functions for parsing project URLs + */ + +/** + * Extract Scratch project ID from Scratch project URL + * @param {string} url - Scratch project URL + * @returns {string|null} - Project ID or null if invalid + */ +export const extractScratchProjectId = url => { + if (!url || typeof url !== 'string') { + return null; + } + + const patterns = [ + // Standard project URL: https://scratch.mit.edu/projects/1209008277/ + /^https?:\/\/scratch\.mit\.edu\/projects\/(\d+)\/?$/, + // Project URL with additional path: https://scratch.mit.edu/projects/1209008277/editor/ + /^https?:\/\/scratch\.mit\.edu\/projects\/(\d+)\/.*$/ + ]; + + for (const pattern of patterns) { + const match = url.trim().match(pattern); + if (match && match[1]) { + return match[1]; + } + } + + return null; +}; + +/** + * Validate if URL is a valid Scratch project URL + * @param {string} url - URL to validate + * @returns {boolean} - True if valid Scratch project URL + */ +export const isValidScratchProjectUrl = url => extractScratchProjectId(url) !== null; diff --git a/src/locales/ja.js b/src/locales/ja.js index 66536e4b851..f8b7dce5459 100644 --- a/src/locales/ja.js +++ b/src/locales/ja.js @@ -1,4 +1,5 @@ export default { + 'gui.menuBar.loadFromUrl': 'URLから読み込む', 'gui.menuBar.seeProjectPage': 'プロジェクトページを見る', 'gui.loader.creating': 'プロジェクトを作成中...', 'gui.smalruby3.crashMessage.description': '申し訳ありません。スモウルビーがクラッシュしたようです。このバグは自動的にスモウルビーチームに報告されました。ページを再読み込みしてください。', From 20d334cc25852a8f0d390e927b9a12dce79a3b3d Mon Sep 17 00:00:00 2001 From: Kouji Takao Date: Tue, 16 Sep 2025 15:11:22 +0900 Subject: [PATCH 02/15] fix: add extensive logging to troubleshoot URL loader integration - Add debug logs to URLLoaderHOC constructor, handler methods, and render - Add debug logs to MenuBar handleClickLoadFromUrl method - Add debug logs to HOC integration in gui.jsx - Create proper click handler in MenuBar instead of direct prop reference This will help identify why the Load from URL menu item is not working. --- src/components/menu-bar/menu-bar.jsx | 14 ++++++++++++-- src/containers/gui.jsx | 2 ++ src/lib/url-loader-hoc.jsx | 3 +++ 3 files changed, 17 insertions(+), 2 deletions(-) diff --git a/src/components/menu-bar/menu-bar.jsx b/src/components/menu-bar/menu-bar.jsx index 02aaf1635c0..dfd3c86113d 100644 --- a/src/components/menu-bar/menu-bar.jsx +++ b/src/components/menu-bar/menu-bar.jsx @@ -188,7 +188,8 @@ class MenuBar extends React.Component { 'handleKeyPress', 'handleRestoreOption', 'getSaveToComputerHandler', - 'restoreOptionMessage' + 'restoreOptionMessage', + 'handleClickLoadFromUrl' ]); } componentDidMount () { @@ -277,6 +278,15 @@ class MenuBar extends React.Component { } }; } + handleClickLoadFromUrl () { + console.log('[MenuBar] handleClickLoadFromUrl called'); + console.log('[MenuBar] onStartSelectingUrlLoad prop:', this.props.onStartSelectingUrlLoad); + if (this.props.onStartSelectingUrlLoad) { + this.props.onStartSelectingUrlLoad(); + } else { + console.error('[MenuBar] onStartSelectingUrlLoad prop is not available'); + } + } restoreOptionMessage (deletedItem) { switch (deletedItem) { case 'Sprite': @@ -493,7 +503,7 @@ class MenuBar extends React.Component { {this.props.intl.formatMessage(sharedMessages.loadFromComputerTitle)} Date: Tue, 16 Sep 2025 15:40:34 +0900 Subject: [PATCH 03/15] fix: resolve React warning for onStartSelectingUrlLoad prop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixed prop drilling issue where onStartSelectingUrlLoad was being passed to DOM elements, causing React to show warnings and ignore the event handler. Added proper prop filtering in URLLoaderHOC render method. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- src/lib/url-loader-hoc.jsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/lib/url-loader-hoc.jsx b/src/lib/url-loader-hoc.jsx index bae9af1eaf0..fc450476ddb 100644 --- a/src/lib/url-loader-hoc.jsx +++ b/src/lib/url-loader-hoc.jsx @@ -202,6 +202,7 @@ const URLLoaderHOC = function (WrappedComponent) { projectChanged, requestProjectUpload: requestProjectUploadProp, userOwnsProject, + onStartSelectingUrlLoad: onStartSelectingUrlLoadProp, /* eslint-enable no-unused-vars */ ...componentProps } = this.props; @@ -228,6 +229,7 @@ const URLLoaderHOC = function (WrappedComponent) { onLoadingFinished: PropTypes.func, onLoadingStarted: PropTypes.func, onSetProjectTitle: PropTypes.func, + onStartSelectingUrlLoad: PropTypes.func, projectChanged: PropTypes.bool, requestProjectUpload: PropTypes.func, userOwnsProject: PropTypes.bool, From b4563d364ac9921f5388ffea52897e153dc0d7ba Mon Sep 17 00:00:00 2001 From: Kouji Takao Date: Tue, 16 Sep 2025 15:45:39 +0900 Subject: [PATCH 04/15] fix: add onStartSelectingUrlLoad prop to GUIComponent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MenuBar was not receiving the onStartSelectingUrlLoad prop because it wasn't being passed down from GUIComponent. Added the prop to: - Function parameter destructuring - MenuBar component props - PropTypes validation 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- src/components/gui/gui.jsx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/components/gui/gui.jsx b/src/components/gui/gui.jsx index 0926f0d4f61..e66799fed84 100644 --- a/src/components/gui/gui.jsx +++ b/src/components/gui/gui.jsx @@ -121,6 +121,7 @@ const GUIComponent = props => { onShare, onShowPrivacyPolicy, onStartSelectingFileUpload, + onStartSelectingUrlLoad, onTelemetryModalCancel, onTelemetryModalOptIn, onTelemetryModalOptOut, @@ -260,6 +261,7 @@ const GUIComponent = props => { onSeeCommunity={onSeeCommunity} onShare={onShare} onStartSelectingFileUpload={onStartSelectingFileUpload} + onStartSelectingUrlLoad={onStartSelectingUrlLoad} onToggleLoginOpen={onToggleLoginOpen} /> @@ -466,6 +468,7 @@ GUIComponent.propTypes = { onShare: PropTypes.func, onShowPrivacyPolicy: PropTypes.func, onStartSelectingFileUpload: PropTypes.func, + onStartSelectingUrlLoad: PropTypes.func, onTabSelect: PropTypes.func, onTelemetryModalCancel: PropTypes.func, onTelemetryModalOptIn: PropTypes.func, From 2c888a3047fc45210569fb66b6f230be36c81bed Mon Sep 17 00:00:00 2001 From: Kouji Takao Date: Tue, 16 Sep 2025 21:51:32 +0900 Subject: [PATCH 05/15] feat: add Japanese translations for URL loader messages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Added missing Japanese translations for URL loader functionality: - gui.urlLoader.loadError: Error message when URL loading fails - gui.urlLoader.invalidUrl: Error message for invalid URLs - gui.urlLoader.urlPrompt: Prompt message for URL input Added to both ja.js and ja-Hira.js localization files. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- src/locales/ja-Hira.js | 4 ++++ src/locales/ja.js | 3 +++ 2 files changed, 7 insertions(+) diff --git a/src/locales/ja-Hira.js b/src/locales/ja-Hira.js index f4c776d8b7f..06a3aa118d4 100644 --- a/src/locales/ja-Hira.js +++ b/src/locales/ja-Hira.js @@ -1,4 +1,8 @@ export default { + 'gui.menuBar.loadFromUrl': 'URLからよみこむ', + 'gui.urlLoader.loadError': 'プロジェクトURLのよみこみにしっぱいしました。', + 'gui.urlLoader.invalidUrl': 'ゆうこうなScratchプロジェクトURLをにゅうりょくしてください。', + 'gui.urlLoader.urlPrompt': 'ScratchプロジェクトのURLをにゅうりょくしてください(れい:https://scratch.mit.edu/projects/1209008277/):', 'gui.menuBar.seeProjectPage': 'プロジェクトページをみる', 'gui.loader.creating': 'プロジェクトをさくせいちゅう...', 'gui.smalruby3.crashMessage.description': 'もうしわけありません。スモウルビーがクラッシュしたようです。このバグはじどうてきにスモウルビーチームにほうこくされました。ページをさいよみこみしてください。', diff --git a/src/locales/ja.js b/src/locales/ja.js index f8b7dce5459..efb2d479979 100644 --- a/src/locales/ja.js +++ b/src/locales/ja.js @@ -1,5 +1,8 @@ export default { 'gui.menuBar.loadFromUrl': 'URLから読み込む', + 'gui.urlLoader.loadError': 'プロジェクトURLの読み込みに失敗しました。', + 'gui.urlLoader.invalidUrl': '有効なScratchプロジェクトURLを入力してください。', + 'gui.urlLoader.urlPrompt': 'ScratchプロジェクトのURLを入力してください(例:https://scratch.mit.edu/projects/1209008277/):', 'gui.menuBar.seeProjectPage': 'プロジェクトページを見る', 'gui.loader.creating': 'プロジェクトを作成中...', 'gui.smalruby3.crashMessage.description': '申し訳ありません。スモウルビーがクラッシュしたようです。このバグは自動的にスモウルビーチームに報告されました。ページを再読み込みしてください。', From 2ae1bc2df77862c4fcc98d19b0f3e92eed1f4be7 Mon Sep 17 00:00:00 2001 From: Kouji Takao Date: Tue, 16 Sep 2025 22:04:55 +0900 Subject: [PATCH 06/15] fix: align URL loader with project-fetcher-hoc pattern MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixed URL loader implementation to match project-fetcher-hoc.jsx pattern: - Use onFetchedProjectData instead of direct VM loading - Set projectId in Redux state before loading - Use proper Redux action dispatching for error handling - Remove loadingState/success parameters from onLoadingFinished - Add missing PropTypes for new functions This prevents RubyTab errors and Redux dispatch issues. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- src/lib/url-loader-hoc.jsx | 45 ++++++++++++++++++++++++-------------- 1 file changed, 28 insertions(+), 17 deletions(-) diff --git a/src/lib/url-loader-hoc.jsx b/src/lib/url-loader-hoc.jsx index fc450476ddb..a1d4a8d171b 100644 --- a/src/lib/url-loader-hoc.jsx +++ b/src/lib/url-loader-hoc.jsx @@ -12,7 +12,9 @@ import { LoadingStates, getIsLoadingUpload, getIsShowingWithoutId, - onLoadedProject, + onFetchedProjectData, + projectError, + setProjectId, requestProjectUpload } from '../reducers/project-state'; import {setProjectTitle} from '../reducers/project-title'; @@ -133,7 +135,9 @@ const URLLoaderHOC = function (WrappedComponent) { // Step 4: Actually load the project data loadProjectFromUrl (projectId) { this.props.onLoadingStarted(); - let loadingSuccess = false; + + // Set project ID in Redux state first (like project-fetcher-hoc.jsx) + this.props.setProjectId(projectId.toString()); // Use the same approach as project-fetcher-hoc.jsx // First get the project token via the proxy API @@ -158,7 +162,7 @@ const URLLoaderHOC = function (WrappedComponent) { .then(data => { const projectToken = data.project_token; - // Now load the project using the VM's storage system + // Now load the project using storage system (like project-fetcher-hoc.jsx) const storage = this.props.vm.runtime.storage; storage.setProjectToken(projectToken); @@ -166,22 +170,23 @@ const URLLoaderHOC = function (WrappedComponent) { }) .then(projectAsset => { if (projectAsset) { - return this.props.vm.loadProject(projectAsset.data); + // Use onFetchedProjectData instead of direct VM loading + this.props.onFetchedProjectData(projectAsset.data, this.props.loadingState); + + // Set project title based on the project data or URL + const projectTitle = `Project ${this.projectIdToLoad}`; + this.props.onSetProjectTitle(projectTitle); + } else { + throw new Error('Could not find project'); } - throw new Error('Could not find project'); - }) - .then(() => { - // Set project title based on the project data or URL - const projectTitle = `Project ${this.projectIdToLoad}`; - this.props.onSetProjectTitle(projectTitle); - loadingSuccess = true; }) .catch(error => { log.warn('URL loader error:', error); + this.props.onError(error); alert(this.props.intl.formatMessage(messages.loadError)); // eslint-disable-line no-alert }) .then(() => { - this.props.onLoadingFinished(this.props.loadingState, loadingSuccess); + this.props.onLoadingFinished(); // Clear the project reference this.projectIdToLoad = null; this.projectUrlToLoad = null; @@ -226,12 +231,15 @@ const URLLoaderHOC = function (WrappedComponent) { isLoadingUpload: PropTypes.bool, isShowingWithoutId: PropTypes.bool, loadingState: PropTypes.oneOf(LoadingStates), + onError: PropTypes.func, + onFetchedProjectData: PropTypes.func, onLoadingFinished: PropTypes.func, onLoadingStarted: PropTypes.func, onSetProjectTitle: PropTypes.func, onStartSelectingUrlLoad: PropTypes.func, projectChanged: PropTypes.bool, requestProjectUpload: PropTypes.func, + setProjectId: PropTypes.func, userOwnsProject: PropTypes.bool, vm: PropTypes.shape({ loadProject: PropTypes.func, @@ -255,17 +263,20 @@ const URLLoaderHOC = function (WrappedComponent) { }; }; - const mapDispatchToProps = (dispatch, ownProps) => ({ - cancelFileUpload: loadingState => dispatch(onLoadedProject(loadingState, false, false)), + const mapDispatchToProps = dispatch => ({ + cancelFileUpload: loadingState => dispatch(onFetchedProjectData(null, loadingState)), closeFileMenu: () => dispatch(closeFileMenu()), - onLoadingFinished: (loadingState, success) => { - dispatch(onLoadedProject(loadingState, ownProps.canSave, success)); + onError: error => dispatch(projectError(error)), + onFetchedProjectData: (projectData, loadingState) => + dispatch(onFetchedProjectData(projectData, loadingState)), + onLoadingFinished: () => { dispatch(closeLoadingProject()); dispatch(closeFileMenu()); }, onLoadingStarted: () => dispatch(openLoadingProject()), onSetProjectTitle: title => dispatch(setProjectTitle(title)), - requestProjectUpload: loadingState => dispatch(requestProjectUpload(loadingState)) + requestProjectUpload: loadingState => dispatch(requestProjectUpload(loadingState)), + setProjectId: projectId => dispatch(setProjectId(projectId)) }); const mergeProps = (stateProps, dispatchProps, ownProps) => Object.assign( From a0f45e31015b12c230a6556e3478b0552799e1a9 Mon Sep 17 00:00:00 2001 From: Kouji Takao Date: Tue, 16 Sep 2025 22:09:49 +0900 Subject: [PATCH 07/15] fix: use onLoadedProject for URL loader Redux actions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixed Redux dispatch error by using onLoadedProject instead of onFetchedProjectData. The LOADING_VM_FILE_UPLOAD state requires onLoadedProject action which properly handles the state transition. Changes: - Import onLoadedProject instead of onFetchedProjectData - Load project directly to VM like sb-file-uploader-hoc.jsx - Use onLoadedProject(loadingState, canSave=true, success=true) - Updated PropTypes and mapDispatchToProps 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- src/lib/url-loader-hoc.jsx | 29 ++++++++++++++++------------- 1 file changed, 16 insertions(+), 13 deletions(-) diff --git a/src/lib/url-loader-hoc.jsx b/src/lib/url-loader-hoc.jsx index a1d4a8d171b..ba40b43339b 100644 --- a/src/lib/url-loader-hoc.jsx +++ b/src/lib/url-loader-hoc.jsx @@ -12,7 +12,7 @@ import { LoadingStates, getIsLoadingUpload, getIsShowingWithoutId, - onFetchedProjectData, + onLoadedProject, projectError, setProjectId, requestProjectUpload @@ -170,15 +170,18 @@ const URLLoaderHOC = function (WrappedComponent) { }) .then(projectAsset => { if (projectAsset) { - // Use onFetchedProjectData instead of direct VM loading - this.props.onFetchedProjectData(projectAsset.data, this.props.loadingState); - - // Set project title based on the project data or URL - const projectTitle = `Project ${this.projectIdToLoad}`; - this.props.onSetProjectTitle(projectTitle); - } else { - throw new Error('Could not find project'); + // Load project directly to VM (like sb-file-uploader-hoc.jsx for LOADING_VM_FILE_UPLOAD) + return this.props.vm.loadProject(projectAsset.data); } + throw new Error('Could not find project'); + }) + .then(() => { + // Set project title based on the project data or URL + const projectTitle = `Project ${this.projectIdToLoad}`; + this.props.onSetProjectTitle(projectTitle); + + // Use onLoadedProject for LOADING_VM_FILE_UPLOAD state + this.props.onLoadedProject(this.props.loadingState, true, true); }) .catch(error => { log.warn('URL loader error:', error); @@ -232,7 +235,7 @@ const URLLoaderHOC = function (WrappedComponent) { isShowingWithoutId: PropTypes.bool, loadingState: PropTypes.oneOf(LoadingStates), onError: PropTypes.func, - onFetchedProjectData: PropTypes.func, + onLoadedProject: PropTypes.func, onLoadingFinished: PropTypes.func, onLoadingStarted: PropTypes.func, onSetProjectTitle: PropTypes.func, @@ -264,11 +267,11 @@ const URLLoaderHOC = function (WrappedComponent) { }; const mapDispatchToProps = dispatch => ({ - cancelFileUpload: loadingState => dispatch(onFetchedProjectData(null, loadingState)), + cancelFileUpload: loadingState => dispatch(onLoadedProject(loadingState, false, false)), closeFileMenu: () => dispatch(closeFileMenu()), onError: error => dispatch(projectError(error)), - onFetchedProjectData: (projectData, loadingState) => - dispatch(onFetchedProjectData(projectData, loadingState)), + onLoadedProject: (loadingState, canSave, success) => + dispatch(onLoadedProject(loadingState, canSave, success)), onLoadingFinished: () => { dispatch(closeLoadingProject()); dispatch(closeFileMenu()); From e7f53448d18f858d2e82f06525a4ec665b6d6622 Mon Sep 17 00:00:00 2001 From: Kouji Takao Date: Tue, 16 Sep 2025 22:17:46 +0900 Subject: [PATCH 08/15] fix: handle undefined return in onLoadedProject default case MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixed Redux dispatch error by ensuring onLoadedProject always returns a valid action object. The default case now returns RETURN_TO_SHOWING action instead of undefined. This resolves the 'Cannot read properties of undefined (reading type)' error that occurred when onLoadedProject was called with SHOWING_WITH_ID state. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- src/reducers/project-state.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/reducers/project-state.js b/src/reducers/project-state.js index 76955d589d5..497f5d93b6f 100644 --- a/src/reducers/project-state.js +++ b/src/reducers/project-state.js @@ -436,7 +436,8 @@ const onLoadedProject = (loadingState, canSave, success) => { // failed to load default project; show error return {type: START_ERROR}; default: - return; + // For states like SHOWING_WITH_ID, return a no-op action + return {type: RETURN_TO_SHOWING}; } }; From de1955f768c1e0db686e85f9a531562a37d9fd06 Mon Sep 17 00:00:00 2001 From: Kouji Takao Date: Tue, 16 Sep 2025 22:22:44 +0900 Subject: [PATCH 09/15] chore: remove debug logs and finalize URL loader implementation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Removed all debug console.log statements from: - url-loader-hoc.jsx: Constructor, handleStartSelectingUrlLoad, render - menu-bar.jsx: handleClickLoadFromUrl method - gui.jsx: Import and creation logs Build successful and ready for production. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- src/components/menu-bar/menu-bar.jsx | 4 ---- src/containers/gui.jsx | 2 -- src/lib/url-loader-hoc.jsx | 3 --- 3 files changed, 9 deletions(-) diff --git a/src/components/menu-bar/menu-bar.jsx b/src/components/menu-bar/menu-bar.jsx index dfd3c86113d..d3f88f3a0c2 100644 --- a/src/components/menu-bar/menu-bar.jsx +++ b/src/components/menu-bar/menu-bar.jsx @@ -279,12 +279,8 @@ class MenuBar extends React.Component { }; } handleClickLoadFromUrl () { - console.log('[MenuBar] handleClickLoadFromUrl called'); - console.log('[MenuBar] onStartSelectingUrlLoad prop:', this.props.onStartSelectingUrlLoad); if (this.props.onStartSelectingUrlLoad) { this.props.onStartSelectingUrlLoad(); - } else { - console.error('[MenuBar] onStartSelectingUrlLoad prop is not available'); } } restoreOptionMessage (deletedItem) { diff --git a/src/containers/gui.jsx b/src/containers/gui.jsx index 6cce475b772..7355c57adbb 100644 --- a/src/containers/gui.jsx +++ b/src/containers/gui.jsx @@ -31,7 +31,6 @@ import FontLoaderHOC from '../lib/font-loader-hoc.jsx'; import LocalizationHOC from '../lib/localization-hoc.jsx'; import SBFileUploaderHOC from '../lib/sb-file-uploader-hoc.jsx'; import URLLoaderHOC from '../lib/url-loader-hoc.jsx'; -console.log('[GUI] URLLoaderHOC imported:', URLLoaderHOC); import ProjectFetcherHOC from '../lib/project-fetcher-hoc.jsx'; import TitledHOC from '../lib/titled-hoc.jsx'; import ProjectSaverHOC from '../lib/project-saver-hoc.jsx'; @@ -203,7 +202,6 @@ const ConnectedGUI = injectIntl(connect( // note that redux's 'compose' function is just being used as a general utility to make // the hierarchy of HOC constructor calls clearer here; it has nothing to do with redux's // ability to compose reducers. -console.log('[GUI] Creating WrappedGui with URLLoaderHOC'); const WrappedGui = compose( LocalizationHOC, ErrorBoundaryHOC('Top Level App'), diff --git a/src/lib/url-loader-hoc.jsx b/src/lib/url-loader-hoc.jsx index ba40b43339b..22cd00bd61d 100644 --- a/src/lib/url-loader-hoc.jsx +++ b/src/lib/url-loader-hoc.jsx @@ -57,7 +57,6 @@ const URLLoaderHOC = function (WrappedComponent) { class URLLoaderComponent extends React.Component { constructor (props) { super(props); - console.log('[URLLoaderHOC] Constructor called'); bindAll(this, [ 'handleStartSelectingUrlLoad', 'handleUrlInput', @@ -73,7 +72,6 @@ const URLLoaderHOC = function (WrappedComponent) { // Step 1: Start the URL loading process handleStartSelectingUrlLoad () { - console.log('[URLLoaderHOC] handleStartSelectingUrlLoad called'); this.handleUrlInput(); } @@ -214,7 +212,6 @@ const URLLoaderHOC = function (WrappedComponent) { /* eslint-enable no-unused-vars */ ...componentProps } = this.props; - console.log('[URLLoaderHOC] Rendering with onStartSelectingUrlLoad:', this.handleStartSelectingUrlLoad); return ( Date: Thu, 18 Sep 2025 01:29:04 +0900 Subject: [PATCH 10/15] fix: correct translate_getTranslate return value format MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Change translate_getTranslate to return proper tuple format [code, order] instead of just code string. This ensures consistency with other value block generators and prevents Ruby code generation errors. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- src/lib/ruby-generator/translate.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lib/ruby-generator/translate.js b/src/lib/ruby-generator/translate.js index ecf7709cd85..981e7978119 100644 --- a/src/lib/ruby-generator/translate.js +++ b/src/lib/ruby-generator/translate.js @@ -7,7 +7,7 @@ export default function (Generator) { Generator.translate_getTranslate = function (block) { const words = Generator.valueToCode(block, 'WORDS', Generator.ORDER_NONE) || null; const language = Generator.valueToCode(block, 'LANGUAGE', Generator.ORDER_NONE); - return `translate(${words}, ${language})\n`; + return [`translate(${words}, ${language})`, Generator.ORDER_FUNCTION_CALL]; }; Generator.translate_menu_languages = Generator.text2speech_menu_languages; From 991e1621100f5ca06f33880d2253696f93c7853e Mon Sep 17 00:00:00 2001 From: Kouji Takao Date: Thu, 18 Sep 2025 02:00:00 +0900 Subject: [PATCH 11/15] feat: improve URL loader UI with modal dialog MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace prompt/confirm dialogs with proper modal UI for loading projects from URL. The modal includes message, URL input field, and Open/Cancel buttons like the block display modal. Changes: - Created URLLoaderModal component with proper styling - Updated URLLoaderHOC to use modal instead of prompt() - Added modal state management to Redux modals - Integrated modal into GUI component - Added missing internationalization messages - Fixed CSS color variables to use motion-primary theme The modal provides better user experience with proper input validation, placeholder text, and consistent UI design. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- src/components/gui/gui.jsx | 13 ++ .../url-loader-modal/url-loader-icon.svg | 25 +++ .../url-loader-modal/url-loader-modal.css | 107 +++++++++++++ .../url-loader-modal/url-loader-modal.jsx | 144 ++++++++++++++++++ src/containers/gui.jsx | 9 +- src/lib/url-loader-hoc.jsx | 24 ++- src/locales/en.js | 11 +- src/locales/ja-Hira.js | 4 + src/locales/ja.js | 4 + src/reducers/modals.js | 14 +- 10 files changed, 336 insertions(+), 19 deletions(-) create mode 100644 src/components/url-loader-modal/url-loader-icon.svg create mode 100644 src/components/url-loader-modal/url-loader-modal.css create mode 100644 src/components/url-loader-modal/url-loader-modal.jsx diff --git a/src/components/gui/gui.jsx b/src/components/gui/gui.jsx index e66799fed84..9a9b690fdec 100644 --- a/src/components/gui/gui.jsx +++ b/src/components/gui/gui.jsx @@ -32,6 +32,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 URLLoaderModal from '../url-loader-modal/url-loader-modal.jsx'; import layout, {STAGE_SIZE_MODES} from '../../lib/layout-constants'; import {resolveStageSize} from '../../lib/screen-utils'; @@ -133,6 +134,9 @@ const GUIComponent = props => { telemetryModalVisible, theme, tipsLibraryVisible, + urlLoaderModalVisible, + closeUrlLoaderModal, + onUrlLoaderSubmit, vm, // Exclude Redux-related props from being passed to DOM setSelectedBlocks: _setSelectedBlocks, @@ -188,6 +192,12 @@ const GUIComponent = props => { onShowPrivacyPolicy={onShowPrivacyPolicy} /> ) : null} + {urlLoaderModalVisible ? ( + + ) : null} {loading ? ( ) : null} @@ -483,6 +493,9 @@ GUIComponent.propTypes = { telemetryModalVisible: PropTypes.bool, theme: PropTypes.string, tipsLibraryVisible: PropTypes.bool, + urlLoaderModalVisible: PropTypes.bool, + closeUrlLoaderModal: PropTypes.func, + onUrlLoaderSubmit: PropTypes.func, vm: PropTypes.instanceOf(VM).isRequired }; GUIComponent.defaultProps = { diff --git a/src/components/url-loader-modal/url-loader-icon.svg b/src/components/url-loader-modal/url-loader-icon.svg new file mode 100644 index 00000000000..bf6a8750fb3 --- /dev/null +++ b/src/components/url-loader-modal/url-loader-icon.svg @@ -0,0 +1,25 @@ + + + + URL Loader + URL Loader Icon + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/components/url-loader-modal/url-loader-modal.css b/src/components/url-loader-modal/url-loader-modal.css new file mode 100644 index 00000000000..14ff36c4af6 --- /dev/null +++ b/src/components/url-loader-modal/url-loader-modal.css @@ -0,0 +1,107 @@ +@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; +} + +.promptSection { + text-align: left; +} + +.promptText { + font-size: 0.875rem; + color: $text-primary; + line-height: 1.5; +} + +.inputSection { + display: flex; + flex-direction: column; +} + +.urlInput { + 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; +} + +.urlInput:focus { + border-color: $motion-primary; + box-shadow: 0 0 0 1px $motion-primary; +} + +.urlInput::placeholder { + color: $text-primary-transparent; +} + +.buttonSection { + display: flex; + justify-content: flex-end; + gap: 0.75rem; + margin-top: 0.5rem; +} + +.cancelButton, +.openButton { + 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; +} + +.cancelButton { + background: $ui-white; + color: $text-primary; + border: 1px solid $ui-black-transparent; +} + +.cancelButton:hover { + background: $ui-secondary; +} + +.cancelButton:active { + background: $ui-black-transparent; +} + +.openButton { + background: $motion-primary; + color: $ui-white; +} + +.openButton:hover:not(.disabled) { + background: $motion-tertiary; +} + +.openButton:active:not(.disabled) { + background: $motion-tertiary; +} + +.openButton.disabled { + background: $ui-black-transparent; + color: $text-primary-transparent; + cursor: not-allowed; +} \ No newline at end of file diff --git a/src/components/url-loader-modal/url-loader-modal.jsx b/src/components/url-loader-modal/url-loader-modal.jsx new file mode 100644 index 00000000000..0ea8c3a9fbb --- /dev/null +++ b/src/components/url-loader-modal/url-loader-modal.jsx @@ -0,0 +1,144 @@ +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 urlLoaderIcon from './url-loader-icon.svg'; + +import styles from './url-loader-modal.css'; + +const messages = defineMessages({ + title: { + defaultMessage: 'Load from URL', + description: 'Title for the URL loader modal', + id: 'gui.urlLoader.title' + }, + prompt: { + defaultMessage: 'Enter a Scratch project URL (e.g., https://scratch.mit.edu/projects/1209008277/):', + description: 'Prompt message for URL input', + id: 'gui.urlLoader.urlPrompt' + }, + urlPlaceholder: { + defaultMessage: 'https://scratch.mit.edu/projects/1234567890/', + description: 'Placeholder text for URL input field', + id: 'gui.urlLoader.urlPlaceholder' + }, + openButton: { + defaultMessage: 'Open', + description: 'Label for open button', + id: 'gui.urlLoader.openButton' + }, + cancelButton: { + defaultMessage: 'Cancel', + description: 'Label for cancel button', + id: 'gui.urlLoader.cancelButton' + } +}); + +class URLLoaderModal extends React.Component { + constructor (props) { + super(props); + bindAll(this, [ + 'handleUrlChange', + 'handleOpenClick', + 'handleCancelClick', + 'handleKeyPress' + ]); + + this.state = { + url: '' + }; + } + + handleUrlChange (event) { + this.setState({url: event.target.value}); + } + + handleOpenClick () { + const {url} = this.state; + if (url.trim()) { + this.props.onLoadUrl(url.trim()); + } + } + + handleCancelClick () { + this.props.onRequestClose(); + } + + handleKeyPress (event) { + if (event.key === 'Enter') { + this.handleOpenClick(); + } + } + + render () { + const {intl, onRequestClose} = this.props; + const {url} = this.state; + + return ( + + + +
+ +
+
+ + + + + + + + + +
+
+ ); + } +} + +URLLoaderModal.propTypes = { + intl: intlShape.isRequired, + onRequestClose: PropTypes.func.isRequired, + onLoadUrl: PropTypes.func.isRequired +}; + +export default injectIntl(URLLoaderModal); diff --git a/src/containers/gui.jsx b/src/containers/gui.jsx index 7355c57adbb..d74ba6ec6ad 100644 --- a/src/containers/gui.jsx +++ b/src/containers/gui.jsx @@ -24,7 +24,9 @@ import { closeBackdropLibrary, closeTelemetryModal, openExtensionLibrary, - closeDebugModal + closeDebugModal, + openUrlLoaderModal, + closeUrlLoaderModal } from '../reducers/modals'; import FontLoaderHOC from '../lib/font-loader-hoc.jsx'; @@ -178,6 +180,7 @@ const mapStateToProps = state => { telemetryModalVisible: state.scratchGui.modals.telemetryModal, tipsLibraryVisible: state.scratchGui.modals.tipsLibrary, rubyTabVisible: state.scratchGui.editorTab.activeTabIndex === RUBY_TAB_INDEX, + urlLoaderModalVisible: state.scratchGui.modals.urlLoaderModal, vm: state.scratchGui.vm }; }; @@ -191,7 +194,9 @@ const mapDispatchToProps = dispatch => ({ onRequestCloseBackdropLibrary: () => dispatch(closeBackdropLibrary()), onRequestCloseCostumeLibrary: () => dispatch(closeCostumeLibrary()), onRequestCloseDebugModal: () => dispatch(closeDebugModal()), - onRequestCloseTelemetryModal: () => dispatch(closeTelemetryModal()) + onRequestCloseTelemetryModal: () => dispatch(closeTelemetryModal()), + openUrlLoaderModal: () => dispatch(openUrlLoaderModal()), + closeUrlLoaderModal: () => dispatch(closeUrlLoaderModal()) }); const ConnectedGUI = injectIntl(connect( diff --git a/src/lib/url-loader-hoc.jsx b/src/lib/url-loader-hoc.jsx index 22cd00bd61d..c596e757a38 100644 --- a/src/lib/url-loader-hoc.jsx +++ b/src/lib/url-loader-hoc.jsx @@ -59,7 +59,7 @@ const URLLoaderHOC = function (WrappedComponent) { super(props); bindAll(this, [ 'handleStartSelectingUrlLoad', - 'handleUrlInput', + 'handleUrlSubmit', 'loadProjectFromUrl', 'handleFinishedLoadingUpload' ]); @@ -72,11 +72,12 @@ const URLLoaderHOC = function (WrappedComponent) { // Step 1: Start the URL loading process handleStartSelectingUrlLoad () { - this.handleUrlInput(); + this.props.openUrlLoaderModal(); + this.props.closeFileMenu(); } - // Step 2: Prompt user for URL input - handleUrlInput () { + // Step 2: Handle URL submission from modal + handleUrlSubmit (url) { const { intl, isShowingWithoutId, @@ -85,18 +86,10 @@ const URLLoaderHOC = function (WrappedComponent) { userOwnsProject } = this.props; - const url = prompt(intl.formatMessage(messages.urlPrompt)); // eslint-disable-line no-alert - - if (!url) { - // User cancelled - this.props.closeFileMenu(); - return; - } - const projectId = extractScratchProjectId(url); if (!projectId) { alert(intl.formatMessage(messages.invalidUrl)); // eslint-disable-line no-alert - this.props.closeFileMenu(); + this.props.closeUrlLoaderModal(); return; } @@ -118,7 +111,7 @@ const URLLoaderHOC = function (WrappedComponent) { this.props.requestProjectUpload(loadingState); } - this.props.closeFileMenu(); + this.props.closeUrlLoaderModal(); } // Step 3: Load project from URL (called from componentDidUpdate) @@ -216,6 +209,7 @@ const URLLoaderHOC = function (WrappedComponent) { @@ -227,6 +221,7 @@ const URLLoaderHOC = function (WrappedComponent) { canSave: PropTypes.bool, cancelFileUpload: PropTypes.func, closeFileMenu: PropTypes.func, + closeUrlLoaderModal: PropTypes.func, intl: intlShape.isRequired, isLoadingUpload: PropTypes.bool, isShowingWithoutId: PropTypes.bool, @@ -237,6 +232,7 @@ const URLLoaderHOC = function (WrappedComponent) { onLoadingStarted: PropTypes.func, onSetProjectTitle: PropTypes.func, onStartSelectingUrlLoad: PropTypes.func, + openUrlLoaderModal: PropTypes.func, projectChanged: PropTypes.bool, requestProjectUpload: PropTypes.func, setProjectId: PropTypes.func, diff --git a/src/locales/en.js b/src/locales/en.js index 782b3c58c28..0407086ae3d 100644 --- a/src/locales/en.js +++ b/src/locales/en.js @@ -175,5 +175,14 @@ export default { 'gui.smalruby3.blockDisplayModal.operator_contains': '(apple) contains (a)?', 'gui.smalruby3.blockDisplayModal.operator_mod': '( ) mod ( )', 'gui.smalruby3.blockDisplayModal.operator_round': 'round ( )', - 'gui.smalruby3.blockDisplayModal.operator_mathop': '[abs ▼] of ( )' + 'gui.smalruby3.blockDisplayModal.operator_mathop': '[abs ▼] of ( )', + + // URL Loader messages + 'gui.urlLoader.loadError': 'The project URL that was entered failed to load.', + 'gui.urlLoader.invalidUrl': 'Please enter a valid Scratch project URL.', + 'gui.urlLoader.urlPrompt': 'Enter a Scratch project URL (e.g., https://scratch.mit.edu/projects/1209008277/):', + 'gui.urlLoader.title': 'Load from URL', + 'gui.urlLoader.urlPlaceholder': 'https://scratch.mit.edu/projects/1234567890/', + 'gui.urlLoader.openButton': 'Open', + 'gui.urlLoader.cancelButton': 'Cancel' }; diff --git a/src/locales/ja-Hira.js b/src/locales/ja-Hira.js index 06a3aa118d4..4e6526ce853 100644 --- a/src/locales/ja-Hira.js +++ b/src/locales/ja-Hira.js @@ -3,6 +3,10 @@ export default { 'gui.urlLoader.loadError': 'プロジェクトURLのよみこみにしっぱいしました。', 'gui.urlLoader.invalidUrl': 'ゆうこうなScratchプロジェクトURLをにゅうりょくしてください。', 'gui.urlLoader.urlPrompt': 'ScratchプロジェクトのURLをにゅうりょくしてください(れい:https://scratch.mit.edu/projects/1209008277/):', + 'gui.urlLoader.title': 'URLからよみこむ', + 'gui.urlLoader.urlPlaceholder': 'https://scratch.mit.edu/projects/1234567890/', + 'gui.urlLoader.openButton': 'ひらく', + 'gui.urlLoader.cancelButton': 'キャンセル', 'gui.menuBar.seeProjectPage': 'プロジェクトページをみる', 'gui.loader.creating': 'プロジェクトをさくせいちゅう...', 'gui.smalruby3.crashMessage.description': 'もうしわけありません。スモウルビーがクラッシュしたようです。このバグはじどうてきにスモウルビーチームにほうこくされました。ページをさいよみこみしてください。', diff --git a/src/locales/ja.js b/src/locales/ja.js index efb2d479979..b35bafe4971 100644 --- a/src/locales/ja.js +++ b/src/locales/ja.js @@ -3,6 +3,10 @@ export default { 'gui.urlLoader.loadError': 'プロジェクトURLの読み込みに失敗しました。', 'gui.urlLoader.invalidUrl': '有効なScratchプロジェクトURLを入力してください。', 'gui.urlLoader.urlPrompt': 'ScratchプロジェクトのURLを入力してください(例:https://scratch.mit.edu/projects/1209008277/):', + 'gui.urlLoader.title': 'URLから読み込む', + 'gui.urlLoader.urlPlaceholder': 'https://scratch.mit.edu/projects/1234567890/', + 'gui.urlLoader.openButton': '開く', + 'gui.urlLoader.cancelButton': 'キャンセル', 'gui.menuBar.seeProjectPage': 'プロジェクトページを見る', 'gui.loader.creating': 'プロジェクトを作成中...', 'gui.smalruby3.crashMessage.description': '申し訳ありません。スモウルビーがクラッシュしたようです。このバグは自動的にスモウルビーチームに報告されました。ページを再読み込みしてください。', diff --git a/src/reducers/modals.js b/src/reducers/modals.js index 5bec3699a1e..a103f676828 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_TIPS_LIBRARY = 'tipsLibrary'; +const MODAL_URL_LOADER = 'urlLoaderModal'; const initialState = { [MODAL_BACKDROP_LIBRARY]: false, @@ -24,7 +25,8 @@ const initialState = { [MODAL_SPRITE_LIBRARY]: false, [MODAL_SOUND_RECORDER]: false, [MODAL_CONNECTION]: false, - [MODAL_TIPS_LIBRARY]: false + [MODAL_TIPS_LIBRARY]: false, + [MODAL_URL_LOADER]: false }; const reducer = function (state, action) { @@ -87,6 +89,9 @@ const openConnectionModal = function () { const openTipsLibrary = function () { return openModal(MODAL_TIPS_LIBRARY); }; +const openUrlLoaderModal = function () { + return openModal(MODAL_URL_LOADER); +}; const closeBackdropLibrary = function () { return closeModal(MODAL_BACKDROP_LIBRARY); }; @@ -120,6 +125,9 @@ const closeTipsLibrary = function () { const closeConnectionModal = function () { return closeModal(MODAL_CONNECTION); }; +const closeUrlLoaderModal = function () { + return closeModal(MODAL_URL_LOADER); +}; export { reducer as default, initialState as modalsInitialState, @@ -134,6 +142,7 @@ export { openTelemetryModal, openTipsLibrary, openConnectionModal, + openUrlLoaderModal, closeBackdropLibrary, closeCostumeLibrary, closeDebugModal, @@ -144,5 +153,6 @@ export { closeSoundRecorder, closeTelemetryModal, closeTipsLibrary, - closeConnectionModal + closeConnectionModal, + closeUrlLoaderModal }; From 4d5ab0bc4165a7e01a3c4eb2cf4053f9ed187dc3 Mon Sep 17 00:00:00 2001 From: Kouji Takao Date: Thu, 18 Sep 2025 12:16:15 +0900 Subject: [PATCH 12/15] fix: add missing modal action creators to URLLoaderHOC MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixed "this.props.openUrlLoaderModal is not a function" error by adding the missing openUrlLoaderModal and closeUrlLoaderModal imports and dispatch functions to the URLLoaderHOC's mapDispatchToProps. The HOC wasn't importing the modal action creators from the modals reducer, so the props weren't being passed through the component chain properly. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- src/lib/url-loader-hoc.jsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/lib/url-loader-hoc.jsx b/src/lib/url-loader-hoc.jsx index c596e757a38..a866fe5c3e3 100644 --- a/src/lib/url-loader-hoc.jsx +++ b/src/lib/url-loader-hoc.jsx @@ -20,7 +20,9 @@ import { import {setProjectTitle} from '../reducers/project-title'; import { openLoadingProject, - closeLoadingProject + closeLoadingProject, + openUrlLoaderModal, + closeUrlLoaderModal } from '../reducers/modals'; import { closeFileMenu @@ -262,6 +264,7 @@ const URLLoaderHOC = function (WrappedComponent) { const mapDispatchToProps = dispatch => ({ cancelFileUpload: loadingState => dispatch(onLoadedProject(loadingState, false, false)), closeFileMenu: () => dispatch(closeFileMenu()), + closeUrlLoaderModal: () => dispatch(closeUrlLoaderModal()), onError: error => dispatch(projectError(error)), onLoadedProject: (loadingState, canSave, success) => dispatch(onLoadedProject(loadingState, canSave, success)), @@ -271,6 +274,7 @@ const URLLoaderHOC = function (WrappedComponent) { }, onLoadingStarted: () => dispatch(openLoadingProject()), onSetProjectTitle: title => dispatch(setProjectTitle(title)), + openUrlLoaderModal: () => dispatch(openUrlLoaderModal()), requestProjectUpload: loadingState => dispatch(requestProjectUpload(loadingState)), setProjectId: projectId => dispatch(setProjectId(projectId)) }); From 5c505ed0da29c29e85c58b9dd2bc46ae3a9aa0bd Mon Sep 17 00:00:00 2001 From: Kouji Takao Date: Thu, 18 Sep 2025 22:01:58 +0900 Subject: [PATCH 13/15] feat: improve URL validation UX in URL loader modal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Instead of showing browser alert() for invalid URLs, now displays validation error messages within the modal itself: - Added error state to URLLoaderModal component - Error message displays below input field with red styling - Modal stays open when validation fails, closes when valid - Updated URLLoaderHOC to pass validation errors via callback - Added CSS styling for error states and messages - Fixed lint errors (arrow-parens, duplicate keys) The modal now provides better user experience by keeping context and allowing users to correct their input without reopening the modal. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- .../url-loader-modal/url-loader-modal.css | 12 ++++++ .../url-loader-modal/url-loader-modal.jsx | 39 ++++++++++++++++--- src/lib/url-loader-hoc.jsx | 15 ++++--- 3 files changed, 55 insertions(+), 11 deletions(-) diff --git a/src/components/url-loader-modal/url-loader-modal.css b/src/components/url-loader-modal/url-loader-modal.css index 14ff36c4af6..cb4c7801a64 100644 --- a/src/components/url-loader-modal/url-loader-modal.css +++ b/src/components/url-loader-modal/url-loader-modal.css @@ -54,6 +54,18 @@ color: $text-primary-transparent; } +.urlInput.inputError { + border-color: $error-primary; + box-shadow: 0 0 0 1px $error-primary; +} + +.errorMessage { + margin-top: 0.5rem; + font-size: 0.75rem; + color: $error-primary; + line-height: 1.4; +} + .buttonSection { display: flex; justify-content: flex-end; diff --git a/src/components/url-loader-modal/url-loader-modal.jsx b/src/components/url-loader-modal/url-loader-modal.jsx index 0ea8c3a9fbb..74ff11642fc 100644 --- a/src/components/url-loader-modal/url-loader-modal.jsx +++ b/src/components/url-loader-modal/url-loader-modal.jsx @@ -35,6 +35,11 @@ const messages = defineMessages({ defaultMessage: 'Cancel', description: 'Label for cancel button', id: 'gui.urlLoader.cancelButton' + }, + invalidUrlError: { + defaultMessage: 'Please enter a valid Scratch project URL.', + description: 'Error message for invalid URL', + id: 'gui.urlLoader.invalidUrl' } }); @@ -45,22 +50,35 @@ class URLLoaderModal extends React.Component { 'handleUrlChange', 'handleOpenClick', 'handleCancelClick', - 'handleKeyPress' + 'handleKeyPress', + 'clearError' ]); this.state = { - url: '' + url: '', + error: null }; } handleUrlChange (event) { - this.setState({url: event.target.value}); + this.setState({ + url: event.target.value, + error: null // Clear error when user types + }); + } + + clearError () { + this.setState({error: null}); } handleOpenClick () { const {url} = this.state; if (url.trim()) { - this.props.onLoadUrl(url.trim()); + this.props.onLoadUrl(url.trim(), error => { + if (error) { + this.setState({error: error}); + } + }); } } @@ -76,7 +94,7 @@ class URLLoaderModal extends React.Component { render () { const {intl, onRequestClose} = this.props; - const {url} = this.state; + const {url, error} = this.state; return ( + {error && ( +
+ +
+ )}
diff --git a/src/lib/url-loader-hoc.jsx b/src/lib/url-loader-hoc.jsx index a866fe5c3e3..cdeab0476e0 100644 --- a/src/lib/url-loader-hoc.jsx +++ b/src/lib/url-loader-hoc.jsx @@ -79,7 +79,7 @@ const URLLoaderHOC = function (WrappedComponent) { } // Step 2: Handle URL submission from modal - handleUrlSubmit (url) { + handleUrlSubmit (url, errorCallback) { const { intl, isShowingWithoutId, @@ -90,8 +90,10 @@ const URLLoaderHOC = function (WrappedComponent) { const projectId = extractScratchProjectId(url); if (!projectId) { - alert(intl.formatMessage(messages.invalidUrl)); // eslint-disable-line no-alert - this.props.closeUrlLoaderModal(); + // Instead of alert, pass error to modal via callback + if (errorCallback) { + errorCallback(intl.formatMessage(messages.invalidUrl)); + } return; } @@ -111,9 +113,12 @@ const URLLoaderHOC = function (WrappedComponent) { if (uploadAllowed) { // Start the loading process this.props.requestProjectUpload(loadingState); + // Close modal only when validation passes and user confirms + this.props.closeUrlLoaderModal(); + } else { + // Close modal if user cancels the replacement + this.props.closeUrlLoaderModal(); } - - this.props.closeUrlLoaderModal(); } // Step 3: Load project from URL (called from componentDidUpdate) From 29e7bb45f94c404714afcaff3e0d9ea86e1d0611 Mon Sep 17 00:00:00 2001 From: Kouji Takao Date: Thu, 18 Sep 2025 23:04:23 +0900 Subject: [PATCH 14/15] refactor: simplify URL loader prompt messages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Improved UI by removing redundant example URL from prompt messages: 1. Updated prompt messages to remove example URL since it's already shown in the input field placeholder: - URLLoaderModal component defaultMessage - Japanese locale (ja.js) - Japanese Hiragana locale (ja-Hira.js) 2. Removed unused urlPrompt message from URLLoaderHOC since the prompt is now only used in the modal component This makes the UI cleaner and avoids duplication between the prompt text and the placeholder text in the input field. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- src/components/url-loader-modal/url-loader-modal.jsx | 2 +- src/lib/url-loader-hoc.jsx | 5 ----- src/locales/ja-Hira.js | 2 +- src/locales/ja.js | 2 +- 4 files changed, 3 insertions(+), 8 deletions(-) diff --git a/src/components/url-loader-modal/url-loader-modal.jsx b/src/components/url-loader-modal/url-loader-modal.jsx index 74ff11642fc..afc330b2ba7 100644 --- a/src/components/url-loader-modal/url-loader-modal.jsx +++ b/src/components/url-loader-modal/url-loader-modal.jsx @@ -17,7 +17,7 @@ const messages = defineMessages({ id: 'gui.urlLoader.title' }, prompt: { - defaultMessage: 'Enter a Scratch project URL (e.g., https://scratch.mit.edu/projects/1209008277/):', + defaultMessage: 'Enter a Scratch project URL:', description: 'Prompt message for URL input', id: 'gui.urlLoader.urlPrompt' }, diff --git a/src/lib/url-loader-hoc.jsx b/src/lib/url-loader-hoc.jsx index cdeab0476e0..b536219781e 100644 --- a/src/lib/url-loader-hoc.jsx +++ b/src/lib/url-loader-hoc.jsx @@ -38,11 +38,6 @@ const messages = defineMessages({ id: 'gui.urlLoader.invalidUrl', defaultMessage: 'Please enter a valid Scratch project URL.', description: 'An error that displays when an invalid URL is entered.' - }, - urlPrompt: { - id: 'gui.urlLoader.urlPrompt', - defaultMessage: 'Enter a Scratch project URL (e.g., https://scratch.mit.edu/projects/1209008277/):', - description: 'Prompt for entering a project URL.' } }); diff --git a/src/locales/ja-Hira.js b/src/locales/ja-Hira.js index 4e6526ce853..5e82057a706 100644 --- a/src/locales/ja-Hira.js +++ b/src/locales/ja-Hira.js @@ -2,7 +2,7 @@ export default { 'gui.menuBar.loadFromUrl': 'URLからよみこむ', 'gui.urlLoader.loadError': 'プロジェクトURLのよみこみにしっぱいしました。', 'gui.urlLoader.invalidUrl': 'ゆうこうなScratchプロジェクトURLをにゅうりょくしてください。', - 'gui.urlLoader.urlPrompt': 'ScratchプロジェクトのURLをにゅうりょくしてください(れい:https://scratch.mit.edu/projects/1209008277/):', + 'gui.urlLoader.urlPrompt': 'ScratchプロジェクトのURLをにゅうりょくしてください:', 'gui.urlLoader.title': 'URLからよみこむ', 'gui.urlLoader.urlPlaceholder': 'https://scratch.mit.edu/projects/1234567890/', 'gui.urlLoader.openButton': 'ひらく', diff --git a/src/locales/ja.js b/src/locales/ja.js index b35bafe4971..bb039ea2a19 100644 --- a/src/locales/ja.js +++ b/src/locales/ja.js @@ -2,7 +2,7 @@ export default { 'gui.menuBar.loadFromUrl': 'URLから読み込む', 'gui.urlLoader.loadError': 'プロジェクトURLの読み込みに失敗しました。', 'gui.urlLoader.invalidUrl': '有効なScratchプロジェクトURLを入力してください。', - 'gui.urlLoader.urlPrompt': 'ScratchプロジェクトのURLを入力してください(例:https://scratch.mit.edu/projects/1209008277/):', + 'gui.urlLoader.urlPrompt': 'ScratchプロジェクトのURLを入力してください:', 'gui.urlLoader.title': 'URLから読み込む', 'gui.urlLoader.urlPlaceholder': 'https://scratch.mit.edu/projects/1234567890/', 'gui.urlLoader.openButton': '開く', From 1091bfbec9213a26b3beafef94a76a7f34298798 Mon Sep 17 00:00:00 2001 From: Kouji Takao Date: Thu, 18 Sep 2025 23:09:37 +0900 Subject: [PATCH 15/15] refactor: reposition Load from URL menu item MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Moved the "Load from URL" menu item to appear after the download section in the File menu for better logical grouping of load/save operations. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- src/components/menu-bar/menu-bar.jsx | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/src/components/menu-bar/menu-bar.jsx b/src/components/menu-bar/menu-bar.jsx index d3f88f3a0c2..47b9926b3f5 100644 --- a/src/components/menu-bar/menu-bar.jsx +++ b/src/components/menu-bar/menu-bar.jsx @@ -498,15 +498,6 @@ class MenuBar extends React.Component { > {this.props.intl.formatMessage(sharedMessages.loadFromComputerTitle)}
- - - {(className, downloadProjectCallback) => ( )} + + +