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 { useSelector } from 'react-redux'; 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: props.projectTitle || '無題', showNewFileInput: false, sharePermission: 'reader', selectedFileId: null, // 共有モーダル用の状態 isShareModalOpen: false, currentSharingFileId: null, emailPermissions: [{ email: '', role: 'reader' }], linkPermission: 'reader', groupPermission: 'reader' }; this.modalContentRef = React.createRef(); this.shareModalContentRef = React.createRef(); } componentDidMount() { // アクセストークンがある場合はファイル一覧を取得 if (this.state.accessToken) { this.fetchDriveFiles(this.state.accessToken); } } handleClick = () => { this.setState({isModalOpen: true}); }; handleCloseModal = () => { if (!this.state.isProcessing) { this.setState({ isModalOpen: false, showNewFileInput: false, isShareModalOpen: 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 }); localStorage.setItem('googleDriveAccessToken', event.data.token); if (event.data.email) localStorage.setItem('googleDriveAccountEmail', event.data.email); if (event.data.name) localStorage.setItem('googleDriveAccountName', event.data.name); 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); this.showAlert("error", "ファイル一覧の取得に失敗しました"); this.setState({isLoading: false}); } }; showAlert = (type, message) => { if (this.props.showAlert) { this.props.showAlert(type, message); } else { alert(`${type}: ${message}`); } }; renderModal() { if (!this.state.isModalOpen) return null; return (

Googleドライブに保存

{this.renderAuthSection()} {this.state.accessToken && this.renderNewFileSection()} {this.state.accessToken && this.renderFileList()}
{this.state.isProcessing && (
処理中...
)}
); } renderAuthSection() { if (this.state.accessToken) { return (
ログイン中: {this.state.currentAccountName || this.state.currentAccountEmail || 'Googleアカウント'}
); } return (

Googleでログインして、プロジェクトを保存または更新します。

); } renderNewFileSection() { if (this.state.showNewFileInput) { return (
this.setState({newFileName: e.target.value})} className={styles.newFileNameInput} placeholder="ファイル名を入力" disabled={this.state.isProcessing} />
); } return (
); } renderFileList() { if (this.state.isLoading) { return
読み込み中...
; } 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
保存されたファイルが見つかりません
; } return (

保存済みプロジェクト

{projectFiles.map(project => this.renderFileItem(project, thumbnailFiles))}
); } renderFileItem(project, thumbnailFiles) { const thumbnail = thumbnailFiles.find( thumb => thumb.name === `Scratch-Thumbnail-${project.id}.png` ); return (
{thumbnail ? ( プロジェクトサムネイル ) : (
サムネイルなし
)}

{project.name.replace('.s4s.txt', '')}

{this.renderShareLink(project.id)}
); } renderShareLink(fileId) { const SHORT_URL = "https://s4.rf.gd/"; return (
{`${SHORT_URL}${fileId}`}
); } // 共有モーダルを開く openShareModal = (fileId) => { this.setState({ isShareModalOpen: true, currentSharingFileId: fileId, emailPermissions: [{ email: '', role: 'reader' }], linkPermission: 'reader', groupPermission: 'reader' }); }; // 共有モーダルを閉じる closeShareModal = () => { if (!this.state.isProcessing) { this.setState({ isShareModalOpen: false }); } }; // オーバーレイクリックで共有モーダルを閉じる handleShareOverlayClick = (e) => { if (!this.state.isProcessing && this.shareModalContentRef.current && !this.shareModalContentRef.current.contains(e.target)) { this.closeShareModal(); } }; // メール権限入力欄を追加 addEmailPermission = () => { this.setState(prevState => ({ emailPermissions: [...prevState.emailPermissions, { email: '', role: 'reader' }] })); }; // メール権限入力欄を更新 updateEmailPermission = (index, field, value) => { this.setState(prevState => ({ emailPermissions: prevState.emailPermissions.map((item, i) => i === index ? { ...item, [field]: value } : item ) })); }; // メール権限入力欄を削除 removeEmailPermission = (index) => { this.setState(prevState => ({ emailPermissions: prevState.emailPermissions.filter((_, i) => i !== index) })); }; // 権限設定を適用 applyPermissions = async () => { this.setState({ isProcessing: true }); try { const { currentSharingFileId, accessToken, emailPermissions, linkPermission } = this.state; // メールごとの権限設定 for (const permission of emailPermissions) { if (permission.email.trim()) { await this.setFilePermission( currentSharingFileId, accessToken, permission.email.trim(), permission.role ); } } // リンクを知っている全員への権限設定 await fetch(`https://www.googleapis.com/drive/v3/files/${currentSharingFileId}/permissions`, { method: "POST", headers: { "Authorization": `Bearer ${accessToken}`, "Content-Type": "application/json", }, body: JSON.stringify({ type: "anyone", role: linkPermission, }), }); this.showAlert("success", "共有設定を適用しました"); this.closeShareModal(); } catch (error) { console.error("権限設定エラー:", error); this.showAlert("error", "共有設定の適用に失敗しました"); } finally { this.setState({ isProcessing: false }); } }; // ファイル権限設定メソッド async setFilePermission(fileId, accessToken, email, role = "reader") { const response = await fetch( `https://www.googleapis.com/drive/v3/files/${fileId}/permissions`, { method: "POST", headers: { "Authorization": `Bearer ${accessToken}`, "Content-Type": "application/json", }, body: JSON.stringify({ type: "user", role: role, emailAddress: email }), } ); if (!response.ok) { throw new Error(await response.text()); } return await response.json(); } // 共有モーダルのレンダリング renderShareModal() { if (!this.state.isShareModalOpen) return null; return (

