Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 37 additions & 10 deletions src/components/url-loader-modal/url-loader-modal.css
Original file line number Diff line number Diff line change
Expand Up @@ -19,16 +19,6 @@
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;
Expand Down Expand Up @@ -66,6 +56,43 @@
line-height: 1.4;
}

.examplesSection {
display: flex;
flex-direction: column;
margin-top: -0.5rem;
}

.examplesTitle {
font-size: 0.75rem;
color: $text-primary;
font-weight: bold;
margin-bottom: 0.5rem;
}

.examplesList {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: 0.25rem;
}

.exampleItem {
font-size: 0.75rem;
color: $text-primary-transparent;
font-family: "Monaco", "Menlo", "Ubuntu Mono", monospace;
padding-left: 1rem;
position: relative;
}

.exampleItem::before {
content: "•";
position: absolute;
left: 0;
color: $text-primary-transparent;
}

.buttonSection {
display: flex;
justify-content: flex-end;
Expand Down
50 changes: 36 additions & 14 deletions src/components/url-loader-modal/url-loader-modal.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,16 +16,26 @@ const messages = defineMessages({
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/',
defaultMessage: 'Enter project URL...',
description: 'Placeholder text for URL input field',
id: 'gui.urlLoader.urlPlaceholder'
},
urlExamplesTitle: {
defaultMessage: 'URL Examples',
description: 'Title for URL examples section',
id: 'gui.urlLoader.urlExamplesTitle'
},
urlExampleScratch: {
defaultMessage: 'https://scratch.mit.edu/projects/{project_id}/',
description: 'Example URL format for Scratch projects',
id: 'gui.urlLoader.urlExampleScratch'
},
urlExampleGoogleDrive: {
defaultMessage: 'https://drive.google.com/file/d/{file_id}/view?usp=drive_link',
description: 'Example URL format for Google Drive files',
id: 'gui.urlLoader.urlExampleGoogleDrive'
},
openButton: {
defaultMessage: 'Open',
description: 'Label for open button',
Expand Down Expand Up @@ -106,14 +116,6 @@ class URLLoaderModal extends React.Component {
onRequestClose={onRequestClose}
>
<Box className={styles.body}>
<Box className={styles.promptSection}>
<div className={styles.promptText}>
<FormattedMessage
{...messages.prompt}
/>
</div>
</Box>

<Box className={styles.inputSection}>
<input
className={classNames(styles.urlInput, {
Expand All @@ -135,6 +137,26 @@ class URLLoaderModal extends React.Component {
)}
</Box>

<Box className={styles.examplesSection}>
<div className={styles.examplesTitle}>
<FormattedMessage
{...messages.urlExamplesTitle}
/>
</div>
<ul className={styles.examplesList}>
<li className={styles.exampleItem}>
<FormattedMessage
{...messages.urlExampleScratch}
/>
</li>
<li className={styles.exampleItem}>
<FormattedMessage
{...messages.urlExampleGoogleDrive}
/>
</li>
</ul>
</Box>

<Box className={styles.buttonSection}>
<button
className={styles.cancelButton}
Expand Down
94 changes: 82 additions & 12 deletions src/lib/url-loader-hoc.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,11 @@ import {connect} from 'react-redux';
import log from '../lib/log';
import sharedMessages from './shared-messages';

import {extractScratchProjectId} from './url-parser';
import {
extractScratchProjectId,
extractGoogleDriveFileId,
getUrlType
} from './url-parser';

import {
LoadingStates,
Expand Down Expand Up @@ -57,8 +61,10 @@ const URLLoaderHOC = function (WrappedComponent) {
bindAll(this, [
'handleStartSelectingUrlLoad',
'handleUrlSubmit',
'loadProjectFromUrl',
'handleFinishedLoadingUpload'
'loadScratchProjectFromUrl',
'loadGoogleDriveProjectFromUrl',
'handleFinishedLoadingUpload',
'clearLoadingReferences'
]);
}
componentDidUpdate (prevProps) {
Expand All @@ -83,18 +89,25 @@ const URLLoaderHOC = function (WrappedComponent) {
userOwnsProject
} = this.props;

const projectId = extractScratchProjectId(url);
if (!projectId) {
const urlType = getUrlType(url);
if (!urlType) {
// Instead of alert, pass error to modal via callback
if (errorCallback) {
errorCallback(intl.formatMessage(messages.invalidUrl));
}
return;
}

this.projectIdToLoad = projectId;
// Store URL type and appropriate identifier
this.urlType = urlType;
this.projectUrlToLoad = url;

if (urlType === 'scratch') {
this.projectIdToLoad = extractScratchProjectId(url);
} else if (urlType === 'google-drive') {
this.googleDriveFileId = extractGoogleDriveFileId(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.
Expand All @@ -118,15 +131,18 @@ const URLLoaderHOC = function (WrappedComponent) {

// Step 3: Load project from URL (called from componentDidUpdate)
handleFinishedLoadingUpload () {
if (this.projectIdToLoad) {
this.loadProjectFromUrl(this.projectIdToLoad);
if (this.urlType === 'scratch' && this.projectIdToLoad) {
this.loadScratchProjectFromUrl(this.projectIdToLoad);
return;
} else if (this.urlType === 'google-drive' && this.googleDriveFileId) {
this.loadGoogleDriveProjectFromUrl(this.googleDriveFileId);
return;
}
this.props.cancelFileUpload(this.props.loadingState);
}

// Step 4: Actually load the project data
loadProjectFromUrl (projectId) {
// Step 4a: Load Scratch project from URL
loadScratchProjectFromUrl (projectId) {
this.props.onLoadingStarted();

// Set project ID in Redux state first (like project-fetcher-hoc.jsx)
Expand Down Expand Up @@ -184,25 +200,78 @@ const URLLoaderHOC = function (WrappedComponent) {
.then(() => {
this.props.onLoadingFinished();
// Clear the project reference
this.projectIdToLoad = null;
this.projectUrlToLoad = null;
this.clearLoadingReferences();
});
}

// Step 4b: Load Google Drive project from URL
loadGoogleDriveProjectFromUrl (fileId) {
this.props.onLoadingStarted();

// Convert Google Drive URL to direct download URL via CORS proxy
const directDownloadUrl = `https://drive.google.com/uc?export=download&id=${fileId}`;
const corsProxyUrl = `https://api.smalruby.app/cors-proxy?url=${encodeURIComponent(directDownloadUrl)}`;

fetch(corsProxyUrl)
.then(response => {
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
return response.arrayBuffer();
})
.then(arrayBuffer =>
// Load SB3 file directly to VM (same as sb-file-uploader-hoc.jsx)
this.props.vm.loadProject(arrayBuffer)
)
.then(() => {
// Set project title based on the Google Drive file
const projectTitle = `Google Drive Project ${fileId}`;
this.props.onSetProjectTitle(projectTitle);

// Use onLoadedProject for LOADING_VM_FILE_UPLOAD state
this.props.onLoadedProject(this.props.loadingState, true, true);
})
.catch(error => {
log.warn('Google Drive 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 references
this.clearLoadingReferences();
});
}

// Clear all loading references
clearLoadingReferences () {
this.urlType = null;
this.projectIdToLoad = null;
this.googleDriveFileId = null;
this.projectUrlToLoad = null;
}

render () {
const {
/* eslint-disable no-unused-vars */
cancelFileUpload,
closeFileMenu: closeFileMenuProp,
closeUrlLoaderModal: closeUrlLoaderModalProp,
intl,
isLoadingUpload,
isShowingWithoutId,
loadingState,
onError,
onLoadedProject: onLoadedProjectProp,
onLoadingFinished,
onLoadingStarted,
onSetProjectTitle,
openUrlLoaderModal: openUrlLoaderModalProp,
projectChanged,
requestProjectUpload: requestProjectUploadProp,
setProjectId: setProjectIdProp,
userOwnsProject,
vm,
onStartSelectingUrlLoad: onStartSelectingUrlLoadProp,
/* eslint-enable no-unused-vars */
...componentProps
Expand All @@ -212,6 +281,7 @@ const URLLoaderHOC = function (WrappedComponent) {
<WrappedComponent
onStartSelectingUrlLoad={this.handleStartSelectingUrlLoad}
onUrlLoaderSubmit={this.handleUrlSubmit}
vm={vm}
{...componentProps}
/>
</React.Fragment>
Expand Down
51 changes: 51 additions & 0 deletions src/lib/url-parser.js
Original file line number Diff line number Diff line change
Expand Up @@ -35,3 +35,54 @@ export const extractScratchProjectId = url => {
* @returns {boolean} - True if valid Scratch project URL
*/
export const isValidScratchProjectUrl = url => extractScratchProjectId(url) !== null;

/**
* Extract Google Drive file ID from Google Drive URL
* @param {string} url - Google Drive URL
* @returns {string|null} - File ID or null if invalid
*/
export const extractGoogleDriveFileId = url => {
if (!url || typeof url !== 'string') {
return null;
}

const patterns = [
// Pattern 1: https://drive.google.com/file/d/FILE_ID/view
/drive\.google\.com\/file\/d\/([a-zA-Z0-9_-]+)/,
// Pattern 2: https://drive.google.com/open?id=FILE_ID
/drive\.google\.com\/open\?id=([a-zA-Z0-9_-]+)/,
// Pattern 3: https://drive.google.com/uc?export=download&id=FILE_ID (or similar)
/drive\.google\.com\/uc\?.*id=([a-zA-Z0-9_-]+)/
];

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 Google Drive URL
* @param {string} url - URL to validate
* @returns {boolean} - True if valid Google Drive URL
*/
export const isValidGoogleDriveUrl = url => extractGoogleDriveFileId(url) !== null;

/**
* Determine URL type (scratch or google-drive)
* @param {string} url - URL to check
* @returns {string|null} - 'scratch', 'google-drive', or null if invalid
*/
export const getUrlType = url => {
if (isValidScratchProjectUrl(url)) {
return 'scratch';
}
if (isValidGoogleDriveUrl(url)) {
return 'google-drive';
}
return null;
};
10 changes: 6 additions & 4 deletions src/locales/en.js
Original file line number Diff line number Diff line change
Expand Up @@ -179,10 +179,12 @@ export default {

// 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.invalidUrl': 'Please enter a valid Scratch project URL or Google Drive URL.',
'gui.urlLoader.title': 'Load from URL',
'gui.urlLoader.urlPlaceholder': 'https://scratch.mit.edu/projects/1234567890/',
'gui.urlLoader.urlPlaceholder': 'Enter project URL...',
'gui.urlLoader.openButton': 'Open',
'gui.urlLoader.cancelButton': 'Cancel'
'gui.urlLoader.cancelButton': 'Cancel',
'gui.urlLoader.urlExamplesTitle': 'URL Examples',
'gui.urlLoader.urlExampleScratch': 'https://scratch.mit.edu/projects/{project_id}/',
'gui.urlLoader.urlExampleGoogleDrive': 'https://drive.google.com/file/d/{file_id}/view?usp=drive_link'
};
8 changes: 5 additions & 3 deletions src/locales/ja-Hira.js
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
export default {
'gui.menuBar.loadFromUrl': 'URLからよみこむ',
'gui.urlLoader.loadError': 'プロジェクトURLのよみこみにしっぱいしました。',
'gui.urlLoader.invalidUrl': 'ゆうこうなScratchプロジェクトURLをにゅうりょくしてください。',
'gui.urlLoader.urlPrompt': 'ScratchプロジェクトのURLをにゅうりょくしてください:',
'gui.urlLoader.invalidUrl': 'ゆうこうなScratchプロジェクトURLまたはGoogle DriveのURLをにゅうりょくしてください。',
'gui.urlLoader.title': 'URLからよみこむ',
'gui.urlLoader.urlPlaceholder': 'https://scratch.mit.edu/projects/1234567890/',
'gui.urlLoader.urlPlaceholder': 'プロジェクトのURLをにゅうりょく...',
'gui.urlLoader.openButton': 'ひらく',
'gui.urlLoader.cancelButton': 'キャンセル',
'gui.urlLoader.urlExamplesTitle': 'URLのれい',
'gui.urlLoader.urlExampleScratch': 'https://scratch.mit.edu/projects/{project_id}/',
'gui.urlLoader.urlExampleGoogleDrive': 'https://drive.google.com/file/d/{file_id}/view?usp=drive_link',
'gui.menuBar.seeProjectPage': 'プロジェクトページをみる',
'gui.loader.creating': 'プロジェクトをさくせいちゅう...',
'gui.smalruby3.crashMessage.description': 'もうしわけありません。スモウルビーがクラッシュしたようです。このバグはじどうてきにスモウルビーチームにほうこくされました。ページをさいよみこみしてください。',
Expand Down
Loading
Loading