From b2118c735a147f3f34e0c8bd6387b49a099e3c8d Mon Sep 17 00:00:00 2001 From: "smalruby3-editor-bot[bot]" <297607354+smalruby3-editor-bot[bot]@users.noreply.github.com> Date: Sat, 18 Jul 2026 02:26:18 +0000 Subject: [PATCH 01/30] feat(classroom-api): shared assignment library store + API (#1068) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit みんなの課題 (EPIC #1066, design: spike #1067 D1-D12): - new SharedAssignments table (no TTL; prod RETAIN + PITR; GSIs status-createdAt / authorSub-createdAt) and SharedAssignmentReports table (90-day TTL) - new smalruby-shared-assignments bucket (no lifecycle = permanent, prod RETAIN) so the classroom retention sweep never touches shares - 7 teacher-auth endpoints: share (snapshot copy into the shared bucket, CC BY consent required, 50MB starter cap, 10/day quota), newest-first filtered catalog with cursor pagination + mine=1, detail with presigned URLs, import into own class (reverse copy + reuseCount), author-only update (overwrite re-snapshot) / unlist, and report (20/day quota, reason required) - responses never expose authorSub / reporterSub; quotas reuse the eval-quota atomic counter pattern - docs: architecture.md route table + data notes; .env.example limits EPIC #1066 Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/classroom/architecture.md | 21 + infra/smalruby-classroom/.env.example | 4 + infra/smalruby-classroom/lambda/handler.ts | 780 +++++++++++++++++- .../tests/handler-shared-assignments.test.ts | 512 ++++++++++++ .../smalruby-classroom/lib/classroom-stack.ts | 132 +++ 5 files changed, 1448 insertions(+), 1 deletion(-) create mode 100644 infra/smalruby-classroom/lambda/tests/handler-shared-assignments.test.ts diff --git a/docs/classroom/architecture.md b/docs/classroom/architecture.md index ac14eb2e61d..db9107d140b 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,24 @@ 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` | 課題を共有(自分のクラスの課題スナップショットを共有ストアへコピー。CC BY 4.0 同意必須・スターター 50MB 上限・10件/日制限) | +| `GET` | `/shared-assignments` | カタログ一覧(新着順・`schoolLevel`/`subject`/`grade`/`tag` で絞り込み・`cursor` ページネーション・`mine=1` で自分の投稿一覧) | +| `GET` | `/shared-assignments/{id}` | 詳細(ページ・画像/スターターの presigned URL・投稿者表示名。authorSub は返さない) | +| `POST` | `/shared-assignments/{id}/import` | 自分のクラス(groupId)に課題として取り込み(S3 逆コピー + reuseCount 増分) | +| `PATCH` | `/shared-assignments/{id}` | 更新(投稿者本人のみ。メタデータ + `classroomId` 指定で内容の再スナップショット=上書き) | +| `DELETE` | `/shared-assignments/{id}` | 取り下げ = `status: 'unlisted'`(物理削除しない。本人のみ) | +| `POST` | `/shared-assignments/{id}/report` | 通報(理由必須・20件/日制限。reporterSub は内部保持のみ) | + +- データ: `SharedAssignments{suffix}`(**TTL なし・prod は RETAIN + PITR**。GSI: `status-createdAt-index` / `authorSub-createdAt-index`)、`SharedAssignmentReports{suffix}`(TTL 90日) +- ファイル: 専用バケット `smalruby-shared-assignments{suffix}`(**lifecycle なし = 永続**、`shared/{sharedId}/` プレフィックス)。クラス側の保存期限と完全に分離 +- 共有/取り込みの実体は既存 duplicate と同じ S3 サーバー側コピー(クロスバケット) + ### 生徒用 (認証不要 / Session Token) | Method | Path | 認証 | 説明 | diff --git a/infra/smalruby-classroom/.env.example b/infra/smalruby-classroom/.env.example index 49db4125072..5b2d8795505 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/lambda/handler.ts b/infra/smalruby-classroom/lambda/handler.ts index 5daef6048e5..46daa8204d3 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 @@ -3364,6 +3392,715 @@ 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. */ +function mapSharedSummary(item: Record) { + 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, + status: item.status, + createdAt: item.createdAt, + updatedAt: item.updatedAt, + }; +} + +/** + * 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 { + if (body.licenseConsent !== true) { + throw new ValidationError('licenseConsent (CC BY 4.0) is required'); + } + 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(); + } + const attributes = validateSharedAttributes(body); + const supplementUrl = validateSupplementUrl(body.supplementUrl); + const profile = validateAuthorProfile(body); + + // 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(); + const item: Record = { + sharedId, + title: title.trim(), + summary, + content: { pages, starterKey }, + supplementUrl, + ...attributes, + ...profile, + authorSub: identity.sub, + 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)) }; +} + +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; + } + + let exclusiveStartKey: Record | undefined; + if (query.cursor) { + try { + exclusiveStartKey = JSON.parse(Buffer.from(query.cursor, 'base64url').toString('utf8')); + } catch { + throw new ValidationError('Invalid cursor'); + } + } + + 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', + ExpressionAttributeNames: { + ...(mine ? {} : { '#status': 'status' }), + ...names, + }, + 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({ + items: (result.Items || []).map(mapSharedSummary), + cursor, + }), + }; +} + +async function handleGetSharedAssignment( + identity: TeacherIdentity, sharedId: string, +): Promise { + const item = await getSharedItem(sharedId); + // Existence-hiding 404; the author may still view their own unlisted item. + if (!item || (item.status !== 'published' && item.authorSub !== identity.sub)) { + 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), + pages, + starterUrl, + isMine: item.authorSub === identity.sub, + }), + }; +} + +async function handleImportSharedAssignment( + identity: TeacherIdentity, sharedId: string, body: Record, +): Promise { + const item = await getSharedItem(sharedId); + if (!item || item.status !== 'published') { + throw new NotFoundError('Shared assignment not found'); + } + + 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(), + }), + }; +} + +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; + } + + // 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 || {})) }; +} + +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 +4152,47 @@ export const handler = async (event: APIGatewayProxyEventV2): Promise { + 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', +}; + +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: '甲子園' }, + })); + + expect(captured?.FilterExpression).toBe('#sl = :sl AND #sub = :sub AND contains(#gr, :gr) AND contains(#tg, :tg)'); + 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'); + 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('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/lib/classroom-stack.ts b/infra/smalruby-classroom/lib/classroom-stack.ts index 3e05b2a8aa2..ce3634accf1 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,77 @@ 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, + }); + + 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 +349,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 +394,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 +432,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 +642,31 @@ 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, + }); + // Groups (組) — teacher-side organizing concept. Own root path so the // /classrooms/{classroomId} patterns never shadow it. this.api.addRoutes({ From ea8e4b503bfd7f03ba08674e3350f42732b6afd8 Mon Sep 17 00:00:00 2001 From: "smalruby3-editor-bot[bot]" <297607354+smalruby3-editor-bot[bot]@users.noreply.github.com> Date: Sat, 18 Jul 2026 02:38:33 +0000 Subject: [PATCH 02/30] feat(classroom): shared assignment publish form (#1069) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit みんなの課題 S2 (EPIC #1066, design: spike #1067): - "Share this assignment" button in the assignment detail opens an inline form: title/summary, school level x grades x subject (controlled vocabulary, free text for "other" — D5), free tags, lesson count, supplement URL with explicit guidance on what belongs there + client-side https check (D4), minimal author profile remembered in localStorage smalruby:sharedAuthorProfile (D6), and a mandatory CC BY 4.0 consent checkbox (D2) - publish confirmation shows the CC BY credit line - classroom-api gains the 7 shared-assignment methods (share / list / detail / import / update / unlist / report) for S2+S3 - new libs: shared-assignment-taxonomy (mirrors the server vocabulary) and shared-author-profile (SSR-guarded persistence) - new hook use-shared-assignments wired through the teacher aggregator - docs: ui-ux.md / testing.md testids EPIC #1066 Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/classroom/testing.md | 13 + docs/classroom/ui-ux.md | 1 + packages/scratch-gui/.prettierignore | 5 + .../classroom-modal/classroom-modal.css | 139 +++++++ .../shared-assignment-form.jsx | 380 ++++++++++++++++++ .../classroom-modal/teacher-class-detail.jsx | 42 ++ .../classroom-teacher-modal.jsx | 2 + .../containers/classroom-teacher-modal.jsx | 1 + .../src/containers/use-shared-assignments.js | 71 ++++ .../src/containers/use-teacher-classroom.js | 11 + packages/scratch-gui/src/lib/classroom-api.js | 85 ++++ .../src/lib/shared-assignment-taxonomy.js | 52 +++ .../src/lib/shared-author-profile.js | 49 +++ packages/scratch-gui/src/locales/en.js | 30 ++ packages/scratch-gui/src/locales/ja-Hira.js | 28 ++ packages/scratch-gui/src/locales/ja.js | 26 ++ .../shared-assignment-form.test.jsx | 119 ++++++ .../lib/shared-assignment-taxonomy.test.js | 36 ++ .../unit/lib/shared-author-profile.test.js | 32 ++ 19 files changed, 1122 insertions(+) create mode 100644 packages/scratch-gui/src/components/classroom-modal/shared-assignment-form.jsx create mode 100644 packages/scratch-gui/src/containers/use-shared-assignments.js create mode 100644 packages/scratch-gui/src/lib/shared-assignment-taxonomy.js create mode 100644 packages/scratch-gui/src/lib/shared-author-profile.js create mode 100644 packages/scratch-gui/test/unit/components/shared-assignment-form.test.jsx create mode 100644 packages/scratch-gui/test/unit/lib/shared-assignment-taxonomy.test.js create mode 100644 packages/scratch-gui/test/unit/lib/shared-author-profile.test.js diff --git a/docs/classroom/testing.md b/docs/classroom/testing.md index baf1f3582c5..5a5f9ed96be 100644 --- a/docs/classroom/testing.md +++ b/docs/classroom/testing.md @@ -242,6 +242,19 @@ 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-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 c2a3ab3a816..a9d75557a52 100644 --- a/docs/classroom/ui-ux.md +++ b/docs/classroom/ui-ux.md @@ -160,6 +160,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/packages/scratch-gui/.prettierignore b/packages/scratch-gui/.prettierignore index a5c156bcbb9..c4ba58e5be3 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 6e6f3811b4f..deae48475b1 100644 --- a/packages/scratch-gui/src/components/classroom-modal/classroom-modal.css +++ b/packages/scratch-gui/src/components/classroom-modal/classroom-modal.css @@ -2349,6 +2349,145 @@ cursor: default; } +/* --- みんなの課題: share form (EPIC #1066 S2) --- */ + +.shared-form { + 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: 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; +} + +.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; +} + +.shared-form-row > * { + flex: 1; +} + +.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; +} + /* --- Assignment board (inside a class) --- */ .assignment-board { 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 00000000000..dc6c0113512 --- /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 ( +
+

+ +

+

+ +

+ + + + +
+ + {subjects.length > 0 ? ( + + ) : ( + + )} + +
+ +
+ + + + {gradesForLevel(schoolLevel).map((grade) => ( + + ))} +
+ + + +
+ +

+ +

+ {urlLooksValid ? null : ( +

+ +

+ )} +
+ +
+ + +
+ + + +
+ + +
+
+ ); +}; + +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-class-detail.jsx b/packages/scratch-gui/src/components/classroom-modal/teacher-class-detail.jsx index c6d2681f44c..dc1f083b310 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 @@ -7,6 +7,7 @@ import ClassCodeDisplay from './class-code-display.jsx'; import ErrorDisplay from './error-display.jsx'; import TeacherAssignmentEditor from './teacher-assignment-editor.jsx'; import TeacherMemberDetail from './teacher-member-detail.jsx'; +import SharedAssignmentForm from './shared-assignment-form.jsx'; import { formatClassLabel } from '../../lib/classroom-class-label.js'; import { retentionLevel } from '../../lib/classroom-retention.js'; @@ -46,6 +47,7 @@ const TeacherClassDetail = ({ onDetailTabChange, assignmentEditor, group, + shared, }) => { const intl = useIntl(); // Destructured with handle-prefixed names for the embedded editor @@ -438,8 +440,47 @@ const TeacherClassDetail = ({ /> )} + {shared ? ( + + ) : null} + {/* みんなの課題: share form + publish confirmation + (EPIC #1066 S2) */} + {shared && shared.showShareForm ? ( + + ) : null} + {shared && shared.lastShared && !shared.showShareForm ? ( +

+ +

+ ) : null} + {/* Description tab: the student-facing pages editor. Changes reach students only on save. */} {activeTab === 'description' && assignmentEditor && ( @@ -772,6 +813,7 @@ TeacherClassDetail.propTypes = { onDetailTabChange: PropTypes.func, assignmentEditor: PropTypes.object, group: PropTypes.object, + shared: PropTypes.object, selectedClassroom: PropTypes.object.isRequired, selectedMember: PropTypes.string, }; 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 fcf3644c96f..df80b72b3af 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 @@ -132,6 +132,7 @@ const ClassroomTeacherModal = ({ containerProps, onClose }) => { onCreateGroup, onUpdateGroup, evaluation, + shared, } = containerProps; // Opening a class scopes the board to its assignments (GC style). @@ -287,6 +288,7 @@ const ClassroomTeacherModal = ({ containerProps, onClose }) => { onReturnSubmission={onReturnSubmission} onSelectMember={onSelectMember} onShowCodeDisplay={onShowCodeDisplay} + shared={shared} onShowPostAssignment={authProvider === 'google' ? onShowPostAssignment : null} onToggleCodeFullscreen={onToggleCodeFullscreen} onUpdateAssignmentName={onUpdateAssignmentName} diff --git a/packages/scratch-gui/src/containers/classroom-teacher-modal.jsx b/packages/scratch-gui/src/containers/classroom-teacher-modal.jsx index aabaa4964c8..5bce07267a9 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 00000000000..c3a0a813808 --- /dev/null +++ b/packages/scratch-gui/src/containers/use-shared-assignments.js @@ -0,0 +1,71 @@ +/** + * みんなの課題 (shared assignment library) hook — EPIC #1066. + * + * S2 (#1069) scope: publishing an assignment from the detail view. The + * catalog / import side (S3 #1070) extends this hook. + */ +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.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, clearError, showError, intl, setIsLoading }) => { + // The share form is shown inline in the assignment detail; after a + // successful publish we keep the created summary so the detail view can + // confirm ("公開しました") instead of silently closing. + const [showShareForm, setShowShareForm] = useState(false); + const [lastShared, setLastShared] = useState(null); + + const handleOpenShareForm = useCallback(() => { + setLastShared(null); + setShowShareForm(true); + }, []); + const handleCloseShareForm = useCallback(() => setShowShareForm(false), []); + + const handleShareAssignment = useCallback( + async (payload) => { + clearError(); + setIsLoading(true); + try { + const shared = await classroomAPI.shareAssignment(idToken, payload); + setLastShared(shared); + setShowShareForm(false); + } catch (err) { + if (err.status === 401) { + await handleTeacher401(); + } else { + showError(translateError(intl, err)); + } + } finally { + setIsLoading(false); + } + }, + [idToken, clearError, showError, handleTeacher401, intl, setIsLoading], + ); + + /** Reset share state (view switch / logout). */ + const resetSharedAssignments = useCallback(() => { + setShowShareForm(false); + setLastShared(null); + }, []); + + return { + showShareForm, + lastShared, + handleOpenShareForm, + handleCloseShareForm, + handleShareAssignment, + 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 4fc0df3993e..cae368794dd 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,15 @@ const useTeacherClassroom = ({ setPhase, }); + const shared = useSharedAssignments({ + idToken: auth.idToken, + handleTeacher401: auth.handleTeacher401, + clearError, + showError, + intl, + setIsLoading, + }); + // --- Composed handlers --- const handleTeacherLogout = useCallback(() => { @@ -232,6 +242,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 e16a30495be..7278e2cba6c 100644 --- a/packages/scratch-gui/src/lib/classroom-api.js +++ b/packages/scratch-gui/src/lib/classroom-api.js @@ -326,6 +326,91 @@ 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. + * @param {string} idToken - Teacher ID token + * @param {object} payload - {classroomId, title, summary?, schoolLevel, + * grades?, subject, tags?, lessonCount?, supplementUrl?, authorName, + * authorAffiliation?, licenseConsent} + * @returns {Promise} Published item summary + */ + async shareAssignment(idToken, payload) { + return this._request('POST', '/shared-assignments', 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 00000000000..35f93b774d9 --- /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 00000000000..1ca0693b785 --- /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 d42afb6f17f..8a411215b64 100644 --- a/packages/scratch-gui/src/locales/en.js +++ b/packages/scratch-gui/src/locales/en.js @@ -344,6 +344,36 @@ export default { '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.openForm': 'Share this assignment', + 'gui.classroom.shared.published': 'Published to みんなの課題: "{title}" (© {author} / CC BY 4.0)', '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 1017efc8d9c..8ef29f612fb 100644 --- a/packages/scratch-gui/src/locales/ja-Hira.js +++ b/packages/scratch-gui/src/locales/ja-Hira.js @@ -356,6 +356,34 @@ export default { '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.openForm': 'このかだいをきょうゆう', + 'gui.classroom.shared.published': '「みんなのかだい」にこうかいしました: {title}(© {author} / CC BY 4.0)', 'gui.classroom.teacherDetail.retentionBanner': 'このかだいとていしゅつぶつは {date} にじどうさくじょされます。ダウンロードしてほぞんしてください。', 'gui.classroom.codeDisplay.title': 'さんかコード', diff --git a/packages/scratch-gui/src/locales/ja.js b/packages/scratch-gui/src/locales/ja.js index 24e679fb53b..bd9ab78b172 100644 --- a/packages/scratch-gui/src/locales/ja.js +++ b/packages/scratch-gui/src/locales/ja.js @@ -351,6 +351,32 @@ export default { '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.openForm': 'この課題を共有', + 'gui.classroom.shared.published': '「みんなの課題」に公開しました: {title}(© {author} / CC BY 4.0)', 'gui.classroom.teacherDetail.retentionBanner': 'この課題と提出物は {date} に自動削除されます。ダウンロードして保存してください。', 'gui.classroom.codeDisplay.title': '参加コード', 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 00000000000..0b20a4c1f7a --- /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/lib/shared-assignment-taxonomy.test.js b/packages/scratch-gui/test/unit/lib/shared-assignment-taxonomy.test.js new file mode 100644 index 00000000000..0d22e068f9d --- /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 00000000000..73dbe1550cb --- /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: '' }); + }); +}); From 19ba288a621ed7cb490f59593584044fc6bb4996 Mon Sep 17 00:00:00 2001 From: "smalruby3-editor-bot[bot]" <297607354+smalruby3-editor-bot[bot]@users.noreply.github.com> Date: Sat, 18 Jul 2026 02:49:16 +0000 Subject: [PATCH 03/30] feat(classroom): shared assignment catalog + import UI (#1070) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit みんなの課題 S3 (EPIC #1066, design: spike #1067): - "Find in みんなの課題" on the assignment board opens the catalog: newest-first cards (attribute badges, tags, author, reuse count) with school-level/subject/grade/tag filters and cursor pagination - detail preview shows the snapshot pages, the CC BY 4.0 credit line and the supplement URL behind an explicit external-domain confirmation (D4); import copies the assignment (starter included) into the current class and surfaces the new join code note - "My posts" tab lists the caller's own items (incl. unlisted) with withdraw / republish; strangers get a report flow (reason required) - use-shared-assignments grows the catalog state machine and refreshes the board after an import - docs: ui-ux.md / testing.md testids EPIC #1066 Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/classroom/testing.md | 21 + docs/classroom/ui-ux.md | 1 + .../classroom-modal/classroom-modal.css | 172 ++++++ .../shared-assignment-catalog.jsx | 575 ++++++++++++++++++ .../teacher-assignment-board.jsx | 34 ++ .../classroom-teacher-modal.jsx | 1 + .../src/containers/use-shared-assignments.js | 200 +++++- .../src/containers/use-teacher-classroom.js | 1 + packages/scratch-gui/src/locales/en.js | 28 + packages/scratch-gui/src/locales/ja-Hira.js | 30 + packages/scratch-gui/src/locales/ja.js | 28 + .../shared-assignment-catalog.test.jsx | 169 +++++ 12 files changed, 1257 insertions(+), 3 deletions(-) create mode 100644 packages/scratch-gui/src/components/classroom-modal/shared-assignment-catalog.jsx create mode 100644 packages/scratch-gui/test/unit/components/shared-assignment-catalog.test.jsx diff --git a/docs/classroom/testing.md b/docs/classroom/testing.md index 5a5f9ed96be..ebb126a5d2a 100644 --- a/docs/classroom/testing.md +++ b/docs/classroom/testing.md @@ -255,6 +255,27 @@ Playwright MCP および Selenium integration tests で使用する `data-testid | `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 a9d75557a52..94b5f2867a7 100644 --- a/docs/classroom/ui-ux.md +++ b/docs/classroom/ui-ux.md @@ -149,6 +149,7 @@ Google または Microsoft アカウントでサインインする画面。先 ![アーカイブ済み課題の一覧と復元](screenshots/0217-board-archived-section.png) - **残り日数バッジ**: 保存期限(自動削除)まで 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」クレジット・補足資料リンクは外部ドメイン名付き確認を挟んで新規タブ)→「このクラスに取り込む」でスターターごと課題として複製(新しい参加コードが発行される)。「自分の投稿」タブから取り下げ / 再公開。他人の投稿には通報(理由必須) ![残り日数バッジと全課題ダウンロード](screenshots/0213-board-expiry-badge-download.png) - 主な 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}` 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 deae48475b1..9d4c684cea5 100644 --- a/packages/scratch-gui/src/components/classroom-modal/classroom-modal.css +++ b/packages/scratch-gui/src/components/classroom-modal/classroom-modal.css @@ -2488,6 +2488,178 @@ 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.4rem; + background: white; + color: #855cd6; + cursor: pointer; + text-decoration: none; + font-size: 0.8rem; +} + +.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) --- */ .assignment-board { 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 00000000000..73f89e5a798 --- /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 ( +
  • + +
  • + ); +}; + +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 ? ( +
    +