diff --git a/src/components/gui/gui.jsx b/src/components/gui/gui.jsx
index 0926f0d4f61..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';
@@ -121,6 +122,7 @@ const GUIComponent = props => {
onShare,
onShowPrivacyPolicy,
onStartSelectingFileUpload,
+ onStartSelectingUrlLoad,
onTelemetryModalCancel,
onTelemetryModalOptIn,
onTelemetryModalOptOut,
@@ -132,6 +134,9 @@ const GUIComponent = props => {
telemetryModalVisible,
theme,
tipsLibraryVisible,
+ urlLoaderModalVisible,
+ closeUrlLoaderModal,
+ onUrlLoaderSubmit,
vm,
// Exclude Redux-related props from being passed to DOM
setSelectedBlocks: _setSelectedBlocks,
@@ -187,6 +192,12 @@ const GUIComponent = props => {
onShowPrivacyPolicy={onShowPrivacyPolicy}
/>
) : null}
+ {urlLoaderModalVisible ? (
+
+ ) : null}
{loading ? (
) : null}
@@ -260,6 +271,7 @@ const GUIComponent = props => {
onSeeCommunity={onSeeCommunity}
onShare={onShare}
onStartSelectingFileUpload={onStartSelectingFileUpload}
+ onStartSelectingUrlLoad={onStartSelectingUrlLoad}
onToggleLoginOpen={onToggleLoginOpen}
/>
@@ -466,6 +478,7 @@ GUIComponent.propTypes = {
onShare: PropTypes.func,
onShowPrivacyPolicy: PropTypes.func,
onStartSelectingFileUpload: PropTypes.func,
+ onStartSelectingUrlLoad: PropTypes.func,
onTabSelect: PropTypes.func,
onTelemetryModalCancel: PropTypes.func,
onTelemetryModalOptIn: PropTypes.func,
@@ -480,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/menu-bar/menu-bar.jsx b/src/components/menu-bar/menu-bar.jsx
index a5cfacb4488..47b9926b3f5 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,11 @@ class MenuBar extends React.Component {
}
};
}
+ handleClickLoadFromUrl () {
+ if (this.props.onStartSelectingUrlLoad) {
+ this.props.onStartSelectingUrlLoad();
+ }
+ }
restoreOptionMessage (deletedItem) {
switch (deletedItem) {
case 'Sprite':
@@ -504,6 +510,15 @@ class MenuBar extends React.Component {
/>
)}
+
@@ -931,6 +946,7 @@ MenuBar.propTypes = {
onSetTimeTravelMode: PropTypes.func,
onShare: PropTypes.func,
onStartSelectingFileUpload: PropTypes.func,
+ onStartSelectingUrlLoad: PropTypes.func,
onToggleLoginOpen: PropTypes.func,
projectTitle: PropTypes.string,
renderLogin: PropTypes.func,
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 @@
+
+
\ 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..cb4c7801a64
--- /dev/null
+++ b/src/components/url-loader-modal/url-loader-modal.css
@@ -0,0 +1,119 @@
+@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;
+}
+
+.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;
+ 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..afc330b2ba7
--- /dev/null
+++ b/src/components/url-loader-modal/url-loader-modal.jsx
@@ -0,0 +1,171 @@
+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:',
+ 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'
+ },
+ invalidUrlError: {
+ defaultMessage: 'Please enter a valid Scratch project URL.',
+ description: 'Error message for invalid URL',
+ id: 'gui.urlLoader.invalidUrl'
+ }
+});
+
+class URLLoaderModal extends React.Component {
+ constructor (props) {
+ super(props);
+ bindAll(this, [
+ 'handleUrlChange',
+ 'handleOpenClick',
+ 'handleCancelClick',
+ 'handleKeyPress',
+ 'clearError'
+ ]);
+
+ this.state = {
+ url: '',
+ error: null
+ };
+ }
+
+ handleUrlChange (event) {
+ 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(), error => {
+ if (error) {
+ this.setState({error: error});
+ }
+ });
+ }
+ }
+
+ handleCancelClick () {
+ this.props.onRequestClose();
+ }
+
+ handleKeyPress (event) {
+ if (event.key === 'Enter') {
+ this.handleOpenClick();
+ }
+ }
+
+ render () {
+ const {intl, onRequestClose} = this.props;
+ const {url, error} = this.state;
+
+ return (
+
+
+
+
+
+
+
+
+
+
+ {error && (
+
+
+
+ )}
+
+
+
+
+
+
+
+
+ );
+ }
+}
+
+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 3a031abf23c..d74ba6ec6ad 100644
--- a/src/containers/gui.jsx
+++ b/src/containers/gui.jsx
@@ -24,12 +24,15 @@ import {
closeBackdropLibrary,
closeTelemetryModal,
openExtensionLibrary,
- closeDebugModal
+ closeDebugModal,
+ openUrlLoaderModal,
+ closeUrlLoaderModal
} from '../reducers/modals';
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';
import ProjectFetcherHOC from '../lib/project-fetcher-hoc.jsx';
import TitledHOC from '../lib/titled-hoc.jsx';
import ProjectSaverHOC from '../lib/project-saver-hoc.jsx';
@@ -177,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
};
};
@@ -190,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(
@@ -212,6 +218,7 @@ const WrappedGui = compose(
vmListenerHOC,
vmManagerHOC,
SBFileUploaderHOC,
+ URLLoaderHOC,
cloudManagerHOC,
systemPreferencesHOC
)(ConnectedGUI);
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;
diff --git a/src/lib/url-loader-hoc.jsx b/src/lib/url-loader-hoc.jsx
new file mode 100644
index 00000000000..b536219781e
--- /dev/null
+++ b/src/lib/url-loader-hoc.jsx
@@ -0,0 +1,295 @@
+import bindAll from 'lodash.bindall';
+import React from 'react';
+import PropTypes from 'prop-types';
+import {defineMessages, intlShape, injectIntl} from 'react-intl';
+import {connect} from 'react-redux';
+import log from '../lib/log';
+import sharedMessages from './shared-messages';
+
+import {extractScratchProjectId} from './url-parser';
+
+import {
+ LoadingStates,
+ getIsLoadingUpload,
+ getIsShowingWithoutId,
+ onLoadedProject,
+ projectError,
+ setProjectId,
+ requestProjectUpload
+} from '../reducers/project-state';
+import {setProjectTitle} from '../reducers/project-title';
+import {
+ openLoadingProject,
+ closeLoadingProject,
+ openUrlLoaderModal,
+ closeUrlLoaderModal
+} from '../reducers/modals';
+import {
+ closeFileMenu
+} from '../reducers/menus';
+
+const messages = defineMessages({
+ loadError: {
+ id: 'gui.urlLoader.loadError',
+ defaultMessage: 'The project URL that was entered failed to load.',
+ description: 'An error that displays when a project URL fails to load.'
+ },
+ invalidUrl: {
+ id: 'gui.urlLoader.invalidUrl',
+ defaultMessage: 'Please enter a valid Scratch project URL.',
+ description: 'An error that displays when an invalid URL is entered.'
+ }
+});
+
+/**
+ * Higher Order Component to provide behavior for loading project from URL into editor.
+ * @param {React.Component} WrappedComponent the component to add URL loading functionality to
+ * @returns {React.Component} WrappedComponent with URL loading functionality added
+ *
+ *
+ *
+ *
+ */
+const URLLoaderHOC = function (WrappedComponent) {
+ class URLLoaderComponent extends React.Component {
+ constructor (props) {
+ super(props);
+ bindAll(this, [
+ 'handleStartSelectingUrlLoad',
+ 'handleUrlSubmit',
+ 'loadProjectFromUrl',
+ 'handleFinishedLoadingUpload'
+ ]);
+ }
+ componentDidUpdate (prevProps) {
+ if (this.props.isLoadingUpload && !prevProps.isLoadingUpload) {
+ this.handleFinishedLoadingUpload();
+ }
+ }
+
+ // Step 1: Start the URL loading process
+ handleStartSelectingUrlLoad () {
+ this.props.openUrlLoaderModal();
+ this.props.closeFileMenu();
+ }
+
+ // Step 2: Handle URL submission from modal
+ handleUrlSubmit (url, errorCallback) {
+ const {
+ intl,
+ isShowingWithoutId,
+ loadingState,
+ projectChanged,
+ userOwnsProject
+ } = this.props;
+
+ const projectId = extractScratchProjectId(url);
+ if (!projectId) {
+ // Instead of alert, pass error to modal via callback
+ if (errorCallback) {
+ errorCallback(intl.formatMessage(messages.invalidUrl));
+ }
+ 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);
+ // Close modal only when validation passes and user confirms
+ this.props.closeUrlLoaderModal();
+ } else {
+ // Close modal if user cancels the replacement
+ this.props.closeUrlLoaderModal();
+ }
+ }
+
+ // 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();
+
+ // 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
+ 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 storage system (like project-fetcher-hoc.jsx)
+ const storage = this.props.vm.runtime.storage;
+ storage.setProjectToken(projectToken);
+
+ return storage.load(storage.AssetType.Project, projectId, storage.DataFormat.JSON);
+ })
+ .then(projectAsset => {
+ if (projectAsset) {
+ // 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);
+ this.props.onError(error);
+ alert(this.props.intl.formatMessage(messages.loadError)); // eslint-disable-line no-alert
+ })
+ .then(() => {
+ this.props.onLoadingFinished();
+ // 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,
+ onStartSelectingUrlLoad: onStartSelectingUrlLoadProp,
+ /* eslint-enable no-unused-vars */
+ ...componentProps
+ } = this.props;
+ return (
+
+
+
+ );
+ }
+ }
+
+ URLLoaderComponent.propTypes = {
+ canSave: PropTypes.bool,
+ cancelFileUpload: PropTypes.func,
+ closeFileMenu: PropTypes.func,
+ closeUrlLoaderModal: PropTypes.func,
+ intl: intlShape.isRequired,
+ isLoadingUpload: PropTypes.bool,
+ isShowingWithoutId: PropTypes.bool,
+ loadingState: PropTypes.oneOf(LoadingStates),
+ onError: PropTypes.func,
+ onLoadedProject: PropTypes.func,
+ onLoadingFinished: PropTypes.func,
+ onLoadingStarted: PropTypes.func,
+ onSetProjectTitle: PropTypes.func,
+ onStartSelectingUrlLoad: PropTypes.func,
+ openUrlLoaderModal: PropTypes.func,
+ projectChanged: PropTypes.bool,
+ requestProjectUpload: PropTypes.func,
+ setProjectId: 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 => ({
+ 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)),
+ onLoadingFinished: () => {
+ dispatch(closeLoadingProject());
+ dispatch(closeFileMenu());
+ },
+ onLoadingStarted: () => dispatch(openLoadingProject()),
+ onSetProjectTitle: title => dispatch(setProjectTitle(title)),
+ openUrlLoaderModal: () => dispatch(openUrlLoaderModal()),
+ requestProjectUpload: loadingState => dispatch(requestProjectUpload(loadingState)),
+ setProjectId: projectId => dispatch(setProjectId(projectId))
+ });
+
+ 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/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 f4c776d8b7f..5e82057a706 100644
--- a/src/locales/ja-Hira.js
+++ b/src/locales/ja-Hira.js
@@ -1,4 +1,12 @@
export default {
+ 'gui.menuBar.loadFromUrl': 'URLからよみこむ',
+ 'gui.urlLoader.loadError': 'プロジェクトURLのよみこみにしっぱいしました。',
+ 'gui.urlLoader.invalidUrl': 'ゆうこうなScratchプロジェクトURLをにゅうりょくしてください。',
+ 'gui.urlLoader.urlPrompt': 'ScratchプロジェクトのURLをにゅうりょくしてください:',
+ '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 66536e4b851..bb039ea2a19 100644
--- a/src/locales/ja.js
+++ b/src/locales/ja.js
@@ -1,4 +1,12 @@
export default {
+ 'gui.menuBar.loadFromUrl': 'URLから読み込む',
+ 'gui.urlLoader.loadError': 'プロジェクトURLの読み込みに失敗しました。',
+ 'gui.urlLoader.invalidUrl': '有効なScratchプロジェクトURLを入力してください。',
+ 'gui.urlLoader.urlPrompt': 'ScratchプロジェクトのURLを入力してください:',
+ '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
};
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};
}
};