s4s-editor / src /components /menu-bar /google-drive-save.jsx
soiz1's picture
Update src/components/menu-bar/google-drive-save.jsx
5fa70a9 verified
import { connect } from 'react-redux';
import React from 'react';
import PropTypes from 'prop-types';
import classNames from 'classnames';
import {FormattedMessage} from 'react-intl';
import Button from '../button/button.jsx';
import styles from './google-drive-save.css';
import { getProjectFilename } from '/src/containers/sb3-downloader.jsx';
class GoogleDriveSave extends React.Component {
constructor(props) {
super(props);
this.state = {
accessToken: localStorage.getItem('googleDriveAccessToken') || null,
currentAccountEmail: localStorage.getItem('googleDriveAccountEmail') || null,
currentAccountName: localStorage.getItem('googleDriveAccountName') || null,
files: [],
isModalOpen: false,
isLoading: false,
isProcessing: false,
newFileName: getProjectFilename || '無題',
showNewFileInput: false,
sharePermission: 'reader', // 'reader', 'writer', or 'owner'
selectedFileId: null
};
this.modalContentRef = React.createRef();
}
componentDidMount() {
// 初期化処理
}
handleClick = () => {
this.setState({isModalOpen: true});
};
handleCloseModal = () => {
if (!this.state.isProcessing) {
this.setState({isModalOpen: false, showNewFileInput: false});
}
};
handleOverlayClick = (e) => {
if (!this.state.isProcessing && this.modalContentRef.current && !this.modalContentRef.current.contains(e.target)) {
this.handleCloseModal();
}
};
startGoogleLogin = () => {
if (this.state.isProcessing) return;
localStorage.removeItem('googleDriveAccessToken');
localStorage.removeItem('googleDriveAccountEmail');
localStorage.removeItem('googleDriveAccountName');
const CLIENT_ID = "169451419993-v1b3s315s8dkui950j2nm15hetr5i0qk.apps.googleusercontent.com";
const REDIRECT_URI = "https://s-4-s-auth.hf.space/close2";
const SCOPES = "https://www.googleapis.com/auth/drive.file";
const messageListener = (event) => {
if (event.data.token) {
window.removeEventListener("message", messageListener);
this.setState({
accessToken: event.data.token,
currentAccountEmail: event.data.email || null,
currentAccountName: event.data.name || null,
isModalOpen: true
});
this.fetchDriveFiles(event.data.token);
}
};
window.addEventListener("message", messageListener);
const authUrl = `https://accounts.google.com/o/oauth2/auth?` +
`client_id=${CLIENT_ID}` +
`&redirect_uri=${encodeURIComponent(REDIRECT_URI)}` +
`&response_type=token` +
`&scope=${encodeURIComponent(SCOPES)}`;
window.open(authUrl, "_blank", "width=500,height=600");
};
fetchDriveFiles = async (accessToken) => {
this.setState({isLoading: true});
try {
const response = await fetch("https://www.googleapis.com/drive/v3/files?q=(mimeType='application/x-scratch' or mimeType='image/png')", {
headers: {
Authorization: `Bearer ${accessToken}`,
},
});
if (!response.ok) {
throw new Error(await response.text());
}
const data = await response.json();
this.setState({files: data.files || [], isLoading: false});
} catch (error) {
console.error("ファイル一覧取得エラー:", error);
alert("error", "ファイル一覧の取得に失敗しました");
this.setState({isLoading: false});
}
};
renderModal() {
if (!this.state.isModalOpen) return null;
return (
<div className={styles.modalOverlay} onClick={this.handleOverlayClick}>
<div className={styles.modalContent} ref={this.modalContentRef}>
<div className={styles.modalHeader}>
<h2>Googleドライブに保存</h2>
<button
onClick={this.handleCloseModal}
className={styles.closeButton}
disabled={this.state.isProcessing}
>
×
</button>
</div>
<div className={styles.modalBody}>
{this.renderAuthSection()}
{this.state.accessToken && this.renderNewFileSection()}
{this.state.accessToken && this.renderFileList()}
</div>
{this.state.isProcessing && (
<div className={styles.processingOverlay}>
<div className={styles.spinner}></div>
<div>処理中...</div>
</div>
)}
</div>
</div>
);
}
renderAuthSection() {
if (this.state.accessToken) {
return (
<div className={styles.authSection}>
<div className={styles.accountInfo}>
ログイン中: {this.state.currentAccountName || this.state.currentAccountEmail || 'Googleアカウント'}
</div>
<button
onClick={this.handleChangeAccount}
className={styles.changeAccountButton}
disabled={this.state.isProcessing}
>
アカウントを変更
</button>
</div>
);
}
return (
<div className={styles.authSection}>
<p>Googleでログインして、プロジェクトを保存または更新します。</p>
<button
onClick={this.startGoogleLogin}
className={styles.loginButton}
disabled={this.state.isProcessing}
>
Googleでログイン
</button>
</div>
);
}
renderNewFileSection() {
if (this.state.showNewFileInput) {
return (
<div className={styles.newFileSection}>
<div className={styles.newFileInputGroup}>
<input
type="text"
value={this.state.newFileName}
onChange={(e) => this.setState({newFileName: e.target.value})}
className={styles.newFileNameInput}
placeholder="ファイル名を入力"
disabled={this.state.isProcessing}
/>
<div className={styles.permissionDropdown}>
<label>公開設定: </label>
<select
value={this.state.sharePermission}
onChange={(e) => this.setState({sharePermission: e.target.value})}
disabled={this.state.isProcessing}
>
<option value="reader">閲覧のみ</option>
<option value="writer">編集可能</option>
<option value="owner">所有者</option>
</select>
</div>
<button
onClick={this.handleNewFileSave}
className={styles.newFileSaveButton}
disabled={!this.state.newFileName.trim() || this.state.isProcessing}
>
保存
</button>
<button
onClick={() => this.setState({showNewFileInput: false})}
className={styles.newFileCancelButton}
disabled={this.state.isProcessing}
>
キャンセル
</button>
</div>
</div>
);
}
return (
<div className={styles.newFileSection}>
<button
onClick={() => this.setState({
showNewFileInput: true,
newFileName: window.vm.runtime.projectName || '無題',
sharePermission: 'reader'
})}
className={styles.newFileButton}
disabled={this.state.isProcessing}
>
新規保存
</button>
</div>
);
}
renderFileList() {
if (this.state.isLoading) {
return <div className={styles.loading}>読み込み中...</div>;
}
const projectFiles = this.state.files.filter(file => file.mimeType === 'application/x-scratch');
const thumbnailFiles = this.state.files.filter(file => file.mimeType === 'image/png');
if (projectFiles.length === 0) {
return <div className={styles.noFiles}>保存されたファイルが見つかりません</div>;
}
return (
<div className={styles.fileListContainer}>
<div className={styles.fileListHeader}>
<h3>保存済みプロジェクト</h3>
</div>
<div className={styles.fileList}>
{projectFiles.map(project => this.renderFileItem(project, thumbnailFiles))}
</div>
</div>
);
}
renderFileItem(project, thumbnailFiles) {
const thumbnail = thumbnailFiles.find(
thumb => thumb.name === `Scratch-Thumbnail-${project.id}.png`
);
return (
<div key={project.id} className={styles.fileItem}>
<div className={styles.thumbnailContainer}>
{thumbnail ? (
<img
src={`https://drive.google.com/thumbnail?id=${thumbnail.id}&sz=w300`}
alt="プロジェクトサムネイル"
className={styles.thumbnail}
/>
) : (
<div className={styles.thumbnailPlaceholder}>
サムネイルなし
</div>
)}
</div>
<h3 className={styles.fileName}>
{project.name.replace('.s4s.txt', '')}
</h3>
{this.renderShareLink(project.id)}
<div className={styles.buttonGroup}>
<button
onClick={() => this.handleLoadFile(project)}
className={styles.actionButton}
disabled={this.state.isProcessing}
>
読み込む
</button>
<button
onClick={() => this.handleReplaceFile(project)}
className={styles.actionButton}
disabled={this.state.isProcessing}
>
上書き
</button>
<button
onClick={() => this.handleShareFile(project.id)}
className={classNames(styles.actionButton, styles.shareButton)}
disabled={this.state.isProcessing}
>
共有
</button>
<button
onClick={() => this.handleDeleteFile(project, thumbnailFiles)}
className={classNames(styles.actionButton, styles.deleteButton)}
disabled={this.state.isProcessing}
>
削除
</button>
</div>
{/* ここにアクセス権限設定のドロップダウンを追加 */}
<div className={styles.permissionDropdown}>
<label>アクセス権限: </label>
<select
value={this.state.sharePermission}
onChange={(e) => this.updateFilePermission(project.id, e.target.value)}
disabled={this.state.isProcessing}
>
<option value="reader">閲覧のみ</option>
<option value="writer">編集可能</option>
<option value="owner">所有者</option>
</select>
</div>
</div>
);
}
updateFilePermission = async (fileId, permission) => {
this.setState({isProcessing: true});
try {
await fetch(`https://www.googleapis.com/drive/v3/files/${fileId}/permissions/anyone`, {
method: "PATCH",
headers: {
Authorization: `Bearer ${this.state.accessToken}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
role: permission,
}),
});
alert("success", "アクセス権限を更新しました");
} catch (error) {
console.error("権限更新エラー:", error);
alert("error", "アクセス権限の更新に失敗しました");
} finally {
this.setState({isProcessing: false});
}
};
renderShareLink(fileId) {
const SHORT_URL = "https://s4.rf.gd/";
return (
<div className={styles.linkContainer}>
<div className={styles.linkHeader}>
<button
onClick={() => this.copyToClipboard(`${SHORT_URL}${fileId}`)}
className={styles.copyButton}
disabled={this.state.isProcessing}
>
リンクをコピー
</button>
<button
onClick={() => window.open(`https://scratch-school.ct.ws/bit.php?id=${fileId}`)}
className={styles.copyButton}
disabled={this.state.isProcessing}
>
リンクを短縮
</button>
<button
onClick={() => this.copyToClipboard(fileId)}
className={styles.copyButton}
disabled={this.state.isProcessing}
>
IDのみコピー
</button>
</div>
<a
href={`${SHORT_URL}${fileId}`}
target="_blank"
rel="noopener noreferrer"
className={styles.linkUrl}
>
{`${SHORT_URL}${fileId}`}
</a>
</div>
);
}
render() {
return (
<div>
<Button
className={classNames(
this.props.className,
styles.saveButton
)}
onClick={this.handleClick}
>
<FormattedMessage
defaultMessage="Googleドライブに保存"
description="Label for Google Drive save button"
id="google.drive.saveButton"
/>
</Button>
{this.renderModal()}
</div>
);
}
handleChangeAccount = () => {
if (this.state.isProcessing) return;
this.setState({
accessToken: null,
currentAccountEmail: null,
currentAccountName: null
});
localStorage.removeItem('googleDriveAccessToken');
localStorage.removeItem('googleDriveAccountEmail');
localStorage.removeItem('googleDriveAccountName');
};
handleNewFileSave = async () => {
this.setState({isProcessing: true});
try {
await this.saveToGoogleDrive(null, `${this.state.newFileName}.s4s.txt`, this.state.sharePermission);
alert("success", "新規保存しました");
this.setState({showNewFileInput: false});
this.fetchDriveFiles(this.state.accessToken);
} catch (error) {
console.error("新規保存エラー:", error);
alert("error", "新規保存に失敗しました");
} finally {
this.setState({isProcessing: false});
}
};
handleLoadFile = (project) => {
if (this.state.isProcessing) return;
const PROXY_URL = "https://soiz1-drive-proxy.hf.space/?file_id=";
if (confirm(`"${project.name}"を読み込みますか?現在のプロジェクトは失われます。`)) {
const url = `${PROXY_URL}${project.id}`;
window.location.href = `?project_url=${encodeURIComponent(url)}`;
}
};
handleReplaceFile = async (project) => {
if (this.state.isProcessing) return;
if (confirm(`"${project.name}"を現在のプロジェクトで上書きしますか?`)) {
this.setState({isProcessing: true});
try {
await this.saveToGoogleDrive(project.id, project.name);
alert("success", "上書き保存しました");
this.fetchDriveFiles(this.state.accessToken);
} catch (error) {
console.error("ファイル上書きエラー:", error);
alert("error", "ファイルの上書きに失敗しました");
} finally {
this.setState({isProcessing: false});
}
}
};
handleShareFile = (fileId) => {
if (this.state.isProcessing) return;
const SHARE_URL = "https://scratch-school.ct.ws/upload?id=";
window.open(`${SHARE_URL}${fileId}`, "_blank");
};
handleDeleteFile = async (project, thumbnailFiles) => {
if (this.state.isProcessing) return;
if (confirm(`"${project.name}"とそのサムネイルを完全に削除しますか?この操作は元に戻せません。`)) {
this.setState({isProcessing: true});
try {
await this.deleteFile(project.id);
const thumbnailToDelete = thumbnailFiles.find(
thumb => thumb.name === `Scratch-Thumbnail-${project.id}.png`
);
if (thumbnailToDelete) {
await this.deleteFile(thumbnailToDelete.id);
}
alert("success", "ファイルを削除しました");
this.fetchDriveFiles(this.state.accessToken);
} catch (error) {
console.error("削除エラー:", error);
alert("error", "ファイルの削除に失敗しました");
} finally {
this.setState({isProcessing: false});
}
}
};
copyToClipboard = (text) => {
if (this.state.isProcessing) return;
navigator.clipboard.writeText(text)
.then(() => alert("success", "リンクをクリップボードにコピーしました"))
.catch(() => alert("error", "リンクのコピーに失敗しました"));
};
async deleteFile(fileId) {
const response = await fetch(`https://www.googleapis.com/drive/v3/files/${fileId}`, {
method: "DELETE",
headers: {
Authorization: `Bearer ${this.state.accessToken}`,
},
});
if (!response.ok) {
throw new Error(await response.text());
}
}
async saveToGoogleDrive(fileId, fileName, permission = 'reader') {
const blob = await window.vm.saveProjectSb3();
const metadata = {
name: fileName,
mimeType: "application/x-scratch",
};
const url = fileId
? `https://www.googleapis.com/upload/drive/v3/files/${fileId}?uploadType=multipart`
: "https://www.googleapis.com/upload/drive/v3/files?uploadType=multipart";
const form = new FormData();
form.append("metadata", new Blob([JSON.stringify(metadata)], { type: "application/json" }));
form.append("file", blob);
const method = fileId ? "PATCH" : "POST";
const uploadResponse = await fetch(url, {
method,
headers: {
Authorization: `Bearer ${this.state.accessToken}`,
},
body: form,
});
if (!uploadResponse.ok) {
throw new Error(await uploadResponse.text());
}
const fileData = await uploadResponse.json();
try {
const thumbnailDataUrl = await this.getProjectThumbnail();
const thumbnailBlob = await (await fetch(thumbnailDataUrl)).blob();
const thumbnailMetadata = {
name: `Scratch-Thumbnail-${fileData.id}.png`,
mimeType: "image/png",
};
const existingThumbnailResponse = await fetch(
`https://www.googleapis.com/drive/v3/files?q=name='${thumbnailMetadata.name}'`,
{
headers: {
Authorization: `Bearer ${this.state.accessToken}`,
},
}
);
const existingThumbnailData = await existingThumbnailResponse.json();
const thumbnailFileId = existingThumbnailData.files?.[0]?.id;
const thumbnailUrl = thumbnailFileId
? `https://www.googleapis.com/upload/drive/v3/files/${thumbnailFileId}?uploadType=multipart`
: "https://www.googleapis.com/upload/drive/v3/files?uploadType=multipart";
const thumbnailForm = new FormData();
thumbnailForm.append("metadata", new Blob([JSON.stringify(thumbnailMetadata)], { type: "application/json" }));
thumbnailForm.append("file", thumbnailBlob);
const thumbnailMethod = thumbnailFileId ? "PATCH" : "POST";
await fetch(thumbnailUrl, {
method: thumbnailMethod,
headers: {
Authorization: `Bearer ${this.state.accessToken}`,
},
body: thumbnailForm,
});
} catch (thumbnailError) {
console.warn("サムネイルの保存に失敗しました:", thumbnailError);
}
if (!fileId) {
await fetch(`https://www.googleapis.com/drive/v3/files/${fileData.id}/permissions`, {
method: "POST",
headers: {
Authorization: `Bearer ${this.state.accessToken}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
role: permission, // ここで公開設定を使用
type: "anyone",
}),
});
}
}
getProjectThumbnail() {
return new Promise(resolve => {
window.vm.renderer.requestSnapshot(uri => {
resolve(uri);
});
});
}
}
GoogleDriveSave.propTypes = {
className: PropTypes.string,
showAlert: PropTypes.func.isRequired,
projectTitle: PropTypes.string
};
const mapStateToProps = state => ({
projectTitle: state.scratchGui.projectTitle
});
export default connect(mapStateToProps)(GoogleDriveSave);