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
5 changes: 5 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -57,5 +57,8 @@
"npm": "10.9.5",
"serialport": "^13.0.0",
"ts-node": "10.9.2"
},
"dependencies": {
"jszip": "^3.10.1"
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,8 @@ const ClassroomModal = ({
onOpenSubmission,
onRefreshDetail,
onReturnSubmission,
onDownloadAll,
downloadProgress,
onStartSubmit,
onConfirmSubmit,
onCancelSubmit,
Expand Down Expand Up @@ -344,6 +346,8 @@ const ClassroomModal = ({
onOpenSubmission={onOpenSubmission}
onRefresh={onRefreshDetail}
onReturnSubmission={onReturnSubmission}
onDownloadAll={onDownloadAll}
downloadProgress={downloadProgress}
onSelectMember={onSelectMember}
onShowCodeDisplay={onShowCodeDisplay}
onToggleCodeFullscreen={onToggleCodeFullscreen}
Expand Down Expand Up @@ -917,6 +921,8 @@ const TeacherClassDetail = ({
onOpenSubmission,
onRefresh,
onReturnSubmission,
onDownloadAll,
downloadProgress,
onShowCodeDisplay,
onCloseCodeDisplay,
onCopyInviteLink,
Expand Down Expand Up @@ -1208,18 +1214,36 @@ const TeacherClassDetail = ({
</div>
</div>
) : (
<button
className={styles.dangerButton}
data-testid="classroom-delete-classroom"
disabled={isLoading}
onClick={handleDeleteClick}
>
<FormattedMessage
defaultMessage="Delete Classroom"
description="Delete classroom button"
id="gui.classroom.teacherDetail.deleteClassroom"
/>
</button>
<div className={styles.detailFooterButtons}>
<button
className={styles.dangerButton}
data-testid="classroom-delete-classroom"
disabled={isLoading}
onClick={handleDeleteClick}
>
<FormattedMessage
defaultMessage="Delete Classroom"
description="Delete classroom button"
id="gui.classroom.teacherDetail.deleteClassroom"
/>
</button>
<button
className={styles.secondaryButton}
data-testid="classroom-download-all"
disabled={isLoading || !!downloadProgress}
onClick={onDownloadAll}
>
{downloadProgress ? (
`${downloadProgress.current}/${downloadProgress.total}`
) : (
<FormattedMessage
defaultMessage="Download All"
description="Download all submissions button"
id="gui.classroom.teacherDetail.downloadAll"
/>
)}
</button>
</div>
)}
</div>
</div>
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
74 changes: 74 additions & 0 deletions packages/scratch-gui/src/containers/classroom-modal.jsx
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -768,6 +840,8 @@ const ClassroomModal = () => {
onCreateClassroom={handleCreateClassroom}
onDeleteClassroom={handleDeleteClassroom}
onDeleteMember={handleDeleteMember}
onDownloadAll={handleDownloadAll}
downloadProgress={downloadProgress}
onJoinWithCode={handleJoinWithCode}
onLeaveClassroom={handleLeaveClassroom}
submitProgress={submitProgress}
Expand Down
1 change: 1 addition & 0 deletions packages/scratch-gui/src/locales/en.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
1 change: 1 addition & 0 deletions packages/scratch-gui/src/locales/ja-Hira.js
Original file line number Diff line number Diff line change
Expand Up @@ -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': 'しょうたいリンクをコピー',
Expand Down
1 change: 1 addition & 0 deletions packages/scratch-gui/src/locales/ja.js
Original file line number Diff line number Diff line change
Expand Up @@ -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': '招待リンクをコピー',
Expand Down
Loading