From 1887b2ba8fc1f34674096b4a1f57076b5b050615 Mon Sep 17 00:00:00 2001 From: Kouji Takao Date: Sun, 5 Apr 2026 01:55:09 +0900 Subject: [PATCH 1/7] feat: add bulk ZIP download for all classroom submissions Teacher can download all submitted projects as a ZIP file from the class detail footer. Uses JSZip for client-side ZIP generation. Each student's files (project.sb3, thumbnail, screenshots) are organized in seat-number folders. Closes #449 Co-Authored-By: Claude Opus 4.6 (1M context) --- package-lock.json | 5 ++ package.json | 3 + .../classroom-modal/classroom-modal.css | 6 ++ .../classroom-modal/classroom-modal.jsx | 58 ++++++++++++--- .../src/containers/classroom-modal.jsx | 74 +++++++++++++++++++ packages/scratch-gui/src/locales/en.js | 1 + packages/scratch-gui/src/locales/ja-Hira.js | 1 + packages/scratch-gui/src/locales/ja.js | 1 + 8 files changed, 137 insertions(+), 12 deletions(-) diff --git a/package-lock.json b/package-lock.json index 62dafa15792..7995ef10503 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,6 +15,9 @@ "packages/scratch-vm", "packages/scratch-gui" ], + "dependencies": { + "jszip": "^3.10.1" + }, "devDependencies": { "@commitlint/cli": "17.8.1", "@commitlint/config-conventional": "17.8.1", @@ -26375,6 +26378,8 @@ }, "node_modules/jszip": { "version": "3.10.1", + "resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz", + "integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==", "license": "(MIT OR GPL-3.0-or-later)", "dependencies": { "lie": "~3.3.0", diff --git a/package.json b/package.json index eea72d070b4..bc4f8c24195 100644 --- a/package.json +++ b/package.json @@ -57,5 +57,8 @@ "npm": "10.9.5", "serialport": "^13.0.0", "ts-node": "10.9.2" + }, + "dependencies": { + "jszip": "^3.10.1" } } diff --git a/packages/scratch-gui/src/components/classroom-modal/classroom-modal.css b/packages/scratch-gui/src/components/classroom-modal/classroom-modal.css index 4addc742510..9404547f1fd 100644 --- a/packages/scratch-gui/src/components/classroom-modal/classroom-modal.css +++ b/packages/scratch-gui/src/components/classroom-modal/classroom-modal.css @@ -638,6 +638,12 @@ border-top: 1px solid #e0e0e0; } +.detail-footer-buttons { + display: flex; + gap: 0.5rem; + flex-wrap: wrap; +} + .delete-confirm-box { background-color: #fff0f3; border: 1px solid #ff6680; diff --git a/packages/scratch-gui/src/components/classroom-modal/classroom-modal.jsx b/packages/scratch-gui/src/components/classroom-modal/classroom-modal.jsx index 51b51b523ca..248c343f1d3 100644 --- a/packages/scratch-gui/src/components/classroom-modal/classroom-modal.jsx +++ b/packages/scratch-gui/src/components/classroom-modal/classroom-modal.jsx @@ -46,6 +46,8 @@ const ClassroomModal = ({ onOpenSubmission, onRefreshDetail, onReturnSubmission, + onDownloadAll, + downloadProgress, onStartSubmit, onConfirmSubmit, onCancelSubmit, @@ -344,6 +346,8 @@ const ClassroomModal = ({ onOpenSubmission={onOpenSubmission} onRefresh={onRefreshDetail} onReturnSubmission={onReturnSubmission} + onDownloadAll={onDownloadAll} + downloadProgress={downloadProgress} onSelectMember={onSelectMember} onShowCodeDisplay={onShowCodeDisplay} onToggleCodeFullscreen={onToggleCodeFullscreen} @@ -917,6 +921,8 @@ const TeacherClassDetail = ({ onOpenSubmission, onRefresh, onReturnSubmission, + onDownloadAll, + downloadProgress, onShowCodeDisplay, onCloseCodeDisplay, onCopyInviteLink, @@ -1208,18 +1214,36 @@ const TeacherClassDetail = ({ ) : ( - +
+ + +
)} @@ -1390,6 +1414,11 @@ TeacherClassDetail.propTypes = { onCopyInviteLink: PropTypes.func.isRequired, onDeleteClassroom: PropTypes.func.isRequired, onDeleteMember: PropTypes.func.isRequired, + onDownloadAll: PropTypes.func.isRequired, + downloadProgress: PropTypes.shape({ + current: PropTypes.number, + total: PropTypes.number, + }), onOpenSubmission: PropTypes.func.isRequired, onRefresh: PropTypes.func.isRequired, onReturnSubmission: PropTypes.func.isRequired, @@ -1613,6 +1642,11 @@ ClassroomModal.propTypes = { onCreateClassroom: PropTypes.func.isRequired, onDeleteClassroom: PropTypes.func.isRequired, onDeleteMember: PropTypes.func.isRequired, + onDownloadAll: PropTypes.func.isRequired, + downloadProgress: PropTypes.shape({ + current: PropTypes.number, + total: PropTypes.number, + }), onJoinWithCode: PropTypes.func.isRequired, onLeaveClassroom: PropTypes.func.isRequired, onOpenSubmission: PropTypes.func.isRequired, diff --git a/packages/scratch-gui/src/containers/classroom-modal.jsx b/packages/scratch-gui/src/containers/classroom-modal.jsx index c6fb74779c2..b5925aa094f 100644 --- a/packages/scratch-gui/src/containers/classroom-modal.jsx +++ b/packages/scratch-gui/src/containers/classroom-modal.jsx @@ -1,3 +1,4 @@ +import JSZip from 'jszip'; import React, { useCallback, useState, useEffect, useRef } from 'react'; import { useIntl } from 'react-intl'; import { useDispatch, useSelector } from 'react-redux'; @@ -114,6 +115,7 @@ const ClassroomModal = () => { // Code display state const [codeDisplayClassroom, setCodeDisplayClassroom] = useState(null); const [codeDisplayFullscreen, setCodeDisplayFullscreen] = useState(false); + const [downloadProgress, setDownloadProgress] = useState(null); // { current, total } // Refresh timer for teacher detail const refreshTimerRef = useRef(null); @@ -714,6 +716,76 @@ const ClassroomModal = () => { [idToken, selectedClassroom, clearError, showError, intl, loadClassroomDetail], ); + // --- Teacher: Download all submissions as ZIP --- + + const handleDownloadAll = useCallback(async () => { + if (!selectedClassroom || !members || members.length === 0) return; + clearError(); + + const submittedMembers = members.filter(m => m.hasSubmission && m.projectUrl); + if (submittedMembers.length === 0) return; + + setDownloadProgress({ current: 0, total: submittedMembers.length }); + + try { + const zip = new JSZip(); + const className = selectedClassroom.className || 'class'; + + for (let i = 0; i < submittedMembers.length; i++) { + const m = submittedMembers[i]; + setDownloadProgress({ current: i + 1, total: submittedMembers.length }); + + const seatLabel = m.memberId.replace('seat-', ''); + const name = m.displayName || ''; + const folderName = name ? `${seatLabel}_${name}` : seatLabel; + const folder = zip.folder(folderName); + + // Download project .sb3 + try { + const res = await fetch(m.projectUrl); + if (res.ok) folder.file(`${m.projectName || 'project'}.sb3`, await res.blob()); + } catch { + // Skip failed downloads + } + + // Download thumbnail + if (m.thumbnailUrl) { + try { + const res = await fetch(m.thumbnailUrl); + if (res.ok) folder.file('thumbnail.png', await res.blob()); + } catch { + // Skip + } + } + + // Download screenshots + for (let si = 0; si < (m.screenshotUrls || []).length; si++) { + try { + const res = await fetch(m.screenshotUrls[si]); + if (res.ok) folder.file(`screenshot-${si}.png`, await res.blob()); + } catch { + // Skip + } + } + } + + // Generate and download ZIP + const blob = await zip.generateAsync({ type: 'blob' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `${className}.zip`; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + } catch (err) { + showError(translateError(intl, err)); + } finally { + setDownloadProgress(null); + } + }, [selectedClassroom, members, clearError, showError, intl]); + // --- Classcode URL parameter auto-join --- useEffect(() => { const urlParams = getUrlParams(); @@ -768,6 +840,8 @@ const ClassroomModal = () => { onCreateClassroom={handleCreateClassroom} onDeleteClassroom={handleDeleteClassroom} onDeleteMember={handleDeleteMember} + onDownloadAll={handleDownloadAll} + downloadProgress={downloadProgress} onJoinWithCode={handleJoinWithCode} onLeaveClassroom={handleLeaveClassroom} submitProgress={submitProgress} diff --git a/packages/scratch-gui/src/locales/en.js b/packages/scratch-gui/src/locales/en.js index 5d6bed3b024..14d83fd70d9 100644 --- a/packages/scratch-gui/src/locales/en.js +++ b/packages/scratch-gui/src/locales/en.js @@ -91,6 +91,7 @@ export default { 'gui.classroom.teacherDetail.openSubmission': 'Open in Smalruby', 'gui.classroom.teacherDetail.returnSubmission': 'Return', 'gui.classroom.teacherDetail.returned': 'Returned', + 'gui.classroom.teacherDetail.downloadAll': 'Download All', 'gui.classroom.teacherDetail.cancelDelete': 'Cancel', 'gui.classroom.codeDisplay.title': 'Class Code', 'gui.classroom.codeDisplay.copyLink': 'Copy invite link', diff --git a/packages/scratch-gui/src/locales/ja-Hira.js b/packages/scratch-gui/src/locales/ja-Hira.js index 749d8ae5577..79a52ad8e26 100644 --- a/packages/scratch-gui/src/locales/ja-Hira.js +++ b/packages/scratch-gui/src/locales/ja-Hira.js @@ -88,6 +88,7 @@ export default { 'gui.classroom.teacherDetail.openSubmission': 'スモウルビーでひらく', 'gui.classroom.teacherDetail.returnSubmission': 'へんきゃくする', 'gui.classroom.teacherDetail.returned': 'へんきゃくずみ', + 'gui.classroom.teacherDetail.downloadAll': 'ぜんさくひんダウンロード', 'gui.classroom.teacherDetail.cancelDelete': 'キャンセル', 'gui.classroom.codeDisplay.title': 'クラスコード', 'gui.classroom.codeDisplay.copyLink': 'しょうたいリンクをコピー', diff --git a/packages/scratch-gui/src/locales/ja.js b/packages/scratch-gui/src/locales/ja.js index fbec717a33f..2af261ed782 100644 --- a/packages/scratch-gui/src/locales/ja.js +++ b/packages/scratch-gui/src/locales/ja.js @@ -87,6 +87,7 @@ export default { 'gui.classroom.teacherDetail.openSubmission': 'スモウルビーで開く', 'gui.classroom.teacherDetail.returnSubmission': '返却する', 'gui.classroom.teacherDetail.returned': '返却済み', + 'gui.classroom.teacherDetail.downloadAll': '全作品ダウンロード', 'gui.classroom.teacherDetail.cancelDelete': 'キャンセル', 'gui.classroom.codeDisplay.title': 'クラスコード', 'gui.classroom.codeDisplay.copyLink': '招待リンクをコピー', From de138d2d93e3c22784da4e32533662761fb518f2 Mon Sep 17 00:00:00 2001 From: Kouji Takao Date: Sun, 5 Apr 2026 01:58:45 +0900 Subject: [PATCH 2/7] fix: add client-side file size check for submission upload (10MB limit) Validate .sb3 file size before uploading to prevent oversized files. Shows localized error message with actual file size in MB. Closes #450 Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/containers/classroom-modal.jsx | 16 +++++++++++++++- packages/scratch-gui/src/locales/en.js | 1 + packages/scratch-gui/src/locales/ja-Hira.js | 1 + packages/scratch-gui/src/locales/ja.js | 1 + 4 files changed, 18 insertions(+), 1 deletion(-) diff --git a/packages/scratch-gui/src/containers/classroom-modal.jsx b/packages/scratch-gui/src/containers/classroom-modal.jsx index c6fb74779c2..a02bdf44fe5 100644 --- a/packages/scratch-gui/src/containers/classroom-modal.jsx +++ b/packages/scratch-gui/src/containers/classroom-modal.jsx @@ -639,9 +639,23 @@ const ClassroomModal = () => { screenshotBlobs.length, ); - // 3. Upload .sb3 + // 3. Upload .sb3 (with size check) setSubmitProgress({ current: 0, total: 1, label: 'project' }); const sb3Data = await vm.saveProjectSb3(); + const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10 MB + if (sb3Data.byteLength > MAX_FILE_SIZE) { + const sizeMB = (sb3Data.byteLength / (1024 * 1024)).toFixed(1); + throw new Error( + intl.formatMessage( + { + defaultMessage: 'Project is too large ({size}MB). Maximum size is 10MB.', + description: 'File too large error', + id: 'gui.classroom.error.fileTooLarge', + }, + { size: sizeMB }, + ), + ); + } await classroomAPI.uploadToPresignedUrl(submissionData.uploadUrl, sb3Data, 'application/octet-stream'); // 4. Upload thumbnail diff --git a/packages/scratch-gui/src/locales/en.js b/packages/scratch-gui/src/locales/en.js index 5d6bed3b024..c00ed335a28 100644 --- a/packages/scratch-gui/src/locales/en.js +++ b/packages/scratch-gui/src/locales/en.js @@ -91,6 +91,7 @@ export default { 'gui.classroom.teacherDetail.openSubmission': 'Open in Smalruby', 'gui.classroom.teacherDetail.returnSubmission': 'Return', 'gui.classroom.teacherDetail.returned': 'Returned', + 'gui.classroom.error.fileTooLarge': 'Project is too large ({size}MB). Maximum size is 10MB.', 'gui.classroom.teacherDetail.cancelDelete': 'Cancel', 'gui.classroom.codeDisplay.title': 'Class Code', 'gui.classroom.codeDisplay.copyLink': 'Copy invite link', diff --git a/packages/scratch-gui/src/locales/ja-Hira.js b/packages/scratch-gui/src/locales/ja-Hira.js index 749d8ae5577..45dfb099110 100644 --- a/packages/scratch-gui/src/locales/ja-Hira.js +++ b/packages/scratch-gui/src/locales/ja-Hira.js @@ -88,6 +88,7 @@ export default { 'gui.classroom.teacherDetail.openSubmission': 'スモウルビーでひらく', 'gui.classroom.teacherDetail.returnSubmission': 'へんきゃくする', 'gui.classroom.teacherDetail.returned': 'へんきゃくずみ', + 'gui.classroom.error.fileTooLarge': 'プロジェクトがおおきすぎます({size}MB)。じょうげんは10MBです。', 'gui.classroom.teacherDetail.cancelDelete': 'キャンセル', 'gui.classroom.codeDisplay.title': 'クラスコード', 'gui.classroom.codeDisplay.copyLink': 'しょうたいリンクをコピー', diff --git a/packages/scratch-gui/src/locales/ja.js b/packages/scratch-gui/src/locales/ja.js index fbec717a33f..8ea11828667 100644 --- a/packages/scratch-gui/src/locales/ja.js +++ b/packages/scratch-gui/src/locales/ja.js @@ -87,6 +87,7 @@ export default { 'gui.classroom.teacherDetail.openSubmission': 'スモウルビーで開く', 'gui.classroom.teacherDetail.returnSubmission': '返却する', 'gui.classroom.teacherDetail.returned': '返却済み', + 'gui.classroom.error.fileTooLarge': 'プロジェクトが大きすぎます({size}MB)。上限は10MBです。', 'gui.classroom.teacherDetail.cancelDelete': 'キャンセル', 'gui.classroom.codeDisplay.title': 'クラスコード', 'gui.classroom.codeDisplay.copyLink': '招待リンクをコピー', From ac8b3049bfaeefb5ea21e066ba612a1ec7ba9f5b Mon Sep 17 00:00:00 2001 From: Kouji Takao Date: Sun, 5 Apr 2026 02:05:29 +0900 Subject: [PATCH 3/7] feat: show returned status and teacher comment to students MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Backend: verify-session now returns latest submission status + comment - Frontend: student status screen shows returned (↩) with teacher comment - Frontend: refresh button (↻) for manual status reload - Frontend: yellow comment box for teacher feedback display Closes #451 Co-Authored-By: Claude Opus 4.6 (1M context) --- infra/smalruby-classroom/lambda/handler.ts | 30 ++++++++++++- .../classroom-modal/classroom-modal.css | 38 ++++++++++++++++ .../classroom-modal/classroom-modal.jsx | 45 ++++++++++++++++++- .../src/containers/classroom-modal.jsx | 44 ++++++++++++------ packages/scratch-gui/src/locales/en.js | 2 + packages/scratch-gui/src/locales/ja-Hira.js | 2 + packages/scratch-gui/src/locales/ja.js | 2 + 7 files changed, 146 insertions(+), 17 deletions(-) diff --git a/infra/smalruby-classroom/lambda/handler.ts b/infra/smalruby-classroom/lambda/handler.ts index 0910fc140af..4e82b9da9cb 100644 --- a/infra/smalruby-classroom/lambda/handler.ts +++ b/infra/smalruby-classroom/lambda/handler.ts @@ -887,8 +887,34 @@ async function handleUpdateSubmission( async function handleVerifySession(sessionToken: string): Promise { // verifySessionToken will throw AuthError if invalid - await verifySessionToken(sessionToken); - return { statusCode: 200, body: JSON.stringify({ valid: true }) }; + const session = await verifySessionToken(sessionToken); + + // Look up latest submission for this member + let submission: Record | null = null; + const subResult = await docClient.send(new QueryCommand({ + TableName: SUBMISSIONS_TABLE, + IndexName: 'classroomId-memberId-index', + KeyConditionExpression: 'classroomId = :cid AND memberId = :mid', + ExpressionAttributeValues: { + ':cid': session.classroomId, + ':mid': session.memberId, + }, + ScanIndexForward: false, + Limit: 1, + })); + if (subResult.Items && subResult.Items.length > 0) { + const item = subResult.Items[0]; + submission = { + status: item.status, + submittedAt: item.submittedAt, + teacherComment: item.teacherComment || null, + }; + } + + return { + statusCode: 200, + body: JSON.stringify({ valid: true, submission }), + }; } // --- Main handler --- diff --git a/packages/scratch-gui/src/components/classroom-modal/classroom-modal.css b/packages/scratch-gui/src/components/classroom-modal/classroom-modal.css index 4addc742510..ef3cee5bed5 100644 --- a/packages/scratch-gui/src/components/classroom-modal/classroom-modal.css +++ b/packages/scratch-gui/src/components/classroom-modal/classroom-modal.css @@ -527,6 +527,44 @@ background-color: #e67e16; } +.refresh-button { + background: none; + border: 1px solid #d9d9d9; + border-radius: 4px; + padding: 0.1rem 0.4rem; + cursor: pointer; + font-size: 0.9rem; + color: #575e75; + margin-left: auto; +} + +.refresh-button:hover:not(:disabled) { + background-color: #f0f0f0; +} + +.refresh-button:disabled { + opacity: 0.3; + cursor: default; +} + +.teacher-comment-box { + background-color: #fff8e1; + border: 1px solid #ffcc02; + border-radius: 4px; + padding: 0.5rem; + margin-top: 0.25rem; + display: flex; + flex-direction: column; + gap: 0.25rem; +} + +.teacher-comment-text { + font-size: 0.85rem; + color: #575e75; + white-space: pre-wrap; + word-break: break-word; +} + .submission-detail { display: flex; gap: 0.5rem; diff --git a/packages/scratch-gui/src/components/classroom-modal/classroom-modal.jsx b/packages/scratch-gui/src/components/classroom-modal/classroom-modal.jsx index 51b51b523ca..ef21f808553 100644 --- a/packages/scratch-gui/src/components/classroom-modal/classroom-modal.jsx +++ b/packages/scratch-gui/src/components/classroom-modal/classroom-modal.jsx @@ -50,6 +50,8 @@ const ClassroomModal = ({ onConfirmSubmit, onCancelSubmit, submitProgress, + teacherComment, + onRefreshStudentStatus, thumbnailDataUrl, onTeacherLogout, classroomState, @@ -531,7 +533,19 @@ const ClassroomModal = ({ className={styles.statusValue} data-testid="classroom-submit-status" > - {classroomState.submissionStatus === 'submitted' ? ( + {classroomState.submissionStatus === 'returned' ? ( + + {'↩ '} + + {classroomState.lastSubmittedAt && ( + {` (${new Date(classroomState.lastSubmittedAt).toLocaleTimeString()})`} + )} + + ) : classroomState.submissionStatus === 'submitted' ? ( {'✓ '} )} + + {classroomState.submissionStatus === 'returned' && teacherComment && ( +
+ + + + + {teacherComment} + +
+ )}
); })} @@ -1318,6 +1328,24 @@ const TeacherClassDetail = ({ {new Date(selectedMemberData.submittedAt).toLocaleTimeString()} )} + + {selectedMemberData.isSeated ? ( + + ) : ( + + )} +