Skip to content

Commit 1a11c6a

Browse files
authored
Merge pull request #412 from smalruby/feature/google-drive-url-support
feat: enhance URL loader with Google Drive support and improved UI
2 parents 6339bbc + d54cfb3 commit 1a11c6a

File tree

7 files changed

+222
-46
lines changed

7 files changed

+222
-46
lines changed

src/components/url-loader-modal/url-loader-modal.css

Lines changed: 37 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -19,16 +19,6 @@
1919
gap: 1.5rem;
2020
}
2121

22-
.promptSection {
23-
text-align: left;
24-
}
25-
26-
.promptText {
27-
font-size: 0.875rem;
28-
color: $text-primary;
29-
line-height: 1.5;
30-
}
31-
3222
.inputSection {
3323
display: flex;
3424
flex-direction: column;
@@ -66,6 +56,43 @@
6656
line-height: 1.4;
6757
}
6858

59+
.examplesSection {
60+
display: flex;
61+
flex-direction: column;
62+
margin-top: -0.5rem;
63+
}
64+
65+
.examplesTitle {
66+
font-size: 0.75rem;
67+
color: $text-primary;
68+
font-weight: bold;
69+
margin-bottom: 0.5rem;
70+
}
71+
72+
.examplesList {
73+
list-style: none;
74+
padding: 0;
75+
margin: 0;
76+
display: flex;
77+
flex-direction: column;
78+
gap: 0.25rem;
79+
}
80+
81+
.exampleItem {
82+
font-size: 0.75rem;
83+
color: $text-primary-transparent;
84+
font-family: "Monaco", "Menlo", "Ubuntu Mono", monospace;
85+
padding-left: 1rem;
86+
position: relative;
87+
}
88+
89+
.exampleItem::before {
90+
content: "•";
91+
position: absolute;
92+
left: 0;
93+
color: $text-primary-transparent;
94+
}
95+
6996
.buttonSection {
7097
display: flex;
7198
justify-content: flex-end;

src/components/url-loader-modal/url-loader-modal.jsx

Lines changed: 36 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -16,16 +16,26 @@ const messages = defineMessages({
1616
description: 'Title for the URL loader modal',
1717
id: 'gui.urlLoader.title'
1818
},
19-
prompt: {
20-
defaultMessage: 'Enter a Scratch project URL:',
21-
description: 'Prompt message for URL input',
22-
id: 'gui.urlLoader.urlPrompt'
23-
},
2419
urlPlaceholder: {
25-
defaultMessage: 'https://scratch.mit.edu/projects/1234567890/',
20+
defaultMessage: 'Enter project URL...',
2621
description: 'Placeholder text for URL input field',
2722
id: 'gui.urlLoader.urlPlaceholder'
2823
},
24+
urlExamplesTitle: {
25+
defaultMessage: 'URL Examples',
26+
description: 'Title for URL examples section',
27+
id: 'gui.urlLoader.urlExamplesTitle'
28+
},
29+
urlExampleScratch: {
30+
defaultMessage: 'https://scratch.mit.edu/projects/{project_id}/',
31+
description: 'Example URL format for Scratch projects',
32+
id: 'gui.urlLoader.urlExampleScratch'
33+
},
34+
urlExampleGoogleDrive: {
35+
defaultMessage: 'https://drive.google.com/file/d/{file_id}/view?usp=drive_link',
36+
description: 'Example URL format for Google Drive files',
37+
id: 'gui.urlLoader.urlExampleGoogleDrive'
38+
},
2939
openButton: {
3040
defaultMessage: 'Open',
3141
description: 'Label for open button',
@@ -106,14 +116,6 @@ class URLLoaderModal extends React.Component {
106116
onRequestClose={onRequestClose}
107117
>
108118
<Box className={styles.body}>
109-
<Box className={styles.promptSection}>
110-
<div className={styles.promptText}>
111-
<FormattedMessage
112-
{...messages.prompt}
113-
/>
114-
</div>
115-
</Box>
116-
117119
<Box className={styles.inputSection}>
118120
<input
119121
className={classNames(styles.urlInput, {
@@ -135,6 +137,26 @@ class URLLoaderModal extends React.Component {
135137
)}
136138
</Box>
137139

140+
<Box className={styles.examplesSection}>
141+
<div className={styles.examplesTitle}>
142+
<FormattedMessage
143+
{...messages.urlExamplesTitle}
144+
/>
145+
</div>
146+
<ul className={styles.examplesList}>
147+
<li className={styles.exampleItem}>
148+
<FormattedMessage
149+
{...messages.urlExampleScratch}
150+
/>
151+
</li>
152+
<li className={styles.exampleItem}>
153+
<FormattedMessage
154+
{...messages.urlExampleGoogleDrive}
155+
/>
156+
</li>
157+
</ul>
158+
</Box>
159+
138160
<Box className={styles.buttonSection}>
139161
<button
140162
className={styles.cancelButton}

src/lib/url-loader-hoc.jsx

Lines changed: 82 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,11 @@ import {connect} from 'react-redux';
66
import log from '../lib/log';
77
import sharedMessages from './shared-messages';
88

9-
import {extractScratchProjectId} from './url-parser';
9+
import {
10+
extractScratchProjectId,
11+
extractGoogleDriveFileId,
12+
getUrlType
13+
} from './url-parser';
1014

1115
import {
1216
LoadingStates,
@@ -57,8 +61,10 @@ const URLLoaderHOC = function (WrappedComponent) {
5761
bindAll(this, [
5862
'handleStartSelectingUrlLoad',
5963
'handleUrlSubmit',
60-
'loadProjectFromUrl',
61-
'handleFinishedLoadingUpload'
64+
'loadScratchProjectFromUrl',
65+
'loadGoogleDriveProjectFromUrl',
66+
'handleFinishedLoadingUpload',
67+
'clearLoadingReferences'
6268
]);
6369
}
6470
componentDidUpdate (prevProps) {
@@ -83,18 +89,25 @@ const URLLoaderHOC = function (WrappedComponent) {
8389
userOwnsProject
8490
} = this.props;
8591

86-
const projectId = extractScratchProjectId(url);
87-
if (!projectId) {
92+
const urlType = getUrlType(url);
93+
if (!urlType) {
8894
// Instead of alert, pass error to modal via callback
8995
if (errorCallback) {
9096
errorCallback(intl.formatMessage(messages.invalidUrl));
9197
}
9298
return;
9399
}
94100

95-
this.projectIdToLoad = projectId;
101+
// Store URL type and appropriate identifier
102+
this.urlType = urlType;
96103
this.projectUrlToLoad = url;
97104

105+
if (urlType === 'scratch') {
106+
this.projectIdToLoad = extractScratchProjectId(url);
107+
} else if (urlType === 'google-drive') {
108+
this.googleDriveFileId = extractGoogleDriveFileId(url);
109+
}
110+
98111
// If user owns the project, or user has changed the project,
99112
// we must confirm with the user that they really intend to
100113
// replace it.
@@ -118,15 +131,18 @@ const URLLoaderHOC = function (WrappedComponent) {
118131

119132
// Step 3: Load project from URL (called from componentDidUpdate)
120133
handleFinishedLoadingUpload () {
121-
if (this.projectIdToLoad) {
122-
this.loadProjectFromUrl(this.projectIdToLoad);
134+
if (this.urlType === 'scratch' && this.projectIdToLoad) {
135+
this.loadScratchProjectFromUrl(this.projectIdToLoad);
136+
return;
137+
} else if (this.urlType === 'google-drive' && this.googleDriveFileId) {
138+
this.loadGoogleDriveProjectFromUrl(this.googleDriveFileId);
123139
return;
124140
}
125141
this.props.cancelFileUpload(this.props.loadingState);
126142
}
127143

128-
// Step 4: Actually load the project data
129-
loadProjectFromUrl (projectId) {
144+
// Step 4a: Load Scratch project from URL
145+
loadScratchProjectFromUrl (projectId) {
130146
this.props.onLoadingStarted();
131147

132148
// Set project ID in Redux state first (like project-fetcher-hoc.jsx)
@@ -184,25 +200,78 @@ const URLLoaderHOC = function (WrappedComponent) {
184200
.then(() => {
185201
this.props.onLoadingFinished();
186202
// Clear the project reference
187-
this.projectIdToLoad = null;
188-
this.projectUrlToLoad = null;
203+
this.clearLoadingReferences();
204+
});
205+
}
206+
207+
// Step 4b: Load Google Drive project from URL
208+
loadGoogleDriveProjectFromUrl (fileId) {
209+
this.props.onLoadingStarted();
210+
211+
// Convert Google Drive URL to direct download URL via CORS proxy
212+
const directDownloadUrl = `https://drive.google.com/uc?export=download&id=${fileId}`;
213+
const corsProxyUrl = `https://api.smalruby.app/cors-proxy?url=${encodeURIComponent(directDownloadUrl)}`;
214+
215+
fetch(corsProxyUrl)
216+
.then(response => {
217+
if (!response.ok) {
218+
throw new Error(`HTTP ${response.status}`);
219+
}
220+
return response.arrayBuffer();
221+
})
222+
.then(arrayBuffer =>
223+
// Load SB3 file directly to VM (same as sb-file-uploader-hoc.jsx)
224+
this.props.vm.loadProject(arrayBuffer)
225+
)
226+
.then(() => {
227+
// Set project title based on the Google Drive file
228+
const projectTitle = `Google Drive Project ${fileId}`;
229+
this.props.onSetProjectTitle(projectTitle);
230+
231+
// Use onLoadedProject for LOADING_VM_FILE_UPLOAD state
232+
this.props.onLoadedProject(this.props.loadingState, true, true);
233+
})
234+
.catch(error => {
235+
log.warn('Google Drive URL loader error:', error);
236+
this.props.onError(error);
237+
alert(this.props.intl.formatMessage(messages.loadError)); // eslint-disable-line no-alert
238+
})
239+
.then(() => {
240+
this.props.onLoadingFinished();
241+
// Clear the references
242+
this.clearLoadingReferences();
189243
});
190244
}
191245

246+
// Clear all loading references
247+
clearLoadingReferences () {
248+
this.urlType = null;
249+
this.projectIdToLoad = null;
250+
this.googleDriveFileId = null;
251+
this.projectUrlToLoad = null;
252+
}
253+
192254
render () {
193255
const {
194256
/* eslint-disable no-unused-vars */
195257
cancelFileUpload,
196258
closeFileMenu: closeFileMenuProp,
259+
closeUrlLoaderModal: closeUrlLoaderModalProp,
260+
intl,
197261
isLoadingUpload,
198262
isShowingWithoutId,
199263
loadingState,
264+
onError,
265+
onLoadedProject: onLoadedProjectProp,
200266
onLoadingFinished,
201267
onLoadingStarted,
202268
onSetProjectTitle,
269+
openUrlLoaderModal: openUrlLoaderModalProp,
203270
projectChanged,
204271
requestProjectUpload: requestProjectUploadProp,
272+
setProjectId: setProjectIdProp,
205273
userOwnsProject,
274+
vm,
206275
onStartSelectingUrlLoad: onStartSelectingUrlLoadProp,
207276
/* eslint-enable no-unused-vars */
208277
...componentProps
@@ -212,6 +281,7 @@ const URLLoaderHOC = function (WrappedComponent) {
212281
<WrappedComponent
213282
onStartSelectingUrlLoad={this.handleStartSelectingUrlLoad}
214283
onUrlLoaderSubmit={this.handleUrlSubmit}
284+
vm={vm}
215285
{...componentProps}
216286
/>
217287
</React.Fragment>

src/lib/url-parser.js

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,3 +35,54 @@ export const extractScratchProjectId = url => {
3535
* @returns {boolean} - True if valid Scratch project URL
3636
*/
3737
export const isValidScratchProjectUrl = url => extractScratchProjectId(url) !== null;
38+
39+
/**
40+
* Extract Google Drive file ID from Google Drive URL
41+
* @param {string} url - Google Drive URL
42+
* @returns {string|null} - File ID or null if invalid
43+
*/
44+
export const extractGoogleDriveFileId = url => {
45+
if (!url || typeof url !== 'string') {
46+
return null;
47+
}
48+
49+
const patterns = [
50+
// Pattern 1: https://drive.google.com/file/d/FILE_ID/view
51+
/drive\.google\.com\/file\/d\/([a-zA-Z0-9_-]+)/,
52+
// Pattern 2: https://drive.google.com/open?id=FILE_ID
53+
/drive\.google\.com\/open\?id=([a-zA-Z0-9_-]+)/,
54+
// Pattern 3: https://drive.google.com/uc?export=download&id=FILE_ID (or similar)
55+
/drive\.google\.com\/uc\?.*id=([a-zA-Z0-9_-]+)/
56+
];
57+
58+
for (const pattern of patterns) {
59+
const match = url.trim().match(pattern);
60+
if (match && match[1]) {
61+
return match[1];
62+
}
63+
}
64+
65+
return null;
66+
};
67+
68+
/**
69+
* Validate if URL is a valid Google Drive URL
70+
* @param {string} url - URL to validate
71+
* @returns {boolean} - True if valid Google Drive URL
72+
*/
73+
export const isValidGoogleDriveUrl = url => extractGoogleDriveFileId(url) !== null;
74+
75+
/**
76+
* Determine URL type (scratch or google-drive)
77+
* @param {string} url - URL to check
78+
* @returns {string|null} - 'scratch', 'google-drive', or null if invalid
79+
*/
80+
export const getUrlType = url => {
81+
if (isValidScratchProjectUrl(url)) {
82+
return 'scratch';
83+
}
84+
if (isValidGoogleDriveUrl(url)) {
85+
return 'google-drive';
86+
}
87+
return null;
88+
};

src/locales/en.js

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -179,10 +179,12 @@ export default {
179179

180180
// URL Loader messages
181181
'gui.urlLoader.loadError': 'The project URL that was entered failed to load.',
182-
'gui.urlLoader.invalidUrl': 'Please enter a valid Scratch project URL.',
183-
'gui.urlLoader.urlPrompt': 'Enter a Scratch project URL (e.g., https://scratch.mit.edu/projects/1209008277/):',
182+
'gui.urlLoader.invalidUrl': 'Please enter a valid Scratch project URL or Google Drive URL.',
184183
'gui.urlLoader.title': 'Load from URL',
185-
'gui.urlLoader.urlPlaceholder': 'https://scratch.mit.edu/projects/1234567890/',
184+
'gui.urlLoader.urlPlaceholder': 'Enter project URL...',
186185
'gui.urlLoader.openButton': 'Open',
187-
'gui.urlLoader.cancelButton': 'Cancel'
186+
'gui.urlLoader.cancelButton': 'Cancel',
187+
'gui.urlLoader.urlExamplesTitle': 'URL Examples',
188+
'gui.urlLoader.urlExampleScratch': 'https://scratch.mit.edu/projects/{project_id}/',
189+
'gui.urlLoader.urlExampleGoogleDrive': 'https://drive.google.com/file/d/{file_id}/view?usp=drive_link'
188190
};

src/locales/ja-Hira.js

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,14 @@
11
export default {
22
'gui.menuBar.loadFromUrl': 'URLからよみこむ',
33
'gui.urlLoader.loadError': 'プロジェクトURLのよみこみにしっぱいしました。',
4-
'gui.urlLoader.invalidUrl': 'ゆうこうなScratchプロジェクトURLをにゅうりょくしてください。',
5-
'gui.urlLoader.urlPrompt': 'ScratchプロジェクトのURLをにゅうりょくしてください:',
4+
'gui.urlLoader.invalidUrl': 'ゆうこうなScratchプロジェクトURLまたはGoogle DriveのURLをにゅうりょくしてください。',
65
'gui.urlLoader.title': 'URLからよみこむ',
7-
'gui.urlLoader.urlPlaceholder': 'https://scratch.mit.edu/projects/1234567890/',
6+
'gui.urlLoader.urlPlaceholder': 'プロジェクトのURLをにゅうりょく...',
87
'gui.urlLoader.openButton': 'ひらく',
98
'gui.urlLoader.cancelButton': 'キャンセル',
9+
'gui.urlLoader.urlExamplesTitle': 'URLのれい',
10+
'gui.urlLoader.urlExampleScratch': 'https://scratch.mit.edu/projects/{project_id}/',
11+
'gui.urlLoader.urlExampleGoogleDrive': 'https://drive.google.com/file/d/{file_id}/view?usp=drive_link',
1012
'gui.menuBar.seeProjectPage': 'プロジェクトページをみる',
1113
'gui.loader.creating': 'プロジェクトをさくせいちゅう...',
1214
'gui.smalruby3.crashMessage.description': 'もうしわけありません。スモウルビーがクラッシュしたようです。このバグはじどうてきにスモウルビーチームにほうこくされました。ページをさいよみこみしてください。',

0 commit comments

Comments
 (0)