diff --git a/docs/assignment-sharing/README.md b/docs/assignment-sharing/README.md
new file mode 100644
index 0000000000..8305a63888
--- /dev/null
+++ b/docs/assignment-sharing/README.md
@@ -0,0 +1,87 @@
+# みんなの課題(共有課題ライブラリ)
+
+> **🆕 Smalruby 独自** — upstream に存在しない、Smalruby のために新規追加された機能
+
+先生が自分で作成した課題(説明ページ + スタータープロジェクト + 補足資料 URL)を**インターネットを通じて全国の先生に共有し、再利用できる**仕組み。他の先生は共有された課題を閲覧・絞り込みし、自分のクラスに取り込むとスタータープロジェクトごと読み込まれ、そのまま授業を進められる。
+
+設計の正典: EPIC #1066 / スパイク #1067 の Decision Log(D1〜D12)。
+
+## 目次
+
+| ドキュメント | 内容 |
+|-------------|------|
+| 本 README | 機能全体像・主要ファイル・設定 |
+| [運用手順書](operations.md) | 通報対応(モデレーション)の運用者向け手順 |
+| [../classroom/architecture.md](../classroom/architecture.md) | API ルート・データモデル(「みんなの課題」セクション) |
+| [../classroom/testing.md](../classroom/testing.md) | data-testid 一覧(`shared-*`) |
+
+## 全体像
+
+```text
+先生A(共有する側) 先生B(使う側)
+課題詳細 →「この課題を共有」 課題ボード →「みんなの課題からさがす」
+ タイトル・属性(学校種×学年×教科×タグ) カタログ(新着順・絞り込み)
+ 補足資料URL(指導案など・https のみ) → 詳細(説明ページ・© 表示名 / CC BY 4.0)
+ 表示名・所属 / CC BY 4.0 同意 「このクラスに取り込む」→ 授業開始
+```
+
+- **閲覧・投稿とも先生ログイン必須**(D1)
+- **ライセンスは CC BY 4.0 に統一**(D2): 投稿時に同意、クレジットは表示名。取り込み後の改変・授業利用は自由
+- **共有はスナップショット**: 投稿後にクラス側の課題を変えても共有側は変わらない(更新は「自分の投稿」から上書き、D10)。取り込みも独立コピー(元の更新に追従しない)
+- **共有データは保存期限(TTL)の対象外**(D7・永続)。クラス機能の 90 日 TTL とは分離
+- **プロフィールは最小限**(D6): 表示名(必須)+ 所属表記(任意)のみ。メール・実名は保持しない
+- **補足資料 URL**(D4): https のみ。入力時に「学習指導案・授業スライドなど授業の進め方がわかる資料(Google ドライブ / ドキュメントの閲覧リンク推奨)」と明示し、閲覧側は外部ドメイン名付きの確認を挟む
+- **モデレーションは事後対応型**(D3): 通報 → 運用 CLI で unpublish([operations.md](operations.md))。Admin SPA(EPIC #1073)完成後は管理画面に移行
+
+## 画面
+
+### 共有フォーム(課題詳細 →「この課題を共有」)
+
+
+
+### カタログ(課題ボード →「みんなの課題からさがす」)
+
+
+
+### 詳細プレビュー(クレジット・補足資料リンク・取り込み)
+
+
+
+### 取り込み完了(ボードに新しい課題として出現)
+
+
+
+## 主要ファイル
+
+### バックエンド(infra/smalruby-classroom)
+
+| ファイル | 役割 |
+|---------|------|
+| `lambda/handler.ts` | `/shared-assignments` 系 7 エンドポイント(共有・一覧・詳細・取り込み・更新・取り下げ・通報)、タクソノミ/URL/プロフィールのバリデータ、`buildSharedSnapshot` |
+| `lambda/shared-admin-lib.ts` | 通報対応 CLI の純粋ロジック |
+| `bin/shared-assignments-admin.ts` | 通報対応 CLI(dry-run 既定) |
+| `lib/classroom-stack.ts` | `SharedAssignments`(TTL なし・prod RETAIN + PITR・GSI×2)/ `SharedAssignmentReports`(TTL 90日)/ 専用バケット(lifecycle なし) |
+
+### フロントエンド(packages/scratch-gui)
+
+| ファイル | 役割 |
+|---------|------|
+| `src/components/classroom-modal/shared-assignment-form.jsx` | 共有フォーム(属性・URL ガイダンス・CC BY 同意) |
+| `src/components/classroom-modal/shared-assignment-catalog.jsx` | カタログ(一覧・絞り込み・詳細・取り込み・自分の投稿・通報) |
+| `src/containers/use-shared-assignments.js` | 共有/カタログの状態管理フック |
+| `src/lib/shared-assignment-taxonomy.js` | 学校種×教科の語彙(サーバーのミラー)+ parseTags |
+| `src/lib/shared-author-profile.js` | プロフィールの localStorage 永続化 |
+| `src/lib/classroom-api.js` | API クライアント(shared 系 7 メソッド) |
+
+## 設定・データ永続化
+
+| 種別 | キー / 変数 | 内容 |
+|------|------------|------|
+| localStorage | `smalruby:sharedAuthorProfile` | 表示名・所属表記の記憶 |
+| env(Lambda) | `SHARE_DAILY_LIMIT`(既定 10)/ `REPORT_DAILY_LIMIT`(既定 20) | 1日あたりの共有 / 通報回数制限 |
+| env(Lambda) | `SHARED_STARTER_MAX_BYTES`(既定 50MB) | 共有スターターの容量上限 |
+
+## テスト
+
+- unit: `lambda/tests/handler-shared-assignments.test.ts`(API 25件)/ `shared-admin-lib.test.ts`(CLI 6件)/ GUI `shared-assignment-form/catalog/taxonomy/author-profile`(24件)
+- E2E: `tools/playwright-verify/verify-assignment-sharing.mjs`(共有 → カタログ → 取り込み → 自分の投稿の通し。`LOCALE=ja-JP` で日本語スクリーンショット)
diff --git a/docs/assignment-sharing/operations.md b/docs/assignment-sharing/operations.md
new file mode 100644
index 0000000000..0fb44b66ce
--- /dev/null
+++ b/docs/assignment-sharing/operations.md
@@ -0,0 +1,64 @@
+# みんなの課題 運用手順書(通報対応)
+
+> **🆕 Smalruby 独自** — みんなの課題(共有課題ライブラリ、EPIC #1066)の通報対応を行う運用者向け手順。
+
+対象読者: AWS クレデンシャル(SSO)を持つ運用者。設計の背景は EPIC #1066 / スパイク #1067 の Decision Log(D3: 事後対応型モデレーション)。
+
+> **将来**: Admin SPA(EPIC #1073)が完成したら通報対応は管理画面に移行する。本 CLI はそれまでの最小ツール兼フォールバック。
+
+## モデレーション方針
+
+- **事後対応型**: 投稿は即公開(投稿には先生ログイン + CC BY 4.0 同意が必須)。先生からの通報を受けて運用者が確認・対応する
+- **物理削除はしない**: 対応は `unpublish`(`status: 'unlisted'` = カタログから非表示)。誤対応は `republish` で復元できる
+- **投稿者への連絡手段は保持していない**(D6: 個人情報最小化のため email を持たない)。対応は非公開化のみで完結させる
+- 通報レコードは 90 日で自動削除(TTL)。通報者の識別子(reporterSub)は悪用対策の内部データで、CLI にも表示されない
+
+## 前提条件
+
+```bash
+cd infra/smalruby-classroom
+
+# ステージを .env symlink で選択(deploy と同じ流儀)
+ls -la .env # -> .env.stg または .env.prod
+
+# AWS クレデンシャル(コンテナ内 SSO)
+aws sso login --sso-session smalruby --use-device-code # 失効時のみ
+export AWS_PROFILE=smalruby AWS_REGION=ap-northeast-1
+eval "$(aws configure export-credentials --profile smalruby --format env)"
+```
+
+## 通報対応フロー
+
+```text
+1. 通報キューを確認
+ npx ts-node bin/shared-assignments-admin.ts list-reports
+ → 通報の多い投稿から順に、タイトル・status・通報理由が並ぶ
+
+2. 内容を確認
+ npx ts-node bin/shared-assignments-admin.ts show
+ → 説明ページ全文・補足 URL・投稿者表示名・属性を表示
+ (補足 URL の先はブラウザで確認。リンク先は投稿者管理のため慎重に)
+
+3. 判断
+ ├─ 問題あり(下記基準)→ 非公開化:
+ │ npx ts-node bin/shared-assignments-admin.ts unpublish # dry-run
+ │ npx ts-node bin/shared-assignments-admin.ts unpublish --apply # 実行
+ └─ 問題なし → 対応不要(通報は 90 日で自動消滅)
+
+4. 誤対応の復元
+ npx ts-node bin/shared-assignments-admin.ts republish --apply
+```
+
+## 非公開化の判断基準(目安)
+
+| 該当 | 対応 |
+|------|------|
+| 個人情報(生徒名・学校の内部情報等)が含まれる | 即 unpublish |
+| 補足 URL が課題と無関係・不適切なサイト | 即 unpublish |
+| 著作権侵害の疑い(第三者の教材の丸写し等) | unpublish して様子見 |
+| 授業として低品質・単なる好みの問題 | 対応しない(通報理由に返答する術はない) |
+
+## 補足
+
+- 取り込み済みの課題は各先生のクラス内のスナップショットなので、**unpublish しても既に取り込んだ先生には影響しない**
+- 投稿の実体(S3 `shared/{sharedId}/`)は unpublish でも残る(復元可能性のため)。完全削除が必要な法的要請の場合のみ AWS コンソールで S3 オブジェクトと DynamoDB アイテムを手動削除する
diff --git a/docs/assignment-sharing/screenshots/0101-share-form.png b/docs/assignment-sharing/screenshots/0101-share-form.png
new file mode 100644
index 0000000000..939d3c408f
Binary files /dev/null and b/docs/assignment-sharing/screenshots/0101-share-form.png differ
diff --git a/docs/assignment-sharing/screenshots/0102-catalog.png b/docs/assignment-sharing/screenshots/0102-catalog.png
new file mode 100644
index 0000000000..d48b1e2ec7
Binary files /dev/null and b/docs/assignment-sharing/screenshots/0102-catalog.png differ
diff --git a/docs/assignment-sharing/screenshots/0103-detail.png b/docs/assignment-sharing/screenshots/0103-detail.png
new file mode 100644
index 0000000000..11e6fcaad3
Binary files /dev/null and b/docs/assignment-sharing/screenshots/0103-detail.png differ
diff --git a/docs/assignment-sharing/screenshots/0104-imported.png b/docs/assignment-sharing/screenshots/0104-imported.png
new file mode 100644
index 0000000000..cf836b47cb
Binary files /dev/null and b/docs/assignment-sharing/screenshots/0104-imported.png differ
diff --git a/docs/classroom/README.md b/docs/classroom/README.md
index 653f65784a..cc3a57ad72 100644
--- a/docs/classroom/README.md
+++ b/docs/classroom/README.md
@@ -31,6 +31,7 @@ Smalruby Classroom は、日本の学校の授業で Smalruby を使うための
| [テスト](testing.md) | data-testid 一覧、Playwright / 結合テスト |
| [Microsoft 認証](microsoft-authentication.md) | MSAL.js 統合、サイレント再認証、Azure Portal 設定 |
| [運用手順書](operations.md) | 「消えたクラスを復旧してほしい」問い合わせ対応(アーカイブ復元の案内 / 期限切れ復元スクリプト) |
+| [みんなの課題](../assignment-sharing/README.md) | 全国の先生と課題を共有・再利用する機能(別 feature ディレクトリ) |

diff --git a/docs/classroom/architecture.md b/docs/classroom/architecture.md
index ac14eb2e61..7e0bfe417e 100644
--- a/docs/classroom/architecture.md
+++ b/docs/classroom/architecture.md
@@ -128,6 +128,9 @@ sequenceDiagram
| **DynamoDB** | `ClassroomMemberships-{stage}` | メンバー (生徒) 情報(Streams: OLD_IMAGE) |
| **DynamoDB** | `ClassroomSubmissions-{stage}` | 提出情報(Streams: OLD_IMAGE) |
| **DynamoDB** | `ClassroomGroups-{stage}` | クラス(学級)情報(Streams: OLD_IMAGE) |
+| **DynamoDB** | `SharedAssignments-{stage}` | みんなの課題(TTL なし・prod RETAIN + PITR) |
+| **DynamoDB** | `SharedAssignmentReports-{stage}` | みんなの課題の通報(TTL 90日) |
+| **S3** | `smalruby-shared-assignments-{stage}` | 共有課題のスナップショット(lifecycle なし = 永続・prod RETAIN) |
| **S3** | `smalruby-classroom-submissions-{stage}` | 提出ファイル (.sb3, サムネイル, スクリーンショット) + `ddb-archive/` スナップショット。lifecycle = `ARCHIVE_RETENTION_DAYS`(既定 365 日) |
| **Route53** | A レコード | カスタムドメイン |
| **ACM** | SSL 証明書 | HTTPS |
@@ -185,6 +188,27 @@ sequenceDiagram
| `POST` | `/classrooms/{id}/duplicate` | クラス(授業)の複製。課題コンテンツの S3 オブジェクトもコピー。`groupId` / `className` / `assignmentName` を上書き可。メンバー・提出は複製しない |
| `POST` | `/classrooms/{id}/evaluate` | AI 評価支援。静的解析結果(シグナル + 擬似コード)を Anthropic API にリレーし、`mode: grade` は S/A/B/C 案 + 根拠 + needsReview、`mode: comment` は生徒向けポジティブコメント下書きを返す。1リクエスト最大10提出(API GW の30秒制限対策、クライアントがチャンク分割)。先生ごとにレート制限(既定 60回/時) |
+### みんなの課題 (共有課題ライブラリ・先生 ID Token 認証)
+
+全国の先生が課題(説明ページ + スターター + 補足資料 URL)を共有・再利用する機能(EPIC #1066。設計の正典は spike #1067)。
+
+| Method | Path | 説明 |
+|--------|------|------|
+| `POST` | `/shared-assignments` | 課題を共有。`visibility`(省略時 `public`)で公開範囲を選ぶ。`public`=みんなの課題カタログ(CC BY 4.0 同意・属性・著者名 必須)。`limited`=合言葉限定公開(#1109。同意・属性・著者名は任意で、参加コード同型の合言葉を発行)。スターター 50MB 上限・10件/日制限 |
+| `GET` | `/shared-assignments` | カタログ一覧(新着順・`schoolLevel`/`subject`/`grade`/`tag` で絞り込み・`cursor` ページネーション・`mine=1` で自分の投稿一覧)。**公開カタログは限定公開を除外**。`mine=1` は自分の合言葉を含む |
+| `GET` | `/shared-assignments/{id}` | 詳細(ページ・画像/スターターの presigned URL・投稿者表示名。authorSub は返さない)。**限定公開は sharedId を知っていても非著者には 404**(合言葉ルックアップ経由でのみアクセス) |
+| `POST` | `/shared-assignments/lookup` | 合言葉プレビュー(#1109)。`{passcode}` → summary(**sharedId は返さない**) |
+| `POST` | `/shared-assignments/import-by-passcode` | 合言葉で取り込み(#1109)。`{passcode, groupId, assignmentName?}` → 自分のクラスに課題として取り込み。sharedId を露出しない内輪取り込み |
+| `POST` | `/shared-assignments/{id}/import` | 自分のクラス(groupId)に課題として取り込み(**全体公開のみ**。限定公開は import-by-passcode を使う)。S3 逆コピー + reuseCount 増分 |
+| `PATCH` | `/shared-assignments/{id}` | 更新(投稿者本人のみ。メタデータ + `classroomId` 指定で内容の再スナップショット=上書き)。**`visibility` を `limited`→`public` に広げる時は CC BY 同意・属性・著者名を必須化** |
+| `DELETE` | `/shared-assignments/{id}` | 取り下げ = `status: 'unlisted'`(物理削除しない。本人のみ) |
+| `POST` | `/shared-assignments/{id}/report` | 通報(理由必須・20件/日制限。reporterSub は内部保持のみ) |
+
+- **公開範囲(#1109)**: 項目は `visibility`(`public`/`limited`)を持つ。#1109 以前の項目は属性を持たず `public` とみなす(後方互換)。`limited` は `passcode`(合言葉)を持ち、公開カタログには出ない。「限定公開(合言葉・内輪)→ Admin が把握 → 推薦 → 全体公開」パイプラインの土台
+- データ: `SharedAssignments{suffix}`(**TTL なし・prod は RETAIN + PITR**。GSI: `status-createdAt-index` / `authorSub-createdAt-index` / `passcode-index`(合言葉ルックアップ・#1109))、`SharedAssignmentReports{suffix}`(TTL 90日)
+- ファイル: 専用バケット `smalruby-shared-assignments{suffix}`(**lifecycle なし = 永続**、`shared/{sharedId}/` プレフィックス)。クラス側の保存期限と完全に分離
+- 共有/取り込みの実体は既存 duplicate と同じ S3 サーバー側コピー(クロスバケット)
+
### 生徒用 (認証不要 / Session Token)
| Method | Path | 認証 | 説明 |
diff --git a/docs/classroom/testing.md b/docs/classroom/testing.md
index baf1f3582c..ebb126a5d2 100644
--- a/docs/classroom/testing.md
+++ b/docs/classroom/testing.md
@@ -242,6 +242,40 @@ Playwright MCP および Selenium integration tests で使用する `data-testid
| `classroom-board-download-class` | button | ボードの「全課題の提出物をダウンロード」(active + アーカイブ済みを 1 つの zip に) |
| `classroom-retention-banner` | div | 課題詳細の保存期限アラートバナー(30 日以下で表示) |
| `classroom-retention-banner-download` | button | バナー内の「全作品ダウンロード」 |
+| `classroom-share-assignment` | button | 課題詳細の「この課題を共有」(みんなの課題フォームを開く) |
+| `shared-form` | form | みんなの課題の共有フォーム |
+| `shared-form-title` / `shared-form-summary` | input | タイトル / 短い説明 |
+| `shared-form-level` | select | 学校種 |
+| `shared-form-subject` | select | 教科(制御語彙。学校種=その他のときは `shared-form-subject-free` input) |
+| `shared-form-grade-{n}` | checkbox | 対象学年 |
+| `shared-form-tags` | input | タグ(カンマ区切り・最大5) |
+| `shared-form-lesson-count` | input | 想定コマ数 |
+| `shared-form-url` | input | 補足資料 URL(https のみ。ガイダンス=`shared-form-url-hint`、エラー=`shared-form-url-error`) |
+| `shared-form-author-name` / `shared-form-author-affiliation` | input | 表示名 / 所属表記(localStorage 記憶) |
+| `shared-form-consent` | checkbox | CC BY 4.0 同意(未チェックだと送信不可) |
+| `shared-form-submit` / `shared-form-cancel` | button | 共有する / キャンセル |
+| `shared-form-success` | p | 公開完了メッセージ(© 表示名 / CC BY 4.0) |
+| `classroom-board-shared-catalog` | button | ボードの「みんなの課題からさがす」 |
+| `shared-catalog` | div | みんなの課題カタログ(ボード内に展開) |
+| `shared-catalog-close` | button | カタログを閉じる |
+| `shared-catalog-tab-all` / `shared-catalog-tab-mine` | button | すべて / 自分の投稿 タブ |
+| `shared-catalog-filter-level/subject/grade/tag` | select/input | 絞り込み(学校種・教科・学年・タグ) |
+| `shared-catalog-filter-apply` | button | 絞り込み実行 |
+| `shared-catalog-list` / `shared-catalog-item-{id}` / `shared-catalog-open-{id}` | ul/li/button | カード一覧(属性バッジ・投稿者・取り込み回数) |
+| `shared-catalog-load-more` | button | 次ページ読み込み(cursor があるときのみ) |
+| `shared-catalog-empty` | p | 空メッセージ |
+| `shared-catalog-detail` | div | 詳細プレビュー |
+| `shared-detail-close` | button | 一覧に戻る |
+| `shared-detail-credit` | p | 「© 表示名(所属) / CC BY 4.0」クレジット行 |
+| `shared-detail-starter` | p | スターター付きの説明 |
+| `shared-detail-url` | button | 補足資料リンク(クリックで確認表示) |
+| `shared-detail-url-confirm` / `shared-detail-url-open` / `shared-detail-url-cancel` | span/a/button | 外部ドメイン名付き確認 →「開く」(rel=noopener・新規タブ) |
+| `shared-detail-import` | button | このクラスに取り込む(published のみ表示) |
+| `shared-detail-report` | button | 通報フォームを開く(他人の投稿のみ) |
+| `shared-report-form` / `shared-report-reason` / `shared-report-submit` | div/textarea/button | 通報理由(必須)と送信 |
+| `shared-report-sent` | p | 通報完了メッセージ |
+| `shared-detail-unlist` / `shared-detail-republish` | button | 自分の投稿の取り下げ / 再公開 |
+| `shared-import-success` | p | 取り込み完了メッセージ(ボード上) |
| `classroom-breadcrumbs` | nav | パンくず(クラス一覧 > 課題一覧 > 課題詳細) |
| `classroom-breadcrumb-class-list` / `classroom-breadcrumb-assignments` | button | パンくずリンク |
| `classroom-board-create-name` / `classroom-board-create-submit` | input / button | インライン課題作成(課題名のみ) |
diff --git a/docs/classroom/ui-ux.md b/docs/classroom/ui-ux.md
index c2a3ab3a81..94b5f2867a 100644
--- a/docs/classroom/ui-ux.md
+++ b/docs/classroom/ui-ux.md
@@ -149,6 +149,7 @@ Google または Microsoft アカウントでサインインする画面。先

- **残り日数バッジ**: 保存期限(自動削除)まで 30 日以下の課題行に「あと{days}日」バッジを表示(7 日以下は警告色)。閾値の根拠は EPIC #1049 の D8
- **全課題の提出物をダウンロード**(`classroom-board-download-class`): クラス内の全課題(アーカイブ済み含む — どちらも保存期限で消えるため)の提出物を 1 つの zip(`課題名/席番号_名前/作品.sb3` + サムネ/スクショ + `提出状況.csv`)でダウンロード。進捗は「n/m」表示
+- **みんなの課題からさがす**(`classroom-board-shared-catalog`、EPIC #1066): 全国の先生が共有した課題のカタログをボード内に展開。学校種・教科・学年・タグで絞り込み → 詳細プレビュー(説明ページ・「© 表示名 / CC BY 4.0」クレジット・補足資料リンクは外部ドメイン名付き確認を挟んで新規タブ)→「このクラスに取り込む」でスターターごと課題として複製(新しい参加コードが発行される)。「自分の投稿」タブから取り下げ / 再公開。他人の投稿には通報(理由必須)

- 主な data-testid: `classroom-board` / `classroom-board-create[-name|-submit]` / `classroom-board-reuse[-view|-filter|-copy-{id}]` / `classroom-board-section-{topic|none}` / `classroom-board-row|open|topic|date-{classroomId}` / `classroom-topic-add[-input]` / `classroom-topic-chip|rename|remove-{topic}` / `classroom-breadcrumbs` / `classroom-board-archived-[section|toggle|list]` / `classroom-board-archived-row-{classroomId}` / `classroom-board-restore-{classroomId}`
@@ -160,6 +161,7 @@ Google または Microsoft アカウントでサインインする画面。先
課題をひらくと「説明」タブが最初に表示される。左に生徒へ表示する説明・画像・スターターの編集フォーム、**右ペインに生徒視点プレビュー**(編集内容をライブ表示・ページ送り。生徒への反映は保存時のみ)。
- 出席・提出のポーリング(30秒)は**メンバータブ表示中のみ**(費用抑制)
+- **この課題を共有**(`classroom-share-assignment`): 課題(説明ページ + スターター)を「みんなの課題」(全国の先生の共有ライブラリ、EPIC #1066)に公開するフォームを開く。属性(学校種・学年・教科・タグ・コマ数)、補足資料 URL(https のみ + 期待内容のガイダンス表示)、表示名・所属(localStorage 記憶)、**CC BY 4.0 同意チェック必須**。公開後は「© 表示名 / CC BY 4.0」付きの完了メッセージを表示
- 課題の所属クラス変更・人数編集・課題単位の共同管理者・複製は**できない**(クラス設定 / 課題一覧の再利用へ集約)。フッターのボタンは「**課題をアーカイブ**」(soft-delete。ボードの「アーカイブ済みの課題」からいつでも復元可能。testid は歴史的経緯で `classroom-delete-classroom` のまま)
- 主な data-testid: `classroom-tab-description` / `classroom-description-editor` / `classroom-description-preview[-body|-prev|-next]` / `classroom-tab-members`
diff --git a/infra/smalruby-classroom/.env.example b/infra/smalruby-classroom/.env.example
index 49db412507..5b2d879550 100644
--- a/infra/smalruby-classroom/.env.example
+++ b/infra/smalruby-classroom/.env.example
@@ -35,3 +35,7 @@ CORS_ALLOWED_ORIGINS=https://smalruby.app,https://smalruby.jp,http://localhost:8
# 期限切れ復元用の長期保持日数(S3 提出物 + DynamoDB 削除スナップショット。CLASSROOM_TTL_DAYS 以上が必須)
ARCHIVE_RETENTION_DAYS=365
+
+# みんなの課題 (共有課題ライブラリ) のレート制限 (省略時 10 / 20)
+SHARE_DAILY_LIMIT=10
+REPORT_DAILY_LIMIT=20
diff --git a/infra/smalruby-classroom/bin/shared-assignments-admin.ts b/infra/smalruby-classroom/bin/shared-assignments-admin.ts
new file mode 100644
index 0000000000..97224c8f21
--- /dev/null
+++ b/infra/smalruby-classroom/bin/shared-assignments-admin.ts
@@ -0,0 +1,128 @@
+#!/usr/bin/env npx ts-node
+/**
+ * Operator CLI: みんなの課題 moderation (issue #1071, EPIC #1066 D3).
+ *
+ * Post-moderation model: teachers report items; operators review the queue
+ * and unpublish (never hard-delete — a mistaken action stays recoverable).
+ * Mutations are dry-run by default; re-run with --apply to execute.
+ *
+ * Usage (from infra/smalruby-classroom, with AWS credentials for the stage):
+ * npx ts-node bin/shared-assignments-admin.ts list-reports
+ * npx ts-node bin/shared-assignments-admin.ts show
+ * npx ts-node bin/shared-assignments-admin.ts unpublish [--apply]
+ * npx ts-node bin/shared-assignments-admin.ts republish [--apply]
+ *
+ * The stage comes from the .env symlink (STAGE), same as cdk deploy.
+ * Full runbook: docs/assignment-sharing/operations.md
+ */
+import 'dotenv/config';
+import { DynamoDBClient } from '@aws-sdk/client-dynamodb';
+import { DynamoDBDocumentClient, GetCommand, ScanCommand, UpdateCommand } from '@aws-sdk/lib-dynamodb';
+import {
+ groupReports,
+ parseAdminArgs,
+ renderReportQueue,
+ renderSharedItem,
+ type ReportRecord,
+} from '../lambda/shared-admin-lib';
+
+const stage = process.env.STAGE || 'stg';
+const suffix = stage === 'prod' ? '' : `-${stage}`;
+const SHARED_TABLE = `SharedAssignments${suffix}`;
+const REPORTS_TABLE = `SharedAssignmentReports${suffix}`;
+
+const docClient = DynamoDBDocumentClient.from(new DynamoDBClient({}));
+
+async function getItem(sharedId: string): Promise | null> {
+ const result = await docClient.send(new GetCommand({
+ TableName: SHARED_TABLE,
+ Key: { sharedId },
+ }));
+ return (result.Item as Record) || null;
+}
+
+async function listReports(): Promise {
+ // Reports are 90-day-TTL'd and low-volume; a scan is fine.
+ const items: ReportRecord[] = [];
+ let lastKey: Record | undefined;
+ do {
+ const page = await docClient.send(new ScanCommand({
+ TableName: REPORTS_TABLE,
+ ExclusiveStartKey: lastKey,
+ }));
+ for (const item of page.Items || []) {
+ items.push({
+ sharedId: String(item.sharedId),
+ reason: String(item.reason || ''),
+ createdAt: String(item.createdAt || ''),
+ });
+ }
+ lastKey = page.LastEvaluatedKey as Record | undefined;
+ } while (lastKey);
+
+ const grouped = groupReports(items);
+ const itemsById = new Map>();
+ for (const sharedId of grouped.keys()) {
+ const item = await getItem(sharedId);
+ if (item) itemsById.set(sharedId, item);
+ }
+ for (const line of renderReportQueue(grouped, itemsById)) {
+ console.log(line);
+ }
+}
+
+async function setStatus(sharedId: string, status: 'published' | 'unlisted', apply: boolean): Promise {
+ const item = await getItem(sharedId);
+ if (!item) {
+ console.log(`スナップショットが見つかりません: ${sharedId}`);
+ return 1;
+ }
+ console.log(`対象: ${item.title} (現在 status=${item.status}) → ${status}`);
+ if (!apply) {
+ console.log('dry-run のため変更していません。実行するには --apply を付けてください。');
+ return 0;
+ }
+ await docClient.send(new UpdateCommand({
+ TableName: SHARED_TABLE,
+ Key: { sharedId },
+ UpdateExpression: 'SET #status = :status, updatedAt = :now',
+ ExpressionAttributeNames: { '#status': 'status' },
+ ExpressionAttributeValues: { ':status': status, ':now': new Date().toISOString() },
+ }));
+ console.log('変更しました。');
+ return 0;
+}
+
+async function main(): Promise {
+ const args = parseAdminArgs(process.argv.slice(2));
+ console.log(`stage=${stage} table=${SHARED_TABLE} mode=${args.apply ? 'APPLY' : 'dry-run'}`);
+
+ switch (args.command) {
+ case 'list-reports':
+ await listReports();
+ return 0;
+ case 'show': {
+ const item = await getItem(args.sharedId as string);
+ if (!item) {
+ console.log(`スナップショットが見つかりません: ${args.sharedId}`);
+ return 1;
+ }
+ for (const line of renderSharedItem(item)) {
+ console.log(line);
+ }
+ return 0;
+ }
+ case 'unpublish':
+ return setStatus(args.sharedId as string, 'unlisted', args.apply);
+ case 'republish':
+ return setStatus(args.sharedId as string, 'published', args.apply);
+ }
+}
+
+main().then(
+ (code) => process.exit(code),
+ (err) => {
+ console.error(err instanceof Error ? err.message : err);
+ process.exit(1);
+ },
+);
diff --git a/infra/smalruby-classroom/lambda/handler.ts b/infra/smalruby-classroom/lambda/handler.ts
index 5daef6048e..f26f1bc1a9 100644
--- a/infra/smalruby-classroom/lambda/handler.ts
+++ b/infra/smalruby-classroom/lambda/handler.ts
@@ -10,7 +10,14 @@ import {
UpdateCommand,
BatchWriteCommand,
} from '@aws-sdk/lib-dynamodb';
-import { S3Client, PutObjectCommand, GetObjectCommand, DeleteObjectCommand, CopyObjectCommand } from '@aws-sdk/client-s3';
+import {
+ S3Client,
+ PutObjectCommand,
+ GetObjectCommand,
+ DeleteObjectCommand,
+ CopyObjectCommand,
+ HeadObjectCommand,
+} from '@aws-sdk/client-s3';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
import { OAuth2Client } from 'google-auth-library';
import { createRemoteJWKSet, jwtVerify } from 'jose';
@@ -24,6 +31,10 @@ const SUBMISSIONS_TABLE = process.env.SUBMISSIONS_TABLE_NAME || 'ClassroomSubmis
const KICK_REQUESTS_TABLE = process.env.KICK_REQUESTS_TABLE_NAME || 'ClassroomKickRequests';
const GROUPS_TABLE = process.env.GROUPS_TABLE_NAME || 'ClassroomGroups';
const SUBMISSIONS_BUCKET = process.env.SUBMISSIONS_BUCKET_NAME || 'smalruby-classroom-submissions';
+// みんなの課題 — nationwide shared assignment library (EPIC #1066)
+const SHARED_ASSIGNMENTS_TABLE = process.env.SHARED_ASSIGNMENTS_TABLE_NAME || 'SharedAssignments';
+const SHARED_REPORTS_TABLE = process.env.SHARED_REPORTS_TABLE_NAME || 'SharedAssignmentReports';
+const SHARED_BUCKET = process.env.SHARED_BUCKET_NAME || 'smalruby-shared-assignments';
const GOOGLE_CLIENT_ID = process.env.GOOGLE_CLIENT_ID || '';
const MICROSOFT_CLIENT_ID = process.env.MICROSOFT_CLIENT_ID || '';
const DEV_BYPASS_TOKEN = process.env.DEV_BYPASS_TOKEN || '';
@@ -73,6 +84,23 @@ const MAX_GROUP_NAME_LENGTH = 50;
// How many prior lessons of the same group to inspect when looking up the
// student's previous returned comment on join (personalized recap).
const PREVIOUS_COMMENT_LOOKBACK = 3;
+// みんなの課題 (EPIC #1066): shared items are permanent (no TTL), so quota
+// counters reuse the Classrooms table's reserved key space (TTL-cleaned)
+// instead. Retention decisions: spike #1067 D10-D12.
+const SHARE_DAILY_LIMIT = parseInt(process.env.SHARE_DAILY_LIMIT || '10', 10);
+const REPORT_DAILY_LIMIT = parseInt(process.env.REPORT_DAILY_LIMIT || '20', 10);
+const SHARED_STARTER_MAX_BYTES = parseInt(process.env.SHARED_STARTER_MAX_BYTES || String(50 * 1024 * 1024), 10);
+const SHARED_REPORT_TTL_SECONDS = 90 * 24 * 60 * 60;
+const SHARED_LIST_PAGE_SIZE = 30;
+const MAX_SHARED_TITLE_LENGTH = 50;
+const MAX_SHARED_SUMMARY_LENGTH = 100;
+const MAX_SHARED_TAGS = 5;
+const MAX_SHARED_TAG_LENGTH = 20;
+const MAX_SUPPLEMENT_URL_LENGTH = 500;
+const MAX_AUTHOR_NAME_LENGTH = 30;
+const MAX_AUTHOR_AFFILIATION_LENGTH = 50;
+const MAX_SHARED_REPORT_REASON_LENGTH = 200;
+
// Class (旧組) v2 data model: every assignment (Classrooms record) belongs to
// a class (ClassroomGroups record), and class-level GC linkage / co-teachers /
// studentCount are authoritative. The version is stamped on each group record
@@ -2479,9 +2507,52 @@ async function handleUpdateGroup(identity: TeacherIdentity, groupId: string, bod
ReturnValues: 'ALL_NEW',
}));
+ // The class's studentCount is authoritative, so a change must flow down to
+ // its existing assignments (classrooms) — otherwise growing/shrinking the
+ // class leaves old assignments on their creation-time snapshot. Increasing
+ // adds seats (safe); decreasing drops seats — the teacher UI warns first
+ // that submissions on the removed seats stop showing.
+ if (updates.studentCount !== undefined) {
+ await propagateStudentCountToClassrooms(
+ String((result.Attributes as Record)?.teacherSub ?? identity.sub),
+ groupId,
+ updates.studentCount as number,
+ );
+ }
+
return { statusCode: 200, body: JSON.stringify(mapGroupSummary(result.Attributes || {})) };
}
+/**
+ * Set every active classroom (assignment) in a class to the class's new seat
+ * count. Enumerates via teacherSub-index + a groupId filter (no groupId GSI on
+ * the classrooms table), the same pattern topic cascades use.
+ * @param teacherSub - owning teacher (partition key of teacherSub-index)
+ * @param groupId - the class whose assignments to update
+ * @param studentCount - the new seat count to write
+ */
+async function propagateStudentCountToClassrooms(
+ teacherSub: string, groupId: string, studentCount: number,
+): Promise {
+ const now = new Date().toISOString();
+ const result = await docClient.send(new QueryCommand({
+ TableName: CLASSROOMS_TABLE,
+ IndexName: 'teacherSub-index',
+ KeyConditionExpression: 'teacherSub = :ts',
+ FilterExpression: 'groupId = :gid AND #status = :active',
+ ExpressionAttributeNames: { '#status': 'status' },
+ ExpressionAttributeValues: { ':ts': teacherSub, ':gid': groupId, ':active': 'active' },
+ }));
+ for (const item of result.Items || []) {
+ await docClient.send(new UpdateCommand({
+ TableName: CLASSROOMS_TABLE,
+ Key: { classroomId: item.classroomId },
+ UpdateExpression: 'SET studentCount = :sc, updatedAt = :now',
+ ExpressionAttributeValues: { ':sc': studentCount, ':now': now },
+ }));
+ }
+}
+
/**
* One-time (but idempotent) v1→v2 bulk migration for the calling teacher.
* Runs the pure plan against the teacher's classrooms + groups, then executes
@@ -3364,6 +3435,899 @@ async function handleEvaluateSubmissions(
return { statusCode: 200, body: JSON.stringify({ mode: request.mode, results }) };
}
+// --- みんなの課題 (shared assignment library, EPIC #1066) ---
+// Teachers publish an assignment snapshot (pages + starter + supplement URL
+// + minimal author profile) under CC BY 4.0; other teachers browse, filter,
+// and import it into their own class. Shared items are permanent (no TTL,
+// D7) and live in their own bucket so the classroom lifecycle never sweeps
+// them. Design canon: spike #1067 (D1-D12).
+
+export const SHARED_SCHOOL_LEVELS = ['elementary', 'junior-high', 'high', 'other'] as const;
+
+/** Controlled subject vocabulary per school level (D5). 'other' is free text. */
+export const SHARED_SUBJECTS: Record = {
+ elementary: ['総合的な学習の時間', '算数', '理科', '図画工作', '特別活動・クラブ', 'その他'],
+ 'junior-high': ['技術・家庭(技術分野)', '数学', '理科', '総合的な学習の時間', 'その他'],
+ high: ['情報Ⅰ', '情報Ⅱ', 'その他'],
+ other: [],
+};
+
+const SHARED_MAX_GRADE: Record = {
+ elementary: 6,
+ 'junior-high': 3,
+ high: 3,
+ other: 6,
+};
+
+interface SharedAttributes {
+ schoolLevel: string;
+ grades: number[];
+ subject: string;
+ tags: string[];
+ lessonCount: number | null;
+}
+
+/**
+ * Validate the school attributes of a share/update request (D5).
+ * @param body - request body
+ * @returns normalized attributes
+ */
+export function validateSharedAttributes(body: Record): SharedAttributes {
+ const schoolLevel = body.schoolLevel;
+ if (typeof schoolLevel !== 'string' || !(SHARED_SCHOOL_LEVELS as readonly string[]).includes(schoolLevel)) {
+ throw new ValidationError(`schoolLevel must be one of: ${SHARED_SCHOOL_LEVELS.join(', ')}`);
+ }
+
+ const subject = body.subject;
+ if (typeof subject !== 'string' || subject.trim().length === 0) {
+ throw new ValidationError('subject is required');
+ }
+ const vocabulary = SHARED_SUBJECTS[schoolLevel];
+ if (vocabulary.length > 0 && !vocabulary.includes(subject)) {
+ throw new ValidationError(`subject must be one of: ${vocabulary.join(' / ')}`);
+ }
+ if (vocabulary.length === 0 && subject.trim().length > MAX_SHARED_TAG_LENGTH) {
+ throw new ValidationError(`subject must be ${MAX_SHARED_TAG_LENGTH} characters or less`);
+ }
+
+ const maxGrade = SHARED_MAX_GRADE[schoolLevel];
+ let grades: number[] = [];
+ if (body.grades !== undefined) {
+ if (!Array.isArray(body.grades)) {
+ throw new ValidationError('grades must be an array');
+ }
+ grades = [...new Set(body.grades)].map(g => {
+ if (typeof g !== 'number' || !Number.isInteger(g) || g < 1 || g > maxGrade) {
+ throw new ValidationError(`grades must be integers between 1 and ${maxGrade}`);
+ }
+ return g;
+ }).sort((a, b) => a - b);
+ }
+
+ let tags: string[] = [];
+ if (body.tags !== undefined) {
+ if (!Array.isArray(body.tags)) {
+ throw new ValidationError('tags must be an array');
+ }
+ tags = [...new Set(body.tags.map(t => {
+ if (typeof t !== 'string' || t.trim().length === 0 || t.trim().length > MAX_SHARED_TAG_LENGTH) {
+ throw new ValidationError(`each tag must be 1-${MAX_SHARED_TAG_LENGTH} characters`);
+ }
+ return t.trim();
+ }))];
+ if (tags.length > MAX_SHARED_TAGS) {
+ throw new ValidationError(`at most ${MAX_SHARED_TAGS} tags are allowed`);
+ }
+ }
+
+ let lessonCount: number | null = null;
+ if (body.lessonCount !== undefined && body.lessonCount !== null) {
+ if (typeof body.lessonCount !== 'number' || !Number.isInteger(body.lessonCount) ||
+ body.lessonCount < 1 || body.lessonCount > 20) {
+ throw new ValidationError('lessonCount must be an integer between 1 and 20');
+ }
+ lessonCount = body.lessonCount;
+ }
+
+ return { schoolLevel, grades, subject: subject.trim(), tags, lessonCount };
+}
+
+/**
+ * Validate the supplement URL (D4): https only, parseable, bounded length.
+ * @param value - raw URL from the request
+ * @returns normalized URL or null when absent
+ */
+export function validateSupplementUrl(value: unknown): string | null {
+ if (value === undefined || value === null || value === '') return null;
+ if (typeof value !== 'string' || value.length > MAX_SUPPLEMENT_URL_LENGTH) {
+ throw new ValidationError(`supplementUrl must be a string of at most ${MAX_SUPPLEMENT_URL_LENGTH} characters`);
+ }
+ let parsed: URL;
+ try {
+ parsed = new URL(value);
+ } catch {
+ throw new ValidationError('supplementUrl must be a valid URL');
+ }
+ if (parsed.protocol !== 'https:') {
+ throw new ValidationError('supplementUrl must use https');
+ }
+ return value;
+}
+
+/**
+ * Validate the minimal public author profile (D6).
+ * @param body - request body
+ * @returns display name + optional affiliation
+ */
+export function validateAuthorProfile(body: Record): {
+ authorName: string;
+ authorAffiliation: string | null;
+} {
+ const name = body.authorName;
+ if (typeof name !== 'string' || name.trim().length === 0 || name.trim().length > MAX_AUTHOR_NAME_LENGTH) {
+ throw new ValidationError(`authorName is required (at most ${MAX_AUTHOR_NAME_LENGTH} characters)`);
+ }
+ let affiliation: string | null = null;
+ if (body.authorAffiliation !== undefined && body.authorAffiliation !== null && body.authorAffiliation !== '') {
+ if (typeof body.authorAffiliation !== 'string' ||
+ body.authorAffiliation.trim().length > MAX_AUTHOR_AFFILIATION_LENGTH) {
+ throw new ValidationError(`authorAffiliation must be at most ${MAX_AUTHOR_AFFILIATION_LENGTH} characters`);
+ }
+ affiliation = body.authorAffiliation.trim();
+ }
+ return { authorName: name.trim(), authorAffiliation: affiliation };
+}
+
+/**
+ * Build the shared snapshot of an assignment: pages/starter keys rewritten
+ * into the shared bucket's `shared/{sharedId}/` prefix plus the copy plan.
+ * Mirror of buildDuplicatedAssignment, kept pure for tests.
+ * @param assignment - the classroom's assignment content
+ * @param sharedId - target shared item id
+ * @returns rewritten pages/starterKey and the copy list
+ */
+export function buildSharedSnapshot(
+ assignment: AssignmentContent | undefined,
+ sharedId: string,
+): { pages: AssignmentPage[]; starterKey?: string; copies: { from: string; to: string }[] } {
+ const copies: { from: string; to: string }[] = [];
+ const rewriteKey = (key: string): string => {
+ const to = `shared/${sharedId}/${key.split('/').pop()}`;
+ copies.push({ from: key, to });
+ return to;
+ };
+ const pages = (assignment?.pages || []).map(page =>
+ page.imageKey ? { text: page.text, imageKey: rewriteKey(page.imageKey) } : { text: page.text },
+ );
+ const starterKey = assignment?.starterKey ? rewriteKey(assignment.starterKey) : undefined;
+ return { pages, starterKey, copies };
+}
+
+/** Public list/detail projection — never exposes authorSub / internal keys. */
+/**
+ * Public-facing shape of a shared assignment.
+ * @param item - the DynamoDB shared item
+ * @param opts - includePasscode echoes the 合言葉 (author-only; never in the
+ * public catalog). visibility defaults to 'public' for pre-#1109 items.
+ * @returns the summary object sent to clients
+ */
+function mapSharedSummary(item: Record, opts: { includePasscode?: boolean } = {}) {
+ return {
+ sharedId: item.sharedId,
+ title: item.title,
+ summary: item.summary || null,
+ schoolLevel: item.schoolLevel,
+ grades: item.grades || [],
+ subject: item.subject,
+ tags: item.tags || [],
+ lessonCount: item.lessonCount || null,
+ supplementUrl: item.supplementUrl || null,
+ authorName: item.authorName,
+ authorAffiliation: item.authorAffiliation || null,
+ pageCount: Array.isArray((item.content as AssignmentContent | undefined)?.pages)
+ ? (item.content as AssignmentContent).pages!.length
+ : 0,
+ hasStarter: !!(item.content as AssignmentContent | undefined)?.starterKey,
+ reuseCount: (item.reuseCount as number) || 0,
+ // 公開範囲: 'public' = みんなの課題カタログ / 'limited' = 合言葉限定公開。
+ visibility: (item.visibility as string) || 'public',
+ status: item.status,
+ createdAt: item.createdAt,
+ updatedAt: item.updatedAt,
+ ...(opts.includePasscode && item.passcode ? { passcode: item.passcode } : {}),
+ };
+}
+
+/**
+ * Generate a shared-assignment 合言葉 (passcode) unique across limited items.
+ * Reuses the join-code alphabet/length and the passcode-index GSI for the
+ * uniqueness check (same retry policy as classroom join codes).
+ * @returns a unique passcode, or '' if none was found after retries
+ */
+async function generateUniqueSharedPasscode(): Promise {
+ for (let attempt = 0; attempt < 5; attempt++) {
+ const candidate = generateJoinCode();
+ const existing = await docClient.send(new QueryCommand({
+ TableName: SHARED_ASSIGNMENTS_TABLE,
+ IndexName: 'passcode-index',
+ KeyConditionExpression: 'passcode = :pc',
+ ExpressionAttributeValues: { ':pc': candidate },
+ Limit: 1,
+ }));
+ if (!existing.Items || existing.Items.length === 0) {
+ return candidate;
+ }
+ }
+ return '';
+}
+
+/**
+ * Look up a limited-published shared item by its 合言葉 (passcode).
+ * @param passcode - the 合言葉
+ * @returns the shared item, or null when no limited item matches
+ */
+async function getSharedItemByPasscode(passcode: string): Promise | null> {
+ const result = await docClient.send(new QueryCommand({
+ TableName: SHARED_ASSIGNMENTS_TABLE,
+ IndexName: 'passcode-index',
+ KeyConditionExpression: 'passcode = :pc',
+ ExpressionAttributeValues: { ':pc': passcode },
+ Limit: 1,
+ }));
+ const item = (result.Items && result.Items[0]) as Record | undefined;
+ if (!item || item.status !== 'published' || item.visibility !== 'limited') return null;
+ return item;
+}
+
+/**
+ * Durable daily quota shared by the share/report endpoints (D12): an atomic
+ * counter per teacher per UTC day, stored in the Classrooms table's reserved
+ * key space (same pattern as eval-quota; TTL cleans it after two days).
+ */
+async function checkSharedDailyLimit(kind: string, teacherSub: string, limit: number): Promise {
+ const day = new Date().toISOString().slice(0, 10);
+ const result = await docClient.send(new UpdateCommand({
+ TableName: CLASSROOMS_TABLE,
+ Key: { classroomId: `${kind}-quota#${teacherSub}#${day}` },
+ UpdateExpression: 'ADD #c :one SET #ttl = if_not_exists(#ttl, :ttl)',
+ ExpressionAttributeNames: { '#c': 'count', '#ttl': 'ttl' },
+ ExpressionAttributeValues: {
+ ':one': 1,
+ ':ttl': Math.floor(Date.now() / 1000) + 2 * 24 * 60 * 60,
+ },
+ ReturnValues: 'UPDATED_NEW',
+ }));
+ const count = (result.Attributes?.count as number) || 0;
+ if (count > limit) {
+ throw new ValidationError(`Daily limit reached (${limit}/day). Please continue tomorrow.`);
+ }
+}
+
+async function getSharedItem(sharedId: string): Promise | null> {
+ const result = await docClient.send(new GetCommand({
+ TableName: SHARED_ASSIGNMENTS_TABLE,
+ Key: { sharedId },
+ }));
+ return (result.Item as Record) || null;
+}
+
+async function handleShareAssignment(
+ identity: TeacherIdentity, body: Record,
+): Promise {
+ // 公開範囲(#1109): 'public' = みんなの課題カタログ / 'limited' = 合言葉限定公開。
+ const visibility = body.visibility === 'limited' ? 'limited' : 'public';
+
+ const title = body.title;
+ if (typeof title !== 'string' || title.trim().length === 0 || title.trim().length > MAX_SHARED_TITLE_LENGTH) {
+ throw new ValidationError(`title is required (at most ${MAX_SHARED_TITLE_LENGTH} characters)`);
+ }
+ let summary: string | null = null;
+ if (body.summary !== undefined && body.summary !== null && body.summary !== '') {
+ if (typeof body.summary !== 'string' || body.summary.trim().length > MAX_SHARED_SUMMARY_LENGTH) {
+ throw new ValidationError(`summary must be at most ${MAX_SHARED_SUMMARY_LENGTH} characters`);
+ }
+ summary = body.summary.trim();
+ }
+
+ // 全体公開は CC BY 同意・属性・著者名が必須。限定公開(合言葉)は内輪向けで
+ // それらは任意(後で全体公開へ広げるときに必須化する。障壁=完璧さの圧を下げる)。
+ let attributes: Record = {};
+ let profile: Record = {};
+ let supplementUrl: string | null = null;
+ if (visibility === 'public') {
+ if (body.licenseConsent !== true) {
+ throw new ValidationError('licenseConsent (CC BY 4.0) is required');
+ }
+ attributes = validateSharedAttributes(body) as unknown as Record;
+ supplementUrl = validateSupplementUrl(body.supplementUrl);
+ profile = validateAuthorProfile(body) as unknown as Record;
+ } else {
+ supplementUrl = validateSupplementUrl(body.supplementUrl);
+ if (body.authorName !== undefined && body.authorName !== null && body.authorName !== '') {
+ profile = validateAuthorProfile(body) as unknown as Record;
+ }
+ }
+
+ // Source classroom: must be the teacher's own, active, with content.
+ const classroomId = body.classroomId;
+ if (typeof classroomId !== 'string' || !classroomId) {
+ throw new ValidationError('classroomId is required');
+ }
+ const classroomResult = await docClient.send(new GetCommand({
+ TableName: CLASSROOMS_TABLE,
+ Key: { classroomId },
+ }));
+ if (!classroomResult.Item || classroomResult.Item.status !== 'active') {
+ throw new NotFoundError('Classroom not found');
+ }
+ if (!(await canManageViaGroup(classroomResult.Item, identity))) {
+ throw new AuthError('Not authorized to share this classroom');
+ }
+ const assignment = classroomResult.Item.assignment as AssignmentContent | undefined;
+ if (!assignment || ((!assignment.pages || assignment.pages.length === 0) && !assignment.starterKey)) {
+ throw new ValidationError('This assignment has no content (pages or starter project) to share');
+ }
+
+ // Starter size cap (D11) — checked before any copy.
+ if (assignment.starterKey) {
+ const head = await s3Client.send(new HeadObjectCommand({
+ Bucket: SUBMISSIONS_BUCKET,
+ Key: assignment.starterKey,
+ }));
+ if ((head.ContentLength || 0) > SHARED_STARTER_MAX_BYTES) {
+ throw new ValidationError(
+ `Starter project exceeds the ${Math.floor(SHARED_STARTER_MAX_BYTES / (1024 * 1024))}MB limit`,
+ );
+ }
+ }
+
+ await checkSharedDailyLimit('share', identity.sub, SHARE_DAILY_LIMIT);
+
+ const sharedId = crypto.randomUUID();
+ const { pages, starterKey, copies } = buildSharedSnapshot(assignment, sharedId);
+
+ // Copy content into the shared bucket first so the record never
+ // references missing objects (same ordering as duplicate).
+ for (const { from, to } of copies) {
+ await s3Client.send(new CopyObjectCommand({
+ Bucket: SHARED_BUCKET,
+ CopySource: `${SUBMISSIONS_BUCKET}/${encodeURIComponent(from)}`,
+ Key: to,
+ }));
+ }
+
+ const now = new Date().toISOString();
+ // 限定公開は合言葉(参加コード同型)を発行。全体公開は発行しない。
+ let passcode = '';
+ if (visibility === 'limited') {
+ passcode = await generateUniqueSharedPasscode();
+ if (!passcode) {
+ return { statusCode: 500, body: JSON.stringify({ error: 'Failed to generate a unique passcode' }) };
+ }
+ }
+ const item: Record = {
+ sharedId,
+ title: title.trim(),
+ summary,
+ content: { pages, starterKey },
+ supplementUrl,
+ ...attributes,
+ ...profile,
+ authorSub: identity.sub,
+ visibility,
+ ...(passcode ? { passcode } : {}),
+ status: 'published',
+ reuseCount: 0,
+ createdAt: now,
+ updatedAt: now,
+ };
+ await docClient.send(new PutCommand({
+ TableName: SHARED_ASSIGNMENTS_TABLE,
+ Item: item,
+ }));
+
+ return { statusCode: 201, body: JSON.stringify(mapSharedSummary(item, { includePasscode: true })) };
+}
+
+async function handleListSharedAssignments(
+ identity: TeacherIdentity, query: Record,
+): Promise {
+ const mine = query.mine === '1' || query.mine === 'true';
+
+ // Optional attribute filters (D8): applied server-side as a
+ // FilterExpression on top of the newest-first GSI query.
+ const filterParts: string[] = [];
+ const names: Record = {};
+ const values: Record = {};
+ if (query.schoolLevel) {
+ filterParts.push('#sl = :sl');
+ names['#sl'] = 'schoolLevel';
+ values[':sl'] = query.schoolLevel;
+ }
+ if (query.subject) {
+ filterParts.push('#sub = :sub');
+ names['#sub'] = 'subject';
+ values[':sub'] = query.subject;
+ }
+ if (query.grade) {
+ const grade = parseInt(query.grade, 10);
+ if (!Number.isNaN(grade)) {
+ filterParts.push('contains(#gr, :gr)');
+ names['#gr'] = 'grades';
+ values[':gr'] = grade;
+ }
+ }
+ if (query.tag) {
+ filterParts.push('contains(#tg, :tg)');
+ names['#tg'] = 'tags';
+ values[':tg'] = query.tag;
+ }
+ // 公開カタログ(mine 以外)は限定公開(合言葉)を除外する。#1109 より前の
+ // 項目は visibility 属性を持たないので「公開」とみなす。mine は両方見せる。
+ if (!mine) {
+ filterParts.push('(attribute_not_exists(#vis) OR #vis = :pub)');
+ names['#vis'] = 'visibility';
+ values[':pub'] = 'public';
+ }
+
+ let exclusiveStartKey: Record | undefined;
+ if (query.cursor) {
+ try {
+ exclusiveStartKey = JSON.parse(Buffer.from(query.cursor, 'base64url').toString('utf8'));
+ } catch {
+ throw new ValidationError('Invalid cursor');
+ }
+ }
+
+ // DynamoDB rejects an EMPTY ExpressionAttributeNames map, so the key must
+ // be omitted when there is nothing to alias (mine=1 with no filters).
+ const expressionNames = {
+ ...(mine ? {} : { '#status': 'status' }),
+ ...names,
+ };
+ const result = await docClient.send(new QueryCommand({
+ TableName: SHARED_ASSIGNMENTS_TABLE,
+ IndexName: mine ? 'authorSub-createdAt-index' : 'status-createdAt-index',
+ KeyConditionExpression: mine ? 'authorSub = :pk' : '#status = :pk',
+ ...(Object.keys(expressionNames).length > 0 ? { ExpressionAttributeNames: expressionNames } : {}),
+ ExpressionAttributeValues: {
+ ':pk': mine ? identity.sub : 'published',
+ ...values,
+ },
+ FilterExpression: filterParts.length > 0 ? filterParts.join(' AND ') : undefined,
+ ScanIndexForward: false,
+ Limit: SHARED_LIST_PAGE_SIZE,
+ ExclusiveStartKey: exclusiveStartKey,
+ }));
+
+ const cursor = result.LastEvaluatedKey
+ ? Buffer.from(JSON.stringify(result.LastEvaluatedKey)).toString('base64url')
+ : null;
+ return {
+ statusCode: 200,
+ body: JSON.stringify({
+ // mine の一覧は自分の項目なので合言葉を含める(限定公開の共有に使う)。
+ items: (result.Items || []).map((it) => mapSharedSummary(it, { includePasscode: mine })),
+ cursor,
+ }),
+ };
+}
+
+async function handleGetSharedAssignment(
+ identity: TeacherIdentity, sharedId: string,
+): Promise {
+ const item = await getSharedItem(sharedId);
+ // Existence-hiding 404. The author may view their own unlisted item.
+ // 限定公開(合言葉)は sharedId を知っていても著者以外には見せない
+ // (非著者は合言葉ルックアップ経由でのみ取り込む)。
+ const isMine = !!item && item.authorSub === identity.sub;
+ if (!item || (!isMine && (item.status !== 'published' || item.visibility === 'limited'))) {
+ throw new NotFoundError('Shared assignment not found');
+ }
+
+ const content = (item.content as AssignmentContent | undefined) || {};
+ const pages = await Promise.all((content.pages || []).map(async page => ({
+ text: page.text,
+ imageUrl: page.imageKey
+ ? await getSignedUrl(
+ s3Client,
+ new GetObjectCommand({ Bucket: SHARED_BUCKET, Key: page.imageKey }),
+ { expiresIn: PRESIGNED_URL_DOWNLOAD_EXPIRY },
+ )
+ : null,
+ })));
+ const starterUrl = content.starterKey
+ ? await getSignedUrl(
+ s3Client,
+ new GetObjectCommand({ Bucket: SHARED_BUCKET, Key: content.starterKey }),
+ { expiresIn: PRESIGNED_URL_DOWNLOAD_EXPIRY },
+ )
+ : null;
+
+ return {
+ statusCode: 200,
+ body: JSON.stringify({
+ ...mapSharedSummary(item, { includePasscode: isMine }),
+ pages,
+ starterUrl,
+ isMine,
+ }),
+ };
+}
+
+async function handleImportSharedAssignment(
+ identity: TeacherIdentity, sharedId: string, body: Record,
+): Promise {
+ const item = await getSharedItem(sharedId);
+ // 全体公開のみ sharedId で取り込める。限定公開(合言葉)は
+ // handleImportByPasscode 経由でのみ取り込む(sharedId は非著者に露出しない)。
+ if (!item || item.status !== 'published' || item.visibility === 'limited') {
+ throw new NotFoundError('Shared assignment not found');
+ }
+ return importSharedItem(identity, item, body);
+}
+
+/**
+ * Import a resolved shared item into one of the caller's classes as a new
+ * assignment. Copies content into the classroom bucket, mints a fresh join
+ * code, and bumps reuseCount. Shared by sharedId-import and passcode-import.
+ * @param identity - the importing teacher
+ * @param item - the resolved shared assignment item
+ * @param body - request body (groupId required, assignmentName optional)
+ * @returns the created classroom summary
+ */
+async function importSharedItem(
+ identity: TeacherIdentity, item: Record, body: Record,
+): Promise {
+ const sharedId = item.sharedId as string;
+ const groupId = body.groupId;
+ if (typeof groupId !== 'string' || !groupId) {
+ throw new ValidationError('groupId is required');
+ }
+ const group = await getOwnedGroup(identity, groupId);
+
+ const assignmentName = body.assignmentName !== undefined
+ ? validateClassName(body.assignmentName)
+ : (item.title as string);
+
+ // Fresh unique join code (same retry policy as creation/duplication).
+ let joinCode = '';
+ for (let attempt = 0; attempt < 5; attempt++) {
+ const candidate = generateJoinCode();
+ const existing = await docClient.send(new QueryCommand({
+ TableName: CLASSROOMS_TABLE,
+ IndexName: 'joinCode-index',
+ KeyConditionExpression: 'joinCode = :jc',
+ ExpressionAttributeValues: { ':jc': candidate },
+ Limit: 1,
+ }));
+ if (!existing.Items || existing.Items.length === 0) {
+ joinCode = candidate;
+ break;
+ }
+ }
+ if (!joinCode) {
+ return { statusCode: 500, body: JSON.stringify({ error: 'Failed to generate unique join code' }) };
+ }
+
+ const newClassroomId = crypto.randomUUID();
+ const content = (item.content as AssignmentContent | undefined) || {};
+ const { assignment, copies } = buildDuplicatedAssignment(content, `shared/${sharedId}`, newClassroomId);
+
+ // Copy shared objects into the classroom bucket first (never reference
+ // missing objects). Source is the shared bucket.
+ for (const { from, to } of copies) {
+ await s3Client.send(new CopyObjectCommand({
+ Bucket: SUBMISSIONS_BUCKET,
+ CopySource: `${SHARED_BUCKET}/${encodeURIComponent(from)}`,
+ Key: to,
+ }));
+ }
+
+ const now = new Date().toISOString();
+ const ttl = Math.floor(Date.now() / 1000) + CLASSROOM_TTL_SECONDS;
+ const studentCount = typeof group.studentCount === 'number' ? group.studentCount : 40;
+ await docClient.send(new PutCommand({
+ TableName: CLASSROOMS_TABLE,
+ Item: {
+ classroomId: newClassroomId,
+ teacherSub: identity.sub,
+ className: group.name,
+ assignmentName,
+ joinCode,
+ studentCount,
+ groupId,
+ sortDate: now,
+ assignment,
+ status: 'active',
+ createdAt: now,
+ updatedAt: now,
+ ttl,
+ },
+ }));
+
+ // Popularity signal (D8 future sort) — best effort, never blocks import.
+ try {
+ await docClient.send(new UpdateCommand({
+ TableName: SHARED_ASSIGNMENTS_TABLE,
+ Key: { sharedId },
+ UpdateExpression: 'ADD reuseCount :one',
+ ExpressionAttributeValues: { ':one': 1 },
+ }));
+ } catch (err) {
+ console.error('Failed to bump reuseCount:', err);
+ }
+
+ return {
+ statusCode: 201,
+ body: JSON.stringify({
+ classroomId: newClassroomId,
+ className: group.name,
+ assignmentName,
+ joinCode,
+ studentCount,
+ groupId,
+ sortDate: now,
+ hasAssignment: !!assignment,
+ status: 'active',
+ createdAt: now,
+ expiresAt: new Date(ttl * 1000).toISOString(),
+ }),
+ };
+}
+
+/**
+ * Look up a limited-published shared assignment by 合言葉 for a preview/confirm
+ * step. Does not expose the sharedId (import re-supplies the passcode), so the
+ * passcode remains the only access token to a limited item.
+ * @param identity - the requesting teacher
+ * @param body - request body ({ passcode })
+ * @returns the shared summary (no sharedId, no passcode echoed)
+ */
+async function handleLookupSharedByPasscode(
+ identity: TeacherIdentity, body: Record,
+): Promise {
+ const passcode = validateJoinCode(body.passcode);
+ const item = await getSharedItemByPasscode(passcode);
+ if (!item) {
+ throw new NotFoundError('Shared assignment not found');
+ }
+ const { sharedId: _omit, ...summary } = mapSharedSummary(item);
+ return { statusCode: 200, body: JSON.stringify(summary) };
+}
+
+/**
+ * Import a limited-published shared assignment by 合言葉 (内輪取り込み).
+ * The passcode is the authorization; the sharedId is never surfaced.
+ * @param identity - the importing teacher
+ * @param body - request body ({ passcode, groupId, assignmentName? })
+ * @returns the created classroom summary
+ */
+async function handleImportSharedByPasscode(
+ identity: TeacherIdentity, body: Record,
+): Promise {
+ const passcode = validateJoinCode(body.passcode);
+ const item = await getSharedItemByPasscode(passcode);
+ if (!item) {
+ throw new NotFoundError('Shared assignment not found');
+ }
+ return importSharedItem(identity, item, body);
+}
+
+async function handleUpdateSharedAssignment(
+ identity: TeacherIdentity, sharedId: string, body: Record,
+): Promise {
+ const item = await getSharedItem(sharedId);
+ if (!item || item.authorSub !== identity.sub) {
+ throw new NotFoundError('Shared assignment not found');
+ }
+
+ const updates: Record = { updatedAt: new Date().toISOString() };
+ if (body.title !== undefined) {
+ if (typeof body.title !== 'string' || body.title.trim().length === 0 ||
+ body.title.trim().length > MAX_SHARED_TITLE_LENGTH) {
+ throw new ValidationError(`title must be 1-${MAX_SHARED_TITLE_LENGTH} characters`);
+ }
+ updates.title = body.title.trim();
+ }
+ if (body.summary !== undefined) {
+ if (body.summary === null || body.summary === '') {
+ updates.summary = null;
+ } else if (typeof body.summary === 'string' && body.summary.trim().length <= MAX_SHARED_SUMMARY_LENGTH) {
+ updates.summary = body.summary.trim();
+ } else {
+ throw new ValidationError(`summary must be at most ${MAX_SHARED_SUMMARY_LENGTH} characters`);
+ }
+ }
+ if (body.schoolLevel !== undefined || body.subject !== undefined ||
+ body.grades !== undefined || body.tags !== undefined || body.lessonCount !== undefined) {
+ // Attributes are validated as a set (subject depends on schoolLevel).
+ const merged = {
+ schoolLevel: body.schoolLevel !== undefined ? body.schoolLevel : item.schoolLevel,
+ subject: body.subject !== undefined ? body.subject : item.subject,
+ grades: body.grades !== undefined ? body.grades : item.grades,
+ tags: body.tags !== undefined ? body.tags : item.tags,
+ lessonCount: body.lessonCount !== undefined ? body.lessonCount : item.lessonCount,
+ };
+ Object.assign(updates, validateSharedAttributes(merged as Record));
+ }
+ if (body.supplementUrl !== undefined) {
+ updates.supplementUrl = validateSupplementUrl(body.supplementUrl);
+ }
+ if (body.authorName !== undefined || body.authorAffiliation !== undefined) {
+ const profile = validateAuthorProfile({
+ authorName: body.authorName !== undefined ? body.authorName : item.authorName,
+ authorAffiliation: body.authorAffiliation !== undefined ? body.authorAffiliation : item.authorAffiliation,
+ });
+ Object.assign(updates, profile);
+ }
+ if (body.status !== undefined) {
+ if (body.status !== 'published' && body.status !== 'unlisted') {
+ throw new ValidationError('Status must be "published" or "unlisted"');
+ }
+ updates.status = body.status;
+ }
+
+ // 公開範囲の変更(#1109)。限定公開 → 全体公開に広げるときは、全体公開に
+ // 必要な属性・著者名・CC BY 同意(body か既存値)が揃っていることを要求する。
+ if (body.visibility !== undefined) {
+ if (body.visibility !== 'public' && body.visibility !== 'limited') {
+ throw new ValidationError('visibility must be "public" or "limited"');
+ }
+ const currentVisibility = (item.visibility as string) || 'public';
+ if (body.visibility === 'public' && currentVisibility !== 'public') {
+ if (body.licenseConsent !== true) {
+ throw new ValidationError('licenseConsent (CC BY 4.0) is required to make it public');
+ }
+ Object.assign(updates, validateSharedAttributes({
+ schoolLevel: body.schoolLevel ?? item.schoolLevel,
+ subject: body.subject ?? item.subject,
+ grades: body.grades ?? item.grades,
+ tags: body.tags ?? item.tags,
+ lessonCount: body.lessonCount ?? item.lessonCount,
+ } as Record));
+ Object.assign(updates, validateAuthorProfile({
+ authorName: body.authorName ?? item.authorName,
+ authorAffiliation: body.authorAffiliation ?? item.authorAffiliation,
+ }));
+ updates.visibility = 'public';
+ } else if (body.visibility === 'limited' && currentVisibility !== 'limited') {
+ updates.visibility = 'limited';
+ if (!item.passcode) {
+ const pc = await generateUniqueSharedPasscode();
+ if (!pc) {
+ return { statusCode: 500, body: JSON.stringify({ error: 'Failed to generate a unique passcode' }) };
+ }
+ updates.passcode = pc;
+ }
+ }
+ }
+
+ // Optional content re-snapshot (D10, overwrite semantics): pull the
+ // current pages/starter from one of the teacher's own classrooms.
+ if (body.classroomId !== undefined) {
+ if (typeof body.classroomId !== 'string' || !body.classroomId) {
+ throw new ValidationError('classroomId must be a string');
+ }
+ const classroomResult = await docClient.send(new GetCommand({
+ TableName: CLASSROOMS_TABLE,
+ Key: { classroomId: body.classroomId },
+ }));
+ if (!classroomResult.Item || classroomResult.Item.status !== 'active') {
+ throw new NotFoundError('Classroom not found');
+ }
+ if (!(await canManageViaGroup(classroomResult.Item, identity))) {
+ throw new AuthError('Not authorized to share this classroom');
+ }
+ const assignment = classroomResult.Item.assignment as AssignmentContent | undefined;
+ if (!assignment || ((!assignment.pages || assignment.pages.length === 0) && !assignment.starterKey)) {
+ throw new ValidationError('This assignment has no content (pages or starter project) to share');
+ }
+ if (assignment.starterKey) {
+ const head = await s3Client.send(new HeadObjectCommand({
+ Bucket: SUBMISSIONS_BUCKET,
+ Key: assignment.starterKey,
+ }));
+ if ((head.ContentLength || 0) > SHARED_STARTER_MAX_BYTES) {
+ throw new ValidationError(
+ `Starter project exceeds the ${Math.floor(SHARED_STARTER_MAX_BYTES / (1024 * 1024))}MB limit`,
+ );
+ }
+ }
+ const { pages, starterKey, copies } = buildSharedSnapshot(assignment, sharedId);
+ for (const { from, to } of copies) {
+ await s3Client.send(new CopyObjectCommand({
+ Bucket: SHARED_BUCKET,
+ CopySource: `${SUBMISSIONS_BUCKET}/${encodeURIComponent(from)}`,
+ Key: to,
+ }));
+ }
+ // Best-effort cleanup of orphaned old objects (keys are content-unique).
+ const oldContent = (item.content as AssignmentContent | undefined) || {};
+ const newKeys = new Set([...pages.map(p => p.imageKey), starterKey].filter(Boolean));
+ const oldKeys = [
+ ...(oldContent.pages || []).map(p => p.imageKey),
+ oldContent.starterKey,
+ ].filter((key): key is string => !!key && !newKeys.has(key));
+ await Promise.allSettled(oldKeys.map(key =>
+ s3Client.send(new DeleteObjectCommand({ Bucket: SHARED_BUCKET, Key: key })),
+ ));
+ updates.content = { pages, starterKey };
+ }
+
+ const expressionParts: string[] = [];
+ const expressionValues: Record = {};
+ const expressionNames: Record = {};
+ let i = 0;
+ for (const [key, value] of Object.entries(updates)) {
+ expressionNames[`#attr${i}`] = key;
+ expressionValues[`:val${i}`] = value;
+ expressionParts.push(`#attr${i} = :val${i}`);
+ i++;
+ }
+ const result = await docClient.send(new UpdateCommand({
+ TableName: SHARED_ASSIGNMENTS_TABLE,
+ Key: { sharedId },
+ UpdateExpression: `SET ${expressionParts.join(', ')}`,
+ ExpressionAttributeNames: expressionNames,
+ ExpressionAttributeValues: expressionValues,
+ ReturnValues: 'ALL_NEW',
+ }));
+
+ return {
+ statusCode: 200,
+ body: JSON.stringify(mapSharedSummary(result.Attributes || {}, { includePasscode: true })),
+ };
+}
+
+async function handleUnlistSharedAssignment(
+ identity: TeacherIdentity, sharedId: string,
+): Promise {
+ const item = await getSharedItem(sharedId);
+ if (!item || item.authorSub !== identity.sub) {
+ throw new NotFoundError('Shared assignment not found');
+ }
+ await docClient.send(new UpdateCommand({
+ TableName: SHARED_ASSIGNMENTS_TABLE,
+ Key: { sharedId },
+ UpdateExpression: 'SET #status = :status, updatedAt = :now',
+ ExpressionAttributeNames: { '#status': 'status' },
+ ExpressionAttributeValues: { ':status': 'unlisted', ':now': new Date().toISOString() },
+ }));
+ return { statusCode: 204, body: '' };
+}
+
+async function handleReportSharedAssignment(
+ identity: TeacherIdentity, sharedId: string, body: Record,
+): Promise {
+ const reason = body.reason;
+ if (typeof reason !== 'string' || reason.trim().length === 0 ||
+ reason.trim().length > MAX_SHARED_REPORT_REASON_LENGTH) {
+ throw new ValidationError(`reason is required (at most ${MAX_SHARED_REPORT_REASON_LENGTH} characters)`);
+ }
+ const item = await getSharedItem(sharedId);
+ if (!item || item.status !== 'published') {
+ throw new NotFoundError('Shared assignment not found');
+ }
+
+ await checkSharedDailyLimit('report', identity.sub, REPORT_DAILY_LIMIT);
+
+ await docClient.send(new PutCommand({
+ TableName: SHARED_REPORTS_TABLE,
+ Item: {
+ sharedId,
+ reportId: crypto.randomUUID(),
+ reason: reason.trim(),
+ // Internal only (abuse tracing) — never returned by any endpoint.
+ reporterSub: identity.sub,
+ createdAt: new Date().toISOString(),
+ ttl: Math.floor(Date.now() / 1000) + SHARED_REPORT_TTL_SECONDS,
+ },
+ }));
+
+ return { statusCode: 201, body: JSON.stringify({}) };
+}
+
// --- Main handler ---
export const handler = async (event: APIGatewayProxyEventV2): Promise => {
@@ -3415,6 +4379,57 @@ export const handler = async (event: APIGatewayProxyEventV2): Promise [sharedId] [--apply]');
+ }
+ const args: AdminArgs = { command, sharedId: null, apply: false };
+ for (const token of rest) {
+ if (token === '--apply') {
+ args.apply = true;
+ } else if (!token.startsWith('-') && !args.sharedId) {
+ args.sharedId = token;
+ } else {
+ throw new Error(`Unknown argument: ${token}`);
+ }
+ }
+ if (COMMANDS_NEEDING_ID.includes(command) && !args.sharedId) {
+ throw new Error(`${command} requires a sharedId`);
+ }
+ return args;
+}
+
+export interface ReportRecord {
+ sharedId: string;
+ reason: string;
+ createdAt: string;
+}
+
+/**
+ * Group report rows by sharedId, newest first inside each group.
+ * @param reports - raw report items
+ * @returns sharedId → reports (sorted), insertion ordered by report count desc
+ */
+export function groupReports(reports: ReportRecord[]): Map {
+ const byId = new Map();
+ for (const report of reports) {
+ const list = byId.get(report.sharedId) || [];
+ list.push(report);
+ byId.set(report.sharedId, list);
+ }
+ for (const list of byId.values()) {
+ list.sort((a, b) => (b.createdAt || '').localeCompare(a.createdAt || ''));
+ }
+ return new Map([...byId.entries()].sort((a, b) => b[1].length - a[1].length));
+}
+
+/**
+ * Render the report queue: one block per reported item with its title,
+ * status and the reasons (reporterSub is never shown).
+ * @param grouped - output of groupReports
+ * @param itemsById - shared items looked up for the reported ids
+ * @returns printable lines
+ */
+export function renderReportQueue(
+ grouped: Map,
+ itemsById: Map>,
+): string[] {
+ if (grouped.size === 0) return ['通報はありません。'];
+ const lines: string[] = [];
+ for (const [sharedId, reports] of grouped.entries()) {
+ const item = itemsById.get(sharedId);
+ const title = item ? String(item.title) : '(削除済み/不明)';
+ const status = item ? String(item.status) : '-';
+ lines.push(`■ ${title} (${sharedId}) status=${status} 通報 ${reports.length} 件`);
+ for (const report of reports) {
+ lines.push(` - [${report.createdAt}] ${report.reason}`);
+ }
+ }
+ return lines;
+}
+
+/**
+ * Render one shared item for the `show` command (public fields only).
+ * @param item - the shared assignment item
+ * @returns printable lines
+ */
+export function renderSharedItem(item: Record): string[] {
+ const content = (item.content || {}) as { pages?: { text: string }[]; starterKey?: string };
+ const lines = [
+ `sharedId: ${item.sharedId}`,
+ `title: ${item.title}`,
+ `status: ${item.status}`,
+ `author: ${item.authorName}${item.authorAffiliation ? `(${item.authorAffiliation})` : ''}`,
+ `attributes: ${item.schoolLevel} / ${item.subject} / 学年 ${(item.grades as number[] | undefined)?.join('・') || '-'}`,
+ `tags: ${(item.tags as string[] | undefined)?.join(', ') || '-'}`,
+ `supplement: ${item.supplementUrl || '-'}`,
+ `reuse: ${item.reuseCount || 0} 回`,
+ `created: ${item.createdAt} / updated: ${item.updatedAt}`,
+ `starter: ${content.starterKey ? 'あり' : 'なし'}`,
+ 'pages:',
+ ];
+ for (const [index, page] of (content.pages || []).entries()) {
+ lines.push(` ${index + 1}. ${page.text.slice(0, 120).replace(/\n/g, ' / ')}`);
+ }
+ return lines;
+}
diff --git a/infra/smalruby-classroom/lambda/tests/handler-group-seatcount.test.ts b/infra/smalruby-classroom/lambda/tests/handler-group-seatcount.test.ts
new file mode 100644
index 0000000000..7265213529
--- /dev/null
+++ b/infra/smalruby-classroom/lambda/tests/handler-group-seatcount.test.ts
@@ -0,0 +1,120 @@
+/**
+ * Class (group) seat-count propagation to existing assignments.
+ *
+ * Changing a class's studentCount must flow down to its active assignments
+ * (classrooms) — growing adds seats, shrinking drops them (the teacher UI
+ * warns before shrinking). Regression for "既存の課題の人数が増えない".
+ */
+
+const mockSend = jest.fn();
+jest.mock('@aws-sdk/lib-dynamodb', () => {
+ const actual = jest.requireActual('@aws-sdk/lib-dynamodb');
+ return {
+ ...actual,
+ DynamoDBDocumentClient: { from: () => ({ send: mockSend }) },
+ };
+});
+jest.mock('@aws-sdk/client-s3', () => {
+ const actual = jest.requireActual('@aws-sdk/client-s3');
+ return { ...actual, S3Client: jest.fn(() => ({ send: jest.fn() })) };
+});
+jest.mock('@aws-sdk/s3-request-presigner', () => ({
+ getSignedUrl: jest.fn(async () => 'https://signed.example/get'),
+}));
+
+const DEV_TOKEN = 'test-dev-bypass';
+
+const makeEvent = (method: string, path: string, pathParameters: Record, body: unknown) => ({
+ requestContext: { http: { method, path, sourceIp: '127.0.0.1' } },
+ headers: { authorization: `Bearer ${DEV_TOKEN}`, origin: 'http://localhost:8601' },
+ pathParameters,
+ body: JSON.stringify(body),
+});
+
+describe('class seat-count propagation (PATCH /classroom-groups/{id})', () => {
+ let handler: (event: unknown) => Promise<{ statusCode?: number }>;
+
+ beforeEach(() => {
+ jest.resetModules();
+ process.env.DEV_BYPASS_TOKEN = DEV_TOKEN;
+ process.env.STAGE = 'stg';
+ process.env.CORS_ALLOWED_ORIGINS = 'http://localhost:8601';
+ mockSend.mockReset();
+ handler = require('../handler').handler;
+ });
+
+ const drive = async (newCount: number, classroomIds: string[]) => {
+ const classroomUpdates: { classroomId: string; studentCount: number }[] = [];
+ mockSend.mockImplementation(async (command: {
+ constructor: { name: string }; input?: Record;
+ }) => {
+ const name = command.constructor.name;
+ const table = String(command.input?.TableName || '');
+ if (name === 'GetCommand') {
+ // getOwnedGroup → the owning class
+ return { Item: { groupId: 'g1', teacherSub: 'dev-test-teacher', name: '2年1組', year: 2026, studentCount: 30, schemaVersion: 2 } };
+ }
+ if (name === 'UpdateCommand' && table.includes('Group')) {
+ return { Attributes: { groupId: 'g1', teacherSub: 'dev-test-teacher', name: '2年1組', year: 2026, studentCount: newCount, schemaVersion: 2 } };
+ }
+ if (name === 'QueryCommand') {
+ // propagation enumerates the class's active classrooms
+ return { Items: classroomIds.map(classroomId => ({ classroomId })) };
+ }
+ if (name === 'UpdateCommand') {
+ const values = command.input?.ExpressionAttributeValues as Record;
+ classroomUpdates.push({
+ classroomId: String((command.input?.Key as Record).classroomId),
+ studentCount: values[':sc'] as number,
+ });
+ return {};
+ }
+ return {};
+ });
+
+ const res = await handler(makeEvent('PATCH', '/classroom-groups/g1', { groupId: 'g1' }, { studentCount: newCount }));
+ return { res, classroomUpdates };
+ };
+
+ test('increasing the class count raises every active assignment (no warning needed server-side)', async () => {
+ const { res, classroomUpdates } = await drive(35, ['c1', 'c2']);
+ expect(res.statusCode).toBe(200);
+ expect(classroomUpdates).toEqual([
+ { classroomId: 'c1', studentCount: 35 },
+ { classroomId: 'c2', studentCount: 35 },
+ ]);
+ });
+
+ test('decreasing the class count lowers every active assignment too', async () => {
+ const { res, classroomUpdates } = await drive(20, ['c1']);
+ expect(res.statusCode).toBe(200);
+ expect(classroomUpdates).toEqual([{ classroomId: 'c1', studentCount: 20 }]);
+ });
+
+ test('the propagation query filters to the class + active status', async () => {
+ let queryInput: Record | undefined;
+ mockSend.mockImplementation(async (command: {
+ constructor: { name: string }; input?: Record;
+ }) => {
+ const name = command.constructor.name;
+ if (name === 'GetCommand') {
+ return { Item: { groupId: 'g1', teacherSub: 'dev-test-teacher', studentCount: 30, schemaVersion: 2 } };
+ }
+ if (name === 'UpdateCommand' && String(command.input?.TableName).includes('Group')) {
+ return { Attributes: { groupId: 'g1', teacherSub: 'dev-test-teacher', studentCount: 40 } };
+ }
+ if (name === 'QueryCommand') {
+ queryInput = command.input;
+ return { Items: [] };
+ }
+ return {};
+ });
+
+ await handler(makeEvent('PATCH', '/classroom-groups/g1', { groupId: 'g1' }, { studentCount: 40 }));
+ expect(queryInput?.IndexName).toBe('teacherSub-index');
+ expect(queryInput?.FilterExpression).toContain('groupId = :gid');
+ const values = queryInput?.ExpressionAttributeValues as Record;
+ expect(values[':gid']).toBe('g1');
+ expect(values[':active']).toBe('active');
+ });
+});
diff --git a/infra/smalruby-classroom/lambda/tests/handler-shared-assignments.test.ts b/infra/smalruby-classroom/lambda/tests/handler-shared-assignments.test.ts
new file mode 100644
index 0000000000..eb49c84b10
--- /dev/null
+++ b/infra/smalruby-classroom/lambda/tests/handler-shared-assignments.test.ts
@@ -0,0 +1,651 @@
+/**
+ * みんなの課題 (shared assignment library) tests — issue #1068 / EPIC #1066.
+ *
+ * Exercises the full request path (router → auth → handlers) with the
+ * DynamoDB document client and S3 mocked, plus the pure validators.
+ * Design canon: spike #1067 (D1-D12).
+ */
+
+const mockSend = jest.fn();
+jest.mock('@aws-sdk/lib-dynamodb', () => {
+ const actual = jest.requireActual('@aws-sdk/lib-dynamodb');
+ return {
+ ...actual,
+ DynamoDBDocumentClient: { from: () => ({ send: mockSend }) },
+ };
+});
+
+const mockS3Send = jest.fn();
+jest.mock('@aws-sdk/client-s3', () => {
+ const actual = jest.requireActual('@aws-sdk/client-s3');
+ return {
+ ...actual,
+ S3Client: jest.fn(() => ({ send: mockS3Send })),
+ };
+});
+
+jest.mock('@aws-sdk/s3-request-presigner', () => ({
+ getSignedUrl: jest.fn(async () => 'https://signed.example/get'),
+}));
+
+const DEV_TOKEN = 'test-dev-bypass';
+
+interface MakeEventOptions {
+ body?: unknown;
+ token?: string;
+ query?: Record;
+}
+
+const makeEvent = (
+ method: string,
+ path: string,
+ pathParameters: Record,
+ { body, token, query }: MakeEventOptions = {},
+) => ({
+ requestContext: { http: { method, path, sourceIp: '127.0.0.1' } },
+ headers: {
+ ...(token ? { authorization: `Bearer ${token}` } : {}),
+ origin: 'http://localhost:8601',
+ },
+ pathParameters,
+ queryStringParameters: query,
+ body: body === undefined ? undefined : JSON.stringify(body),
+});
+
+const ownClassroom = {
+ classroomId: 'c1',
+ status: 'active',
+ teacherSub: 'dev-test-teacher',
+ className: '技術',
+ assignmentName: 'ねこあつめ',
+ studentCount: 30,
+ assignment: {
+ pages: [
+ { text: 'ページ1', imageKey: 'c1/assignment/image-abc.png' },
+ { text: 'ページ2' },
+ ],
+ starterKey: 'c1/assignment/starter-xyz.sb3',
+ },
+};
+
+const shareBody = {
+ classroomId: 'c1',
+ title: 'ねこあつめ入門',
+ summary: 'はじめてのゲームづくり',
+ schoolLevel: 'junior-high',
+ grades: [1, 2],
+ subject: '技術・家庭(技術分野)',
+ tags: ['甲子園', '入門'],
+ lessonCount: 3,
+ supplementUrl: 'https://docs.google.com/document/d/abc/view',
+ authorName: 'すもう るびお',
+ authorAffiliation: '島根県 公立中学校',
+ licenseConsent: true,
+};
+
+const publishedItem = {
+ sharedId: 's1',
+ title: 'ねこあつめ入門',
+ summary: 'はじめてのゲームづくり',
+ content: {
+ pages: [{ text: 'ページ1', imageKey: 'shared/s1/image-abc.png' }, { text: 'ページ2' }],
+ starterKey: 'shared/s1/starter-xyz.sb3',
+ },
+ supplementUrl: 'https://docs.google.com/document/d/abc/view',
+ schoolLevel: 'junior-high',
+ grades: [1, 2],
+ subject: '技術・家庭(技術分野)',
+ tags: ['甲子園'],
+ lessonCount: 3,
+ authorName: 'すもう るびお',
+ authorAffiliation: '島根県 公立中学校',
+ authorSub: 'someone-else',
+ status: 'published',
+ reuseCount: 4,
+ createdAt: '2026-07-18T00:00:00.000Z',
+ updatedAt: '2026-07-18T00:00:00.000Z',
+};
+
+// 合言葉限定公開の項目(#1109)。他人の限定公開を合言葉で内輪取り込みする想定。
+const limitedItem = {
+ ...publishedItem,
+ visibility: 'limited',
+ passcode: 'abc234',
+ status: 'published',
+};
+
+describe('みんなの課題 (issue #1068)', () => {
+ let handler: (event: unknown) => Promise<{ statusCode?: number; body?: string }>;
+ let validateSharedAttributes: (body: Record) => Record;
+ let validateSupplementUrl: (value: unknown) => string | null;
+ let buildSharedSnapshot: (
+ assignment: Record | undefined, sharedId: string,
+ ) => { pages: unknown[]; starterKey?: string; copies: { from: string; to: string }[] };
+
+ beforeEach(() => {
+ jest.resetModules();
+ process.env.DEV_BYPASS_TOKEN = DEV_TOKEN;
+ process.env.STAGE = 'stg';
+ mockSend.mockReset();
+ mockS3Send.mockReset();
+ mockS3Send.mockResolvedValue({ ContentLength: 1024 });
+ const mod = require('../handler');
+ handler = mod.handler;
+ validateSharedAttributes = mod.validateSharedAttributes;
+ validateSupplementUrl = mod.validateSupplementUrl;
+ buildSharedSnapshot = mod.buildSharedSnapshot;
+ });
+
+ const commandNames = () => mockSend.mock.calls.map((c) => c[0]?.constructor?.name);
+
+ describe('validators (pure)', () => {
+ test('validateSharedAttributes accepts the controlled vocabulary', () => {
+ expect(validateSharedAttributes({
+ schoolLevel: 'high',
+ subject: '情報Ⅰ',
+ grades: [2, 1],
+ tags: [' AI '],
+ lessonCount: 5,
+ })).toEqual({
+ schoolLevel: 'high',
+ subject: '情報Ⅰ',
+ grades: [1, 2],
+ tags: ['AI'],
+ lessonCount: 5,
+ });
+ });
+
+ test('validateSharedAttributes rejects out-of-vocabulary and out-of-range values', () => {
+ expect(() => validateSharedAttributes({ schoolLevel: 'university', subject: 'x' })).toThrow('schoolLevel');
+ expect(() => validateSharedAttributes({ schoolLevel: 'high', subject: '体育' })).toThrow('subject');
+ expect(() => validateSharedAttributes({ schoolLevel: 'high', subject: '情報Ⅰ', grades: [4] })).toThrow('grades');
+ expect(() => validateSharedAttributes({
+ schoolLevel: 'high', subject: '情報Ⅰ', tags: ['a', 'b', 'c', 'd', 'e', 'f'],
+ })).toThrow('tags');
+ expect(() => validateSharedAttributes({
+ schoolLevel: 'high', subject: '情報Ⅰ', lessonCount: 0,
+ })).toThrow('lessonCount');
+ });
+
+ test('validateSharedAttributes lets the "other" school level use free-text subjects', () => {
+ expect(validateSharedAttributes({ schoolLevel: 'other', subject: '高専 情報工学' }).subject)
+ .toBe('高専 情報工学');
+ });
+
+ test('validateSupplementUrl enforces https and length', () => {
+ expect(validateSupplementUrl('https://example.com/plan')).toBe('https://example.com/plan');
+ expect(validateSupplementUrl(undefined)).toBeNull();
+ expect(validateSupplementUrl('')).toBeNull();
+ expect(() => validateSupplementUrl('http://example.com')).toThrow('https');
+ expect(() => validateSupplementUrl('not a url')).toThrow('valid URL');
+ expect(() => validateSupplementUrl(`https://example.com/${'a'.repeat(500)}`)).toThrow('500');
+ });
+
+ test('buildSharedSnapshot rewrites keys into the shared prefix', () => {
+ const { pages, starterKey, copies } = buildSharedSnapshot(ownClassroom.assignment, 's1');
+ expect(pages).toEqual([
+ { text: 'ページ1', imageKey: 'shared/s1/image-abc.png' },
+ { text: 'ページ2' },
+ ]);
+ expect(starterKey).toBe('shared/s1/starter-xyz.sb3');
+ expect(copies).toEqual([
+ { from: 'c1/assignment/image-abc.png', to: 'shared/s1/image-abc.png' },
+ { from: 'c1/assignment/starter-xyz.sb3', to: 'shared/s1/starter-xyz.sb3' },
+ ]);
+ });
+ });
+
+ describe('POST /shared-assignments (share)', () => {
+ const shareMocks = (classroom: Record | null = ownClassroom) => {
+ mockSend.mockImplementation(async (command: { constructor: { name: string } }) => {
+ const name = command.constructor.name;
+ if (name === 'GetCommand') return { Item: classroom };
+ if (name === 'UpdateCommand') return { Attributes: { count: 1 } };
+ return {};
+ });
+ };
+
+ test('publishes a snapshot: S3 copies + Put, response has no authorSub', async () => {
+ shareMocks();
+ const res = await handler(makeEvent('POST', '/shared-assignments', {}, {
+ token: DEV_TOKEN, body: shareBody,
+ }));
+
+ expect(res.statusCode).toBe(201);
+ const published = JSON.parse(res.body as string);
+ expect(published.title).toBe('ねこあつめ入門');
+ expect(published.hasStarter).toBe(true);
+ expect(published.pageCount).toBe(2);
+ expect(published.authorSub).toBeUndefined();
+
+ // 1 HeadObject (size cap) + 2 CopyObject (image + starter).
+ const s3Names = mockS3Send.mock.calls.map((c) => c[0]?.constructor?.name);
+ expect(s3Names.filter((n) => n === 'CopyObjectCommand')).toHaveLength(2);
+ expect(s3Names).toContain('HeadObjectCommand');
+ expect(commandNames()).toContain('PutCommand');
+ });
+
+ test('rejects a share without license consent, before touching anything', async () => {
+ shareMocks();
+ const res = await handler(makeEvent('POST', '/shared-assignments', {}, {
+ token: DEV_TOKEN, body: { ...shareBody, licenseConsent: false },
+ }));
+ expect(res.statusCode).toBe(400);
+ expect(mockSend).not.toHaveBeenCalled();
+ });
+
+ test('rejects an http supplement URL', async () => {
+ shareMocks();
+ const res = await handler(makeEvent('POST', '/shared-assignments', {}, {
+ token: DEV_TOKEN, body: { ...shareBody, supplementUrl: 'http://example.com' },
+ }));
+ expect(res.statusCode).toBe(400);
+ });
+
+ test('rejects sharing someone else\'s classroom (401)', async () => {
+ shareMocks({ ...ownClassroom, teacherSub: 'owner-A' });
+ const res = await handler(makeEvent('POST', '/shared-assignments', {}, {
+ token: DEV_TOKEN, body: shareBody,
+ }));
+ expect(res.statusCode).toBe(401);
+ expect(commandNames()).not.toContain('PutCommand');
+ });
+
+ test('rejects a classroom without content (400)', async () => {
+ shareMocks({ ...ownClassroom, assignment: { pages: [] } });
+ const res = await handler(makeEvent('POST', '/shared-assignments', {}, {
+ token: DEV_TOKEN, body: shareBody,
+ }));
+ expect(res.statusCode).toBe(400);
+ });
+
+ test('rejects an oversized starter project (D11)', async () => {
+ shareMocks();
+ mockS3Send.mockResolvedValue({ ContentLength: 51 * 1024 * 1024 });
+ const res = await handler(makeEvent('POST', '/shared-assignments', {}, {
+ token: DEV_TOKEN, body: shareBody,
+ }));
+ expect(res.statusCode).toBe(400);
+ expect(JSON.parse(res.body as string).error).toContain('50MB');
+ });
+
+ test('enforces the daily share quota (D12)', async () => {
+ mockSend.mockImplementation(async (command: { constructor: { name: string } }) => {
+ const name = command.constructor.name;
+ if (name === 'GetCommand') return { Item: ownClassroom };
+ if (name === 'UpdateCommand') return { Attributes: { count: 11 } };
+ return {};
+ });
+ const res = await handler(makeEvent('POST', '/shared-assignments', {}, {
+ token: DEV_TOKEN, body: shareBody,
+ }));
+ expect(res.statusCode).toBe(400);
+ expect(JSON.parse(res.body as string).error).toContain('Daily limit');
+ expect(commandNames()).not.toContain('PutCommand');
+ });
+ });
+
+ describe('GET /shared-assignments (catalog)', () => {
+ test('lists published items newest-first without exposing authorSub', async () => {
+ mockSend.mockImplementation(async (command: {
+ constructor: { name: string }; input?: Record;
+ }) => {
+ expect(command.input?.IndexName).toBe('status-createdAt-index');
+ expect(command.input?.ScanIndexForward).toBe(false);
+ return { Items: [publishedItem] };
+ });
+
+ const res = await handler(makeEvent('GET', '/shared-assignments', {}, { token: DEV_TOKEN }));
+ expect(res.statusCode).toBe(200);
+ const { items, cursor } = JSON.parse(res.body as string);
+ expect(items).toHaveLength(1);
+ expect(items[0].authorName).toBe('すもう るびお');
+ expect(items[0].authorSub).toBeUndefined();
+ expect(items[0].hasStarter).toBe(true);
+ expect(cursor).toBeNull();
+ });
+
+ test('builds a FilterExpression from the attribute filters', async () => {
+ let captured: Record | undefined;
+ mockSend.mockImplementation(async (command: { input?: Record }) => {
+ captured = command.input;
+ return { Items: [] };
+ });
+
+ await handler(makeEvent('GET', '/shared-assignments', {}, {
+ token: DEV_TOKEN,
+ query: { schoolLevel: 'junior-high', subject: '技術・家庭(技術分野)', grade: '2', tag: '甲子園' },
+ }));
+
+ // 公開カタログは限定公開(合言葉)を除外する句が末尾に付く(#1109)。
+ expect(captured?.FilterExpression).toBe(
+ '#sl = :sl AND #sub = :sub AND contains(#gr, :gr) AND contains(#tg, :tg) AND ' +
+ '(attribute_not_exists(#vis) OR #vis = :pub)',
+ );
+ expect((captured?.ExpressionAttributeValues as Record)[':gr']).toBe(2);
+ });
+
+ test('mine=1 lists the caller\'s own items via the author GSI', async () => {
+ let captured: Record | undefined;
+ mockSend.mockImplementation(async (command: { input?: Record }) => {
+ captured = command.input;
+ return { Items: [{ ...publishedItem, authorSub: 'dev-test-teacher', status: 'unlisted' }] };
+ });
+
+ const res = await handler(makeEvent('GET', '/shared-assignments', {}, {
+ token: DEV_TOKEN, query: { mine: '1' },
+ }));
+ expect(captured?.IndexName).toBe('authorSub-createdAt-index');
+ expect((captured?.ExpressionAttributeValues as Record)[':pk']).toBe('dev-test-teacher');
+ // DynamoDB rejects an EMPTY ExpressionAttributeNames map — with no
+ // filters and no #status alias, the key must be omitted entirely
+ // (prod 500 on 自分の投稿, 2026-07-18).
+ expect(captured?.ExpressionAttributeNames).toBeUndefined();
+ expect(JSON.parse(res.body as string).items[0].status).toBe('unlisted');
+ });
+ });
+
+ describe('GET /shared-assignments/{id} (detail)', () => {
+ test('returns pages with presigned URLs for a published item', async () => {
+ mockSend.mockResolvedValue({ Item: publishedItem });
+ const res = await handler(makeEvent('GET', '/shared-assignments/s1', { sharedId: 's1' }, {
+ token: DEV_TOKEN,
+ }));
+ expect(res.statusCode).toBe(200);
+ const detail = JSON.parse(res.body as string);
+ expect(detail.pages).toEqual([
+ { text: 'ページ1', imageUrl: 'https://signed.example/get' },
+ { text: 'ページ2', imageUrl: null },
+ ]);
+ expect(detail.starterUrl).toBe('https://signed.example/get');
+ expect(detail.isMine).toBe(false);
+ expect(detail.authorSub).toBeUndefined();
+ });
+
+ test('hides an unlisted item from strangers (404) but not from its author', async () => {
+ mockSend.mockResolvedValue({ Item: { ...publishedItem, status: 'unlisted' } });
+ const strangers = await handler(makeEvent('GET', '/shared-assignments/s1', { sharedId: 's1' }, {
+ token: DEV_TOKEN,
+ }));
+ expect(strangers.statusCode).toBe(404);
+
+ mockSend.mockResolvedValue({
+ Item: { ...publishedItem, status: 'unlisted', authorSub: 'dev-test-teacher' },
+ });
+ const author = await handler(makeEvent('GET', '/shared-assignments/s1', { sharedId: 's1' }, {
+ token: DEV_TOKEN,
+ }));
+ expect(author.statusCode).toBe(200);
+ expect(JSON.parse(author.body as string).isMine).toBe(true);
+ });
+ });
+
+ describe('POST /shared-assignments/{id}/import', () => {
+ const importMocks = () => {
+ mockSend.mockImplementation(async (command: {
+ constructor: { name: string }; input?: Record;
+ }) => {
+ const name = command.constructor.name;
+ if (name === 'GetCommand' && command.input?.TableName?.toString().includes('SharedAssignments')) {
+ return { Item: publishedItem };
+ }
+ if (name === 'GetCommand') {
+ return { Item: { groupId: 'g1', teacherSub: 'dev-test-teacher', name: '2年1組', studentCount: 32 } };
+ }
+ if (name === 'QueryCommand') return { Items: [] }; // joinCode uniqueness
+ return {};
+ });
+ };
+
+ test('creates a classroom in the caller\'s group and bumps reuseCount', async () => {
+ importMocks();
+ const res = await handler(makeEvent('POST', '/shared-assignments/s1/import', { sharedId: 's1' }, {
+ token: DEV_TOKEN, body: { groupId: 'g1' },
+ }));
+
+ expect(res.statusCode).toBe(201);
+ const created = JSON.parse(res.body as string);
+ expect(created.className).toBe('2年1組');
+ expect(created.assignmentName).toBe('ねこあつめ入門');
+ expect(created.studentCount).toBe(32);
+ expect(created.hasAssignment).toBe(true);
+
+ // Copies flow from the shared bucket into the classroom bucket.
+ const copyCalls = mockS3Send.mock.calls.filter((c) => c[0]?.constructor?.name === 'CopyObjectCommand');
+ expect(copyCalls).toHaveLength(2);
+ expect(copyCalls[0][0].input.CopySource).toContain('shared%2Fs1%2F');
+ expect(commandNames()).toContain('PutCommand');
+ expect(commandNames()).toContain('UpdateCommand'); // reuseCount ADD
+ });
+
+ test('rejects importing into someone else\'s group (404)', async () => {
+ mockSend.mockImplementation(async (command: {
+ constructor: { name: string }; input?: Record;
+ }) => {
+ if (command.constructor.name === 'GetCommand' &&
+ command.input?.TableName?.toString().includes('SharedAssignments')) {
+ return { Item: publishedItem };
+ }
+ return { Item: { groupId: 'g1', teacherSub: 'owner-A' } };
+ });
+ const res = await handler(makeEvent('POST', '/shared-assignments/s1/import', { sharedId: 's1' }, {
+ token: DEV_TOKEN, body: { groupId: 'g1' },
+ }));
+ expect(res.statusCode).toBe(404);
+ expect(commandNames()).not.toContain('PutCommand');
+ });
+
+ test('rejects importing an unlisted item (404)', async () => {
+ mockSend.mockResolvedValue({ Item: { ...publishedItem, status: 'unlisted' } });
+ const res = await handler(makeEvent('POST', '/shared-assignments/s1/import', { sharedId: 's1' }, {
+ token: DEV_TOKEN, body: { groupId: 'g1' },
+ }));
+ expect(res.statusCode).toBe(404);
+ });
+ });
+
+ describe('限定公開・合言葉 (#1109)', () => {
+ test('限定公開は同意なしで発行され、passcode と visibility を返す', async () => {
+ mockSend.mockImplementation(async (command: { constructor: { name: string } }) => {
+ const name = command.constructor.name;
+ if (name === 'GetCommand') return { Item: ownClassroom };
+ if (name === 'UpdateCommand') return { Attributes: { count: 1 } };
+ if (name === 'QueryCommand') return { Items: [] }; // passcode uniqueness
+ return {};
+ });
+ const res = await handler(makeEvent('POST', '/shared-assignments', {}, {
+ token: DEV_TOKEN, body: { classroomId: 'c1', title: 'ためし課題', visibility: 'limited' },
+ }));
+ expect(res.statusCode).toBe(201);
+ const pub = JSON.parse(res.body as string);
+ expect(pub.visibility).toBe('limited');
+ expect(typeof pub.passcode).toBe('string');
+ expect(pub.passcode).toHaveLength(6);
+ const put = mockSend.mock.calls.find((c) => c[0]?.constructor?.name === 'PutCommand');
+ expect((put?.[0].input.Item as Record).visibility).toBe('limited');
+ expect((put?.[0].input.Item as Record).passcode).toBe(pub.passcode);
+ });
+
+ test('公開カタログは限定公開を除外する FilterExpression を持つ', async () => {
+ let captured: Record | undefined;
+ mockSend.mockImplementation(async (command: { input?: Record }) => {
+ captured = command.input;
+ return { Items: [] };
+ });
+ await handler(makeEvent('GET', '/shared-assignments', {}, { token: DEV_TOKEN }));
+ expect(captured?.FilterExpression).toContain('attribute_not_exists(#vis) OR #vis = :pub');
+ expect((captured?.ExpressionAttributeValues as Record)[':pub']).toBe('public');
+ });
+
+ test('mine=1 は自分の限定公開の合言葉を含める', async () => {
+ mockSend.mockImplementation(async () => ({
+ Items: [{ ...limitedItem, authorSub: 'dev-test-teacher' }],
+ }));
+ const res = await handler(makeEvent('GET', '/shared-assignments', {}, {
+ token: DEV_TOKEN, query: { mine: '1' },
+ }));
+ const { items } = JSON.parse(res.body as string);
+ expect(items[0].visibility).toBe('limited');
+ expect(items[0].passcode).toBe('abc234');
+ });
+
+ test('合言葉ルックアップは summary を返し sharedId / passcode は出さない', async () => {
+ mockSend.mockImplementation(async (command: { constructor: { name: string } }) => {
+ if (command.constructor.name === 'QueryCommand') return { Items: [limitedItem] };
+ return {};
+ });
+ const res = await handler(makeEvent('POST', '/shared-assignments/lookup', {}, {
+ token: DEV_TOKEN, body: { passcode: 'abc234' },
+ }));
+ expect(res.statusCode).toBe(200);
+ const s = JSON.parse(res.body as string);
+ expect(s.title).toBe(limitedItem.title);
+ expect(s.sharedId).toBeUndefined();
+ expect(s.passcode).toBeUndefined();
+ });
+
+ test('合言葉取り込みはクラスを作成し reuseCount を増やす', async () => {
+ mockSend.mockImplementation(async (command: {
+ constructor: { name: string }; input?: Record;
+ }) => {
+ const name = command.constructor.name;
+ const idx = command.input?.IndexName;
+ if (name === 'QueryCommand' && idx === 'passcode-index') return { Items: [limitedItem] };
+ if (name === 'QueryCommand' && idx === 'joinCode-index') return { Items: [] };
+ if (name === 'GetCommand') {
+ return { Item: { groupId: 'g1', teacherSub: 'dev-test-teacher', name: '2年1組', studentCount: 32 } };
+ }
+ return {};
+ });
+ const res = await handler(makeEvent('POST', '/shared-assignments/import-by-passcode', {}, {
+ token: DEV_TOKEN, body: { passcode: 'abc234', groupId: 'g1' },
+ }));
+ expect(res.statusCode).toBe(201);
+ const created = JSON.parse(res.body as string);
+ expect(created.className).toBe('2年1組');
+ expect(commandNames()).toContain('PutCommand');
+ expect(commandNames()).toContain('UpdateCommand');
+ });
+
+ test('不明な合言葉は 404', async () => {
+ mockSend.mockImplementation(async (command: { constructor: { name: string } }) => {
+ if (command.constructor.name === 'QueryCommand') return { Items: [] };
+ return {};
+ });
+ const res = await handler(makeEvent('POST', '/shared-assignments/import-by-passcode', {}, {
+ token: DEV_TOKEN, body: { passcode: 'zzz999', groupId: 'g1' },
+ }));
+ expect(res.statusCode).toBe(404);
+ });
+
+ test('限定公開は sharedId を知っていても非著者には 404', async () => {
+ mockSend.mockResolvedValue({ Item: limitedItem }); // authorSub 'someone-else'
+ const res = await handler(makeEvent('GET', '/shared-assignments/s1', { sharedId: 's1' }, {
+ token: DEV_TOKEN,
+ }));
+ expect(res.statusCode).toBe(404);
+ });
+
+ test('限定公開→全体公開は同意が無ければ 400、あれば 200', async () => {
+ const mine = { ...limitedItem, authorSub: 'dev-test-teacher' };
+ mockSend.mockImplementation(async (command: { constructor: { name: string } }) => {
+ const name = command.constructor.name;
+ if (name === 'GetCommand') return { Item: mine };
+ if (name === 'UpdateCommand') return { Attributes: { ...mine, visibility: 'public' } };
+ if (name === 'QueryCommand') return { Items: [] };
+ return {};
+ });
+ const noConsent = await handler(makeEvent('PATCH', '/shared-assignments/s1', { sharedId: 's1' }, {
+ token: DEV_TOKEN, body: { visibility: 'public' },
+ }));
+ expect(noConsent.statusCode).toBe(400);
+
+ const withConsent = await handler(makeEvent('PATCH', '/shared-assignments/s1', { sharedId: 's1' }, {
+ token: DEV_TOKEN, body: { visibility: 'public', licenseConsent: true },
+ }));
+ expect(withConsent.statusCode).toBe(200);
+ });
+ });
+
+ describe('PATCH / DELETE /shared-assignments/{id} (author only)', () => {
+ test('the author updates metadata and can republish', async () => {
+ const mine = { ...publishedItem, authorSub: 'dev-test-teacher', status: 'unlisted' };
+ mockSend.mockImplementation(async (command: { constructor: { name: string } }) => {
+ if (command.constructor.name === 'GetCommand') return { Item: mine };
+ if (command.constructor.name === 'UpdateCommand') {
+ return { Attributes: { ...mine, title: '改訂版', status: 'published' } };
+ }
+ return {};
+ });
+ const res = await handler(makeEvent('PATCH', '/shared-assignments/s1', { sharedId: 's1' }, {
+ token: DEV_TOKEN, body: { title: '改訂版', status: 'published' },
+ }));
+ expect(res.statusCode).toBe(200);
+ const updated = JSON.parse(res.body as string);
+ expect(updated.title).toBe('改訂版');
+ expect(updated.status).toBe('published');
+ expect(updated.authorSub).toBeUndefined();
+ });
+
+ test('a stranger cannot update or unlist (404, no write)', async () => {
+ mockSend.mockResolvedValue({ Item: publishedItem }); // authorSub: someone-else
+ const patch = await handler(makeEvent('PATCH', '/shared-assignments/s1', { sharedId: 's1' }, {
+ token: DEV_TOKEN, body: { title: '乗っ取り' },
+ }));
+ expect(patch.statusCode).toBe(404);
+
+ const del = await handler(makeEvent('DELETE', '/shared-assignments/s1', { sharedId: 's1' }, {
+ token: DEV_TOKEN,
+ }));
+ expect(del.statusCode).toBe(404);
+ expect(commandNames()).not.toContain('UpdateCommand');
+ });
+
+ test('the author unlists their item (204)', async () => {
+ mockSend.mockImplementation(async (command: { constructor: { name: string } }) => {
+ if (command.constructor.name === 'GetCommand') {
+ return { Item: { ...publishedItem, authorSub: 'dev-test-teacher' } };
+ }
+ return {};
+ });
+ const res = await handler(makeEvent('DELETE', '/shared-assignments/s1', { sharedId: 's1' }, {
+ token: DEV_TOKEN,
+ }));
+ expect(res.statusCode).toBe(204);
+ expect(commandNames()).toContain('UpdateCommand');
+ });
+ });
+
+ describe('POST /shared-assignments/{id}/report', () => {
+ test('stores a report with the reason (201)', async () => {
+ let putItem: Record | undefined;
+ mockSend.mockImplementation(async (command: {
+ constructor: { name: string }; input?: Record;
+ }) => {
+ const name = command.constructor.name;
+ if (name === 'GetCommand') return { Item: publishedItem };
+ if (name === 'UpdateCommand') return { Attributes: { count: 1 } };
+ if (name === 'PutCommand') {
+ putItem = command.input?.Item as Record;
+ return {};
+ }
+ return {};
+ });
+ const res = await handler(makeEvent('POST', '/shared-assignments/s1/report', { sharedId: 's1' }, {
+ token: DEV_TOKEN, body: { reason: '不適切な内容が含まれています' },
+ }));
+ expect(res.statusCode).toBe(201);
+ expect(putItem?.reason).toBe('不適切な内容が含まれています');
+ expect(putItem?.reporterSub).toBe('dev-test-teacher');
+ expect(typeof putItem?.ttl).toBe('number');
+ });
+
+ test('rejects an empty reason (400)', async () => {
+ const res = await handler(makeEvent('POST', '/shared-assignments/s1/report', { sharedId: 's1' }, {
+ token: DEV_TOKEN, body: { reason: '' },
+ }));
+ expect(res.statusCode).toBe(400);
+ expect(mockSend).not.toHaveBeenCalled();
+ });
+ });
+});
diff --git a/infra/smalruby-classroom/lambda/tests/shared-admin-lib.test.ts b/infra/smalruby-classroom/lambda/tests/shared-admin-lib.test.ts
new file mode 100644
index 0000000000..db0af9e3cf
--- /dev/null
+++ b/infra/smalruby-classroom/lambda/tests/shared-admin-lib.test.ts
@@ -0,0 +1,85 @@
+/**
+ * Moderation CLI planning tests (issue #1071).
+ */
+import {
+ groupReports,
+ parseAdminArgs,
+ renderReportQueue,
+ renderSharedItem,
+} from '../shared-admin-lib';
+
+describe('parseAdminArgs', () => {
+ test('parses the four commands, dry-run by default', () => {
+ expect(parseAdminArgs(['list-reports'])).toEqual({ command: 'list-reports', sharedId: null, apply: false });
+ expect(parseAdminArgs(['show', 's1'])).toEqual({ command: 'show', sharedId: 's1', apply: false });
+ expect(parseAdminArgs(['unpublish', 's1', '--apply'])).toEqual({
+ command: 'unpublish', sharedId: 's1', apply: true,
+ });
+ expect(parseAdminArgs(['republish', 's1'])).toEqual({ command: 'republish', sharedId: 's1', apply: false });
+ });
+
+ test('rejects unknown commands, unknown flags and missing ids', () => {
+ expect(() => parseAdminArgs(['delete', 's1'])).toThrow('Usage');
+ expect(() => parseAdminArgs(['unpublish'])).toThrow('requires a sharedId');
+ expect(() => parseAdminArgs(['show', 's1', '--force'])).toThrow('Unknown argument');
+ });
+});
+
+describe('groupReports', () => {
+ test('groups by sharedId, most-reported first, newest report first inside', () => {
+ const grouped = groupReports([
+ { sharedId: 'a', reason: 'r1', createdAt: '2026-07-18T01:00:00Z' },
+ { sharedId: 'b', reason: 'r2', createdAt: '2026-07-18T02:00:00Z' },
+ { sharedId: 'b', reason: 'r3', createdAt: '2026-07-18T03:00:00Z' },
+ ]);
+ expect([...grouped.keys()]).toEqual(['b', 'a']);
+ expect(grouped.get('b')!.map((r) => r.reason)).toEqual(['r3', 'r2']);
+ });
+});
+
+describe('renderReportQueue', () => {
+ test('shows title/status per reported item and never the reporter', () => {
+ const grouped = groupReports([
+ { sharedId: 's1', reason: '不適切', createdAt: '2026-07-18T00:00:00Z' },
+ ]);
+ const lines = renderReportQueue(grouped, new Map([
+ ['s1', { title: 'ねこあつめ', status: 'published' }],
+ ]));
+ expect(lines[0]).toContain('ねこあつめ');
+ expect(lines[0]).toContain('status=published');
+ expect(lines[1]).toContain('不適切');
+ expect(lines.join('\n')).not.toContain('reporterSub');
+ });
+
+ test('handles an empty queue and deleted items', () => {
+ expect(renderReportQueue(new Map(), new Map())).toEqual(['通報はありません。']);
+ const grouped = groupReports([{ sharedId: 'gone', reason: 'x', createdAt: '' }]);
+ expect(renderReportQueue(grouped, new Map())[0]).toContain('(削除済み/不明)');
+ });
+});
+
+describe('renderSharedItem', () => {
+ test('renders the public fields and truncated page texts', () => {
+ const lines = renderSharedItem({
+ sharedId: 's1',
+ title: 'ねこあつめ',
+ status: 'published',
+ authorName: 'るびお',
+ authorAffiliation: '島根県',
+ schoolLevel: 'junior-high',
+ subject: '技術・家庭(技術分野)',
+ grades: [1, 2],
+ tags: ['甲子園'],
+ supplementUrl: 'https://example.com',
+ reuseCount: 3,
+ createdAt: 'c',
+ updatedAt: 'u',
+ content: { pages: [{ text: 'ページ1\n続き' }], starterKey: 'shared/s1/starter.sb3' },
+ });
+ const text = lines.join('\n');
+ expect(text).toContain('るびお(島根県)');
+ expect(text).toContain('学年 1・2');
+ expect(text).toContain('starter: あり');
+ expect(text).toContain('1. ページ1 / 続き');
+ });
+});
diff --git a/infra/smalruby-classroom/lib/classroom-stack.ts b/infra/smalruby-classroom/lib/classroom-stack.ts
index 3e05b2a8aa..15e7f7f391 100644
--- a/infra/smalruby-classroom/lib/classroom-stack.ts
+++ b/infra/smalruby-classroom/lib/classroom-stack.ts
@@ -19,7 +19,10 @@ export class ClassroomStack extends cdk.Stack {
public readonly submissionsTable: dynamodb.Table;
public readonly kickRequestsTable: dynamodb.Table;
public readonly groupsTable: dynamodb.Table;
+ public readonly sharedAssignmentsTable: dynamodb.Table;
+ public readonly sharedReportsTable: dynamodb.Table;
public readonly submissionsBucket: s3.Bucket;
+ public readonly sharedBucket: s3.Bucket;
public readonly api: apigatewayv2.HttpApi;
constructor(scope: Construct, id: string, props?: cdk.StackProps) {
@@ -247,6 +250,88 @@ export class ClassroomStack extends cdk.Stack {
cdk.Tags.of(this.groupsTable).add('ResourceType', 'DynamoDB');
+ // --- みんなの課題 (shared assignment library, EPIC #1066) ---
+ // Shared items are nationwide teacher contributions: permanent (no TTL)
+ // and RETAINed in prod so a stack replacement can never destroy them
+ // (deliberately different from the DESTROY classroom tables).
+
+ const sharedRemovalPolicy = stage === 'prod' ? cdk.RemovalPolicy.RETAIN : cdk.RemovalPolicy.DESTROY;
+
+ this.sharedAssignmentsTable = new dynamodb.Table(this, 'SharedAssignmentsTable', {
+ tableName: `SharedAssignments${stageSuffix}`,
+ partitionKey: {
+ name: 'sharedId',
+ type: dynamodb.AttributeType.STRING,
+ },
+ billingMode: dynamodb.BillingMode.PAY_PER_REQUEST,
+ removalPolicy: sharedRemovalPolicy,
+ pointInTimeRecoverySpecification: {
+ pointInTimeRecoveryEnabled: stage === 'prod',
+ },
+ });
+
+ // Newest-first catalog (D8).
+ this.sharedAssignmentsTable.addGlobalSecondaryIndex({
+ indexName: 'status-createdAt-index',
+ partitionKey: {
+ name: 'status',
+ type: dynamodb.AttributeType.STRING,
+ },
+ sortKey: {
+ name: 'createdAt',
+ type: dynamodb.AttributeType.STRING,
+ },
+ projectionType: dynamodb.ProjectionType.ALL,
+ });
+
+ // "自分の投稿" tab: an author lists their own items incl. unlisted ones.
+ this.sharedAssignmentsTable.addGlobalSecondaryIndex({
+ indexName: 'authorSub-createdAt-index',
+ partitionKey: {
+ name: 'authorSub',
+ type: dynamodb.AttributeType.STRING,
+ },
+ sortKey: {
+ name: 'createdAt',
+ type: dynamodb.AttributeType.STRING,
+ },
+ projectionType: dynamodb.ProjectionType.ALL,
+ });
+
+ // 合言葉(限定公開)での取り込み(#1109): passcode で一意ルックアップする。
+ // limited 項目のみ passcode を持つのでスパースな索引になる。
+ this.sharedAssignmentsTable.addGlobalSecondaryIndex({
+ indexName: 'passcode-index',
+ partitionKey: {
+ name: 'passcode',
+ type: dynamodb.AttributeType.STRING,
+ },
+ projectionType: dynamodb.ProjectionType.ALL,
+ });
+
+ cdk.Tags.of(this.sharedAssignmentsTable).add('ResourceType', 'DynamoDB');
+
+ // Reports are ephemeral moderation inputs (90-day TTL, D3).
+ this.sharedReportsTable = new dynamodb.Table(this, 'SharedReportsTable', {
+ tableName: `SharedAssignmentReports${stageSuffix}`,
+ partitionKey: {
+ name: 'sharedId',
+ type: dynamodb.AttributeType.STRING,
+ },
+ sortKey: {
+ name: 'reportId',
+ type: dynamodb.AttributeType.STRING,
+ },
+ billingMode: dynamodb.BillingMode.PAY_PER_REQUEST,
+ removalPolicy: cdk.RemovalPolicy.DESTROY,
+ pointInTimeRecoverySpecification: {
+ pointInTimeRecoveryEnabled: false,
+ },
+ timeToLiveAttribute: 'ttl',
+ });
+
+ cdk.Tags.of(this.sharedReportsTable).add('ResourceType', 'DynamoDB');
+
// --- S3 Bucket for submissions ---
this.submissionsBucket = new s3.Bucket(this, 'SubmissionsBucket', {
@@ -275,6 +360,29 @@ export class ClassroomStack extends cdk.Stack {
cdk.Tags.of(this.submissionsBucket).add('ResourceType', 'S3');
+ // --- S3 Bucket for shared assignments (みんなの課題) ---
+ // No lifecycle rule: shared content is permanent (D7). The classroom
+ // bucket's retention sweep must never touch these objects, hence the
+ // separate bucket. RETAINed in prod like the table.
+
+ this.sharedBucket = new s3.Bucket(this, 'SharedAssignmentsBucket', {
+ bucketName: `smalruby-shared-assignments${stageSuffix}`,
+ removalPolicy: sharedRemovalPolicy,
+ autoDeleteObjects: stage !== 'prod',
+ cors: [
+ {
+ allowedMethods: [s3.HttpMethods.GET],
+ allowedOrigins: corsAllowOrigins,
+ allowedHeaders: ['Content-Type'],
+ maxAge: 3600,
+ },
+ ],
+ blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL,
+ encryption: s3.BucketEncryption.S3_MANAGED,
+ });
+
+ cdk.Tags.of(this.sharedBucket).add('ResourceType', 'S3');
+
// --- Lambda ---
const logGroup = new logs.LogGroup(this, 'ClassroomHandlerLogGroup', {
@@ -297,7 +405,12 @@ export class ClassroomStack extends cdk.Stack {
SUBMISSIONS_TABLE_NAME: this.submissionsTable.tableName,
KICK_REQUESTS_TABLE_NAME: this.kickRequestsTable.tableName,
GROUPS_TABLE_NAME: this.groupsTable.tableName,
+ SHARED_ASSIGNMENTS_TABLE_NAME: this.sharedAssignmentsTable.tableName,
+ SHARED_REPORTS_TABLE_NAME: this.sharedReportsTable.tableName,
SUBMISSIONS_BUCKET_NAME: this.submissionsBucket.bucketName,
+ SHARED_BUCKET_NAME: this.sharedBucket.bucketName,
+ SHARE_DAILY_LIMIT: process.env.SHARE_DAILY_LIMIT || '10',
+ REPORT_DAILY_LIMIT: process.env.REPORT_DAILY_LIMIT || '20',
GOOGLE_CLIENT_ID: googleClientId,
MICROSOFT_CLIENT_ID: microsoftClientId,
DEV_BYPASS_TOKEN: devBypassToken,
@@ -330,8 +443,13 @@ export class ClassroomStack extends cdk.Stack {
this.submissionsTable.grantReadWriteData(handlerFn);
this.kickRequestsTable.grantReadWriteData(handlerFn);
this.groupsTable.grantReadWriteData(handlerFn);
+ this.sharedAssignmentsTable.grantReadWriteData(handlerFn);
+ this.sharedReportsTable.grantReadWriteData(handlerFn);
this.submissionsBucket.grantPut(handlerFn);
this.submissionsBucket.grantRead(handlerFn);
+ this.sharedBucket.grantPut(handlerFn);
+ this.sharedBucket.grantRead(handlerFn);
+ this.sharedBucket.grantDelete(handlerFn);
// --- Delete-snapshot archiver (issue #1053) ---
// Streams on the four data tables feed every REMOVE (TTL sweep or
@@ -535,6 +653,45 @@ export class ClassroomStack extends cdk.Stack {
integration,
});
+ // みんなの課題 (shared assignment library) — own root path.
+ this.api.addRoutes({
+ path: '/shared-assignments',
+ methods: [apigatewayv2.HttpMethod.POST, apigatewayv2.HttpMethod.GET],
+ integration,
+ });
+
+ this.api.addRoutes({
+ path: '/shared-assignments/{sharedId}',
+ methods: [apigatewayv2.HttpMethod.GET, apigatewayv2.HttpMethod.PATCH, apigatewayv2.HttpMethod.DELETE],
+ integration,
+ });
+
+ this.api.addRoutes({
+ path: '/shared-assignments/{sharedId}/import',
+ methods: [apigatewayv2.HttpMethod.POST],
+ integration,
+ });
+
+ this.api.addRoutes({
+ path: '/shared-assignments/{sharedId}/report',
+ methods: [apigatewayv2.HttpMethod.POST],
+ integration,
+ });
+
+ // 合言葉(限定公開)による preview / 取り込み(#1109)。POST の literal path
+ // なので GET/PATCH/DELETE の /{sharedId} ルートとは衝突しない。
+ this.api.addRoutes({
+ path: '/shared-assignments/lookup',
+ methods: [apigatewayv2.HttpMethod.POST],
+ integration,
+ });
+
+ this.api.addRoutes({
+ path: '/shared-assignments/import-by-passcode',
+ methods: [apigatewayv2.HttpMethod.POST],
+ integration,
+ });
+
// Groups (組) — teacher-side organizing concept. Own root path so the
// /classrooms/{classroomId} patterns never shadow it.
this.api.addRoutes({
diff --git a/packages/scratch-gui/.prettierignore b/packages/scratch-gui/.prettierignore
index a5c156bcbb..c4ba58e5be 100644
--- a/packages/scratch-gui/.prettierignore
+++ b/packages/scratch-gui/.prettierignore
@@ -109,6 +109,7 @@ src/containers/*
!src/containers/use-teacher-auth.js
!src/containers/use-teacher-classroom.js
!src/containers/use-teacher-classrooms.js
+!src/containers/use-shared-assignments.js
!src/containers/use-teacher-evaluation.js
!src/containers/use-teacher-groups.js
!src/containers/use-teacher-submissions.js
@@ -149,6 +150,8 @@ src/lib/*
!src/lib/classroom-download-utils.js
!src/lib/classroom-group-utils.js
!src/lib/classroom-retention.js
+!src/lib/shared-assignment-taxonomy.js
+!src/lib/shared-author-profile.js
!src/lib/classroom-kick-request-storage.js
!src/lib/google-classroom-auth.js
!src/lib/block-utils.js
@@ -381,6 +384,8 @@ test/unit/lib/*
!test/unit/lib/classroom-download-utils.test.js
!test/unit/lib/classroom-group-utils.test.js
!test/unit/lib/classroom-retention.test.js
+!test/unit/lib/shared-assignment-taxonomy.test.js
+!test/unit/lib/shared-author-profile.test.js
!test/unit/lib/evaluation-utils.test.js
!test/unit/lib/sb3-analyzer.test.js
!test/unit/lib/classroom-kick-request-storage.test.js
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 6e6f3811b4..44075ba0a0 100644
--- a/packages/scratch-gui/src/components/classroom-modal/classroom-modal.css
+++ b/packages/scratch-gui/src/components/classroom-modal/classroom-modal.css
@@ -140,6 +140,7 @@
font-size: 0.9rem;
cursor: pointer;
font-weight: bold;
+ text-decoration: none;
}
.primary-button:hover {
@@ -278,25 +279,186 @@
margin: 0.3rem 0;
}
+/* 課題名(左)と参加コード(右)を均等幅 2 カラムに分ける。両カラムは
+ stretch で高さが揃うので、課題名入力の高さは参加コードカードに一致する。
+ 幅が足りず参加コードカードが 340px を割り込むと、行が折り返して参加コード
+ カードは単独行(フル幅)になり、狭いウインドウでも 2 行に収まる。 */
+.name-code-row {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.5rem;
+ align-items: stretch;
+ margin: 0.3rem 0;
+}
+
+/* 課題名: ラベルと 2 行入力を横並び。ラベルは縦中央、入力はカラム(=カード)
+ 高さいっぱいに伸ばして参加コードカードと高さを揃える。 */
+.name-col {
+ flex: 1 1 340px;
+ min-width: 0;
+ display: flex;
+ flex-direction: row;
+ align-items: stretch;
+ gap: 0.3rem;
+}
+
+.name-col .assignment-name-label {
+ flex: 0 0 auto;
+ align-self: center;
+ white-space: nowrap;
+}
+
+/* 課題名は 2 行分の入力領域(長い名前が折り返して見える)。カード高さに
+ 合わせて縦に伸びる。単一行の値なので monospace を避け、リサイズは無効。 */
+.name-col .assignment-name-input {
+ flex: 1 1 auto;
+ min-width: 0;
+ min-height: 2.8rem;
+ box-sizing: border-box;
+ resize: none;
+ font-family: inherit;
+ line-height: 1.3;
+ overflow-y: auto;
+}
+
+/* 参加コードカード (redesign): 右カラム。コードと 3 つのボタンを 1 つの
+ flex flow に並べ、同じ行から始めることで(幅が足りなくても)2 行に収める。 */
+.join-code-card {
+ flex: 1 1 340px;
+ min-width: 0;
+ container-type: inline-size;
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ justify-content: center;
+ gap: 0.35rem 0.4rem;
+ background-color: #f2f2f2;
+ border: 2px dashed #d9d9d9;
+ border-radius: 8px;
+ padding: 0.4rem 0.5rem;
+}
+
+/* 参加コード(ラベル + 値)は折り返さず 1 単位として先頭に置く。 */
+.join-code-codepart {
+ flex: 0 0 auto;
+ display: inline-flex;
+ align-items: baseline;
+ gap: 0.2rem;
+ white-space: nowrap;
+}
+
+.join-code-action {
+ flex: 0 0 auto;
+ display: inline-flex;
+ align-items: center;
+ gap: 0.25rem;
+ padding: 0.3rem 0.7rem;
+ border: 1px solid #c8cdd8;
+ border-radius: 6px;
+ background-color: #fff;
+ color: #575e75;
+ font-size: 0.85rem;
+ line-height: 1.2;
+ cursor: pointer;
+ text-decoration: none;
+ white-space: nowrap;
+}
+
+.join-code-action:hover {
+ background-color: #f6f7fa;
+}
+
+/* Google Classroom への共有は主要導線として強調 */
+.join-code-action-gc {
+ border-color: #4c97ff;
+ background-color: #e5f0ff;
+ color: #3373cc;
+ font-weight: bold;
+}
+
+.join-code-action-gc:hover {
+ background-color: #d6e7ff;
+}
+
+/* コピー完了のリアクション: アイコンがチェックに変わり緑で強調 */
+.join-code-action-copied {
+ border-color: #4caf50;
+ background-color: #eafaea;
+ color: #4caf50;
+}
+
+.join-code-action-copied:hover {
+ background-color: #d9f5d9;
+}
+
+.join-code-icon {
+ flex: 0 0 auto;
+ display: inline-flex;
+ align-items: center;
+}
+
+/* 値・ラベルは課題名ラベル (1rem) と同サイズ(値はボールド・色を維持)。 */
.join-code-value {
- font-size: 1.5rem;
+ font-size: 1rem;
font-weight: bold;
font-family: monospace;
- letter-spacing: 0.3em;
+ letter-spacing: 0.15em;
color: #4c97ff;
user-select: text;
cursor: text;
}
+.join-code-label {
+ font-size: 1rem;
+ color: #575e75;
+}
+
+/* 全画面表示・招待リンクコピーは幅に関わらずアイコンのみ、Google Classroom
+ 共有は "[アイコン]に共有" の短縮表示で固定。ラベル切り替えの container query
+ は廃止し、常にコンパクトにしてレイアウト崩れを防ぐ。 */
+.join-code-gc-label {
+ font-size: 0.85rem;
+}
+
+/* 保存期限 + 期限警告を1行に。狭い幅では末尾ヒントを省く container query。 */
.expires-at-text {
+ container-type: inline-size;
+ display: flex;
+ flex-wrap: wrap;
+ align-items: baseline;
+ gap: 0.5rem;
font-size: 0.8rem;
color: #999;
margin: 0.25rem 0 0.5rem;
}
-.join-code-label {
- font-size: 0.75rem;
- color: #575e75;
+.expires-at-date {
+ flex: 0 0 auto;
+}
+
+/* 期限が近いときの警告メッセージ(保存期限の右側にインライン表示)。 */
+.expires-at-alert {
+ display: inline-flex;
+ align-items: baseline;
+ flex-wrap: wrap;
+ gap: 0.15rem 0.3rem;
+ font-weight: bold;
+ color: hsla(30, 70%, 35%, 1);
+}
+
+.expires-at-warning .expires-at-alert {
+ color: hsla(10, 80%, 42%, 1);
+}
+
+.expires-at-alert-mark {
+ align-self: center;
+}
+
+/* 幅が狭ければ「ダウンロードして保存してください」を省いて領域を確保。 */
+@container (max-width: 520px) {
+ .expires-at-hint {
+ display: none;
+ }
}
.assignment-name-label {
@@ -356,16 +518,17 @@
background-color: #e9f0ff;
}
-.success-message {
- color: #0fbd8c;
- font-weight: bold;
- text-align: center;
- padding: 2rem 0;
- font-size: 1.1rem;
+/* 配信画面の本文コンテナ。課題詳細(.detail-layout)と同じ左右オフセットに
+ 合わせる(上端はパンくずラッパーが持つ)。旧 .phaseContainer は未定義で
+ 左マージンが出ていなかったため専用クラスに置き換え。 */
+.post-assignment-container {
+ padding: 0 2rem 1.25rem;
}
+/* クラス名(課題詳細と同じ表記)を配信画面のサブタイトルとして表示。 */
.post-assignment-target {
- font-size: 0.9rem;
+ font-size: 1rem;
+ font-weight: bold;
color: #575e75;
margin-bottom: 1rem;
}
@@ -378,20 +541,105 @@
margin-bottom: 1rem;
}
-.post-assignment-success-area {
- text-align: center;
- padding: 2rem 0;
+/* 配信後の成功タイトル(基本レイアウトに合わせ左寄せ・緑で強調)。 */
+.post-assignment-success-title {
+ color: #0fbd8c;
+ font-weight: bold;
+ font-size: 1.1rem;
+ margin-bottom: 0.5rem;
}
-.post-assignment-actions {
- margin-bottom: 1rem;
+/* フォーム下部の共通フッター: キャンセル(左)+ プライマリー(右)。
+ クラス管理の各入力フォームで共有するポリシー(プライマリー右下)。 */
+.form-footer {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 0.5rem;
+ margin-top: 1.5rem;
+}
+
+/* 共有設定ステップ(#1109): 公開範囲の選択カード + 合言葉の表示。 */
+.share-mode-row {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.6rem;
+ margin: 0.5rem 0 1rem;
+}
+
+.share-mode-card {
+ flex: 1 1 260px;
+ display: flex;
+ flex-direction: column;
+ gap: 0.2rem;
+ padding: 0.7rem 0.9rem;
+ border: 2px solid #d9d9d9;
+ border-radius: 8px;
+ background: #fff;
+ cursor: pointer;
+ text-align: left;
}
-.post-assignment-hint-center {
+.share-mode-card:hover {
+ border-color: #b3c7ff;
+}
+
+.share-mode-card-active {
+ border-color: #4c97ff;
+ background: #e9f0ff;
+}
+
+.share-mode-name {
+ font-weight: bold;
+ color: #575e75;
+}
+
+.share-mode-desc {
font-size: 0.8rem;
color: #888;
- line-height: 1.5;
- text-align: center;
+}
+
+.share-passcode-box {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 0.5rem;
+ padding: 0.8rem 1rem;
+ margin: 0.5rem 0;
+ background: #f2f7ff;
+ border: 2px dashed #4c97ff;
+ border-radius: 8px;
+}
+
+.share-passcode-label {
+ font-weight: bold;
+ color: #575e75;
+}
+
+.share-passcode-value {
+ font-family: monospace;
+ font-size: 1.6rem;
+ font-weight: bold;
+ letter-spacing: 0.2em;
+ color: #3373cc;
+ user-select: text;
+ cursor: text;
+}
+
+/* 合言葉で取り込み(#1109・受け取る側)のプレビュー / エラー。 */
+.passcode-preview {
+ padding: 0.6rem 0.9rem;
+ margin: 0.5rem 0;
+ background: #e9f0ff;
+ border-radius: 8px;
+ color: #575e75;
+ font-weight: bold;
+}
+
+.passcode-error {
+ color: hsla(10, 80%, 42%, 1);
+ font-size: 0.85rem;
+ margin: 0.3rem 0;
}
.class-item-main {
@@ -2292,61 +2540,380 @@
/* --- Retention (auto-delete) alerts (issue #1052) --- */
-.expiry-badge-notice,
-.expiry-badge-warning {
- align-self: center;
- flex-shrink: 0;
- padding: 0.1rem 0.5rem;
- border-radius: 0.75rem;
- font-size: 0.75rem;
- white-space: nowrap;
+/* 期限が近い課題の行に出す自動削除メッセージ + 全作品ダウンロード。
+ バッジ(あと N 日)は廃止し、詳細と同じ文面 + DL 動線を一覧にも置く。 */
+.board-row-retention,
+.board-row-retention-warning {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 0.4rem 0.6rem;
+ padding: 0.1rem 0.3rem 0.2rem;
+ font-size: 0.8rem;
+ font-weight: bold;
}
-.expiry-badge-notice {
- background: hsla(35, 90%, 55%, 0.15);
+.board-row-retention {
color: hsla(30, 70%, 35%, 1);
}
-.expiry-badge-warning {
- background: hsla(20, 100%, 55%, 0.18);
- color: hsla(10, 80%, 40%, 1);
+.board-row-retention-warning {
+ color: hsla(10, 80%, 42%, 1);
+}
+
+.board-row-retention-mark {
+ flex: 0 0 auto;
+}
+
+.board-row-retention-text {
+ flex: 1 1 auto;
+ min-width: 0;
+}
+
+.board-row-retention-download {
+ flex: 0 0 auto;
+ padding: 0.3rem 0.7rem;
+ border: 1px solid currentColor;
+ border-radius: 0.4rem;
+ background: white;
+ color: inherit;
+ font-weight: bold;
+ cursor: pointer;
+ white-space: nowrap;
+}
+
+.board-row-retention-download:hover:not(:disabled) {
+ background: hsla(10, 85%, 55%, 0.1);
}
-.retention-banner,
-.retention-banner-warning {
+.board-row-retention-download:disabled {
+ opacity: 0.5;
+ cursor: default;
+}
+
+/* 期限が近いとき、全作品ダウンロードボタンを警告色にしてDLを促す(#1052)。
+ secondary-button / detail-tabs-download より後に定義し、element+class で
+ 確実に色を上書きする。 */
+button.detail-tabs-download-urgent {
+ border-color: hsla(10, 80%, 45%, 1);
+ background: hsla(10, 85%, 55%, 1);
+ color: white;
+ font-weight: bold;
+}
+
+button.detail-tabs-download-urgent:hover:not(:disabled) {
+ background: hsla(10, 85%, 48%, 1);
+}
+
+/* --- みんなの課題: share form (EPIC #1066 S2) --- */
+
+.shared-form {
display: flex;
- align-items: center;
- gap: 0.75rem;
+ flex-direction: column;
+ gap: 0.6rem;
margin: 0.75rem 0;
- padding: 0.6rem 0.9rem;
+ padding: 1rem;
+ border: 1px solid hsla(260, 60%, 60%, 0.35);
border-radius: 0.5rem;
+ background: hsla(260, 60%, 60%, 0.05);
+}
+
+.shared-form input[type='text'],
+.shared-form input[type='url'],
+.shared-form input[type='number'],
+.shared-form select {
+ padding: 0.45rem 0.6rem;
+ border: 1px solid hsla(0, 0%, 0%, 0.15);
+ border-radius: 0.4rem;
font-size: 0.875rem;
}
-.retention-banner {
- background: hsla(35, 90%, 55%, 0.12);
- color: hsla(30, 60%, 30%, 1);
+.shared-form-title {
+ margin: 0;
+ font-size: 1rem;
+ color: #855cd6;
+}
+
+.shared-form-hint {
+ margin: 0;
+ font-size: 0.8rem;
+ color: hsla(225, 15%, 40%, 1);
+}
+
+.shared-form-row {
+ display: flex;
+ gap: 0.5rem;
}
-.retention-banner-warning {
- background: hsla(20, 100%, 55%, 0.15);
- color: hsla(10, 80%, 35%, 1);
+.shared-form-row > * {
+ flex: 1;
}
-.retention-banner-download {
- flex-shrink: 0;
+.shared-form-row input[type='number'] {
+ max-width: 6rem;
+}
+
+.shared-form-grades {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.6rem;
+ align-items: center;
+ font-size: 0.875rem;
+}
+
+.shared-form-grade {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.25rem;
+ cursor: pointer;
+}
+
+.shared-form-label {
+ color: hsla(225, 15%, 40%, 1);
+}
+
+.shared-form-url-block {
+ display: flex;
+ flex-direction: column;
+ gap: 0.25rem;
+}
+
+.shared-form-url-block input {
+ width: 100%;
+ box-sizing: border-box;
+}
+
+.shared-form-url-hint {
+ margin: 0;
+ font-size: 0.75rem;
+ color: hsla(225, 15%, 40%, 1);
+}
+
+.shared-form-url-error {
+ margin: 0;
+ font-size: 0.75rem;
+ color: hsla(10, 80%, 40%, 1);
+}
+
+.shared-form-consent {
+ display: flex;
+ align-items: flex-start;
+ gap: 0.5rem;
+ padding: 0.6rem;
+ border-radius: 0.4rem;
+ background: hsla(35, 90%, 55%, 0.1);
+ font-size: 0.8rem;
+ cursor: pointer;
+}
+
+.shared-form-consent input {
+ margin-top: 0.15rem;
+}
+
+.shared-form-actions {
+ display: flex;
+ justify-content: flex-end;
+ gap: 0.5rem;
+}
+
+.shared-form-actions button {
+ padding: 0.45rem 1rem;
+ border: 1px solid hsla(0, 0%, 0%, 0.15);
+ border-radius: 0.4rem;
+ background: white;
+ cursor: pointer;
+}
+
+.shared-form-submit {
+ border-color: #855cd6 !important;
+ background: #855cd6 !important;
+ color: white;
+}
+
+.shared-form-submit:disabled {
+ opacity: 0.5;
+ cursor: default;
+}
+
+.shared-form-success {
+ margin: 0.5rem 0;
+ padding: 0.5rem 0.75rem;
+ border-radius: 0.4rem;
+ background: hsla(145, 60%, 45%, 0.12);
+ color: hsla(145, 70%, 25%, 1);
+ font-size: 0.875rem;
+}
+
+/* --- みんなの課題: catalog (EPIC #1066 S3) --- */
+
+.shared-catalog {
+ display: flex;
+ flex-direction: column;
+ gap: 0.6rem;
+ margin: 0.75rem 0;
+ padding: 1rem;
+ border: 1px solid hsla(260, 60%, 60%, 0.35);
+ border-radius: 0.5rem;
+ background: white;
+}
+
+.shared-catalog-filters {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.4rem;
+}
+
+.shared-catalog-filters select,
+.shared-catalog-filters input,
+.shared-catalog-filters button {
+ padding: 0.35rem 0.5rem;
+ border: 1px solid hsla(0, 0%, 0%, 0.15);
+ border-radius: 0.4rem;
+ background: white;
+ font-size: 0.8rem;
+}
+
+.shared-cards {
+ list-style: none;
+ margin: 0;
+ padding: 0;
+ display: flex;
+ flex-direction: column;
+ gap: 0.5rem;
+}
+
+.shared-card {
+ border: 1px solid hsla(0, 0%, 0%, 0.1);
+ border-radius: 0.5rem;
+}
+
+.shared-card-main {
+ display: flex;
+ flex-direction: column;
+ gap: 0.3rem;
+ width: 100%;
+ padding: 0.6rem 0.8rem;
+ border: none;
+ background: none;
+ text-align: left;
+ cursor: pointer;
+}
+
+.shared-card-main:hover {
+ background: hsla(260, 60%, 60%, 0.05);
+}
+
+.shared-card-title {
+ font-weight: bold;
+ display: flex;
+ align-items: center;
+ gap: 0.4rem;
+}
+
+.shared-card-unlisted {
+ padding: 0.05rem 0.4rem;
+ border-radius: 0.25rem;
+ background: hsla(0, 0%, 0%, 0.08);
+ color: hsla(225, 15%, 40%, 1);
+ font-size: 0.7rem;
+ font-weight: normal;
+}
+
+.shared-card-summary {
+ font-size: 0.8rem;
+ color: hsla(225, 15%, 40%, 1);
+}
+
+.shared-card-badges {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.3rem;
+}
+
+.shared-card-tag {
+ font-size: 0.75rem;
+ padding: 0.1rem 0.4rem;
+ border-radius: 0.25rem;
+ background: hsla(200, 70%, 55%, 0.12);
+ color: hsla(205, 70%, 35%, 1);
+}
+
+.shared-card-meta {
+ font-size: 0.75rem;
+ color: hsla(225, 15%, 45%, 1);
+}
+
+.shared-detail {
+ display: flex;
+ flex-direction: column;
+ gap: 0.6rem;
+}
+
+.shared-detail-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 0.5rem;
+}
+
+.shared-detail-header button {
padding: 0.35rem 0.8rem;
+ border: 1px solid hsla(0, 0%, 0%, 0.15);
+ border-radius: 0.4rem;
+ background: white;
+ cursor: pointer;
+}
+
+.shared-detail-page {
+ padding: 0.6rem;
+ border: 1px solid hsla(0, 0%, 0%, 0.08);
+ border-radius: 0.4rem;
+}
+
+.shared-detail-url-block button,
+.shared-detail-url-confirm a,
+.shared-detail-url-confirm button {
+ padding: 0.3rem 0.7rem;
border: 1px solid #855cd6;
- border-radius: 0.5rem;
+ border-radius: 0.4rem;
background: white;
color: #855cd6;
cursor: pointer;
- white-space: nowrap;
+ text-decoration: none;
+ font-size: 0.8rem;
}
-.retention-banner-download:disabled {
- opacity: 0.5;
- cursor: default;
+.shared-detail-url-confirm {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.5rem;
+ padding: 0.5rem 0.7rem;
+ border-radius: 0.4rem;
+ background: hsla(35, 90%, 55%, 0.12);
+ font-size: 0.8rem;
+}
+
+.shared-report-form {
+ display: flex;
+ gap: 0.5rem;
+ align-items: flex-start;
+}
+
+.shared-report-form textarea {
+ flex: 1;
+ min-height: 3rem;
+ padding: 0.45rem 0.6rem;
+ border: 1px solid hsla(0, 0%, 0%, 0.15);
+ border-radius: 0.4rem;
+ font-size: 0.85rem;
+}
+
+.shared-report-form button {
+ padding: 0.45rem 0.8rem;
+ border: 1px solid hsla(0, 0%, 0%, 0.15);
+ border-radius: 0.4rem;
+ background: white;
+ cursor: pointer;
}
/* --- Assignment board (inside a class) --- */
@@ -2486,6 +3053,21 @@
gap: 0.5rem;
}
+/* 期限メッセージを行の下に積むため、アクティブ課題の行は縦積みにする
+ (アーカイブ行は従来どおり .board-row の横並びのまま)。 */
+.board-row-item {
+ display: flex;
+ flex-direction: column;
+ align-items: stretch;
+ gap: 0.25rem;
+}
+
+.board-row-controls {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+}
+
.board-row-main {
flex: 1;
display: flex;
@@ -2523,6 +3105,29 @@
background: white;
}
+/* 各行の「共有」ボタン(#1109。共有導線をボードに一本化)。 */
+.board-row-share {
+ flex: 0 0 auto;
+ padding: 0.35rem 0.7rem;
+ border: 1px solid #4c97ff;
+ border-radius: 0.3rem;
+ background: white;
+ color: #3373cc;
+ font-size: 0.8rem;
+ font-weight: bold;
+ cursor: pointer;
+ white-space: nowrap;
+}
+
+.board-row-share:hover:not(:disabled) {
+ background: #e9f0ff;
+}
+
+.board-row-share:disabled {
+ opacity: 0.5;
+ cursor: default;
+}
+
.class-list-import-button {
margin-left: 0.5rem;
border: 1px solid #855cd6;
@@ -2797,6 +3402,8 @@
.teacher-view-title {
margin: 0.25rem 0 1rem;
font-size: 1.3rem;
+ /* 課題一覧の見出し(h2.board-title)と同じ太字に揃える。 */
+ font-weight: bold;
color: #575e75;
overflow: hidden;
text-overflow: ellipsis;
diff --git a/packages/scratch-gui/src/components/classroom-modal/shared-assignment-catalog.jsx b/packages/scratch-gui/src/components/classroom-modal/shared-assignment-catalog.jsx
new file mode 100644
index 0000000000..73f89e5a79
--- /dev/null
+++ b/packages/scratch-gui/src/components/classroom-modal/shared-assignment-catalog.jsx
@@ -0,0 +1,575 @@
+/**
+ * みんなの課題 catalog (EPIC #1066, S3): browse/filter the nationwide shared
+ * assignment library, preview an item, import it into the current class, and
+ * manage the caller's own posts (unlist / republish). Supplement URLs open
+ * behind an explicit confirmation that names the external domain (D4).
+ */
+import PropTypes from 'prop-types';
+import React, { useCallback, useState } from 'react';
+import { FormattedMessage, defineMessages, useIntl } from 'react-intl';
+
+import { SCHOOL_LEVELS, SUBJECTS_BY_LEVEL } from '../../lib/shared-assignment-taxonomy.js';
+
+import styles from './classroom-modal.css';
+
+// Static message map so the level label can be resolved at runtime from the
+// taxonomy value (React Intl requires statically evaluate-able messages).
+const levelMessages = defineMessages({
+ elementary: {
+ defaultMessage: 'Elementary school',
+ description: 'School level choice: elementary',
+ id: 'gui.classroom.shared.levelElementary',
+ },
+ 'junior-high': {
+ defaultMessage: 'Junior high school',
+ description: 'School level choice: junior high',
+ id: 'gui.classroom.shared.levelJuniorHigh',
+ },
+ high: {
+ defaultMessage: 'High school',
+ description: 'School level choice: high school',
+ id: 'gui.classroom.shared.levelHigh',
+ },
+ other: {
+ defaultMessage: 'Other',
+ description: 'School level choice: other',
+ id: 'gui.classroom.shared.levelOther',
+ },
+});
+
+const CatalogCard = ({ item, onOpenDetail }) => {
+ const intl = useIntl();
+ const handleOpen = useCallback(() => onOpenDetail(item.sharedId), [onOpenDetail, item.sharedId]);
+ return (
+
+
+
+ {item.title}
+ {item.status === 'unlisted' ? (
+
+
+
+ ) : null}
+
+ {item.summary ? {item.summary} : null}
+
+
+ {intl.formatMessage(levelMessages[item.schoolLevel] || levelMessages.other)}
+
+ {item.subject}
+ {(item.grades || []).length > 0 ? (
+
+ {intl.formatMessage(
+ {
+ defaultMessage: 'Grade {grades}',
+ description: 'Grades badge on a catalog card',
+ id: 'gui.classroom.shared.gradesBadge',
+ },
+ { grades: (item.grades || []).join('・') },
+ )}
+
+ ) : null}
+ {(item.tags || []).map((tag) => (
+ {tag}
+ ))}
+
+
+ {intl.formatMessage(
+ {
+ defaultMessage: 'by {author} — imported {count} times',
+ description: 'Author + reuse count line on a catalog card',
+ id: 'gui.classroom.shared.cardMeta',
+ },
+ { author: item.authorName, count: item.reuseCount || 0 },
+ )}
+
+
+
+ );
+};
+
+CatalogCard.propTypes = {
+ item: PropTypes.object.isRequired,
+ onOpenDetail: PropTypes.func.isRequired,
+};
+
+const SharedAssignmentDetail = ({
+ detail,
+ group,
+ isLoading,
+ reportSent,
+ onClose,
+ onImport,
+ onReport,
+ onSetStatus,
+}) => {
+ const intl = useIntl();
+ const [showUrlConfirm, setShowUrlConfirm] = useState(false);
+ const [showReport, setShowReport] = useState(false);
+ const [reportReason, setReportReason] = useState('');
+
+ const handleImport = useCallback(
+ () => onImport(detail.sharedId, group.groupId),
+ [onImport, detail.sharedId, group.groupId],
+ );
+ const handleShowUrl = useCallback(() => setShowUrlConfirm(true), []);
+ const handleHideUrl = useCallback(() => setShowUrlConfirm(false), []);
+ const handleToggleReport = useCallback(() => setShowReport((v) => !v), []);
+ const handleReasonChange = useCallback((e) => setReportReason(e.target.value), []);
+ const handleSendReport = useCallback(() => {
+ if (reportReason.trim()) {
+ onReport(detail.sharedId, reportReason.trim());
+ }
+ }, [onReport, detail.sharedId, reportReason]);
+ const handleUnlist = useCallback(
+ () => onSetStatus(detail.sharedId, 'unlisted'),
+ [onSetStatus, detail.sharedId],
+ );
+ const handleRepublish = useCallback(
+ () => onSetStatus(detail.sharedId, 'published'),
+ [onSetStatus, detail.sharedId],
+ );
+
+ let urlDomain = '';
+ if (detail.supplementUrl) {
+ try {
+ urlDomain = new URL(detail.supplementUrl).hostname;
+ } catch {
+ urlDomain = '';
+ }
+ }
+
+ return (
+
+
+
{detail.title}
+
+
+
+
+
+ {`© ${detail.authorName}${detail.authorAffiliation ? `(${detail.authorAffiliation})` : ''} / CC BY 4.0`}
+
+ {detail.summary ?
{detail.summary}
: null}
+
+ {(detail.pages || []).map((page, index) => (
+
+ {page.imageUrl ? (
+
+ ) : null}
+
{page.text}
+
+ ))}
+
+ {detail.hasStarter ? (
+
+
+
+ ) : null}
+
+ {detail.supplementUrl ? (
+
+ {showUrlConfirm ? (
+
+
+
+
+
+
+
+
+
+ ) : (
+
+
+
+ )}
+
+ ) : null}
+
+
+ {detail.isMine ? (
+ detail.status === 'published' ? (
+
+
+
+ ) : (
+
+
+
+ )
+ ) : (
+
+
+
+ )}
+
+ {detail.status === 'published' ? (
+
+
+
+ ) : null}
+
+
+ {showReport && !reportSent ? (
+
+
+
+
+
+
+ ) : null}
+ {reportSent ? (
+
+
+
+ ) : null}
+
+ );
+};
+
+SharedAssignmentDetail.propTypes = {
+ detail: PropTypes.object.isRequired,
+ group: PropTypes.object.isRequired,
+ isLoading: PropTypes.bool,
+ onClose: PropTypes.func.isRequired,
+ onImport: PropTypes.func.isRequired,
+ onReport: PropTypes.func.isRequired,
+ onSetStatus: PropTypes.func.isRequired,
+ reportSent: PropTypes.bool,
+};
+
+const SharedAssignmentCatalog = ({ group, isLoading, shared }) => {
+ const intl = useIntl();
+ const [schoolLevel, setSchoolLevel] = useState('');
+ const [subject, setSubject] = useState('');
+ const [grade, setGrade] = useState('');
+ const [tag, setTag] = useState('');
+
+ const filters = {};
+ if (schoolLevel) filters.schoolLevel = schoolLevel;
+ if (subject) filters.subject = subject;
+ if (grade) filters.grade = grade;
+ if (tag.trim()) filters.tag = tag.trim();
+
+ const handleLevelChange = useCallback((e) => {
+ setSchoolLevel(e.target.value);
+ setSubject('');
+ }, []);
+ const handleSubjectChange = useCallback((e) => setSubject(e.target.value), []);
+ const handleGradeChange = useCallback((e) => setGrade(e.target.value), []);
+ const handleTagChange = useCallback((e) => setTag(e.target.value), []);
+ const handleApply = useCallback(() => {
+ shared.handleApplyCatalogFilters(filters);
+ }, [shared, schoolLevel, subject, grade, tag]);
+ const handleLoadMore = useCallback(() => {
+ shared.handleLoadMoreCatalog(filters);
+ }, [shared, schoolLevel, subject, grade, tag]);
+ const handleTabAll = useCallback(() => shared.handleCatalogTabChange('all'), [shared]);
+ const handleTabMine = useCallback(() => shared.handleCatalogTabChange('mine'), [shared]);
+
+ const subjects = SUBJECTS_BY_LEVEL[schoolLevel] || [];
+ const busy = isLoading || shared.catalogLoading;
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {shared.sharedDetail ? (
+
+ ) : (
+
+ {shared.catalogTab === 'all' ? (
+
+
+
+ {intl.formatMessage({
+ defaultMessage: 'All school levels',
+ description: 'Filter option: every school level',
+ id: 'gui.classroom.shared.filterAllLevels',
+ })}
+
+ {SCHOOL_LEVELS.map((level) => (
+
+ {intl.formatMessage(levelMessages[level.value])}
+
+ ))}
+
+
+
+ {intl.formatMessage({
+ defaultMessage: 'All subjects',
+ description: 'Filter option: every subject',
+ id: 'gui.classroom.shared.filterAllSubjects',
+ })}
+
+ {subjects.map((s) => (
+ {s}
+ ))}
+
+
+
+ {intl.formatMessage({
+ defaultMessage: 'All grades',
+ description: 'Filter option: every grade',
+ id: 'gui.classroom.shared.filterAllGrades',
+ })}
+
+ {[1, 2, 3, 4, 5, 6].map((g) => (
+
+ {intl.formatMessage(
+ {
+ defaultMessage: 'Grade {grade}',
+ description: 'Grade checkbox label',
+ id: 'gui.classroom.shared.gradeN',
+ },
+ { grade: g },
+ )}
+
+ ))}
+
+
+
+
+
+
+ ) : null}
+
+ {shared.catalogItems.length === 0 && !busy ? (
+
+
+
+ ) : null}
+
+ {shared.catalogItems.map((item) => (
+
+ ))}
+
+ {shared.catalogCursor ? (
+
+
+
+ ) : null}
+
+ )}
+
+ );
+};
+
+SharedAssignmentCatalog.propTypes = {
+ group: PropTypes.object.isRequired,
+ isLoading: PropTypes.bool,
+ shared: PropTypes.object.isRequired,
+};
+
+export default SharedAssignmentCatalog;
diff --git a/packages/scratch-gui/src/components/classroom-modal/shared-assignment-form.jsx b/packages/scratch-gui/src/components/classroom-modal/shared-assignment-form.jsx
new file mode 100644
index 0000000000..dc6c011351
--- /dev/null
+++ b/packages/scratch-gui/src/components/classroom-modal/shared-assignment-form.jsx
@@ -0,0 +1,380 @@
+/**
+ * Share form for みんなの課題 (EPIC #1066, S2): publishes the currently open
+ * assignment (pages + starter) to the nationwide shared library. Collects
+ * the school attributes (D5), the supplement URL with explicit guidance on
+ * what belongs there (D4), the minimal author profile (D6), and the CC BY
+ * 4.0 consent (D2).
+ */
+import PropTypes from 'prop-types';
+import React, { useCallback, useState } from 'react';
+import { FormattedMessage, useIntl } from 'react-intl';
+
+import {
+ SCHOOL_LEVELS,
+ SUBJECTS_BY_LEVEL,
+ gradesForLevel,
+ parseTags,
+} from '../../lib/shared-assignment-taxonomy.js';
+import { detectSharedAuthorProfile, persistSharedAuthorProfile } from '../../lib/shared-author-profile.js';
+
+import styles from './classroom-modal.css';
+
+const schoolLevelMessages = {
+ elementary: {
+ defaultMessage: 'Elementary school',
+ description: 'School level choice: elementary',
+ id: 'gui.classroom.shared.levelElementary',
+ },
+ 'junior-high': {
+ defaultMessage: 'Junior high school',
+ description: 'School level choice: junior high',
+ id: 'gui.classroom.shared.levelJuniorHigh',
+ },
+ high: {
+ defaultMessage: 'High school',
+ description: 'School level choice: high school',
+ id: 'gui.classroom.shared.levelHigh',
+ },
+ other: {
+ defaultMessage: 'Other',
+ description: 'School level choice: other',
+ id: 'gui.classroom.shared.levelOther',
+ },
+};
+
+const SharedAssignmentForm = ({ selectedClassroom, isLoading, onCancel, onShare }) => {
+ const intl = useIntl();
+ const savedProfile = detectSharedAuthorProfile();
+ const [title, setTitle] = useState(
+ selectedClassroom.assignmentName || selectedClassroom.className || '',
+ );
+ const [summary, setSummary] = useState('');
+ const [schoolLevel, setSchoolLevel] = useState('junior-high');
+ const [grades, setGrades] = useState([]);
+ const [subject, setSubject] = useState('');
+ const [tagsText, setTagsText] = useState('');
+ const [lessonCount, setLessonCount] = useState('');
+ const [supplementUrl, setSupplementUrl] = useState('');
+ const [authorName, setAuthorName] = useState(savedProfile.authorName);
+ const [authorAffiliation, setAuthorAffiliation] = useState(savedProfile.authorAffiliation);
+ const [consent, setConsent] = useState(false);
+
+ const handleTitleChange = useCallback((e) => setTitle(e.target.value), []);
+ const handleSummaryChange = useCallback((e) => setSummary(e.target.value), []);
+ const handleLevelChange = useCallback((e) => {
+ setSchoolLevel(e.target.value);
+ // Grades and the subject vocabulary depend on the level.
+ setGrades([]);
+ setSubject('');
+ }, []);
+ const handleGradeToggle = useCallback((e) => {
+ const grade = parseInt(e.target.value, 10);
+ setGrades((prev) => (prev.includes(grade) ? prev.filter((g) => g !== grade) : [...prev, grade]));
+ }, []);
+ const handleSubjectChange = useCallback((e) => setSubject(e.target.value), []);
+ const handleTagsChange = useCallback((e) => setTagsText(e.target.value), []);
+ const handleLessonCountChange = useCallback((e) => setLessonCount(e.target.value), []);
+ const handleUrlChange = useCallback((e) => setSupplementUrl(e.target.value), []);
+ const handleAuthorNameChange = useCallback((e) => setAuthorName(e.target.value), []);
+ const handleAffiliationChange = useCallback((e) => setAuthorAffiliation(e.target.value), []);
+ const handleConsentChange = useCallback((e) => setConsent(e.target.checked), []);
+
+ const subjects = SUBJECTS_BY_LEVEL[schoolLevel] || [];
+ const urlLooksValid = supplementUrl.trim() === '' || supplementUrl.trim().startsWith('https://');
+ const canSubmit =
+ title.trim().length > 0 &&
+ subject.trim().length > 0 &&
+ authorName.trim().length > 0 &&
+ urlLooksValid &&
+ consent &&
+ !isLoading;
+
+ const handleSubmit = useCallback(
+ (e) => {
+ e.preventDefault();
+ if (!canSubmit) return;
+ persistSharedAuthorProfile({ authorName: authorName.trim(), authorAffiliation: authorAffiliation.trim() });
+ onShare({
+ classroomId: selectedClassroom.classroomId,
+ title: title.trim(),
+ summary: summary.trim() || null,
+ schoolLevel,
+ grades: [...grades].sort((a, b) => a - b),
+ subject: subject.trim(),
+ tags: parseTags(tagsText),
+ lessonCount: lessonCount ? parseInt(lessonCount, 10) : null,
+ supplementUrl: supplementUrl.trim() || null,
+ authorName: authorName.trim(),
+ authorAffiliation: authorAffiliation.trim() || null,
+ licenseConsent: true,
+ });
+ },
+ [
+ canSubmit, onShare, selectedClassroom.classroomId, title, summary, schoolLevel,
+ grades, subject, tagsText, lessonCount, supplementUrl, authorName, authorAffiliation,
+ ],
+ );
+
+ return (
+
+ );
+};
+
+SharedAssignmentForm.propTypes = {
+ isLoading: PropTypes.bool,
+ onCancel: PropTypes.func.isRequired,
+ onShare: PropTypes.func.isRequired,
+ selectedClassroom: PropTypes.object.isRequired,
+};
+
+export default SharedAssignmentForm;
diff --git a/packages/scratch-gui/src/components/classroom-modal/teacher-assignment-board.jsx b/packages/scratch-gui/src/components/classroom-modal/teacher-assignment-board.jsx
index 6b0c02033d..f7b1f7afdc 100644
--- a/packages/scratch-gui/src/components/classroom-modal/teacher-assignment-board.jsx
+++ b/packages/scratch-gui/src/components/classroom-modal/teacher-assignment-board.jsx
@@ -6,13 +6,16 @@
* assignments on the server).
*/
import PropTypes from 'prop-types';
-import React, { useCallback, useState } from 'react';
+import React, { useCallback, useEffect, useState } from 'react';
import { FormattedMessage, useIntl } from 'react-intl';
import { buildAssignmentSections } from '../../lib/classroom-group-utils.js';
import { formatClassLabel } from '../../lib/classroom-class-label.js';
-import { daysUntil, retentionLevel } from '../../lib/classroom-retention.js';
+import { retentionLevel } from '../../lib/classroom-retention.js';
import ErrorDisplay from './error-display.jsx';
+import SharedAssignmentCatalog from './shared-assignment-catalog.jsx';
+import TeacherShareStep from './teacher-share-step.jsx';
+import TeacherPasscodeImport from './teacher-passcode-import.jsx';
import TeacherBreadcrumbs from './teacher-breadcrumbs.jsx';
import styles from './classroom-modal.css';
@@ -89,12 +92,27 @@ TopicChip.propTypes = {
topic: PropTypes.string.isRequired,
};
-const AssignmentRow = ({ classroom, topics, isLoading, onSelectClassroom, onUpdateAssignmentMeta }) => {
+const AssignmentRow = ({
+ classroom,
+ topics,
+ isLoading,
+ downloadProgress,
+ downloadingId,
+ onSelectClassroom,
+ onUpdateAssignmentMeta,
+ onDownloadAssignment,
+ onShare,
+}) => {
const intl = useIntl();
const handleOpen = useCallback(
() => onSelectClassroom(classroom.classroomId),
[onSelectClassroom, classroom.classroomId],
);
+ const handleShare = useCallback(() => onShare(classroom), [onShare, classroom]);
+ const handleDownload = useCallback(
+ () => onDownloadAssignment(classroom),
+ [onDownloadAssignment, classroom],
+ );
const handleTopicChange = useCallback(
(e) => {
onUpdateAssignmentMeta(classroom.classroomId, { topic: e.target.value || null });
@@ -117,83 +135,132 @@ const AssignmentRow = ({ classroom, topics, isLoading, onSelectClassroom, onUpda
// before it hits, so "expired" never looks like a mysterious archive.
const retention = retentionLevel(classroom.expiresAt);
+ const isDownloading = downloadingId === classroom.classroomId && !!downloadProgress;
+
return (
-
-
- {classroom.assignmentName || classroom.className}
- {classroom.joinCode}
-
+
+
+
+
+ {classroom.assignmentName || classroom.className}
+
+ {classroom.joinCode}
+
+
+
+ {intl.formatMessage({
+ defaultMessage: '(no topic)',
+ description: 'Option for an assignment without a topic',
+ id: 'gui.classroom.board.noTopic',
+ })}
+
+ {topics.map((t) => (
+
+ {t}
+
+ ))}
+
+
+ {/* 共有導線はここ(ボード各行)に一本化(#1109・課題詳細からは廃止)。
+ 深さは クラス > 課題 > 共有 に収まる。 */}
+ {onShare ? (
+
+
+
+ ) : null}
+
+ {/* 期限が近い課題は「あと N 日」バッジをやめ、課題詳細と同じ自動削除
+ メッセージ + 全作品ダウンロードを行に表示する(issue #1052/#1049)。 */}
{retention === 'none' ? null : (
-
- {intl.formatMessage(
- {
- defaultMessage: '{days} days left',
- description: 'Days-until-deletion badge on an assignment row',
- id: 'gui.classroom.board.expiryBadge',
- },
- { days: daysUntil(classroom.expiresAt) },
- )}
-
+ {'⚠'}
+
+
+
+
+ {isDownloading ? (
+ `${downloadProgress.current}/${downloadProgress.total}`
+ ) : (
+
+ )}
+
+
)}
-
-
- {intl.formatMessage({
- defaultMessage: '(no topic)',
- description: 'Option for an assignment without a topic',
- id: 'gui.classroom.board.noTopic',
- })}
-
- {topics.map((t) => (
-
- {t}
-
- ))}
-
-
);
};
AssignmentRow.propTypes = {
classroom: PropTypes.object.isRequired,
+ downloadingId: PropTypes.string,
+ downloadProgress: PropTypes.shape({ current: PropTypes.number, total: PropTypes.number }),
isLoading: PropTypes.bool,
+ onDownloadAssignment: PropTypes.func.isRequired,
onSelectClassroom: PropTypes.func.isRequired,
+ onShare: PropTypes.func,
onUpdateAssignmentMeta: PropTypes.func.isRequired,
topics: PropTypes.arrayOf(PropTypes.string).isRequired,
};
@@ -216,6 +283,7 @@ const TeacherAssignmentBoard = ({
onShowClassList,
onUpdateAssignmentMeta,
onUpdateGroupTopics,
+ shared,
}) => {
const intl = useIntl();
const [newTopic, setNewTopic] = useState('');
@@ -245,6 +313,20 @@ const TeacherAssignmentBoard = ({
() => onDownloadClassAll(group, [...classrooms, ...(archivedClassrooms || [])]),
[onDownloadClassAll, group, classrooms, archivedClassrooms],
);
+ // Per-assignment download from a near-deadline row (issue #1052): reuse the
+ // class-wide zipper with a single assignment. Track which row is downloading
+ // so only that row's button shows progress (download runs one at a time).
+ const [downloadingId, setDownloadingId] = useState(null);
+ useEffect(() => {
+ if (!downloadProgress) setDownloadingId(null);
+ }, [downloadProgress]);
+ const handleDownloadAssignment = useCallback(
+ (classroom) => {
+ setDownloadingId(classroom.classroomId);
+ onDownloadClassAll(group, [classroom]);
+ },
+ [onDownloadClassAll, group],
+ );
const handleToggleInlineCreate = useCallback(() => {
setShowReuse(false);
@@ -328,6 +410,7 @@ const TeacherAssignmentBoard = ({
},
]}
/>
+ {!(shared && (shared.shareTarget || shared.showPasscodeImport)) && (
{formatClassLabel(group)}
) : null}
+ {shared ? (
+
+
+
+ ) : null}
+ {shared ? (
+
+
+
+ ) : null}
+ )}
+ {shared && shared.lastImported ? (
+
+
+
+ ) : null}
+ {shared && shared.shareTarget ? (
+
+ ) : shared && shared.showPasscodeImport ? (
+
+ ) : shared && shared.showCatalog ? (
+
+ ) : (
+
{showInlineCreate ? (
))}
@@ -628,6 +778,8 @@ const TeacherAssignmentBoard = ({
) : null}
) : null}
+
+ )}
);
};
@@ -653,6 +805,7 @@ TeacherAssignmentBoard.propTypes = {
onShowClassList: PropTypes.func.isRequired,
onUpdateAssignmentMeta: PropTypes.func.isRequired,
onUpdateGroupTopics: PropTypes.func.isRequired,
+ shared: PropTypes.object,
};
export default TeacherAssignmentBoard;
diff --git a/packages/scratch-gui/src/components/classroom-modal/teacher-class-detail.jsx b/packages/scratch-gui/src/components/classroom-modal/teacher-class-detail.jsx
index c6d2681f44..75a7fc316e 100644
--- a/packages/scratch-gui/src/components/classroom-modal/teacher-class-detail.jsx
+++ b/packages/scratch-gui/src/components/classroom-modal/teacher-class-detail.jsx
@@ -47,7 +47,6 @@ const TeacherClassDetail = ({
assignmentEditor,
group,
}) => {
- const intl = useIntl();
// Destructured with handle-prefixed names for the embedded editor
// (react/jsx-handler-names requires handler props to look like handlers).
const descEditor = assignmentEditor || {};
@@ -139,6 +138,15 @@ const TeacherClassDetail = ({
setEditAssignmentName(e.target.value);
}, []);
+ // The name field is a 2-row textarea (long names wrap), but it is still a
+ // single-line value — Enter saves and leaves rather than inserting a break.
+ const handleAssignmentNameKeyDown = useCallback((e) => {
+ if (e.key === 'Enter') {
+ e.preventDefault();
+ e.target.blur();
+ }
+ }, []);
+
const handleAssignmentNameBlur = useCallback(() => {
const trimmed = editAssignmentName.trim();
if (trimmed && trimmed !== (selectedClassroom.assignmentName || '') && onUpdateAssignmentName) {
@@ -149,11 +157,24 @@ const TeacherClassDetail = ({
+ const intl = useIntl();
+
+ // Retention alert level (issue #1052/#1049): drives the inline warning
+ // next to the deadline and the urgent styling on the download button.
+ const retention = retentionLevel(selectedClassroom && selectedClassroom.expiresAt);
+
const handleShowCode = useCallback(() => {
setShowCodeDisplay(true);
onShowCodeDisplay(selectedClassroom.classroomId);
}, [onShowCodeDisplay, selectedClassroom]);
+ const [inviteCopied, setInviteCopied] = useState(false);
+ const handleCopyInvite = useCallback(() => {
+ onCopyInviteLink(selectedClassroom);
+ setInviteCopied(true);
+ setTimeout(() => setInviteCopied(false), 2000);
+ }, [onCopyInviteLink, selectedClassroom]);
+
const handleCloseCode = useCallback(() => {
setShowCodeDisplay(false);
onCloseCodeDisplay();
@@ -220,173 +241,220 @@ const TeacherClassDetail = ({
{group ? formatClassLabel(group) : selectedClassroom.className}
- {/* Editable assignment name + post assignment button */}
-
-
-
+ {/* 左: 課題名(ラベルと 2 行入力を横並び・ラベルは縦中央) */}
+
+
+
+ {': '}
+
+
- {': '}
-
-
- {/* Google Classroom linkage now lives on the class
- (group), so an assignment posts to the group's
- course even when it has no courseId of its own.
- The posted/unposted branch below still keys off
- the assignment's own alternateLink. */}
- {(selectedClassroom.googleClassroomCourseId ||
- (group && group.googleClassroomCourseId)) &&
- (selectedClassroom.googleClassroomAlternateLink ? (
-
-
+
+
+ {/* 右: 参加コードカード。コードとボタンを 1 つの
+ flex flow に並べ、同じ行から始めることで 3 つの
+ ボタンが 2 行に収まる。狭い幅では全画面表示・招待
+ リンクコピーをアイコンのみ、GC 共有を短縮表示。 */}
+
+
+
-
- ) : (
+ {': '}
+
+
+ {selectedClassroom.joinCode.toLowerCase()}
+
+
+ {/* 全画面表示: 幅に関わらずアイコンのみ(tooltip でラベル補足) */}
+
+ {'⛶'}
+
+ {/* 招待リンクをコピー: アイコンのみ。クリックすると
+ アイコンがチェックに変わり、コピー完了を示す。 */}
-
-
+ {inviteCopied ? (
+
+
+
+ ) : (
+
+
+
+
+ )}
- ))}
-
-
-
- {/* Join code with expand button */}
-
-
-
- {': '}
-
-
- {selectedClassroom.joinCode.toLowerCase()}
-
-
- {'⛶'}
-
+ {/* Google Classroom linkage lives on the class
+ (group), so an assignment posts to the group's
+ course even without its own courseId. ボタンは
+ 幅に関わらず "[アイコン]に共有" の短縮表示で固定。 */}
+ {(selectedClassroom.googleClassroomCourseId ||
+ (group && group.googleClassroomCourseId)) &&
+ (selectedClassroom.googleClassroomAlternateLink ? (
+
+
+
+
+
+
+ ) : (
+
+
+
+
+
+
+ ))}
+
+ {/* 保存期限とその警告を1行に統合(issue #1052/#1049):
+ 期限が近いときだけ日付の右に警告を出し、専用バナーを
+ 廃して縦の表示領域を確保する。狭い幅では「ダウンロード
+ して保存してください」を省略する(CSS の container query)。 */}
{selectedClassroom.expiresAt && (
-
-
-
- )}
-
- {/* Retention alert (issue #1052): within 30 days
- of auto-deletion, prompt a bulk download. */}
- {retentionLevel(selectedClassroom.expiresAt) === 'none' ? null : (
-
-
- {downloadProgress ? (
- `${downloadProgress.current}/${downloadProgress.total}`
- ) : (
+
+
+
+ {retention !== 'none' && (
+
+ {'⚠'}
- )}
-
+
+
+
+
+ )}
)}
@@ -423,7 +491,9 @@ const TeacherClassDetail = ({
/>
)}
+ {/* 共有導線はボード(課題一覧)の各行「共有」に一本化した
+ (#1109)。課題詳細タブ行の共有ボタンは廃止。 */}
{/* Description tab: the student-facing pages editor.
diff --git a/packages/scratch-gui/src/components/classroom-modal/teacher-class-list.jsx b/packages/scratch-gui/src/components/classroom-modal/teacher-class-list.jsx
index f60f55fc4a..2edb8821fe 100644
--- a/packages/scratch-gui/src/components/classroom-modal/teacher-class-list.jsx
+++ b/packages/scratch-gui/src/components/classroom-modal/teacher-class-list.jsx
@@ -34,6 +34,10 @@ const ClassSettingsForm = ({ group, isLoading, onCancel, onUpdateGroup }) => {
// Archiving asks for confirmation (issue #1051: an accidental one-click
// archive was the original support inquiry). Unarchiving stays immediate.
const [confirmingArchive, setConfirmingArchive] = useState(false);
+ // Lowering the seat count drops seats from every assignment, so submissions
+ // on the removed seats stop showing — warn before that. Raising is silent.
+ const [confirmingDecrease, setConfirmingDecrease] = useState(false);
+ const originalCount = typeof group.studentCount === 'number' ? group.studentCount : 0;
const handleNameChange = useCallback((e) => setName(e.target.value), []);
const handleYearChange = useCallback((e) => setYear(e.target.value), []);
@@ -63,25 +67,38 @@ const ClassSettingsForm = ({ group, isLoading, onCancel, onUpdateGroup }) => {
const handleCancelArchive = useCallback(() => setConfirmingArchive(false), []);
const canSave = name.trim().length > 0 && parseInt(year, 10) >= 2000;
+ const newCount = parseInt(studentCount, 10);
+ const isDecrease = newCount > 0 && originalCount > 0 && newCount < originalCount;
+
+ const doSave = useCallback(() => {
+ const updates = {
+ name: name.trim(),
+ year: parseInt(year, 10),
+ section: section.trim() || null,
+ coTeacherEmails: coTeachers,
+ };
+ const count = parseInt(studentCount, 10);
+ if (count > 0) {
+ updates.studentCount = count;
+ }
+ onUpdateGroup(group.groupId, updates);
+ onCancel();
+ }, [name, year, section, studentCount, coTeachers, onUpdateGroup, group.groupId, onCancel]);
+
const handleSave = useCallback(
(e) => {
e.preventDefault();
if (!canSave || isLoading) return;
- const updates = {
- name: name.trim(),
- year: parseInt(year, 10),
- section: section.trim() || null,
- coTeacherEmails: coTeachers,
- };
- const count = parseInt(studentCount, 10);
- if (count > 0) {
- updates.studentCount = count;
+ // Decreasing the seat count needs an explicit confirmation first.
+ if (isDecrease && !confirmingDecrease) {
+ setConfirmingDecrease(true);
+ return;
}
- onUpdateGroup(group.groupId, updates);
- onCancel();
+ doSave();
},
- [canSave, isLoading, name, year, section, studentCount, coTeachers, onUpdateGroup, group.groupId, onCancel],
+ [canSave, isLoading, isDecrease, confirmingDecrease, doSave],
);
+ const handleCancelDecrease = useCallback(() => setConfirmingDecrease(false), []);
return (
) : null}
+ {confirmingDecrease ? (
+
+
+
+ ) : null}
{
data-testid="classroom-class-settings-cancel"
disabled={isLoading}
type="button"
- onClick={onCancel}
+ onClick={confirmingDecrease ? handleCancelDecrease : onCancel}
>
-
+ {confirmingDecrease ? (
+
+ ) : (
+
+ )}
{
disabled={!canSave || isLoading}
type="submit"
>
-
+ {confirmingDecrease ? (
+
+ ) : (
+
+ )}
diff --git a/packages/scratch-gui/src/components/classroom-modal/teacher-passcode-import.jsx b/packages/scratch-gui/src/components/classroom-modal/teacher-passcode-import.jsx
new file mode 100644
index 0000000000..7fcc1d0512
--- /dev/null
+++ b/packages/scratch-gui/src/components/classroom-modal/teacher-passcode-import.jsx
@@ -0,0 +1,135 @@
+/**
+ * 合言葉で取り込み(#1109・受け取る側)。先生からもらった合言葉で、限定公開の
+ * 課題をこの組に取り込む。入力 → プレビュー(lookup・sharedId は露出しない)→
+ * 取り込み。ボード(課題一覧)の「合言葉で取り込み」から開く。
+ */
+import PropTypes from 'prop-types';
+import React, { useCallback, useState } from 'react';
+import { FormattedMessage } from 'react-intl';
+
+import styles from './classroom-modal.css';
+
+const TeacherPasscodeImport = ({ group, isLoading, lookup, error, onLookup, onImport, onCancel }) => {
+ const [passcode, setPasscode] = useState('');
+
+ const handleChange = useCallback((e) => {
+ // 参加コード同型(英数字小文字)。空白を除き小文字化して扱う。
+ setPasscode(e.target.value.trim().toLowerCase());
+ }, []);
+ const handleLookup = useCallback(() => {
+ if (passcode.trim()) onLookup(passcode.trim());
+ }, [onLookup, passcode]);
+ const handleImport = useCallback(() => {
+ onImport(passcode.trim(), group.groupId);
+ }, [onImport, passcode, group]);
+
+ return (
+
+
+
+
+
+
+
+
+
+
+ {': '}
+
+
+
+ {error ? (
+
+ {error}
+
+ ) : null}
+ {lookup ? (
+
+
+
+ ) : null}
+
+
+
+
+ {lookup ? (
+
+
+
+ ) : (
+
+
+
+ )}
+
+
+ );
+};
+
+TeacherPasscodeImport.propTypes = {
+ error: PropTypes.string,
+ group: PropTypes.shape({ groupId: PropTypes.string }).isRequired,
+ isLoading: PropTypes.bool,
+ lookup: PropTypes.shape({ title: PropTypes.string }),
+ onCancel: PropTypes.func.isRequired,
+ onImport: PropTypes.func.isRequired,
+ onLookup: PropTypes.func.isRequired,
+};
+
+export default TeacherPasscodeImport;
diff --git a/packages/scratch-gui/src/components/classroom-modal/teacher-post-assignment.jsx b/packages/scratch-gui/src/components/classroom-modal/teacher-post-assignment.jsx
index d843c82885..568ee85847 100644
--- a/packages/scratch-gui/src/components/classroom-modal/teacher-post-assignment.jsx
+++ b/packages/scratch-gui/src/components/classroom-modal/teacher-post-assignment.jsx
@@ -4,6 +4,7 @@ import React, { useCallback, useState } from 'react';
import ErrorDisplay from './error-display.jsx';
+import { formatClassLabel } from '../../lib/classroom-class-label.js';
import googleClassroomIcon from '../classroom-teacher-modal/google-classroom-icon.png';
import styles from './classroom-modal.css';
@@ -11,6 +12,7 @@ const TeacherPostAssignment = ({
error,
errorTitle,
isLoading,
+ group,
selectedClassroom,
onBack,
onPostAssignment,
@@ -24,10 +26,7 @@ const TeacherPostAssignment = ({
const handlePost = useCallback(async () => {
if (!title.trim()) return;
try {
- const result = await onPostAssignment(
- title.trim(),
- description.trim(),
- );
+ const result = await onPostAssignment(title.trim(), description.trim());
setPosted(true);
if (result?.alternateLink) {
setAlternateLink(result.alternateLink);
@@ -45,58 +44,66 @@ const TeacherPostAssignment = ({
setDescription(e.target.value);
}, []);
+ // 課題詳細と同じ表記(例: 技術 2026年度)。group が無ければ従来の className。
+ const targetLabel = group ? formatClassLabel(group) : selectedClassroom?.className;
+
return (
-
- {'< '}
-
-
{posted ? (
-
-
+ // 配信後: タイトル(成功) + ヒント + フッター(左=戻る / 右=確認)。
+ // 基本レイアウト(プライマリー右下・キャンセル左下)に揃える。
+
+
+ {'✓ '}
- {alternateLink && (
-
+
+
+
+
+
+
+
+ {alternateLink && (
+
-
- )}
-
-
+ )}
) : (
@@ -108,16 +115,8 @@ const TeacherPostAssignment = ({
id="gui.classroom.postAssignment.pageTitle"
/>
-
-
-
+ {/* クラス名(対象ラベルなし・課題詳細と同じ表記) */}
+
{targetLabel}
-
-
-
-
+ {/* 基本レイアウト: キャンセル左下(戻ると同じ挙動)/ プライマリー右下 */}
+
+
+
+
+
+
+
+
+
>
)}
@@ -184,6 +198,11 @@ const TeacherPostAssignment = ({
TeacherPostAssignment.propTypes = {
error: PropTypes.string,
errorTitle: PropTypes.string,
+ group: PropTypes.shape({
+ name: PropTypes.string,
+ year: PropTypes.number,
+ section: PropTypes.string,
+ }),
isLoading: PropTypes.bool,
onBack: PropTypes.func,
onPostAssignment: PropTypes.func.isRequired,
diff --git a/packages/scratch-gui/src/components/classroom-modal/teacher-share-step.jsx b/packages/scratch-gui/src/components/classroom-modal/teacher-share-step.jsx
new file mode 100644
index 0000000000..f46fe70928
--- /dev/null
+++ b/packages/scratch-gui/src/components/classroom-modal/teacher-share-step.jsx
@@ -0,0 +1,268 @@
+/**
+ * 課題の共有設定ステップ(#1109)。ボード(課題一覧)の各行「共有」から開く。
+ * 深さは クラス > 課題 > 共有 に収める。公開範囲を選ぶ:
+ * - 限定公開(合言葉): 内輪だけに合言葉で共有。同意・属性なしのかんたん発行で、
+ * 研究授業で少し試した課題でも出せる(完璧さの圧を下げる)。あとで全体公開へ。
+ * - 全体公開(みんなの課題): 既存の SharedAssignmentForm(属性・CC BY 同意)。
+ */
+import classNames from 'classnames';
+import PropTypes from 'prop-types';
+import React, { useCallback, useState } from 'react';
+import { FormattedMessage } from 'react-intl';
+
+import SharedAssignmentForm from './shared-assignment-form.jsx';
+import styles from './classroom-modal.css';
+
+const TeacherShareStep = ({ classroom, isLoading, lastShared, onShare, onCancel }) => {
+ const [mode, setMode] = useState('limited');
+ const [title, setTitle] = useState(classroom.assignmentName || classroom.className || '');
+ const [copied, setCopied] = useState(false);
+
+ const handleTitleChange = useCallback((e) => setTitle(e.target.value), []);
+ const handleModeLimited = useCallback(() => setMode('limited'), []);
+ const handleModePublic = useCallback(() => setMode('public'), []);
+ const handleShareLimited = useCallback(() => {
+ if (!title.trim()) return;
+ onShare({ classroomId: classroom.classroomId, title: title.trim(), visibility: 'limited' });
+ }, [onShare, classroom.classroomId, title]);
+ const handleCopyPasscode = useCallback(() => {
+ if (lastShared && lastShared.passcode && navigator.clipboard) {
+ navigator.clipboard.writeText(lastShared.passcode).catch(() => {});
+ setCopied(true);
+ setTimeout(() => setCopied(false), 2000);
+ }
+ }, [lastShared]);
+
+ // 発行後の確認。限定公開なら合言葉を大きく表示し、配布を促す。
+ if (lastShared) {
+ return (
+
+
+
+
+ {lastShared.passcode ? (
+ <>
+
+
+
+ {': '}
+
+
+ {lastShared.passcode}
+
+
+ {copied ? (
+
+ ) : (
+
+ )}
+
+
+
+
+
+ >
+ ) : (
+
+
+
+ )}
+
+
+
+
+
+
+
+ );
+ }
+
+ return (
+
+
+
+
+
+ {classroom.assignmentName || classroom.className}
+
+
+ {/* 公開範囲の選択 */}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {mode === 'limited' ? (
+ <>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ >
+ ) : (
+
+ )}
+
+ );
+};
+
+TeacherShareStep.propTypes = {
+ classroom: PropTypes.shape({
+ classroomId: PropTypes.string,
+ assignmentName: PropTypes.string,
+ className: PropTypes.string,
+ }).isRequired,
+ isLoading: PropTypes.bool,
+ lastShared: PropTypes.shape({
+ title: PropTypes.string,
+ passcode: PropTypes.string,
+ }),
+ onCancel: PropTypes.func.isRequired,
+ onShare: PropTypes.func.isRequired,
+};
+
+export default TeacherShareStep;
diff --git a/packages/scratch-gui/src/components/classroom-teacher-modal/classroom-teacher-modal.jsx b/packages/scratch-gui/src/components/classroom-teacher-modal/classroom-teacher-modal.jsx
index fcf3644c96..beb3e28de4 100644
--- a/packages/scratch-gui/src/components/classroom-teacher-modal/classroom-teacher-modal.jsx
+++ b/packages/scratch-gui/src/components/classroom-teacher-modal/classroom-teacher-modal.jsx
@@ -41,6 +41,11 @@ const messagesBreadcrumbs = defineMessages({
description: 'Breadcrumb label of the assignment detail view',
id: 'gui.classroom.breadcrumbs.assignmentDetail',
},
+ shareGc: {
+ defaultMessage: 'Share to Google Classroom',
+ description: 'Breadcrumb label of the Google Classroom share view',
+ id: 'gui.classroom.breadcrumbs.shareGc',
+ },
});
const messages = defineMessages({
@@ -132,6 +137,7 @@ const ClassroomTeacherModal = ({ containerProps, onClose }) => {
onCreateGroup,
onUpdateGroup,
evaluation,
+ shared,
} = containerProps;
// Opening a class scopes the board to its assignments (GC style).
@@ -287,6 +293,7 @@ const ClassroomTeacherModal = ({ containerProps, onClose }) => {
onReturnSubmission={onReturnSubmission}
onSelectMember={onSelectMember}
onShowCodeDisplay={onShowCodeDisplay}
+ shared={shared}
onShowPostAssignment={authProvider === 'google' ? onShowPostAssignment : null}
onToggleCodeFullscreen={onToggleCodeFullscreen}
onUpdateAssignmentName={onUpdateAssignmentName}
@@ -319,14 +326,39 @@ const ClassroomTeacherModal = ({ containerProps, onClose }) => {
if (phase === 'teacher-post-assignment') {
return (
-
+
);
}
@@ -346,6 +378,7 @@ const ClassroomTeacherModal = ({ containerProps, onClose }) => {
errorTitle={errorTitle}
group={selectedGroup}
isLoading={isLoading}
+ shared={shared}
onCreateAssignmentInClass={onCreateAssignmentInClass}
onDownloadClassAll={onDownloadClassAll}
onRestoreClassroom={onRestoreClassroom}
diff --git a/packages/scratch-gui/src/containers/classroom-teacher-modal.jsx b/packages/scratch-gui/src/containers/classroom-teacher-modal.jsx
index aabaa4964c..5bce07267a 100644
--- a/packages/scratch-gui/src/containers/classroom-teacher-modal.jsx
+++ b/packages/scratch-gui/src/containers/classroom-teacher-modal.jsx
@@ -198,6 +198,7 @@ const ClassroomTeacherModal = () => {
onReuseAssignment: teacher.handleReuseAssignment,
onUpdateGroup: teacher.handleUpdateGroup,
evaluation: teacher.evaluation,
+ shared: teacher.shared,
};
return
;
};
diff --git a/packages/scratch-gui/src/containers/use-shared-assignments.js b/packages/scratch-gui/src/containers/use-shared-assignments.js
new file mode 100644
index 0000000000..7bad0c46e7
--- /dev/null
+++ b/packages/scratch-gui/src/containers/use-shared-assignments.js
@@ -0,0 +1,343 @@
+/**
+ * みんなの課題 (shared assignment library) hook — EPIC #1066.
+ *
+ * S2 (#1069): publishing an assignment from the detail view.
+ * S3 (#1070): the catalog — browse/filter, detail preview, import into the
+ * current class, own-posts management (unlist/republish) and reporting.
+ */
+import { useCallback, useState } from 'react';
+import classroomAPI from '../lib/classroom-api.js';
+import translateError from './classroom-error-utils.js';
+
+/**
+ * @param {object} params - hook dependencies
+ * @param {string} params.idToken - teacher ID token
+ * @param {Function} params.handleTeacher401 - 401 handler from auth hook
+ * @param {Function} params.loadClassrooms - refresh the assignment list after an import
+ * @param {Function} params.clearError - clear error helper
+ * @param {Function} params.showError - error display helper
+ * @param {object} params.intl - react-intl intl object
+ * @param {Function} params.setIsLoading - loading state setter
+ * @returns {object} shared assignment state and handlers
+ */
+const useSharedAssignments = ({
+ idToken,
+ handleTeacher401,
+ loadClassrooms,
+ clearError,
+ showError,
+ intl,
+ setIsLoading,
+}) => {
+ // 共有導線はボード(課題一覧)の各行から開く(#1109)。shareTarget が
+ // その課題(classroom)で、これが立っている間だけ共有設定ステップを出す。
+ // lastShared に発行結果(限定公開なら合言葉)を保持してステップ内で確認する。
+ const [shareTarget, setShareTarget] = useState(null);
+ const [lastShared, setLastShared] = useState(null);
+
+ // Catalog state (S3). `catalogTab` switches the whole list between the
+ // public catalog and the caller's own posts (mine=1).
+ // 合言葉で取り込み(#1109・受け取る側)。プレビュー(lookup)→ 取り込み。
+ const [showPasscodeImport, setShowPasscodeImport] = useState(false);
+ const [passcodeLookup, setPasscodeLookup] = useState(null);
+ const [passcodeError, setPasscodeError] = useState(null);
+
+ const [showCatalog, setShowCatalog] = useState(false);
+ const [catalogTab, setCatalogTab] = useState('all');
+ const [catalogItems, setCatalogItems] = useState([]);
+ const [catalogCursor, setCatalogCursor] = useState(null);
+ const [catalogLoading, setCatalogLoading] = useState(false);
+ const [sharedDetail, setSharedDetail] = useState(null);
+ const [lastImported, setLastImported] = useState(null);
+ const [reportSent, setReportSent] = useState(false);
+
+ const handleOpenShareFor = useCallback((classroom) => {
+ setLastShared(null);
+ setShareTarget(classroom);
+ }, []);
+ const handleCloseShareForm = useCallback(() => {
+ setShareTarget(null);
+ setLastShared(null);
+ }, []);
+
+ const handleShareAssignment = useCallback(
+ async (payload) => {
+ clearError();
+ setIsLoading(true);
+ try {
+ const shared = await classroomAPI.shareAssignment(idToken, payload);
+ // ステップに留まり、結果(限定公開なら合言葉)を確認させる。
+ setLastShared(shared);
+ } catch (err) {
+ if (err.status === 401) {
+ await handleTeacher401();
+ } else {
+ showError(translateError(intl, err));
+ }
+ } finally {
+ setIsLoading(false);
+ }
+ },
+ [idToken, clearError, showError, handleTeacher401, intl, setIsLoading],
+ );
+
+ // --- 合言葉で取り込み(#1109・受け取る側) ---
+
+ const handleOpenPasscodeImport = useCallback(() => {
+ setPasscodeLookup(null);
+ setPasscodeError(null);
+ setShowPasscodeImport(true);
+ }, []);
+ const handleClosePasscodeImport = useCallback(() => {
+ setShowPasscodeImport(false);
+ setPasscodeLookup(null);
+ setPasscodeError(null);
+ }, []);
+
+ /** Preview a limited-published item by 合言葉 (sharedId は返らない). */
+ const handleLookupPasscode = useCallback(
+ async (passcode) => {
+ setPasscodeError(null);
+ setPasscodeLookup(null);
+ setIsLoading(true);
+ try {
+ setPasscodeLookup(await classroomAPI.lookupSharedByPasscode(idToken, passcode));
+ } catch (err) {
+ if (err.status === 401) {
+ await handleTeacher401();
+ } else {
+ setPasscodeError(translateError(intl, err));
+ }
+ } finally {
+ setIsLoading(false);
+ }
+ },
+ [idToken, handleTeacher401, intl, setIsLoading],
+ );
+
+ /** Import a limited item by 合言葉 into the given class (組). */
+ const handleImportByPasscode = useCallback(
+ async (passcode, groupId) => {
+ setPasscodeError(null);
+ setIsLoading(true);
+ try {
+ const created = await classroomAPI.importSharedByPasscode(idToken, { passcode, groupId });
+ setLastImported(created);
+ setShowPasscodeImport(false);
+ setPasscodeLookup(null);
+ if (loadClassrooms) {
+ await loadClassrooms();
+ }
+ } catch (err) {
+ if (err.status === 401) {
+ await handleTeacher401();
+ } else {
+ setPasscodeError(translateError(intl, err));
+ }
+ } finally {
+ setIsLoading(false);
+ }
+ },
+ [idToken, loadClassrooms, handleTeacher401, intl, setIsLoading],
+ );
+
+ // --- Catalog (S3) ---
+
+ const loadCatalog = useCallback(
+ async ({ tab = catalogTab, filters = {}, cursor = null, append = false } = {}) => {
+ clearError();
+ setCatalogLoading(true);
+ try {
+ const data = await classroomAPI.listSharedAssignments(idToken, {
+ ...filters,
+ ...(tab === 'mine' ? { mine: '1' } : {}),
+ ...(cursor ? { cursor } : {}),
+ });
+ setCatalogItems((prev) => (append ? [...prev, ...(data.items || [])] : data.items || []));
+ setCatalogCursor(data.cursor || null);
+ } catch (err) {
+ if (err.status === 401) {
+ await handleTeacher401();
+ } else {
+ showError(translateError(intl, err));
+ }
+ } finally {
+ setCatalogLoading(false);
+ }
+ },
+ [idToken, catalogTab, clearError, showError, handleTeacher401, intl],
+ );
+
+ const handleOpenCatalog = useCallback(() => {
+ setShowCatalog(true);
+ setCatalogTab('all');
+ setSharedDetail(null);
+ setLastImported(null);
+ loadCatalog({ tab: 'all' });
+ }, [loadCatalog]);
+
+ const handleCloseCatalog = useCallback(() => {
+ setShowCatalog(false);
+ setSharedDetail(null);
+ }, []);
+
+ const handleCatalogTabChange = useCallback(
+ (tab) => {
+ setCatalogTab(tab);
+ setSharedDetail(null);
+ loadCatalog({ tab });
+ },
+ [loadCatalog],
+ );
+
+ const handleApplyCatalogFilters = useCallback((filters) => loadCatalog({ filters }), [loadCatalog]);
+
+ const handleLoadMoreCatalog = useCallback(
+ (filters) => loadCatalog({ filters, cursor: catalogCursor, append: true }),
+ [loadCatalog, catalogCursor],
+ );
+
+ const handleOpenSharedDetail = useCallback(
+ async (sharedId) => {
+ clearError();
+ setCatalogLoading(true);
+ setReportSent(false);
+ try {
+ setSharedDetail(await classroomAPI.getSharedAssignment(idToken, sharedId));
+ } catch (err) {
+ if (err.status === 401) {
+ await handleTeacher401();
+ } else {
+ showError(translateError(intl, err));
+ }
+ } finally {
+ setCatalogLoading(false);
+ }
+ },
+ [idToken, clearError, showError, handleTeacher401, intl],
+ );
+
+ const handleCloseSharedDetail = useCallback(() => setSharedDetail(null), []);
+
+ const handleImportShared = useCallback(
+ async (sharedId, groupId) => {
+ clearError();
+ setIsLoading(true);
+ try {
+ const created = await classroomAPI.importSharedAssignment(idToken, sharedId, { groupId });
+ setLastImported(created);
+ setShowCatalog(false);
+ setSharedDetail(null);
+ if (loadClassrooms) {
+ await loadClassrooms();
+ }
+ } catch (err) {
+ if (err.status === 401) {
+ await handleTeacher401();
+ } else {
+ showError(translateError(intl, err));
+ }
+ } finally {
+ setIsLoading(false);
+ }
+ },
+ [idToken, clearError, loadClassrooms, showError, handleTeacher401, intl, setIsLoading],
+ );
+
+ /** Own-posts management: unlist / republish, then refresh the mine tab. */
+ const handleSetSharedStatus = useCallback(
+ async (sharedId, status) => {
+ clearError();
+ setCatalogLoading(true);
+ try {
+ if (status === 'unlisted') {
+ await classroomAPI.unlistSharedAssignment(idToken, sharedId);
+ } else {
+ await classroomAPI.updateSharedAssignment(idToken, sharedId, { status });
+ }
+ setSharedDetail(null);
+ await loadCatalog({ tab: 'mine' });
+ } catch (err) {
+ if (err.status === 401) {
+ await handleTeacher401();
+ } else {
+ showError(translateError(intl, err));
+ }
+ } finally {
+ setCatalogLoading(false);
+ }
+ },
+ [idToken, clearError, loadCatalog, showError, handleTeacher401, intl],
+ );
+
+ const handleReportShared = useCallback(
+ async (sharedId, reason) => {
+ clearError();
+ setCatalogLoading(true);
+ try {
+ await classroomAPI.reportSharedAssignment(idToken, sharedId, reason);
+ setReportSent(true);
+ } catch (err) {
+ if (err.status === 401) {
+ await handleTeacher401();
+ } else {
+ showError(translateError(intl, err));
+ }
+ } finally {
+ setCatalogLoading(false);
+ }
+ },
+ [idToken, clearError, showError, handleTeacher401, intl],
+ );
+
+ /** Reset share state (view switch / logout). */
+ const resetSharedAssignments = useCallback(() => {
+ setShareTarget(null);
+ setLastShared(null);
+ setShowPasscodeImport(false);
+ setPasscodeLookup(null);
+ setPasscodeError(null);
+ setShowCatalog(false);
+ setCatalogItems([]);
+ setCatalogCursor(null);
+ setSharedDetail(null);
+ setLastImported(null);
+ setReportSent(false);
+ }, []);
+
+ return {
+ shareTarget,
+ lastShared,
+ handleOpenShareFor,
+ handleCloseShareForm,
+ handleShareAssignment,
+ showPasscodeImport,
+ passcodeLookup,
+ passcodeError,
+ handleOpenPasscodeImport,
+ handleClosePasscodeImport,
+ handleLookupPasscode,
+ handleImportByPasscode,
+ showCatalog,
+ catalogTab,
+ catalogItems,
+ catalogCursor,
+ catalogLoading,
+ sharedDetail,
+ lastImported,
+ reportSent,
+ handleOpenCatalog,
+ handleCloseCatalog,
+ handleCatalogTabChange,
+ handleApplyCatalogFilters,
+ handleLoadMoreCatalog,
+ handleOpenSharedDetail,
+ handleCloseSharedDetail,
+ handleImportShared,
+ handleSetSharedStatus,
+ handleReportShared,
+ resetSharedAssignments,
+ };
+};
+
+export default useSharedAssignments;
diff --git a/packages/scratch-gui/src/containers/use-teacher-classroom.js b/packages/scratch-gui/src/containers/use-teacher-classroom.js
index 4fc0df3993..8ebeb34257 100644
--- a/packages/scratch-gui/src/containers/use-teacher-classroom.js
+++ b/packages/scratch-gui/src/containers/use-teacher-classroom.js
@@ -7,6 +7,7 @@
*/
import { useCallback, useRef } from 'react';
import useGoogleClassroom from './use-google-classroom.js';
+import useSharedAssignments from './use-shared-assignments.js';
import useTeacherAssignment from './use-teacher-assignment.js';
import useTeacherAuth, { getCachedTeacherIdToken, setCachedTeacherIdToken } from './use-teacher-auth.js';
import useTeacherClassrooms from './use-teacher-classrooms.js';
@@ -134,6 +135,16 @@ const useTeacherClassroom = ({
setPhase,
});
+ const shared = useSharedAssignments({
+ idToken: auth.idToken,
+ handleTeacher401: auth.handleTeacher401,
+ loadClassrooms: classrooms.loadClassrooms,
+ clearError,
+ showError,
+ intl,
+ setIsLoading,
+ });
+
// --- Composed handlers ---
const handleTeacherLogout = useCallback(() => {
@@ -232,6 +243,7 @@ const useTeacherClassroom = ({
// Evaluation (期末評価)
evaluation,
+ shared,
// Groups (組)
groups: groups.groups,
diff --git a/packages/scratch-gui/src/lib/classroom-api.js b/packages/scratch-gui/src/lib/classroom-api.js
index e16a30495b..2514c72281 100644
--- a/packages/scratch-gui/src/lib/classroom-api.js
+++ b/packages/scratch-gui/src/lib/classroom-api.js
@@ -326,6 +326,116 @@ class ClassroomAPI {
return this._request('PATCH', `/classroom-groups/${groupId}/topics`, payload, idToken);
}
+ // --- みんなの課題 (shared assignment library, EPIC #1066) ---
+
+ /**
+ * Publish one of the teacher's assignments to the shared library.
+ * `visibility: 'limited'` (#1109) shares as 合言葉限定公開 with light
+ * validation and returns a passcode; 'public' (default) is the みんなの課題
+ * catalog and requires the full metadata + CC BY consent.
+ * @param {string} idToken - Teacher ID token
+ * @param {object} payload - {classroomId, title, visibility?, summary?,
+ * schoolLevel?, grades?, subject?, tags?, lessonCount?, supplementUrl?,
+ * authorName?, authorAffiliation?, licenseConsent?}
+ * @returns {Promise
} Published item summary (passcode when limited)
+ */
+ async shareAssignment(idToken, payload) {
+ return this._request('POST', '/shared-assignments', payload, idToken);
+ }
+
+ /**
+ * Preview a limited-published shared assignment by 合言葉 (#1109) before
+ * importing. Does not expose the sharedId.
+ * @param {string} idToken - Teacher ID token
+ * @param {string} passcode - the 合言葉
+ * @returns {Promise} Shared summary (no sharedId)
+ */
+ async lookupSharedByPasscode(idToken, passcode) {
+ return this._request('POST', '/shared-assignments/lookup', { passcode }, idToken);
+ }
+
+ /**
+ * Import a limited-published shared assignment by 合言葉 (#1109) into one of
+ * the teacher's own classes.
+ * @param {string} idToken - Teacher ID token
+ * @param {object} payload - {passcode, groupId, assignmentName?}
+ * @returns {Promise} Created classroom summary
+ */
+ async importSharedByPasscode(idToken, payload) {
+ return this._request('POST', '/shared-assignments/import-by-passcode', payload, idToken);
+ }
+
+ /**
+ * Browse the shared assignment catalog (newest first).
+ * @param {string} idToken - Teacher ID token
+ * @param {object} [filters] - {schoolLevel?, subject?, grade?, tag?, cursor?, mine?}
+ * @returns {Promise} {items, cursor}
+ */
+ async listSharedAssignments(idToken, filters = {}) {
+ const params = new URLSearchParams();
+ for (const [key, value] of Object.entries(filters)) {
+ if (typeof value !== 'undefined' && value !== null && value !== '') {
+ params.set(key, String(value));
+ }
+ }
+ const qs = params.toString();
+ return this._request('GET', `/shared-assignments${qs ? `?${qs}` : ''}`, null, idToken);
+ }
+
+ /**
+ * Get a shared assignment's detail (pages with presigned image URLs).
+ * @param {string} idToken - Teacher ID token
+ * @param {string} sharedId - Shared assignment ID
+ * @returns {Promise} Detail including pages and starterUrl
+ */
+ async getSharedAssignment(idToken, sharedId) {
+ return this._request('GET', `/shared-assignments/${sharedId}`, null, idToken);
+ }
+
+ /**
+ * Import a shared assignment into one of the teacher's own classes.
+ * @param {string} idToken - Teacher ID token
+ * @param {string} sharedId - Shared assignment ID
+ * @param {object} payload - {groupId, assignmentName?}
+ * @returns {Promise} Created classroom summary
+ */
+ async importSharedAssignment(idToken, sharedId, payload) {
+ return this._request('POST', `/shared-assignments/${sharedId}/import`, payload, idToken);
+ }
+
+ /**
+ * Update the caller's own shared assignment (metadata, or content
+ * re-snapshot when classroomId is given — overwrite semantics).
+ * @param {string} idToken - Teacher ID token
+ * @param {string} sharedId - Shared assignment ID
+ * @param {object} updates - Fields to update
+ * @returns {Promise} Updated item summary
+ */
+ async updateSharedAssignment(idToken, sharedId, updates) {
+ return this._request('PATCH', `/shared-assignments/${sharedId}`, updates, idToken);
+ }
+
+ /**
+ * Unlist (withdraw) the caller's own shared assignment.
+ * @param {string} idToken - Teacher ID token
+ * @param {string} sharedId - Shared assignment ID
+ * @returns {Promise} resolves on 204
+ */
+ async unlistSharedAssignment(idToken, sharedId) {
+ return this._request('DELETE', `/shared-assignments/${sharedId}`, null, idToken);
+ }
+
+ /**
+ * Report a shared assignment for moderation.
+ * @param {string} idToken - Teacher ID token
+ * @param {string} sharedId - Shared assignment ID
+ * @param {string} reason - Report reason (required)
+ * @returns {Promise} empty object on success
+ */
+ async reportSharedAssignment(idToken, sharedId, reason) {
+ return this._request('POST', `/shared-assignments/${sharedId}/report`, { reason }, idToken);
+ }
+
/**
* List the teacher's groups (active and archived).
* @param {string} idToken - Teacher ID token
diff --git a/packages/scratch-gui/src/lib/shared-assignment-taxonomy.js b/packages/scratch-gui/src/lib/shared-assignment-taxonomy.js
new file mode 100644
index 0000000000..35f93b774d
--- /dev/null
+++ b/packages/scratch-gui/src/lib/shared-assignment-taxonomy.js
@@ -0,0 +1,52 @@
+/**
+ * みんなの課題 (shared assignment library) attribute taxonomy — EPIC #1066 D5.
+ *
+ * Mirrors the server's controlled vocabulary (infra/smalruby-classroom
+ * lambda/handler.ts SHARED_SUBJECTS): school level × grades × subject plus
+ * free tags. Subjects are Japanese strings by design (they ARE the values,
+ * not i18n keys); school level labels are translated by the components.
+ */
+
+export const SCHOOL_LEVELS = [
+ { value: 'elementary', maxGrade: 6 },
+ { value: 'junior-high', maxGrade: 3 },
+ { value: 'high', maxGrade: 3 },
+ { value: 'other', maxGrade: 6 },
+];
+
+/** Controlled subject vocabulary per school level ('other' is free text). */
+export const SUBJECTS_BY_LEVEL = {
+ elementary: ['総合的な学習の時間', '算数', '理科', '図画工作', '特別活動・クラブ', 'その他'],
+ 'junior-high': ['技術・家庭(技術分野)', '数学', '理科', '総合的な学習の時間', 'その他'],
+ high: ['情報Ⅰ', '情報Ⅱ', 'その他'],
+ other: [],
+};
+
+export const MAX_TAGS = 5;
+export const MAX_TAG_LENGTH = 20;
+
+/**
+ * Grade choices (1..max) for a school level.
+ * @param {string} schoolLevel - one of SCHOOL_LEVELS values
+ * @returns {Array} selectable grades
+ */
+export const gradesForLevel = (schoolLevel) => {
+ const level = SCHOOL_LEVELS.find((l) => l.value === schoolLevel);
+ return level ? Array.from({ length: level.maxGrade }, (_, i) => i + 1) : [];
+};
+
+/**
+ * Parse a free tag input ("甲子園, 入門" / space separated) into a clean
+ * tag list: trimmed, deduplicated, capped at MAX_TAGS.
+ * @param {string} raw - raw input text
+ * @returns {Array} normalized tags
+ */
+export const parseTags = (raw) =>
+ [
+ ...new Set(
+ String(raw || '')
+ .split(/[,、\s]+/)
+ .map((t) => t.trim())
+ .filter((t) => t.length > 0 && t.length <= MAX_TAG_LENGTH),
+ ),
+ ].slice(0, MAX_TAGS);
diff --git a/packages/scratch-gui/src/lib/shared-author-profile.js b/packages/scratch-gui/src/lib/shared-author-profile.js
new file mode 100644
index 0000000000..1ca0693b78
--- /dev/null
+++ b/packages/scratch-gui/src/lib/shared-author-profile.js
@@ -0,0 +1,49 @@
+/**
+ * Author profile persistence for みんなの課題 — EPIC #1066 D6.
+ *
+ * The public profile is deliberately minimal (display name + optional
+ * affiliation; no email, no real-name requirement) and is remembered in
+ * localStorage so a teacher types it once.
+ */
+
+const STORAGE_KEY = 'smalruby:sharedAuthorProfile';
+
+/**
+ * Load the remembered author profile.
+ * @returns {{authorName: string, authorAffiliation: string}} the profile
+ * (empty strings when nothing is stored)
+ */
+export const detectSharedAuthorProfile = () => {
+ const empty = { authorName: '', authorAffiliation: '' };
+ if (typeof window === 'undefined' || !window.localStorage) return empty;
+ try {
+ const raw = window.localStorage.getItem(STORAGE_KEY);
+ if (!raw) return empty;
+ const parsed = JSON.parse(raw);
+ return {
+ authorName: typeof parsed.authorName === 'string' ? parsed.authorName : '',
+ authorAffiliation: typeof parsed.authorAffiliation === 'string' ? parsed.authorAffiliation : '',
+ };
+ } catch {
+ return empty;
+ }
+};
+
+/**
+ * Remember the author profile for the next share.
+ * @param {{authorName: string, authorAffiliation?: string}} profile - profile to store
+ */
+export const persistSharedAuthorProfile = (profile) => {
+ if (typeof window === 'undefined' || !window.localStorage) return;
+ try {
+ window.localStorage.setItem(
+ STORAGE_KEY,
+ JSON.stringify({
+ authorName: profile.authorName || '',
+ authorAffiliation: profile.authorAffiliation || '',
+ }),
+ );
+ } catch {
+ // Storage may be unavailable (private mode) — sharing still works.
+ }
+};
diff --git a/packages/scratch-gui/src/locales/en.js b/packages/scratch-gui/src/locales/en.js
index d42afb6f17..a72769defa 100644
--- a/packages/scratch-gui/src/locales/en.js
+++ b/packages/scratch-gui/src/locales/en.js
@@ -318,7 +318,6 @@ export default {
'gui.classroom.assignmentEditor.errorStarterSave': 'Failed to save the current project as starter',
'gui.classroom.postAssignment.title': 'Post Assignment',
'gui.classroom.postAssignment.pageTitle': 'Post Assignment to Google Classroom',
- 'gui.classroom.postAssignment.target': 'Target: {className}',
'gui.classroom.postAssignment.titleLabel': 'Title:',
'gui.classroom.postAssignment.descriptionLabel': 'Assignment details (optional)',
'gui.classroom.postAssignment.hint':
@@ -342,8 +341,64 @@ export default {
'gui.classroom.board.archivedToggle': 'Archived assignments ({count})',
'gui.classroom.board.archivedExpires': 'Kept until {date}',
'gui.classroom.board.restore': 'Restore',
- 'gui.classroom.board.expiryBadge': '{days} days left',
'gui.classroom.board.downloadClass': 'Download all submissions',
+ 'gui.classroom.shared.levelElementary': 'Elementary school',
+ 'gui.classroom.shared.levelJuniorHigh': 'Junior high school',
+ 'gui.classroom.shared.levelHigh': 'High school',
+ 'gui.classroom.shared.levelOther': 'Other',
+ 'gui.classroom.shared.formTitle': 'Share this assignment with teachers nationwide',
+ 'gui.classroom.shared.formHint':
+ 'The assignment pages and starter project are published as a snapshot. ' +
+ 'Submissions and student data are never shared.',
+ 'gui.classroom.shared.titlePlaceholder': 'Title (required)',
+ 'gui.classroom.shared.summaryPlaceholder': 'Short description shown on the catalog card (optional)',
+ 'gui.classroom.shared.subjectPlaceholder': 'Subject (required)',
+ 'gui.classroom.shared.lessonCountPlaceholder': 'Lessons',
+ 'gui.classroom.shared.gradesLabel': 'Grades (optional)',
+ 'gui.classroom.shared.gradeN': 'Grade {grade}',
+ 'gui.classroom.shared.tagsPlaceholder': 'Tags, comma separated — e.g. 甲子園, メッシュ (up to 5)',
+ 'gui.classroom.shared.urlPlaceholder': 'Supplement URL (optional, https only)',
+ 'gui.classroom.shared.urlHint':
+ 'Link to material that shows how to run the lesson — a lesson plan, slides, or a study ' +
+ 'report. We recommend a Google Drive / Google Docs link set to ' +
+ '“anyone with the link can view”.',
+ 'gui.classroom.shared.urlError': 'The URL must start with https://',
+ 'gui.classroom.shared.authorNamePlaceholder': 'Display name (required — shown as the author credit)',
+ 'gui.classroom.shared.affiliationPlaceholder': 'Affiliation (optional — e.g. Shimane / public junior high)',
+ 'gui.classroom.shared.consent':
+ 'I publish this assignment under the CC BY 4.0 license and allow other teachers to use ' +
+ 'and adapt it in their lessons (credited to my display name).',
+ 'gui.classroom.shared.cancel': 'Cancel',
+ 'gui.classroom.shared.submit': 'Share',
+ 'gui.classroom.shared.published': 'Published to みんなの課題: "{title}" (© {author} / CC BY 4.0)',
+ 'gui.classroom.shared.openCatalog': 'Find in みんなの課題',
+ 'gui.classroom.shared.catalogTitle': 'みんなの課題 — assignments shared by teachers nationwide',
+ 'gui.classroom.shared.catalogClose': 'Close',
+ 'gui.classroom.shared.tabAll': 'Browse all',
+ 'gui.classroom.shared.tabMine': 'My posts',
+ 'gui.classroom.shared.filterAllLevels': 'All school levels',
+ 'gui.classroom.shared.filterAllSubjects': 'All subjects',
+ 'gui.classroom.shared.filterAllGrades': 'All grades',
+ 'gui.classroom.shared.filterTag': 'Tag',
+ 'gui.classroom.shared.filterApply': 'Filter',
+ 'gui.classroom.shared.catalogEmpty': 'No shared assignments found.',
+ 'gui.classroom.shared.loadMore': 'Load more',
+ 'gui.classroom.shared.unlistedBadge': 'Unlisted',
+ 'gui.classroom.shared.gradesBadge': 'Grade {grades}',
+ 'gui.classroom.shared.cardMeta': 'by {author} — imported {count} times',
+ 'gui.classroom.shared.detailBack': 'Back to the list',
+ 'gui.classroom.shared.hasStarter': 'Includes a starter project — importing loads it for your class.',
+ 'gui.classroom.shared.urlButton': 'Supplement material (lesson plan etc.)',
+ 'gui.classroom.shared.urlConfirm': 'Opens an external site ({domain}). Its content is managed by the author.',
+ 'gui.classroom.shared.urlOpen': 'Open',
+ 'gui.classroom.shared.unlist': 'Withdraw (unlist)',
+ 'gui.classroom.shared.republish': 'Republish',
+ 'gui.classroom.shared.report': 'Report',
+ 'gui.classroom.shared.reportPlaceholder': 'Why are you reporting this? (required)',
+ 'gui.classroom.shared.reportSubmit': 'Send report',
+ 'gui.classroom.shared.reportSent': 'Thank you. The report has been sent to the operators.',
+ 'gui.classroom.shared.import': 'Import into this class',
+ 'gui.classroom.shared.imported': 'Imported "{name}" into this class. A new join code was issued.',
'gui.classroom.teacherDetail.retentionBanner':
'This assignment and its submissions will be deleted automatically on {date}. ' +
'Download them to keep a copy.',
diff --git a/packages/scratch-gui/src/locales/ja-Hira.js b/packages/scratch-gui/src/locales/ja-Hira.js
index 1017efc8d9..d6bb7bf5c0 100644
--- a/packages/scratch-gui/src/locales/ja-Hira.js
+++ b/packages/scratch-gui/src/locales/ja-Hira.js
@@ -219,6 +219,7 @@ export default {
'gui.classroom.breadcrumbs.classList': 'クラスいちらん',
'gui.classroom.breadcrumbs.assignments': 'かだいいちらん',
'gui.classroom.breadcrumbs.assignmentDetail': 'かだいしょうさい',
+ 'gui.classroom.breadcrumbs.shareGc': 'Google Classroom に きょうゆう',
'gui.classroom.board.createNamePlaceholder': 'かだいめい(れい: ねこをうごかそう)',
'gui.classroom.board.createSubmit': 'つくる',
'gui.classroom.board.reuse': 'かだいをさいりよう',
@@ -331,12 +332,13 @@ export default {
'gui.classroom.assignmentEditor.errorStarterSave': 'ひらいているプロジェクトのほぞんにしっぱいしました',
'gui.classroom.postAssignment.title': 'かだいをはいしん',
'gui.classroom.postAssignment.pageTitle': 'Google Classroom にかだいをはいしんします',
- 'gui.classroom.postAssignment.target': 'たいしょう: {className}',
'gui.classroom.postAssignment.titleLabel': 'タイトル:',
'gui.classroom.postAssignment.descriptionLabel': 'かだいのしょうさい(しょうりゃくか)',
'gui.classroom.postAssignment.hint':
'はいしんご、かだいのしょうさいのそうしょく、わりあてさき、てんすうなどのせっていは Google Classroom でおこなえます。はいしんしたかだいは Google Classroom からさくじょできます。',
'gui.classroom.postAssignment.post': 'Google Classroom にはいしんする',
+ 'gui.classroom.postAssignment.cancel': 'キャンセル',
+ 'gui.classroom.postAssignment.backToDetail': 'かだいしょうさいにもどる',
'gui.classroom.postAssignment.success': 'はいしんしました!',
'gui.classroom.postAssignment.viewOnGC': 'Google Classroom でかくにんする',
'gui.classroom.postAssignment.postHint': 'はいしんしたかだいは Google Classroom でへんしゅう・さくじょできます。',
@@ -351,18 +353,109 @@ export default {
'このクラスをアーカイブしますか?クラスいちらんからみえなくなりますが、「アーカイブずみのクラス」からいつでももとにもどせます。',
'gui.classroom.classSettings.archiveConfirmYes': 'アーカイブする',
'gui.classroom.classSettings.archiveConfirmNo': 'アーカイブしない',
+ 'gui.classroom.classSettings.decreaseConfirmMessage':
+ 'このクラスの にんずうを {count} にん に へらしますか?このクラスの すべての かだいで {from}〜{to} ばんの せきが なくなり、その せきに ていしゅつされた さくひんは ひょうじされなくなります。',
+ 'gui.classroom.classSettings.decreaseConfirmYes': 'へらして ほぞん',
+ 'gui.classroom.classSettings.decreaseConfirmNo': 'にんずうは そのまま',
'gui.classroom.board.archivedToggle': 'アーカイブずみのかだい({count})',
'gui.classroom.board.archivedExpires': 'ほぞんきげん: {date}',
'gui.classroom.board.restore': 'もとにもどす',
- 'gui.classroom.board.expiryBadge': 'あと{days}にち',
'gui.classroom.board.downloadClass': 'ぜんかだいのていしゅつぶつをダウンロード',
+ 'gui.classroom.shared.levelElementary': 'しょうがっこう',
+ 'gui.classroom.shared.levelJuniorHigh': 'ちゅうがっこう',
+ 'gui.classroom.shared.levelHigh': 'こうとうがっこう',
+ 'gui.classroom.shared.levelOther': 'そのた',
+ 'gui.classroom.shared.formTitle': 'このかだいをぜんこくのせんせいにきょうゆう',
+ 'gui.classroom.shared.formHint':
+ 'かだいのせつめいページとスタータープロジェクトがスナップショットとしてこうかいされます。ていしゅつぶつやせいとのじょうほうはきょうゆうされません。',
+ 'gui.classroom.shared.titlePlaceholder': 'タイトル(ひっす)',
+ 'gui.classroom.shared.summaryPlaceholder': 'カタログのカードにひょうじするみじかいせつめい(にんい)',
+ 'gui.classroom.shared.subjectPlaceholder': 'きょうか(ひっす)',
+ 'gui.classroom.shared.lessonCountPlaceholder': 'コマすう',
+ 'gui.classroom.shared.gradesLabel': 'たいしょうがくねん(にんい):',
+ 'gui.classroom.shared.gradeN': '{grade}ねん',
+ 'gui.classroom.shared.tagsPlaceholder': 'タグ(カンマくぎり・さいだい5こ)れい: こうしえん, メッシュ',
+ 'gui.classroom.shared.urlPlaceholder': 'ほそくしりょうの URL(にんい・https のみ)',
+ 'gui.classroom.shared.urlHint':
+ 'がくしゅうしどうあん・じゅぎょうスライド・けんきゅうはっぴょうしりょうなど、じゅぎょうのすすめかたがわかるしりょうへのリンクをいれてください。Google ドライブ / Google ドキュメントの「リンクをしっているぜんいんがえつらんか」のきょうゆうリンクをすいしょうします。',
+ 'gui.classroom.shared.urlError': 'URL は https:// ではじまるひつようがあります',
+ 'gui.classroom.shared.authorNamePlaceholder':
+ 'ひょうじめい(ひっす・とうこうしゃクレジットとしてこうかいされます)',
+ 'gui.classroom.shared.affiliationPlaceholder':
+ 'しょぞくひょうき(にんい)れい: しまねけん こうりつちゅうがっこう',
+ 'gui.classroom.shared.consent':
+ 'このかだいを CC BY 4.0 ライセンスでこうかいし、ほかのせんせいがじゅぎょうでりよう・かいへんすることをきょだくします(クレジットはひょうじめいでひょうじされます)。',
+ 'gui.classroom.shared.cancel': 'キャンセル',
+ 'gui.classroom.shared.submit': 'きょうゆうする',
+ 'gui.classroom.shared.published': '「みんなのかだい」にこうかいしました: {title}(© {author} / CC BY 4.0)',
+ 'gui.classroom.shared.shareRow': 'きょうゆう',
+ 'gui.classroom.shared.shareStepTitle': 'かだいをきょうゆう',
+ 'gui.classroom.shared.shareDoneTitle': 'きょうゆうしました',
+ 'gui.classroom.shared.modeLimited': 'げんていこうかい(あいことば)',
+ 'gui.classroom.shared.modeLimitedDesc':
+ 'うちわのせんせいだけにあいことばできょうゆう。かんたん・かんぺきでなくてOK',
+ 'gui.classroom.shared.modePublic': 'ぜんたいこうかい(みんなのかだい)',
+ 'gui.classroom.shared.modePublicDesc': 'だれでもみられるカタログにこうかい',
+ 'gui.classroom.shared.limitedTitleLabel': 'タイトル:',
+ 'gui.classroom.shared.limitedHint':
+ 'あいことばをはっこうします。けんきゅうじゅぎょうでちょっとためしたかだいでもだいじょうぶ。あとでぜんたいこうかいにひろげられます。',
+ 'gui.classroom.shared.limitedSubmit': 'あいことばをはっこうしてげんていこうかいする',
+ 'gui.classroom.shared.shareCancel': 'キャンセル',
+ 'gui.classroom.shared.shareClose': 'とじる',
+ 'gui.classroom.shared.passcodeLabel': 'あいことば',
+ 'gui.classroom.shared.passcodeHint':
+ 'このあいことばをくばると、うちわのせんせいだけが「あいことばでとりこみ」できます。あとでぜんたいこうかいにひろげられます。',
+ 'gui.classroom.shared.copyPasscode': 'コピー',
+ 'gui.classroom.shared.publishedShort': '「みんなのかだい」にこうかいしました: {title}',
+ 'gui.classroom.shared.passcodeImport': 'あいことばでとりこみ',
+ 'gui.classroom.shared.passcodeImportTitle': 'あいことばでとりこみ',
+ 'gui.classroom.shared.passcodeImportHint':
+ 'せんせいからもらったあいことばをにゅうりょくすると、このクラスにかだいとしてとりこめます。',
+ 'gui.classroom.shared.passcodeLookupBtn': 'かくにん',
+ 'gui.classroom.shared.passcodePreview': '「{title}」をとりこみます。',
+ 'gui.classroom.shared.passcodeImportBtn': 'このクラスにとりこむ',
+ 'gui.classroom.shared.openCatalog': 'みんなのかだいからさがす',
+ 'gui.classroom.shared.catalogTitle': 'みんなのかだい — ぜんこくのせんせいがきょうゆうしたかだい',
+ 'gui.classroom.shared.catalogClose': 'とじる',
+ 'gui.classroom.shared.tabAll': 'すべて',
+ 'gui.classroom.shared.tabMine': 'じぶんのとうこう',
+ 'gui.classroom.shared.filterAllLevels': 'すべてのがっこうしゅ',
+ 'gui.classroom.shared.filterAllSubjects': 'すべてのきょうか',
+ 'gui.classroom.shared.filterAllGrades': 'すべてのがくねん',
+ 'gui.classroom.shared.filterTag': 'タグ',
+ 'gui.classroom.shared.filterApply': 'しぼりこむ',
+ 'gui.classroom.shared.catalogEmpty': 'きょうゆうされたかだいがみつかりませんでした。',
+ 'gui.classroom.shared.loadMore': 'さらによみこむ',
+ 'gui.classroom.shared.unlistedBadge': 'とりさげちゅう',
+ 'gui.classroom.shared.gradesBadge': '{grades}ねん',
+ 'gui.classroom.shared.cardMeta': 'とうこう: {author} ・ とりこみ {count} かい',
+ 'gui.classroom.shared.detailBack': 'いちらんにもどる',
+ 'gui.classroom.shared.hasStarter': 'スタータープロジェクトつき — とりこむとじゅぎょうでそのままつかえます。',
+ 'gui.classroom.shared.urlButton': 'ほそくしりょう(がくしゅうしどうあんなど)',
+ 'gui.classroom.shared.urlConfirm':
+ 'がいぶサイト({domain})をひらきます。リンクさきのないようはとうこうしゃがかんりしています。',
+ 'gui.classroom.shared.urlOpen': 'ひらく',
+ 'gui.classroom.shared.unlist': 'とりさげる',
+ 'gui.classroom.shared.republish': 'さいこうかいする',
+ 'gui.classroom.shared.report': 'つうほう',
+ 'gui.classroom.shared.reportPlaceholder': 'つうほうのりゆう(ひっす・200じまで)',
+ 'gui.classroom.shared.reportSubmit': 'つうほうをおくる',
+ 'gui.classroom.shared.reportSent': 'ありがとうございます。うんえいにつうほうをおくりました。',
+ 'gui.classroom.shared.import': 'このクラスにとりこむ',
+ 'gui.classroom.shared.imported':
+ '「{name}」をこのクラスにとりこみました。あたらしいさんかコードがはっこうされています。',
'gui.classroom.teacherDetail.retentionBanner':
'このかだいとていしゅつぶつは {date} にじどうさくじょされます。ダウンロードしてほぞんしてください。',
+ 'gui.classroom.teacherDetail.retentionInline':
+ 'ほぞんきげんをすぎるとこのかだいとていしゅつぶつはじどうさくじょされます。',
+ 'gui.classroom.teacherDetail.retentionInlineHint': 'ダウンロードしてほぞんしてください。',
'gui.classroom.codeDisplay.title': 'さんかコード',
'gui.classroom.codeDisplay.copyLink': 'しょうたいリンクをコピー',
'gui.classroom.codeDisplay.copied': 'コピーしました',
'gui.classroom.teacherDetail.selectMember': 'しゅっせきばんごうをクリックしてせいとのしょうさいをみる',
'gui.classroom.joinCode.fullscreen': 'ぜんがめんひょうじ',
+ 'gui.classroom.joinCode.shareToGcShort': 'に きょうゆう',
+ 'gui.classroom.joinCode.openInGcShort': 'で ひらく',
'gui.classroom.teacherDetail.selectClassroom': 'サイドバーからクラスをせんたくしてください',
'gui.classroom.management.loginPrompt': 'ログインしてクラスをかんり',
'gui.classroom.management.loginDescription':
diff --git a/packages/scratch-gui/src/locales/ja.js b/packages/scratch-gui/src/locales/ja.js
index 24e679fb53..dbed6fb864 100644
--- a/packages/scratch-gui/src/locales/ja.js
+++ b/packages/scratch-gui/src/locales/ja.js
@@ -215,6 +215,7 @@ export default {
'gui.classroom.breadcrumbs.classList': 'クラス一覧',
'gui.classroom.breadcrumbs.assignments': '課題一覧',
'gui.classroom.breadcrumbs.assignmentDetail': '課題詳細',
+ 'gui.classroom.breadcrumbs.shareGc': 'Google Classroom に共有',
'gui.classroom.board.createNamePlaceholder': '課題名(例: ねこを動かそう)',
'gui.classroom.board.createSubmit': '作成',
'gui.classroom.board.reuse': '課題を再利用',
@@ -326,12 +327,13 @@ export default {
'gui.classroom.assignmentEditor.errorStarterSave': '開いているプロジェクトの保存に失敗しました',
'gui.classroom.postAssignment.title': '課題を配信',
'gui.classroom.postAssignment.pageTitle': 'Google Classroom に課題を配信します',
- 'gui.classroom.postAssignment.target': '対象: {className}',
'gui.classroom.postAssignment.titleLabel': 'タイトル:',
'gui.classroom.postAssignment.descriptionLabel': '課題の詳細(省略可)',
'gui.classroom.postAssignment.hint':
'配信後、課題の詳細の装飾、割当先、点数などの設定は Google Classroom で行えます。配信した課題は Google Classroom から削除できます。',
'gui.classroom.postAssignment.post': 'Google Classroom に配信する',
+ 'gui.classroom.postAssignment.cancel': 'キャンセル',
+ 'gui.classroom.postAssignment.backToDetail': '課題詳細にもどる',
'gui.classroom.postAssignment.success': '配信しました!',
'gui.classroom.postAssignment.viewOnGC': 'Google Classroom で確認する',
'gui.classroom.postAssignment.postHint': '配信した課題は Google Classroom で編集・削除できます。',
@@ -346,18 +348,103 @@ export default {
'このクラスをアーカイブしますか?クラス一覧から見えなくなりますが、「アーカイブ済みのクラス」からいつでも元に戻せます。',
'gui.classroom.classSettings.archiveConfirmYes': 'アーカイブする',
'gui.classroom.classSettings.archiveConfirmNo': 'アーカイブしない',
+ 'gui.classroom.classSettings.decreaseConfirmMessage':
+ 'このクラスの人数を {count} 人に減らしますか?このクラスのすべての課題で {from}〜{to} 番の席がなくなり、その席に提出された作品は表示されなくなります。',
+ 'gui.classroom.classSettings.decreaseConfirmYes': '減らして保存',
+ 'gui.classroom.classSettings.decreaseConfirmNo': '人数はそのまま',
'gui.classroom.board.archivedToggle': 'アーカイブ済みの課題({count})',
'gui.classroom.board.archivedExpires': '保存期限: {date}',
'gui.classroom.board.restore': '元に戻す',
- 'gui.classroom.board.expiryBadge': 'あと{days}日',
'gui.classroom.board.downloadClass': '全課題の提出物をダウンロード',
+ 'gui.classroom.shared.levelElementary': '小学校',
+ 'gui.classroom.shared.levelJuniorHigh': '中学校',
+ 'gui.classroom.shared.levelHigh': '高等学校',
+ 'gui.classroom.shared.levelOther': 'その他',
+ 'gui.classroom.shared.formTitle': 'この課題を全国の先生に共有',
+ 'gui.classroom.shared.formHint':
+ '課題の説明ページとスタータープロジェクトがスナップショットとして公開されます。提出物や生徒の情報は共有されません。',
+ 'gui.classroom.shared.titlePlaceholder': 'タイトル(必須)',
+ 'gui.classroom.shared.summaryPlaceholder': 'カタログのカードに表示する短い説明(任意)',
+ 'gui.classroom.shared.subjectPlaceholder': '教科(必須)',
+ 'gui.classroom.shared.lessonCountPlaceholder': 'コマ数',
+ 'gui.classroom.shared.gradesLabel': '対象学年(任意):',
+ 'gui.classroom.shared.gradeN': '{grade}年',
+ 'gui.classroom.shared.tagsPlaceholder': 'タグ(カンマ区切り・最大5個)例: 甲子園, メッシュ',
+ 'gui.classroom.shared.urlPlaceholder': '補足資料の URL(任意・https のみ)',
+ 'gui.classroom.shared.urlHint':
+ '学習指導案・授業スライド・研究発表資料など、授業の進め方がわかる資料へのリンクを入れてください。Google ドライブ / Google ドキュメントの「リンクを知っている全員が閲覧可」の共有リンクを推奨します。',
+ 'gui.classroom.shared.urlError': 'URL は https:// で始まる必要があります',
+ 'gui.classroom.shared.authorNamePlaceholder': '表示名(必須・投稿者クレジットとして公開されます)',
+ 'gui.classroom.shared.affiliationPlaceholder': '所属表記(任意)例: 島根県 公立中学校',
+ 'gui.classroom.shared.consent':
+ 'この課題を CC BY 4.0 ライセンスで公開し、他の先生が授業で利用・改変することを許諾します(クレジットは表示名で表示されます)。',
+ 'gui.classroom.shared.cancel': 'キャンセル',
+ 'gui.classroom.shared.submit': '共有する',
+ 'gui.classroom.shared.published': '「みんなの課題」に公開しました: {title}(© {author} / CC BY 4.0)',
+ 'gui.classroom.shared.shareRow': '共有',
+ 'gui.classroom.shared.shareStepTitle': '課題を共有',
+ 'gui.classroom.shared.shareDoneTitle': '共有しました',
+ 'gui.classroom.shared.modeLimited': '限定公開(合言葉)',
+ 'gui.classroom.shared.modeLimitedDesc': '内輪の先生だけに合言葉で共有。かんたん・完璧でなくてOK',
+ 'gui.classroom.shared.modePublic': '全体公開(みんなの課題)',
+ 'gui.classroom.shared.modePublicDesc': 'だれでも見られるカタログに公開',
+ 'gui.classroom.shared.limitedTitleLabel': 'タイトル:',
+ 'gui.classroom.shared.limitedHint':
+ '合言葉を発行します。研究授業でちょっと試した課題でも大丈夫。あとで全体公開に広げられます。',
+ 'gui.classroom.shared.limitedSubmit': '合言葉を発行して限定公開する',
+ 'gui.classroom.shared.shareCancel': 'キャンセル',
+ 'gui.classroom.shared.shareClose': '閉じる',
+ 'gui.classroom.shared.passcodeLabel': '合言葉',
+ 'gui.classroom.shared.passcodeHint':
+ 'この合言葉を配ると、内輪の先生だけが「合言葉で取り込み」できます。あとで全体公開に広げられます。',
+ 'gui.classroom.shared.copyPasscode': 'コピー',
+ 'gui.classroom.shared.publishedShort': '「みんなの課題」に公開しました: {title}',
+ 'gui.classroom.shared.passcodeImport': '合言葉で取り込み',
+ 'gui.classroom.shared.passcodeImportTitle': '合言葉で取り込み',
+ 'gui.classroom.shared.passcodeImportHint':
+ '先生からもらった合言葉を入力すると、このクラスに課題として取り込めます。',
+ 'gui.classroom.shared.passcodeLookupBtn': '確認',
+ 'gui.classroom.shared.passcodePreview': '「{title}」を取り込みます。',
+ 'gui.classroom.shared.passcodeImportBtn': 'このクラスに取り込む',
+ 'gui.classroom.shared.openCatalog': 'みんなの課題からさがす',
+ 'gui.classroom.shared.catalogTitle': 'みんなの課題 — 全国の先生が共有した課題',
+ 'gui.classroom.shared.catalogClose': '閉じる',
+ 'gui.classroom.shared.tabAll': 'すべて',
+ 'gui.classroom.shared.tabMine': '自分の投稿',
+ 'gui.classroom.shared.filterAllLevels': 'すべての学校種',
+ 'gui.classroom.shared.filterAllSubjects': 'すべての教科',
+ 'gui.classroom.shared.filterAllGrades': 'すべての学年',
+ 'gui.classroom.shared.filterTag': 'タグ',
+ 'gui.classroom.shared.filterApply': '絞り込む',
+ 'gui.classroom.shared.catalogEmpty': '共有された課題が見つかりませんでした。',
+ 'gui.classroom.shared.loadMore': 'さらに読み込む',
+ 'gui.classroom.shared.unlistedBadge': '取り下げ中',
+ 'gui.classroom.shared.gradesBadge': '{grades}年',
+ 'gui.classroom.shared.cardMeta': '投稿: {author} ・ 取り込み {count} 回',
+ 'gui.classroom.shared.detailBack': '一覧に戻る',
+ 'gui.classroom.shared.hasStarter': 'スタータープロジェクト付き — 取り込むと授業でそのまま使えます。',
+ 'gui.classroom.shared.urlButton': '補足資料(学習指導案など)',
+ 'gui.classroom.shared.urlConfirm': '外部サイト({domain})を開きます。リンク先の内容は投稿者が管理しています。',
+ 'gui.classroom.shared.urlOpen': '開く',
+ 'gui.classroom.shared.unlist': '取り下げる',
+ 'gui.classroom.shared.republish': '再公開する',
+ 'gui.classroom.shared.report': '通報',
+ 'gui.classroom.shared.reportPlaceholder': '通報の理由(必須・200字まで)',
+ 'gui.classroom.shared.reportSubmit': '通報を送る',
+ 'gui.classroom.shared.reportSent': 'ありがとうございます。運営に通報を送りました。',
+ 'gui.classroom.shared.import': 'このクラスに取り込む',
+ 'gui.classroom.shared.imported': '「{name}」をこのクラスに取り込みました。新しい参加コードが発行されています。',
'gui.classroom.teacherDetail.retentionBanner':
'この課題と提出物は {date} に自動削除されます。ダウンロードして保存してください。',
+ 'gui.classroom.teacherDetail.retentionInline': '保存期限をすぎるとこの課題と提出物は自動削除されます。',
+ 'gui.classroom.teacherDetail.retentionInlineHint': 'ダウンロードして保存してください。',
'gui.classroom.codeDisplay.title': '参加コード',
'gui.classroom.codeDisplay.copyLink': '招待リンクをコピー',
'gui.classroom.codeDisplay.copied': 'コピーしました',
'gui.classroom.teacherDetail.selectMember': '出席番号をクリックして生徒の詳細を見る',
'gui.classroom.joinCode.fullscreen': '全画面表示',
+ 'gui.classroom.joinCode.shareToGcShort': 'に共有',
+ 'gui.classroom.joinCode.openInGcShort': 'で開く',
'gui.classroom.teacherDetail.selectClassroom': 'サイドバーからクラスを選択してください',
'gui.classroom.management.loginPrompt': 'ログインしてクラスを管理',
'gui.classroom.management.loginDescription': '学校のアカウントでログインして、クラスを作成・管理します。',
diff --git a/packages/scratch-gui/test/unit/components/shared-assignment-catalog.test.jsx b/packages/scratch-gui/test/unit/components/shared-assignment-catalog.test.jsx
new file mode 100644
index 0000000000..18ec98dc3d
--- /dev/null
+++ b/packages/scratch-gui/test/unit/components/shared-assignment-catalog.test.jsx
@@ -0,0 +1,169 @@
+/* eslint-env jest */
+import '@testing-library/jest-dom';
+import { fireEvent, render } from '@testing-library/react';
+import React from 'react';
+import { IntlProvider } from 'react-intl';
+import SharedAssignmentCatalog from '../../../src/components/classroom-modal/shared-assignment-catalog.jsx';
+
+const item = (over = {}) => ({
+ sharedId: 's1',
+ title: 'ねこあつめ入門',
+ summary: 'はじめてのゲームづくり',
+ schoolLevel: 'junior-high',
+ grades: [1, 2],
+ subject: '技術・家庭(技術分野)',
+ tags: ['甲子園'],
+ authorName: 'るびお',
+ reuseCount: 4,
+ status: 'published',
+ ...over,
+});
+
+const detail = (over = {}) => ({
+ ...item(),
+ pages: [{ text: 'ページ1', imageUrl: null }],
+ starterUrl: 'https://signed.example/starter',
+ hasStarter: true,
+ supplementUrl: 'https://docs.google.com/document/d/abc/view',
+ isMine: false,
+ ...over,
+});
+
+const sharedState = (over = {}) => ({
+ showCatalog: true,
+ catalogTab: 'all',
+ catalogItems: [item()],
+ catalogCursor: null,
+ catalogLoading: false,
+ sharedDetail: null,
+ lastImported: null,
+ reportSent: false,
+ handleOpenCatalog: jest.fn(),
+ handleCloseCatalog: jest.fn(),
+ handleCatalogTabChange: jest.fn(),
+ handleApplyCatalogFilters: jest.fn(),
+ handleLoadMoreCatalog: jest.fn(),
+ handleOpenSharedDetail: jest.fn(),
+ handleCloseSharedDetail: jest.fn(),
+ handleImportShared: jest.fn(),
+ handleSetSharedStatus: jest.fn(),
+ handleReportShared: jest.fn(),
+ ...over,
+});
+
+const group = { groupId: 'g1', name: '技術', year: 2026 };
+
+const renderCatalog = (shared) =>
+ render(
+
+
+ ,
+ );
+
+const byTestId = (id) => document.querySelector(`[data-testid="${id}"]`);
+
+describe('SharedAssignmentCatalog (issue #1070)', () => {
+ test('renders catalog cards with author and reuse count', () => {
+ renderCatalog(sharedState());
+ const card = byTestId('shared-catalog-item-s1');
+ expect(card).toBeInTheDocument();
+ expect(card.textContent).toContain('ねこあつめ入門');
+ expect(card.textContent).toContain('るびお');
+ expect(card.textContent).toContain('4');
+ });
+
+ test('applies attribute filters through the hook', () => {
+ const shared = sharedState();
+ renderCatalog(shared);
+ fireEvent.change(byTestId('shared-catalog-filter-level'), { target: { value: 'junior-high' } });
+ fireEvent.change(byTestId('shared-catalog-filter-subject'), {
+ target: { value: '技術・家庭(技術分野)' },
+ });
+ fireEvent.change(byTestId('shared-catalog-filter-tag'), { target: { value: '甲子園' } });
+ fireEvent.click(byTestId('shared-catalog-filter-apply'));
+
+ expect(shared.handleApplyCatalogFilters).toHaveBeenCalledWith({
+ schoolLevel: 'junior-high',
+ subject: '技術・家庭(技術分野)',
+ tag: '甲子園',
+ });
+ });
+
+ test('opening a card requests its detail; import passes the current group', () => {
+ const shared = sharedState();
+ renderCatalog(shared);
+ fireEvent.click(byTestId('shared-catalog-open-s1'));
+ expect(shared.handleOpenSharedDetail).toHaveBeenCalledWith('s1');
+
+ const withDetail = sharedState({ sharedDetail: detail() });
+ renderCatalog(withDetail);
+ fireEvent.click(byTestId('shared-detail-import'));
+ expect(withDetail.handleImportShared).toHaveBeenCalledWith('s1', 'g1');
+ });
+
+ test('the supplement URL opens only after an explicit confirmation naming the domain (D4)', () => {
+ renderCatalog(sharedState({ sharedDetail: detail() }));
+ expect(byTestId('shared-detail-url-open')).not.toBeInTheDocument();
+
+ fireEvent.click(byTestId('shared-detail-url'));
+ expect(byTestId('shared-detail-url-confirm').textContent).toContain('docs.google.com');
+ const anchor = byTestId('shared-detail-url-open');
+ expect(anchor).toHaveAttribute('href', 'https://docs.google.com/document/d/abc/view');
+ expect(anchor).toHaveAttribute('rel', 'noopener noreferrer');
+ expect(anchor).toHaveAttribute('target', '_blank');
+ });
+
+ test('shows the CC BY credit line on the detail', () => {
+ renderCatalog(sharedState({
+ sharedDetail: detail({ authorAffiliation: '島根県 公立中学校' }),
+ }));
+ expect(byTestId('shared-detail-credit').textContent).toBe(
+ '© るびお(島根県 公立中学校) / CC BY 4.0',
+ );
+ });
+
+ test('strangers get a report flow; the reason is required', () => {
+ const shared = sharedState({ sharedDetail: detail() });
+ renderCatalog(shared);
+ fireEvent.click(byTestId('shared-detail-report'));
+ expect(byTestId('shared-report-submit')).toBeDisabled();
+ fireEvent.change(byTestId('shared-report-reason'), { target: { value: '不適切な内容' } });
+ fireEvent.click(byTestId('shared-report-submit'));
+ expect(shared.handleReportShared).toHaveBeenCalledWith('s1', '不適切な内容');
+ });
+
+ test('own published posts offer unlist; own unlisted posts offer republish (no import)', () => {
+ const mine = sharedState({ sharedDetail: detail({ isMine: true }) });
+ const { unmount } = renderCatalog(mine);
+ fireEvent.click(byTestId('shared-detail-unlist'));
+ expect(mine.handleSetSharedStatus).toHaveBeenCalledWith('s1', 'unlisted');
+ unmount();
+
+ const unlisted = sharedState({
+ sharedDetail: detail({ isMine: true, status: 'unlisted' }),
+ });
+ renderCatalog(unlisted);
+ expect(byTestId('shared-detail-import')).not.toBeInTheDocument();
+ fireEvent.click(byTestId('shared-detail-republish'));
+ expect(unlisted.handleSetSharedStatus).toHaveBeenCalledWith('s1', 'published');
+ });
+
+ test('shows load-more only when a cursor is present', () => {
+ renderCatalog(sharedState());
+ expect(byTestId('shared-catalog-load-more')).not.toBeInTheDocument();
+
+ const shared = sharedState({ catalogCursor: 'abc' });
+ renderCatalog(shared);
+ fireEvent.click(byTestId('shared-catalog-load-more'));
+ expect(shared.handleLoadMoreCatalog).toHaveBeenCalled();
+ });
+
+ test('the mine tab hides the filters and marks unlisted items', () => {
+ renderCatalog(sharedState({
+ catalogTab: 'mine',
+ catalogItems: [item({ status: 'unlisted' })],
+ }));
+ expect(byTestId('shared-catalog-filter-apply')).not.toBeInTheDocument();
+ expect(byTestId('shared-catalog-item-s1').textContent).toContain('Unlisted');
+ });
+});
diff --git a/packages/scratch-gui/test/unit/components/shared-assignment-form.test.jsx b/packages/scratch-gui/test/unit/components/shared-assignment-form.test.jsx
new file mode 100644
index 0000000000..0b20a4c1f7
--- /dev/null
+++ b/packages/scratch-gui/test/unit/components/shared-assignment-form.test.jsx
@@ -0,0 +1,119 @@
+/* eslint-env jest */
+import '@testing-library/jest-dom';
+import { fireEvent, render } from '@testing-library/react';
+import React from 'react';
+import { IntlProvider } from 'react-intl';
+import SharedAssignmentForm from '../../../src/components/classroom-modal/shared-assignment-form.jsx';
+
+const classroom = (over = {}) => ({
+ classroomId: 'c1',
+ className: '技術',
+ assignmentName: 'ねこあつめ',
+ ...over,
+});
+
+const defaultProps = () => ({
+ selectedClassroom: classroom(),
+ isLoading: false,
+ onCancel: jest.fn(),
+ onShare: jest.fn(),
+});
+
+const renderForm = (props) =>
+ render(
+
+
+ ,
+ );
+
+const byTestId = (id) => document.querySelector(`[data-testid="${id}"]`);
+
+const fillRequired = () => {
+ fireEvent.change(byTestId('shared-form-subject'), {
+ target: { value: '技術・家庭(技術分野)' },
+ });
+ fireEvent.change(byTestId('shared-form-author-name'), { target: { value: 'るびお' } });
+ fireEvent.click(byTestId('shared-form-consent'));
+};
+
+describe('SharedAssignmentForm (issue #1069)', () => {
+ beforeEach(() => {
+ window.localStorage.clear();
+ });
+
+ test('prefills the title from the assignment name and shows the URL guidance (D4)', () => {
+ renderForm();
+ expect(byTestId('shared-form-title')).toHaveValue('ねこあつめ');
+ expect(byTestId('shared-form-url-hint').textContent).toContain('lesson plan');
+ });
+
+ test('submit stays disabled until subject, author name and CC BY consent are given (D2)', () => {
+ renderForm();
+ expect(byTestId('shared-form-submit')).toBeDisabled();
+ fillRequired();
+ expect(byTestId('shared-form-submit')).not.toBeDisabled();
+ });
+
+ test('rejects a non-https supplement URL client-side', () => {
+ renderForm();
+ fillRequired();
+ fireEvent.change(byTestId('shared-form-url'), { target: { value: 'http://example.com' } });
+ expect(byTestId('shared-form-url-error')).toBeInTheDocument();
+ expect(byTestId('shared-form-submit')).toBeDisabled();
+ });
+
+ test('submits the normalized payload (grades sorted, tags parsed, license consent)', () => {
+ const onShare = jest.fn();
+ renderForm({ onShare });
+ fillRequired();
+ fireEvent.click(byTestId('shared-form-grade-2'));
+ fireEvent.click(byTestId('shared-form-grade-1'));
+ fireEvent.change(byTestId('shared-form-tags'), { target: { value: '甲子園, 入門' } });
+ fireEvent.change(byTestId('shared-form-url'), {
+ target: { value: 'https://docs.google.com/document/d/x/view' },
+ });
+ fireEvent.click(byTestId('shared-form-submit'));
+
+ expect(onShare).toHaveBeenCalledWith(
+ expect.objectContaining({
+ classroomId: 'c1',
+ title: 'ねこあつめ',
+ schoolLevel: 'junior-high',
+ grades: [1, 2],
+ subject: '技術・家庭(技術分野)',
+ tags: ['甲子園', '入門'],
+ supplementUrl: 'https://docs.google.com/document/d/x/view',
+ authorName: 'るびお',
+ licenseConsent: true,
+ }),
+ );
+ });
+
+ test('changing the school level resets subject and grades', () => {
+ renderForm();
+ fillRequired();
+ fireEvent.click(byTestId('shared-form-grade-3'));
+ fireEvent.change(byTestId('shared-form-level'), { target: { value: 'high' } });
+ expect(byTestId('shared-form-subject')).toHaveValue('');
+ expect(byTestId('shared-form-grade-3')).not.toBeChecked();
+ // High school vocabulary is offered now.
+ expect(byTestId('shared-form-subject').textContent).toContain('情報Ⅰ');
+ });
+
+ test('the "other" school level switches the subject to free text input', () => {
+ renderForm();
+ fireEvent.change(byTestId('shared-form-level'), { target: { value: 'other' } });
+ expect(byTestId('shared-form-subject')).not.toBeInTheDocument();
+ expect(byTestId('shared-form-subject-free')).toBeInTheDocument();
+ });
+
+ test('remembers the author profile for the next share (D6)', () => {
+ window.localStorage.setItem(
+ 'smalruby:sharedAuthorProfile',
+ JSON.stringify({ authorName: '記憶済み', authorAffiliation: '島根県' }),
+ );
+ renderForm();
+ expect(byTestId('shared-form-author-name')).toHaveValue('記憶済み');
+ expect(byTestId('shared-form-author-affiliation')).toHaveValue('島根県');
+ });
+});
diff --git a/packages/scratch-gui/test/unit/components/teacher-assignment-board.test.jsx b/packages/scratch-gui/test/unit/components/teacher-assignment-board.test.jsx
index c81dfd6917..de14f76e15 100644
--- a/packages/scratch-gui/test/unit/components/teacher-assignment-board.test.jsx
+++ b/packages/scratch-gui/test/unit/components/teacher-assignment-board.test.jsx
@@ -122,23 +122,73 @@ describe('TeacherAssignmentBoard — class-wide bulk download (issue #1055)', ()
});
});
-describe('TeacherAssignmentBoard — retention badge (issue #1052)', () => {
+describe('TeacherAssignmentBoard — share entry (#1109)', () => {
+ const sharedStub = (over = {}) => ({
+ shareTarget: null,
+ showCatalog: false,
+ lastShared: null,
+ lastImported: null,
+ handleOpenShareFor: jest.fn(),
+ handleCloseShareForm: jest.fn(),
+ handleShareAssignment: jest.fn(),
+ handleOpenCatalog: jest.fn(),
+ ...over,
+ });
+
+ test('each assignment row has a 共有 button that opens the share step for that class', () => {
+ const shared = sharedStub();
+ renderBoard({ shared });
+ const btn = byTestId('classroom-board-share-c1');
+ expect(btn).toBeInTheDocument();
+ fireEvent.click(btn);
+ expect(shared.handleOpenShareFor).toHaveBeenCalledWith(expect.objectContaining({ classroomId: 'c1' }));
+ });
+
+ test('with a shareTarget the share step replaces the board body', () => {
+ const shared = sharedStub({ shareTarget: classroom() });
+ renderBoard({ shared });
+ expect(byTestId('classroom-phase-share-step')).toBeInTheDocument();
+ // The create action bar is hidden while sharing.
+ expect(byTestId('classroom-board-create')).not.toBeInTheDocument();
+ });
+
+ test('no 共有 button when the shared hook is absent', () => {
+ renderBoard();
+ expect(byTestId('classroom-board-share-c1')).not.toBeInTheDocument();
+ });
+});
+
+describe('TeacherAssignmentBoard — retention notice (issue #1052)', () => {
const DAY = 24 * 60 * 60 * 1000;
const inDays = (days) => new Date(Date.now() + days * DAY).toISOString();
- test('shows no badge while the deadline is far away', () => {
+ test('shows no retention notice while the deadline is far away', () => {
renderBoard({ classrooms: [classroom({ expiresAt: inDays(60) })] });
expect(byTestId('classroom-board-expiry-c1')).not.toBeInTheDocument();
});
- test('shows a days-left badge within 30 days of deletion', () => {
+ test('shows the auto-delete notice with a download button within 30 days', () => {
renderBoard({ classrooms: [classroom({ expiresAt: inDays(20) })] });
- const badge = byTestId('classroom-board-expiry-c1');
- expect(badge).toBeInTheDocument();
- expect(badge.textContent).toContain('20 days left');
+ const notice = byTestId('classroom-board-expiry-c1');
+ expect(notice).toBeInTheDocument();
+ expect(notice.textContent).toContain('deleted automatically');
+ expect(byTestId('classroom-board-download-c1')).toBeInTheDocument();
+ });
+
+ test('the row download button downloads that one assignment', () => {
+ const onDownloadClassAll = jest.fn();
+ renderBoard({
+ classrooms: [classroom({ expiresAt: inDays(5) })],
+ onDownloadClassAll,
+ });
+ fireEvent.click(byTestId('classroom-board-download-c1'));
+ expect(onDownloadClassAll).toHaveBeenCalledWith(
+ expect.objectContaining({ groupId: 'g1' }),
+ [expect.objectContaining({ classroomId: 'c1' })],
+ );
});
- test('shows the badge for assignments without a deadline never', () => {
+ test('never shows the notice for assignments without a deadline', () => {
renderBoard({ classrooms: [classroom({ expiresAt: null })] });
expect(byTestId('classroom-board-expiry-c1')).not.toBeInTheDocument();
});
diff --git a/packages/scratch-gui/test/unit/components/teacher-class-detail.test.jsx b/packages/scratch-gui/test/unit/components/teacher-class-detail.test.jsx
index a12d1c6a59..f221d0e1dc 100644
--- a/packages/scratch-gui/test/unit/components/teacher-class-detail.test.jsx
+++ b/packages/scratch-gui/test/unit/components/teacher-class-detail.test.jsx
@@ -87,25 +87,25 @@ describe('TeacherClassDetail — retention banner (issue #1052)', () => {
const inDays = (days) => new Date(Date.now() + days * DAY).toISOString();
const byTestId = (id) => document.querySelector(`[data-testid="${id}"]`);
- test('shows no banner while the deadline is far away', () => {
+ test('shows no inline alert while the deadline is far away', () => {
renderDetail({ selectedClassroom: classroom({ expiresAt: inDays(60) }) });
expect(byTestId('classroom-retention-banner')).not.toBeInTheDocument();
});
- test('shows the banner with the deadline within 30 days of deletion', () => {
+ test('shows the inline alert next to the deadline within 30 days of deletion', () => {
renderDetail({ selectedClassroom: classroom({ expiresAt: inDays(20) }) });
- const banner = byTestId('classroom-retention-banner');
- expect(banner).toBeInTheDocument();
- expect(banner.textContent).toContain('deleted automatically');
+ const alert = byTestId('classroom-retention-banner');
+ expect(alert).toBeInTheDocument();
+ expect(alert.textContent).toContain('deleted automatically');
});
- test('the banner download button triggers onDownloadAll', () => {
+ test('the download-all button triggers onDownloadAll near the deadline', () => {
const onDownloadAll = jest.fn();
renderDetail({
selectedClassroom: classroom({ expiresAt: inDays(5) }),
onDownloadAll,
});
- fireEvent.click(byTestId('classroom-retention-banner-download'));
+ fireEvent.click(byTestId('classroom-download-all'));
expect(onDownloadAll).toHaveBeenCalled();
});
});
diff --git a/packages/scratch-gui/test/unit/components/teacher-class-list.test.jsx b/packages/scratch-gui/test/unit/components/teacher-class-list.test.jsx
index 7f7247b1e5..0fb8de979e 100644
--- a/packages/scratch-gui/test/unit/components/teacher-class-list.test.jsx
+++ b/packages/scratch-gui/test/unit/components/teacher-class-list.test.jsx
@@ -137,6 +137,56 @@ describe('TeacherClassList', () => {
// The form closes back to the card
expect(document.querySelector('[data-testid="classroom-class-settings-g1"]')).not.toBeInTheDocument();
});
+
+ test('increasing the student count saves immediately without a warning', () => {
+ const onUpdateGroup = jest.fn();
+ renderList({ groups: [group({ studentCount: 30 })], onUpdateGroup });
+ fireEvent.click(document.querySelector('[data-testid="classroom-class-settings-open-g1"]'));
+ fireEvent.change(document.querySelector('[data-testid="classroom-class-settings-count"]'), {
+ target: { value: '35' },
+ });
+ fireEvent.click(document.querySelector('[data-testid="classroom-class-settings-save"]'));
+
+ expect(document.querySelector('[data-testid="classroom-class-settings-decrease-confirm-message"]'))
+ .not.toBeInTheDocument();
+ expect(onUpdateGroup).toHaveBeenCalledWith('g1', expect.objectContaining({ studentCount: 35 }));
+ });
+
+ test('decreasing the student count warns first, then saves on confirm', () => {
+ const onUpdateGroup = jest.fn();
+ renderList({ groups: [group({ studentCount: 30 })], onUpdateGroup });
+ fireEvent.click(document.querySelector('[data-testid="classroom-class-settings-open-g1"]'));
+ fireEvent.change(document.querySelector('[data-testid="classroom-class-settings-count"]'), {
+ target: { value: '20' },
+ });
+
+ // First save shows the warning and does NOT call the API.
+ fireEvent.click(document.querySelector('[data-testid="classroom-class-settings-save"]'));
+ expect(onUpdateGroup).not.toHaveBeenCalled();
+ expect(document.querySelector('[data-testid="classroom-class-settings-decrease-confirm-message"]'))
+ .toBeInTheDocument();
+
+ // Confirming saves the reduced count.
+ fireEvent.click(document.querySelector('[data-testid="classroom-class-settings-save"]'));
+ expect(onUpdateGroup).toHaveBeenCalledWith('g1', expect.objectContaining({ studentCount: 20 }));
+ });
+
+ test('cancelling the decrease warning keeps the count and calls nothing', () => {
+ const onUpdateGroup = jest.fn();
+ renderList({ groups: [group({ studentCount: 30 })], onUpdateGroup });
+ fireEvent.click(document.querySelector('[data-testid="classroom-class-settings-open-g1"]'));
+ fireEvent.change(document.querySelector('[data-testid="classroom-class-settings-count"]'), {
+ target: { value: '20' },
+ });
+ fireEvent.click(document.querySelector('[data-testid="classroom-class-settings-save"]'));
+ fireEvent.click(document.querySelector('[data-testid="classroom-class-settings-cancel"]'));
+
+ expect(onUpdateGroup).not.toHaveBeenCalled();
+ expect(document.querySelector('[data-testid="classroom-class-settings-decrease-confirm-message"]'))
+ .not.toBeInTheDocument();
+ // The form stays open (only the warning was dismissed).
+ expect(document.querySelector('[data-testid="classroom-class-settings-g1"]')).toBeInTheDocument();
+ });
});
describe('TeacherClassList — archived classes section (issue #1051)', () => {
diff --git a/packages/scratch-gui/test/unit/components/teacher-passcode-import.test.jsx b/packages/scratch-gui/test/unit/components/teacher-passcode-import.test.jsx
new file mode 100644
index 0000000000..35b0571e52
--- /dev/null
+++ b/packages/scratch-gui/test/unit/components/teacher-passcode-import.test.jsx
@@ -0,0 +1,52 @@
+/* eslint-env jest */
+import '@testing-library/jest-dom';
+import { fireEvent, render } from '@testing-library/react';
+import React from 'react';
+import { IntlProvider } from 'react-intl';
+import TeacherPasscodeImport from '../../../src/components/classroom-modal/teacher-passcode-import.jsx';
+
+const defaultProps = (over = {}) => ({
+ group: { groupId: 'g1' },
+ isLoading: false,
+ lookup: null,
+ error: null,
+ onCancel: jest.fn(),
+ onImport: jest.fn(),
+ onLookup: jest.fn(),
+ ...over,
+});
+
+const renderImport = (props) =>
+ render(
+
+
+ ,
+ );
+
+const byTestId = (id) => document.querySelector(`[data-testid="${id}"]`);
+
+describe('TeacherPasscodeImport (#1109)', () => {
+ test('the check button is disabled until a passcode is entered, then looks it up', () => {
+ const onLookup = jest.fn();
+ renderImport({ onLookup });
+ expect(byTestId('classroom-passcode-lookup')).toBeDisabled();
+ fireEvent.change(byTestId('classroom-passcode-input'), { target: { value: 'crehle' } });
+ expect(byTestId('classroom-passcode-lookup')).not.toBeDisabled();
+ fireEvent.click(byTestId('classroom-passcode-lookup'));
+ expect(onLookup).toHaveBeenCalledWith('crehle');
+ });
+
+ test('after a lookup, shows the preview and imports into the class', () => {
+ const onImport = jest.fn();
+ renderImport({ lookup: { title: 'ねこを動かそう' }, onImport });
+ expect(byTestId('classroom-passcode-preview')).toHaveTextContent('ねこを動かそう');
+ fireEvent.change(byTestId('classroom-passcode-input'), { target: { value: 'crehle' } });
+ fireEvent.click(byTestId('classroom-passcode-import'));
+ expect(onImport).toHaveBeenCalledWith('crehle', 'g1');
+ });
+
+ test('shows an error message', () => {
+ renderImport({ error: '合言葉が見つかりません' });
+ expect(byTestId('classroom-passcode-error')).toHaveTextContent('合言葉が見つかりません');
+ });
+});
diff --git a/packages/scratch-gui/test/unit/components/teacher-post-assignment.test.jsx b/packages/scratch-gui/test/unit/components/teacher-post-assignment.test.jsx
new file mode 100644
index 0000000000..15c88c2724
--- /dev/null
+++ b/packages/scratch-gui/test/unit/components/teacher-post-assignment.test.jsx
@@ -0,0 +1,72 @@
+/* eslint-env jest */
+import '@testing-library/jest-dom';
+import { fireEvent, render, waitFor } from '@testing-library/react';
+import React from 'react';
+import { IntlProvider } from 'react-intl';
+import TeacherPostAssignment from '../../../src/components/classroom-modal/teacher-post-assignment.jsx';
+
+const defaultProps = (over = {}) => ({
+ error: null,
+ errorTitle: null,
+ isLoading: false,
+ group: { name: '技術', year: 2026, section: null },
+ selectedClassroom: { assignmentName: 'ねこを動かそう', className: '技術' },
+ onBack: jest.fn(),
+ onPostAssignment: jest.fn().mockResolvedValue({ alternateLink: 'https://classroom.google.com/x' }),
+ ...over,
+});
+
+const renderPost = (props) =>
+ render(
+
+
+ ,
+ );
+
+const byTestId = (id) => document.querySelector(`[data-testid="${id}"]`);
+
+describe('TeacherPostAssignment', () => {
+ test('shows the class label in the class-detail format (name + year), no "Target:" prefix', () => {
+ renderPost();
+ const phase = byTestId('classroom-phase-teacher-post-assignment');
+ expect(phase.textContent).toContain('技術 2026年度');
+ expect(phase.textContent).not.toContain('Target:');
+ });
+
+ test('the submit button is disabled until a title is entered', () => {
+ renderPost({ selectedClassroom: { assignmentName: '', className: '技術' } });
+ const submit = byTestId('classroom-post-assignment-submit');
+ expect(submit).toBeDisabled();
+ fireEvent.change(byTestId('classroom-post-assignment-title'), { target: { value: 'ねこ' } });
+ expect(submit).not.toBeDisabled();
+ });
+
+ test('the cancel button behaves like back', () => {
+ const onBack = jest.fn();
+ renderPost({ onBack });
+ fireEvent.click(byTestId('classroom-post-assignment-cancel'));
+ expect(onBack).toHaveBeenCalled();
+ });
+
+ test('does not render a legacy back button (breadcrumb + cancel replace it)', () => {
+ renderPost();
+ expect(byTestId('classroom-back')).not.toBeInTheDocument();
+ });
+
+ test('after posting, shows the success view with a link to Google Classroom and a back button', async () => {
+ const onBack = jest.fn();
+ const onPostAssignment = jest
+ .fn()
+ .mockResolvedValue({ alternateLink: 'https://classroom.google.com/c/abc' });
+ renderPost({ onBack, onPostAssignment });
+
+ fireEvent.click(byTestId('classroom-post-assignment-submit'));
+
+ await waitFor(() => expect(byTestId('classroom-post-assignment-success')).toBeInTheDocument());
+ const link = byTestId('classroom-view-posted-assignment');
+ expect(link).toHaveAttribute('href', 'https://classroom.google.com/c/abc');
+
+ fireEvent.click(byTestId('classroom-post-assignment-done'));
+ expect(onBack).toHaveBeenCalled();
+ });
+});
diff --git a/packages/scratch-gui/test/unit/components/teacher-share-step.test.jsx b/packages/scratch-gui/test/unit/components/teacher-share-step.test.jsx
new file mode 100644
index 0000000000..d8d8b09154
--- /dev/null
+++ b/packages/scratch-gui/test/unit/components/teacher-share-step.test.jsx
@@ -0,0 +1,65 @@
+/* eslint-env jest */
+import '@testing-library/jest-dom';
+import { fireEvent, render } from '@testing-library/react';
+import React from 'react';
+import { IntlProvider } from 'react-intl';
+import TeacherShareStep from '../../../src/components/classroom-modal/teacher-share-step.jsx';
+
+const defaultProps = (over = {}) => ({
+ classroom: { classroomId: 'c1', assignmentName: 'ねこを動かそう', className: '技術' },
+ isLoading: false,
+ lastShared: null,
+ onCancel: jest.fn(),
+ onShare: jest.fn(),
+ ...over,
+});
+
+const renderStep = (props) =>
+ render(
+
+
+ ,
+ );
+
+const byTestId = (id) => document.querySelector(`[data-testid="${id}"]`);
+
+describe('TeacherShareStep (#1109)', () => {
+ test('defaults to limited (合言葉) mode and shares with visibility:limited', () => {
+ const onShare = jest.fn();
+ renderStep({ onShare });
+ // Title prefilled from the assignment name.
+ expect(byTestId('classroom-share-limited-title').value).toBe('ねこを動かそう');
+ fireEvent.click(byTestId('classroom-share-limited-submit'));
+ expect(onShare).toHaveBeenCalledWith({
+ classroomId: 'c1',
+ title: 'ねこを動かそう',
+ visibility: 'limited',
+ });
+ });
+
+ test('the limited submit is disabled when the title is empty', () => {
+ renderStep({ classroom: { classroomId: 'c1', assignmentName: '', className: '' } });
+ expect(byTestId('classroom-share-limited-submit')).toBeDisabled();
+ });
+
+ test('switching to public shows the full SharedAssignmentForm', () => {
+ renderStep();
+ expect(byTestId('shared-form')).not.toBeInTheDocument();
+ fireEvent.click(byTestId('classroom-share-mode-public'));
+ expect(byTestId('shared-form')).toBeInTheDocument();
+ });
+
+ test('after a limited share, the passcode is shown and Close calls onCancel', () => {
+ const onCancel = jest.fn();
+ renderStep({ lastShared: { title: 'ねこを動かそう', visibility: 'limited', passcode: 'crehle' }, onCancel });
+ expect(byTestId('classroom-share-passcode-value')).toHaveTextContent('crehle');
+ fireEvent.click(byTestId('classroom-share-done'));
+ expect(onCancel).toHaveBeenCalled();
+ });
+
+ test('after a public share (no passcode), shows the published confirmation', () => {
+ renderStep({ lastShared: { title: 'ねこを動かそう', visibility: 'public' } });
+ expect(byTestId('classroom-share-passcode')).not.toBeInTheDocument();
+ expect(byTestId('shared-form-success')).toHaveTextContent('ねこを動かそう');
+ });
+});
diff --git a/packages/scratch-gui/test/unit/lib/shared-assignment-taxonomy.test.js b/packages/scratch-gui/test/unit/lib/shared-assignment-taxonomy.test.js
new file mode 100644
index 0000000000..0d22e068f9
--- /dev/null
+++ b/packages/scratch-gui/test/unit/lib/shared-assignment-taxonomy.test.js
@@ -0,0 +1,36 @@
+import {
+ MAX_TAGS,
+ SCHOOL_LEVELS,
+ SUBJECTS_BY_LEVEL,
+ gradesForLevel,
+ parseTags,
+} from '../../../src/lib/shared-assignment-taxonomy.js';
+
+describe('shared assignment taxonomy (D5)', () => {
+ test('school levels carry their grade ranges', () => {
+ expect(SCHOOL_LEVELS.map((l) => l.value)).toEqual(['elementary', 'junior-high', 'high', 'other']);
+ expect(gradesForLevel('elementary')).toEqual([1, 2, 3, 4, 5, 6]);
+ expect(gradesForLevel('junior-high')).toEqual([1, 2, 3]);
+ expect(gradesForLevel('high')).toEqual([1, 2, 3]);
+ expect(gradesForLevel('nope')).toEqual([]);
+ });
+
+ test('subject vocabularies mirror the server', () => {
+ expect(SUBJECTS_BY_LEVEL['junior-high']).toContain('技術・家庭(技術分野)');
+ expect(SUBJECTS_BY_LEVEL.high).toEqual(['情報Ⅰ', '情報Ⅱ', 'その他']);
+ expect(SUBJECTS_BY_LEVEL.other).toEqual([]);
+ });
+});
+
+describe('parseTags', () => {
+ test('splits on commas (ja/en) and whitespace, trims and dedupes', () => {
+ expect(parseTags('甲子園, メッシュ、入門 甲子園')).toEqual(['甲子園', 'メッシュ', '入門']);
+ });
+
+ test('caps at MAX_TAGS and drops empty/overlong tags', () => {
+ expect(parseTags('a,b,c,d,e,f,g')).toHaveLength(MAX_TAGS);
+ expect(parseTags(`ok,${'x'.repeat(21)}`)).toEqual(['ok']);
+ expect(parseTags('')).toEqual([]);
+ expect(parseTags(null)).toEqual([]);
+ });
+});
diff --git a/packages/scratch-gui/test/unit/lib/shared-author-profile.test.js b/packages/scratch-gui/test/unit/lib/shared-author-profile.test.js
new file mode 100644
index 0000000000..73dbe1550c
--- /dev/null
+++ b/packages/scratch-gui/test/unit/lib/shared-author-profile.test.js
@@ -0,0 +1,32 @@
+import { detectSharedAuthorProfile, persistSharedAuthorProfile } from '../../../src/lib/shared-author-profile.js';
+
+const KEY = 'smalruby:sharedAuthorProfile';
+
+describe('shared author profile persistence (D6)', () => {
+ beforeEach(() => {
+ window.localStorage.clear();
+ });
+
+ test('round-trips the profile through localStorage', () => {
+ persistSharedAuthorProfile({ authorName: 'すもう るびお', authorAffiliation: '島根県' });
+ expect(detectSharedAuthorProfile()).toEqual({
+ authorName: 'すもう るびお',
+ authorAffiliation: '島根県',
+ });
+ expect(JSON.parse(window.localStorage.getItem(KEY)).authorName).toBe('すもう るびお');
+ });
+
+ test('returns empty strings when nothing is stored', () => {
+ expect(detectSharedAuthorProfile()).toEqual({ authorName: '', authorAffiliation: '' });
+ });
+
+ test('survives corrupted storage', () => {
+ window.localStorage.setItem(KEY, '{broken json');
+ expect(detectSharedAuthorProfile()).toEqual({ authorName: '', authorAffiliation: '' });
+ });
+
+ test('normalizes missing affiliation to an empty string', () => {
+ persistSharedAuthorProfile({ authorName: 'A' });
+ expect(detectSharedAuthorProfile()).toEqual({ authorName: 'A', authorAffiliation: '' });
+ });
+});
diff --git a/tools/playwright-verify/README.md b/tools/playwright-verify/README.md
index 7e3d2cca29..861ecabb0b 100644
--- a/tools/playwright-verify/README.md
+++ b/tools/playwright-verify/README.md
@@ -62,6 +62,7 @@ env トグル: `HEADLESS=false` 表示 / `CHANNEL=chrome` 実 Chrome / `SLOWMO=<
| ファイル | 検証対象 |
|---|---|
+| `verify-assignment-sharing.mjs` | みんなの課題(EPIC #1066)の通し。クラス+課題作成→説明ページ保存→共有フォーム(CC BY 同意)→カタログ絞り込み→詳細(© クレジット・外部リンク確認 D4)→このクラスに取り込み→ボードに新行→自分の投稿で取り下げ/再公開。stg に S1 API(#1068)が必要。`LOCALE=ja-JP` で日本語スクリーンショット |
| `verify-admin.mjs` | 管理 SPA(EPIC #1073)の通し。stg API への `/admin/me` プリフライト(403 = allowlist 未登録を明示)→ 8602 の SPA dev server を自前起動(`REUSE_SERVER=1` で再利用)→ ログインゲート → `?devlogin=` バイパス → みんなの課題キュー → クラス検索 + 期限切れ復元タブ → バグ報告一覧(詳細に書き込み UI が無いこと)。前提: AdminStack-stg デプロイ + `SmalrubyAdmins-stg` に `dev-admin@example.com` 登録(docs/admin/operations.md) |
| `verify-classroom-archive-recovery.mjs` | アーカイブ復旧(EPIC #1049)の通し。クラス+課題作成→保存期限バッジ(`あと{days}日`)→課題詳細の期限バナー+全作品DL CTA→クラス全体一括DL(zip 名 `_全課題.zip`)→課題アーカイブ(確認文言が「アーカイブ」であること)→(S1 API デプロイ後)アーカイブ済み課題の復元→クラスの 2 段階アーカイブ確認→アーカイブ済みクラス一覧→復元。`LOCALE=ja-JP` で日本語スクリーンショット(docs 用) |
| `mesh-v2-classroom-binding.mjs` | クラス管理と Mesh v2 ドメインの連動。教師タブでクラス作成→サイドバーで選択、生徒タブで `?classcode=` 経由参加し、両方の `state.scratchGui.meshV2.domain` が参加コードに揃うこと、接続モーダル入力欄が disabled になること、解除時に元のドメインに戻ることをチェック |
diff --git a/tools/playwright-verify/verify-assignment-sharing.mjs b/tools/playwright-verify/verify-assignment-sharing.mjs
new file mode 100644
index 0000000000..e2320764df
--- /dev/null
+++ b/tools/playwright-verify/verify-assignment-sharing.mjs
@@ -0,0 +1,211 @@
+// みんなの課題 (shared assignment library, EPIC #1066) regression:
+// share -> catalog -> filter -> detail (CC BY credit) -> import -> my posts
+// (unlist / republish).
+//
+// Requires the dev server + the stg classroom API with the S1 endpoints
+// (#1068) deployed. When the API is missing the share step fails fast.
+//
+// # In the container (headless, default):
+// node verify-assignment-sharing.mjs
+//
+// # On the host (watch it):
+// HEADLESS=false CHANNEL=chrome SLOWMO=200 node verify-assignment-sharing.mjs
+//
+// Env: HEADLESS / CHANNEL / SLOWMO / KEEP_OPEN / BASE_URL / LOCALE
+// (LOCALE=ja-JP renders the UI in Japanese for docs screenshots)
+import { chromium } from 'playwright';
+import { readFileSync, mkdirSync } from 'node:fs';
+import { dirname, resolve } from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+const log = (...a) => console.log('[shared]', ...a);
+const __dirname = dirname(fileURLToPath(import.meta.url));
+const REPO_ROOT = resolve(__dirname, '..', '..');
+const ENV_PATH = resolve(REPO_ROOT, '.env');
+const SHOTS = resolve(__dirname, '.screenshots');
+mkdirSync(SHOTS, { recursive: true });
+
+const envText = readFileSync(ENV_PATH, 'utf8');
+const tokenMatch = envText.match(/^DEV_BYPASS_TOKEN=(.+)$/m);
+const DEV_TOKEN = tokenMatch ? tokenMatch[1].trim().replace(/^"|"$/g, '') : null;
+if (!DEV_TOKEN) throw new Error('DEV_BYPASS_TOKEN missing in .env');
+
+const BASE_URL = process.env.BASE_URL || 'http://localhost:8601';
+const TEACHER_URL = `${BASE_URL}/?no_beforeunload=1&devlogin=${encodeURIComponent(DEV_TOKEN)}`;
+const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
+
+const HEADLESS = process.env.HEADLESS !== 'false';
+const launchOpts = { headless: HEADLESS };
+if (process.env.CHANNEL) launchOpts.channel = process.env.CHANNEL;
+if (process.env.SLOWMO) launchOpts.slowMo = Number(process.env.SLOWMO);
+
+const STAMP = `${Date.now()}`.slice(-6);
+const CLASS_NAME = `共有検証${STAMP}`;
+const ASSIGNMENT_NAME = `共有課題${STAMP}`;
+const SHARED_TITLE = `ねこあつめ入門${STAMP}`;
+
+const browser = await chromium.launch(launchOpts);
+const page = await browser.newPage({
+ viewport: { width: 1280, height: 900 },
+ ...(process.env.LOCALE ? { locale: process.env.LOCALE } : {}),
+});
+page.on('pageerror', (e) => log('[pageerror]', e.message));
+
+const tid = (id) => `[data-testid="${id}"]`;
+const shot = async (name) => {
+ const p = resolve(SHOTS, `shared-${name}.png`);
+ await page.screenshot({ path: p });
+ log('screenshot:', p);
+};
+const assert = (cond, msg) => {
+ if (!cond) throw new Error(`ASSERT FAILED: ${msg}`);
+ log('OK:', msg);
+};
+
+let ok = false;
+try {
+ log('navigating with devlogin...');
+ await page.goto(TEACHER_URL, { waitUntil: 'domcontentloaded' });
+ await page.waitForSelector('[class*="gui_editor-wrapper"]', { timeout: 90000 });
+ await sleep(1500);
+
+ await page.click(tid('settings-menu'));
+ await sleep(300);
+ await page.click(tid('settings-classroom-management'));
+ await page.waitForSelector(tid('classroom-phase-teacher-class-list'), { timeout: 60000 });
+
+ // --- 1. class + assignment with content ---
+ log(`creating class "${CLASS_NAME}" + assignment...`);
+ await page.click(tid('classroom-class-create'));
+ await page.fill(tid('classroom-class-create-name'), CLASS_NAME);
+ await page.fill(tid('classroom-class-create-count'), '5');
+ await page.fill(tid('classroom-class-create-assignment'), ASSIGNMENT_NAME);
+ await page.click(tid('classroom-class-create-submit'));
+ await page.waitForSelector(tid('classroom-board'), { timeout: 30000 });
+ await sleep(800);
+
+ log('adding assignment pages (share requires content)...');
+ await page.click('[data-testid^="classroom-board-open-"]');
+ await page.waitForSelector(tid('classroom-description-editor'), { timeout: 20000 });
+ await page.fill(tid('classroom-assignment-page-text-0'), 'ねこを10歩動かすプログラムを作ろう');
+ await page.click(tid('classroom-assignment-save'));
+ await sleep(1500);
+
+ // --- 2. share ---
+ log('sharing the assignment...');
+ await page.click(tid('classroom-share-assignment'));
+ await page.waitForSelector(tid('shared-form'), { timeout: 10000 });
+ await page.fill(tid('shared-form-title'), SHARED_TITLE);
+ await page.fill(tid('shared-form-summary'), 'E2E 検証用の共有課題');
+ await page.selectOption(tid('shared-form-level'), 'junior-high');
+ await page.selectOption(tid('shared-form-subject'), '技術・家庭(技術分野)');
+ await page.click(tid('shared-form-grade-1'));
+ await page.fill(tid('shared-form-tags'), '甲子園, E2E');
+ await page.fill(tid('shared-form-url'), 'https://docs.google.com/document/d/e2e/view');
+ await page.fill(tid('shared-form-author-name'), 'E2E検証太郎');
+ await page.fill(tid('shared-form-author-affiliation'), '島根県 検証中学校');
+ await shot('form');
+ await page.click(tid('shared-form-consent'));
+ await page.click(tid('shared-form-submit'));
+ await page.waitForSelector(tid('shared-form-success'), { timeout: 30000 });
+ const successText = await page.textContent(tid('shared-form-success'));
+ assert(/CC BY 4\.0/.test(successText || ''), `publish confirmation carries the CC BY credit ("${successText}")`);
+
+ // --- 3. catalog: filter + detail ---
+ log('browsing the catalog...');
+ await page.click(tid('classroom-breadcrumb-assignments'));
+ await page.waitForSelector(tid('classroom-board'), { timeout: 20000 });
+ await page.click(tid('classroom-board-shared-catalog'));
+ await page.waitForSelector(tid('shared-catalog'), { timeout: 20000 });
+ await page.selectOption(tid('shared-catalog-filter-level'), 'junior-high');
+ await page.fill(tid('shared-catalog-filter-tag'), 'E2E');
+ await page.click(tid('shared-catalog-filter-apply'));
+ await sleep(1500);
+
+ const card = await page.waitForSelector(
+ `${tid('shared-catalog-list')} >> text=${SHARED_TITLE}`,
+ { timeout: 20000 },
+ );
+ assert(card, 'the shared item appears in the filtered catalog');
+ await shot('catalog');
+
+ // Open the specific card that carries our title.
+ const cardButton = await page.evaluateHandle((title) => {
+ const buttons = Array.from(document.querySelectorAll('[data-testid^="shared-catalog-open-"]'));
+ return buttons.find((b) => b.textContent.includes(title)) || null;
+ }, SHARED_TITLE);
+ await cardButton.asElement().click();
+ await page.waitForSelector(tid('shared-catalog-detail'), { timeout: 20000 });
+ const credit = await page.textContent(tid('shared-detail-credit'));
+ assert(/© E2E検証太郎(島根県 検証中学校) \/ CC BY 4\.0/.test(credit || ''), `detail shows the credit line ("${credit}")`);
+
+ // Supplement URL sits behind the external-domain confirmation (D4).
+ await page.click(tid('shared-detail-url'));
+ const urlConfirm = await page.textContent(tid('shared-detail-url-confirm'));
+ assert((urlConfirm || '').includes('docs.google.com'), 'external link confirmation names the domain');
+ await page.click(tid('shared-detail-url-cancel'));
+ await shot('detail');
+
+ // --- 4. import into this class ---
+ log('importing into the class...');
+ await page.click(tid('shared-detail-import'));
+ await page.waitForSelector(tid('shared-import-success'), { timeout: 30000 });
+ await sleep(1000);
+ const rows = await page.$$eval('[data-testid^="classroom-board-open-"]', (els) =>
+ els.map((el) => el.textContent),
+ );
+ assert(
+ rows.some((text) => text.includes(SHARED_TITLE)),
+ 'the imported assignment appears on the board as a new row',
+ );
+ await shot('imported');
+
+ // --- 5. my posts: unlist -> republish ---
+ log('managing my posts...');
+ await page.click(tid('classroom-board-shared-catalog'));
+ await page.waitForSelector(tid('shared-catalog'), { timeout: 20000 });
+ await page.click(tid('shared-catalog-tab-mine'));
+ await sleep(1500);
+ const mineButton = await page.evaluateHandle((title) => {
+ const buttons = Array.from(document.querySelectorAll('[data-testid^="shared-catalog-open-"]'));
+ return buttons.find((b) => b.textContent.includes(title)) || null;
+ }, SHARED_TITLE);
+ await mineButton.asElement().click();
+ await page.waitForSelector(tid('shared-detail-unlist'), { timeout: 20000 });
+ await page.click(tid('shared-detail-unlist'));
+ await sleep(1500);
+
+ const unlistedCard = await page.evaluateHandle((title) => {
+ const buttons = Array.from(document.querySelectorAll('[data-testid^="shared-catalog-open-"]'));
+ return buttons.find((b) => b.textContent.includes(title)) || null;
+ }, SHARED_TITLE);
+ await unlistedCard.asElement().click();
+ await page.waitForSelector(tid('shared-detail-republish'), { timeout: 20000 });
+ await page.click(tid('shared-detail-republish'));
+ await sleep(1500);
+ assert(true, 'unlist -> republish round-trip completed');
+
+ // Keep stg tidy: withdraw the E2E item at the end (it stays restorable).
+ const cleanupCard = await page.evaluateHandle((title) => {
+ const buttons = Array.from(document.querySelectorAll('[data-testid^="shared-catalog-open-"]'));
+ return buttons.find((b) => b.textContent.includes(title)) || null;
+ }, SHARED_TITLE);
+ await cleanupCard.asElement().click();
+ await page.waitForSelector(tid('shared-detail-unlist'), { timeout: 20000 });
+ await page.click(tid('shared-detail-unlist'));
+ await sleep(1000);
+ log('cleanup: E2E item unlisted');
+
+ ok = true;
+} catch (e) {
+ log('ERROR:', e.message);
+ await shot('failure').catch(() => {});
+} finally {
+ log(ok ? 'PASS' : 'FAIL');
+ if (process.env.KEEP_OPEN === '1') {
+ log('KEEP_OPEN=1 — leaving browser open. Ctrl+C to exit.');
+ await new Promise(() => {});
+ }
+ await browser.close();
+ process.exit(ok ? 0 : 1);
+}