共有設定

ユーザーごとの共有

{this.state.emailPermissions.map((permission, index) => (
this.updateEmailPermission(index, 'email', e.target.value)} className={styles.emailInput} disabled={this.state.isProcessing} /> {this.state.emailPermissions.length > 1 && ( )}
))}

リンクを知っている全員

グループ内

{this.state.isProcessing && (
処理中...
)}
); } render() { return (
{this.renderModal()} {this.renderShareModal()}
); } handleChangeAccount = () => { if (this.state.isProcessing) return; this.setState({ accessToken: null, currentAccountEmail: null, currentAccountName: null, files: [] }); 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); this.showAlert("success", "新規保存しました"); this.setState({showNewFileInput: false}); this.fetchDriveFiles(this.state.accessToken); } catch (error) { console.error("新規保存エラー:", error); this.showAlert("error", "新規保存に失敗しました"); } finally { this.setState({isProcessing: false}); } }; handleLoadFile = (project) => { if (this.state.isProcessing) return; const PROXY_URL = "https://drive-proxy-s4s.vercel.app/?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); this.showAlert("success", "上書き保存しました"); this.fetchDriveFiles(this.state.accessToken); } catch (error) { console.error("ファイル上書きエラー:", error); this.showAlert("error", "ファイルの上書きに失敗しました"); } finally { this.setState({isProcessing: false}); } } }; handleShareFile = async (fileId) => { if (this.state.isProcessing) return; this.setState({ isProcessing: true }); try { // ファイルを公開設定(anyoneに閲覧権限を付与) await fetch(`https://www.googleapis.com/drive/v3/files/${fileId}/permissions`, { method: "POST", headers: { "Authorization": `Bearer ${this.state.accessToken}`, "Content-Type": "application/json", }, body: JSON.stringify({ type: "anyone", role: "reader", }), }); const SHARE_URL = "https://scratch-school.ct.ws/upload?id="; const shortUrl = `${SHARE_URL}${fileId}`; // 短縮URLをクリップボードにコピー navigator.clipboard.writeText(shortUrl) .then(() => { this.showAlert("success", "公開リンクをクリップボードにコピーしました"); // 新しいタブで公開ページを開く window.open(shortUrl, "_blank"); }) .catch(() => { this.showAlert("error", "リンクのコピーに失敗しました"); }); } catch (error) { console.error("公開エラー:", error); this.showAlert("error", "ファイルの公開に失敗しました"); } finally { this.setState({ isProcessing: false }); } }; 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); } this.showAlert("success", "ファイルを削除しました"); this.fetchDriveFiles(this.state.accessToken); } catch (error) { console.error("削除エラー:", error); this.showAlert("error", "ファイルの削除に失敗しました"); } finally { this.setState({isProcessing: false}); } } }; copyToClipboard = (text) => { if (this.state.isProcessing) return; navigator.clipboard.writeText(text) .then(() => this.showAlert("success", "リンクをクリップボードにコピーしました")) .catch(() => this.showAlert("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') { if (!window.vm) { throw new Error("VMが初期化されていません"); } 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", }), }); } return fileData; } getProjectThumbnail() { return new Promise((resolve) => { if (window.vm && window.vm.renderer && window.vm.renderer.requestSnapshot) { window.vm.renderer.requestSnapshot(uri => { resolve(uri); }); } else { // デフォルトのサムネイル resolve('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=='); } }); } } GoogleDriveSave.propTypes = { className: PropTypes.string, showAlert: PropTypes.func.isRequired, projectTitle: PropTypes.string }; const mapStateToProps = state => ({ projectTitle: state.scratchGui.projectTitle }); export default connect(mapStateToProps)(GoogleDriveSave);