diff --git a/.claude/plans/course-management.md b/.claude/plans/course-management.md new file mode 100644 index 000000000..8ad5c2669 --- /dev/null +++ b/.claude/plans/course-management.md @@ -0,0 +1,246 @@ +# Course Management Feature - Implementation Plan + +## 需求概述 + +为 OpenMAIC 添加「课程」管理功能,使教师能够创建课程并将多个课堂组织到课程中。 + +### 核心需求 +1. Course 元数据:学院、专业、课程名称、课程简介、教师姓名 +2. 前端 UI:创建/编辑/删除课程 +3. 生成流程:选择目标课程 → 生成课堂 → 发布到课程 +4. 课程浏览:按课程查看有序的课堂列表 +5. 向后兼容:现有独立课堂继续工作 + +## 设计原则 + +### 数据结构 - 索引关系而非嵌套 +- Course 只存储 `classroomIds[]`,不嵌入完整 classroom 数据 +- Classroom 仍然独立存储在 `data/classrooms/{id}/` +- 关系是 Course → Classroom (1:N),通过 ID 引用 + +### 零破坏性 +- 不修改现有 `Stage` 类型 +- 不修改现有 `POST /api/classroom` 逻辑 +- 现有 classroom 自动成为「独立课堂」 +- 所有现有 URL 和 API 保持不变 + +## 实现步骤 + +### Phase 1: 数据模型与服务端存储 + +#### 1.1 新增类型定义 +**文件**: `lib/types/course.ts` (新建) + +```typescript +export interface Course { + id: string; + name: string; // 课程名称 + college: string; // 学院 + major: string; // 专业 + description: string; // 课程简介 + teacherName: string; // 教师姓名 + classroomIds: string[]; // 有序的课堂ID列表 + createdAt: string; // ISO 8601 + updatedAt: string; +} + +export interface CourseListItem { + id: string; + name: string; + college: string; + major: string; + description: string; + teacherName: string; + classroomCount: number; + createdAt: string; +} +``` + +#### 1.2 服务端存储模块 +**文件**: `lib/server/course-storage.ts` (新建) + +实现函数: +- `persistCourse(course: Course): Promise` - 原子写入 +- `readCourse(id: string): Promise` +- `listCourses(): Promise` - 按 createdAt 降序 +- `deleteCourse(id: string): Promise` +- `addClassroomToCourse(courseId: string, classroomId: string): Promise` +- `removeClassroomFromCourse(courseId: string, classroomId: string): Promise` +- `reorderClassrooms(courseId: string, newOrder: string[]): Promise` + +存储路径:`data/courses/{courseId}.json` + +#### 1.3 API 路由设计 +**文件**: `app/api/course/route.ts` (新建) + +```typescript +GET /api/course → listCourses() +GET /api/course?id=xxx → readCourse(id) +POST /api/course → persistCourse(body) +DELETE /api/course?id=xxx → deleteCourse(id) +``` + +**文件**: `app/api/course/classrooms/route.ts` (新建) + +```typescript +POST /api/course/classrooms → addClassroomToCourse(courseId, classroomId) +DELETE /api/course/classrooms → removeClassroomFromCourse(courseId, classroomId) +PUT /api/course/classrooms → reorderClassrooms(courseId, newOrder) +``` + +### Phase 2: 前端状态管理 + +#### 2.1 Course Store +**文件**: `lib/store/course.ts` (新建) + +状态: +- `courses: CourseListItem[]` - 课程列表 +- `currentCourse: Course | null` - 当前编辑的课程 +- `isLoading: boolean` + +Actions: +- `fetchCourses()` - 从服务器加载课程列表 +- `fetchCourse(id)` - 加载单个课程详情 +- `createCourse(data)` - 创建新课程 +- `updateCourse(id, data)` - 更新课程 +- `deleteCourse(id)` - 删除课程 +- `addClassroom(courseId, classroomId)` - 添加课堂到课程 +- `removeClassroom(courseId, classroomId)` - 从课程移除课堂 +- `reorderClassrooms(courseId, newOrder)` - 重排课堂顺序 + +### Phase 3: UI 组件与页面 + +#### 3.1 课程列表页 +**文件**: `app/course/page.tsx` (新建) + +功能: +- 显示所有课程的卡片列表 +- 每个卡片显示:课程名、学院、专业、课堂数量 +- 操作按钮:新建课程、编辑、删除 +- 点击卡片进入课程详情页 + +#### 3.2 课程详情页 +**文件**: `app/course/[id]/page.tsx` (新建) + +功能: +- 显示课程元数据(可编辑) +- 显示课程内的课堂列表(可拖拽排序) +- 每个课堂显示缩略图、标题、创建时间 +- 操作:添加现有课堂、生成新课堂、移除课堂、预览课堂 +- 点击课堂进入播放页面 + +#### 3.3 课程表单组件 +**文件**: `components/course/course-form.tsx` (新建) + +表单字段: +- 课程名称 (name) +- 学院 (college) +- 专业 (major) +- 课程简介 (description) +- 教师姓名 (teacherName) + +用于创建和编辑课程。 + +### Phase 4: 生成流程集成 + +#### 4.1 修改生成器传递 courseId +**文件**: `lib/hooks/use-scene-generator.ts` + +在 `generateRemaining()` 中添加可选的 `courseId` 参数,传递给后端 API。 + +#### 4.2 修改发布流程 +**文件**: `components/header.tsx` + +`publishClassroom()` 函数: +- 如果当前在课程上下文中,调用 `POST /api/course/classrooms` 将课堂关联到课程 +- 保持现有独立发布流程不变 + +#### 4.3 生成页面课程选择 +**文件**: `app/page.tsx` 或生成表单组件 + +在生成表单中添加: +- 可选的"目标课程"下拉选择 +- 如果选择了课程,生成后自动关联到该课程 +- 如果未选择,生成独立课堂 + +### Phase 5: 首页修改 + +**文件**: `app/page.tsx` + +添加 Tab 切换: +- Tab 1: 我的课程 (显示课程列表) +- Tab 2: 独立课堂 (显示未关联到任何课程的课堂) + +逻辑: +- 课程 Tab:调用 `GET /api/course` 获取课程列表 +- 独立课堂 Tab:调用 `GET /api/classroom`,过滤出不在任何课程中的课堂 + +### Phase 6: 国际化 + +**文件**: `locales/zh-CN.json` 和 `locales/en-US.json` + +添加翻译键: +- `course.name` - 课程名称 / Course Name +- `course.college` - 学院 / College +- `course.major` - 专业 / Major +- `course.description` - 课程简介 / Description +- `course.teacherName` - 教师姓名 / Teacher Name +- `course.create` - 创建课程 / Create Course +- `course.edit` - 编辑课程 / Edit Course +- `course.delete` - 删除课程 / Delete Course +- `course.classroomCount` - 课堂数量 / Classroom Count +- `course.myCourses` - 我的课程 / My Courses +- `course.standaloneClassrooms` - 独立课堂 / Standalone Classrooms + +## 验证计划 + +### 端到端测试流程 + +1. **创建课程** + - 访问 `/course` + - 点击"创建课程" + - 填写表单:学院、专业、课程名称、简介、教师姓名 + - 提交,验证课程出现在列表中 + +2. **生成课堂到课程** + - 在首页生成表单中选择目标课程 + - 填写课堂主题,点击生成 + - 等待生成完成 + - 验证课堂出现在课程详情页的课堂列表中 + +3. **课程详情管理** + - 访问 `/course/[id]` + - 验证课程元数据显示正确 + - 拖拽课堂重新排序 + - 点击课堂进入播放页面 + - 移除一个课堂,验证它变成独立课堂 + +4. **向后兼容性** + - 访问首页"独立课堂" Tab + - 验证现有课堂仍然可见 + - 点击播放,验证功能正常 + - 验证所有现有 API 端点仍然工作 + +5. **数据持久化** + - 重启服务器 + - 验证课程和课堂关系保持不变 + - 检查 `data/courses/` 目录下的 JSON 文件格式正确 + +## 关键文件清单 + +### 新建文件 (8个) +1. `lib/types/course.ts` - 类型定义 +2. `lib/server/course-storage.ts` - 服务端存储 +3. `lib/store/course.ts` - 前端状态管理 +4. `app/api/course/route.ts` - 课程 CRUD API +5. `app/api/course/classrooms/route.ts` - 课程-课堂关系 API +6. `app/course/page.tsx` - 课程列表页 +7. `app/course/[id]/page.tsx` - 课程详情页 +8. `components/course/course-form.tsx` - 课程表单组件 + +### 修改文件 (4个) +1. `lib/hooks/use-scene-generator.ts` - 添加 courseId 参数 +2. `components/header.tsx` - 发布时关联课程 +3. `app/page.tsx` - 添加 Tab 切换 +4. `locales/*.json` - 国际化字符串 + diff --git a/.gitignore b/.gitignore index 86c2726c6..491aec2fe 100644 --- a/.gitignore +++ b/.gitignore @@ -61,3 +61,6 @@ next-env.d.ts # docs /docs + +# local deploy guide (contains machine-specific config) +CLAUDE_DEPLOY_GUIDE.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 000000000..4c4c92289 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,46 @@ +# OpenMAIC Agent Notes + +## 8002 Service Startup + +- Project root: `/home/sunli/MyWork/myproject/OpenMAIC` +- The production-style `8002` service uses the Next.js standalone build. +- Do not use `pnpm start` for the `8002` service. + +Use this preferred detached startup command: + +```bash +cd /home/sunli/MyWork/myproject/OpenMAIC +tmux new-session -d -s openmaic-svc-8002 './scripts/start-8002.sh >>/tmp/openmaic-8002.log 2>&1' +``` + +## Verify + +```bash +ss -ltnp 'sport = :8002' +curl http://127.0.0.1:8002/api/health +curl http://127.0.0.1:8002/api/classroom +``` + +## Stop + +```bash +tmux kill-session -t openmaic-svc-8002 +``` + +## Rebuild Before Restart + +If server-side code changed, rebuild before restarting: + +```bash +cd /home/sunli/MyWork/myproject/OpenMAIC +pnpm build +tmux kill-session -t openmaic-svc-8002 || true +tmux new-session -d -s openmaic-svc-8002 './scripts/start-8002.sh >>/tmp/openmaic-8002.log 2>&1' +``` + +## Important Pitfalls + +- `node .next/standalone/server.js` changes `process.cwd()` to `.next/standalone`. +- Server-side storage must resolve back to the repo root, not the standalone snapshot directory. +- On this host, shell `HOSTNAME` may point to a machine hostname such as `k8s-node3-gpu`; if passed through unchanged, Next.js may bind to `127.0.1.1` instead of `0.0.0.0`. +- `./scripts/start-8002.sh` is the safe entry because it sets `OPENMAIC_PROJECT_ROOT`, `PORT`, and a safe `HOSTNAME`. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 000000000..46ab405a0 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,112 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Commands + +```bash +pnpm dev # Development server on :3000 +pnpm build # Production build (standalone output) +pnpm start # Start production server (use scripts/start-8002.sh for port 8002) +pnpm lint # ESLint +pnpm check # Prettier check +pnpm format # Prettier format +``` + +**Note:** `pnpm install` and `pnpm build` require the proxy (`export http_proxy="http://127.0.0.1:17891" https_proxy="http://127.0.0.1:17891"`) for downloading packages from overseas registries. + +### Production Service (Port 8002) + +The deployed instance runs via standalone build on port 8002. Preferred startup: + +```bash +tmux new-session -d -s openmaic-svc-8002 './scripts/start-8002.sh >>/tmp/openmaic-8002.log 2>&1' +``` + +After any server-side code change: `pnpm build` → kill session → re-launch. + +Verify: `curl http://127.0.0.1:8002/api/health` + +## Architecture Overview + +Next.js 16 App Router + React 19 + TypeScript. State management via Zustand. Multi-agent orchestration via LangGraph. + +### Key Data Flows + +**Provider Configuration:** +``` +.env.local / server-providers.yml + → lib/server/provider-config.ts (server-side, never exposes keys) + → GET /api/server-providers (metadata only) + → lib/store/settings.ts (Zustand, merged into providersConfig) +``` + +**Model Resolution (per API call):** +``` +POST /api/chat { model: "qn:gemini-3.1-pro-preview" } + → lib/server/resolve-model.ts (parse modelString, fetch apiKey/baseUrl) + → lib/ai/providers.ts getModel() (create @ai-sdk/* client by provider.type) +``` + +**Classroom Generation (two-stage pipeline):** +``` +Stage 1: POST /api/generate/scene-outlines-stream (SSE, LLM builds outline) +Stage 2: parallel per outline item: + POST /api/generate/scene-content → slides/quiz/interactive/pbl content + POST /api/generate/scene-actions → teacher actions (laser, spotlight, speech) + POST /api/generate/agent-profiles → agent personas + POST /api/generate/tts → audio synthesis +Stage 2.5 (client-side): generateMediaForOutlines() → image/video generation +``` + +Async API mode: `POST /api/generate-classroom` submits a job; poll `GET /api/generate-classroom/{jobId}` until `status: succeeded`. + +### Core Modules + +| Module | Purpose | +|--------|---------| +| `lib/ai/providers.ts` | PROVIDERS registry + `getModel()`. Supports openai/anthropic/google types plus any OpenAI-compatible endpoint | +| `lib/server/provider-config.ts` | Loads config from `server-providers.yml` + env vars; `LLM_ENV_MAP` maps env prefixes to provider IDs | +| `lib/server/resolve-model.ts` | Parses `"providerId:modelId"` strings; handles `DEFAULT_MODEL` fallback | +| `lib/generation/` | Two-stage pipeline: outline → scenes. `pipeline-runner.ts` orchestrates; `scene-generator.ts` generates per-scene content | +| `lib/orchestration/director-graph.ts` | LangGraph state machine for multi-agent classroom discussion | +| `lib/playback/` | State machine: idle → playing → live; drives scene transitions and agent turns | +| `lib/action/` | Executes 28+ action types (speech, whiteboard draw/write/shape, spotlight, laser, etc.) | +| `lib/server/classroom-storage.ts` | `persistClassroom`, `readClassroom`, `listClassrooms` — reads both new (`{id}/classroom.json`) and old (`{id}.json`) formats | +| `lib/server/media-storage.ts` | `saveMedia`, `getMediaPath`, `mediaExists` — atomic writes to `data/classrooms/{id}/media/` | + +### Frontend Storage + +- **Settings**: `localStorage` key `settings-storage` (Zustand persist) +- **Classroom data** (scenes, audio, media, chat): IndexedDB, database name `MAIC-Database` +- **Server-persisted classrooms**: `data/classrooms/{id}/` on the server + +### Scene Types + +| Type | Components | +|------|-----------| +| `slides` | `components/slide-renderer/` — canvas-based editor with ProseMirror text editing | +| `quiz` | `components/scene-renderers/` — single/multiple choice + short answer | +| `interactive` | `components/scene-renderers/` — sandboxed HTML simulations | +| `pbl` | Project-Based Learning with role selection and milestones | + +### Adding a New LLM Provider + +Requires changes to 4 files — see `CLAUDE_DEPLOY_GUIDE.md` §3 for the complete walkthrough: + +1. `lib/types/provider.ts` — add to `BuiltInProviderId` +2. `lib/ai/providers.ts` — add entry to `PROVIDERS` object +3. `lib/server/provider-config.ts` — add prefix to `LLM_ENV_MAP` +4. `.env.local` — set `{PREFIX}_API_KEY` and `{PREFIX}_BASE_URL` + +Models must be registered in `providers.ts` before referencing them via `*_MODELS` env var — otherwise the filter removes them. + +### Workspace Packages + +`packages/pptxgenjs` and `packages/mathml2omml` are local workspace packages rebuilt during `pnpm install` via `postinstall`. They are transpiled into the Next.js build via `transpilePackages` in `next.config.ts`. + +### Proxy Notes + +- LLM provider proxy is **per-provider** in `server-providers.yml` (`proxy: "http://127.0.0.1:17891"`) — only Google provider currently consumes this field. +- Web search (Tavily) proxy uses `HTTP_PROXY`/`HTTPS_PROXY` in `.env.local`. +- OpenAI/Anthropic-type providers do **not** support proxy forwarding in the current code. diff --git a/RUNBOOK-8002.md b/RUNBOOK-8002.md new file mode 100644 index 000000000..3a2a31f16 --- /dev/null +++ b/RUNBOOK-8002.md @@ -0,0 +1,73 @@ +# OpenMAIC 8002 Runbook + +## Correct startup mode + +This project is built with Next.js `output: 'standalone'`. + +Do not use `pnpm start` for the production-style 8002 service. It prints a warning and may not behave as expected with the standalone output. + +Use the standalone server entry instead: + +```bash +cd /home/sunli/MyWork/myproject/OpenMAIC +PORT=8002 HOSTNAME=0.0.0.0 OPENMAIC_PROJECT_ROOT=/home/sunli/MyWork/myproject/OpenMAIC node .next/standalone/server.js +``` + +Or use the helper script: + +```bash +cd /home/sunli/MyWork/myproject/OpenMAIC +./scripts/start-8002.sh +``` + +## Detached startup with tmux + +```bash +cd /home/sunli/MyWork/myproject/OpenMAIC +tmux new-session -d -s openmaic-svc-8002 './scripts/start-8002.sh >>/tmp/openmaic-8002.log 2>&1' +``` + +## Verify service + +```bash +ss -ltnp 'sport = :8002' +curl http://127.0.0.1:8002/api/health +curl http://127.0.0.1:8002/api/classroom +``` + +## Stop service + +```bash +tmux kill-session -t openmaic-svc-8002 +``` + +## Rebuild when needed + +If server code changed, rebuild before restarting: + +```bash +cd /home/sunli/MyWork/myproject/OpenMAIC +pnpm build +tmux kill-session -t openmaic-svc-8002 || true +tmux new-session -d -s openmaic-svc-8002 './scripts/start-8002.sh >>/tmp/openmaic-8002.log 2>&1' +``` + +## Why the public-course count dropped from 4 to 3 + +`node .next/standalone/server.js` changes the working directory to `.next/standalone`. + +Also note that many Linux hosts already define a shell `HOSTNAME` value such as `k8s-node3-gpu`. If you pass that through unchanged, Next.js may bind to `127.0.1.1` instead of `0.0.0.0`. The helper script forces `HOSTNAME=0.0.0.0` unless `OPENMAIC_HOSTNAME` is set explicitly. + +Before the fix in `lib/server/classroom-storage.ts`, server-side classroom storage used `process.cwd()`, so the live service read: + +```text +.next/standalone/data/classrooms +``` + +instead of: + +```text +data/classrooms +``` + +That standalone snapshot only had 3 classroom JSON files, so `/api/classroom` returned 3 public courses. diff --git a/app/[mediaId]/route.ts b/app/[mediaId]/route.ts new file mode 100644 index 000000000..d04604253 --- /dev/null +++ b/app/[mediaId]/route.ts @@ -0,0 +1,46 @@ +import { type NextRequest, NextResponse } from 'next/server'; +import { getMediaPath } from '@/lib/server/media-storage'; +import { promises as fs } from 'fs'; +import path from 'path'; + +const MIME_MAP: Record = { + '.mp3': 'audio/mpeg', + '.wav': 'audio/wav', + '.png': 'image/png', + '.jpg': 'image/jpeg', + '.webp': 'image/webp', + '.mp4': 'video/mp4', + '.webm': 'video/webm', +}; + +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ mediaId: string }> } +) { + const { mediaId } = await params; + + if (!mediaId.startsWith('gen_img_') && !mediaId.startsWith('tts_')) { + return NextResponse.json({ error: 'Not found' }, { status: 404 }); + } + + const classroomId = request.nextUrl.searchParams.get('classroomId'); + if (!classroomId) { + return NextResponse.json({ error: 'Missing classroomId' }, { status: 400 }); + } + + const filePath = await getMediaPath(classroomId, mediaId); + if (!filePath) { + return NextResponse.json({ error: 'Media not found' }, { status: 404 }); + } + + const ext = path.extname(filePath).toLowerCase(); + const contentType = MIME_MAP[ext] || 'application/octet-stream'; + const buffer = await fs.readFile(filePath); + + return new NextResponse(buffer, { + headers: { + 'Content-Type': contentType, + 'Cache-Control': 'public, max-age=31536000, immutable', + }, + }); +} diff --git a/app/api/classroom/media/route.ts b/app/api/classroom/media/route.ts new file mode 100644 index 000000000..ec03fbf02 --- /dev/null +++ b/app/api/classroom/media/route.ts @@ -0,0 +1,100 @@ +import { type NextRequest, NextResponse } from 'next/server'; +import { promises as fs } from 'fs'; +import path from 'path'; +import { apiSuccess, apiError, API_ERROR_CODES } from '@/lib/server/api-response'; +import { isValidClassroomId } from '@/lib/server/classroom-storage'; +import { listMedia, saveMedia, getMediaPath, mediaExists } from '@/lib/server/media-storage'; + +const MIME_MAP: Record = { + '.mp3': 'audio/mpeg', + '.wav': 'audio/wav', + '.png': 'image/png', + '.jpg': 'image/jpeg', + '.webp': 'image/webp', + '.mp4': 'video/mp4', + '.webm': 'video/webm', +}; + +const MAX_UPLOAD_SIZE = 50 * 1024 * 1024; // 50MB + +export async function GET(request: NextRequest) { + const id = request.nextUrl.searchParams.get('id'); + if (!id || !isValidClassroomId(id)) { + return apiError(API_ERROR_CODES.INVALID_REQUEST, 400, 'Invalid or missing classroom id'); + } + + const listAll = request.nextUrl.searchParams.get('list'); + if (listAll === 'true') { + const files = await listMedia(id); + return apiSuccess({ files }); + } + + const file = request.nextUrl.searchParams.get('file'); + if (!file) { + return apiError(API_ERROR_CODES.MISSING_REQUIRED_FIELD, 400, 'Missing file parameter'); + } + + // file can be either a bare fileId or fileId.ext + const dotIdx = file.lastIndexOf('.'); + const fileId = dotIdx > 0 ? file.slice(0, dotIdx) : file; + + const filePath = await getMediaPath(id, fileId); + if (!filePath) { + return apiError(API_ERROR_CODES.INVALID_REQUEST, 404, 'Media file not found'); + } + + const ext = path.extname(filePath).toLowerCase(); + const contentType = MIME_MAP[ext] || 'application/octet-stream'; + + const stat = await fs.stat(filePath); + const fileBuffer = await fs.readFile(filePath); + + return new NextResponse(fileBuffer, { + status: 200, + headers: { + 'Content-Type': contentType, + 'Content-Length': String(stat.size), + 'Cache-Control': 'public, max-age=31536000, immutable', + }, + }); +} + +export async function POST(request: NextRequest) { + try { + const body = await request.json(); + const { classroomId, fileId, ext, base64 } = body; + + if (!classroomId || !fileId || !ext || !base64) { + return apiError( + API_ERROR_CODES.MISSING_REQUIRED_FIELD, + 400, + 'Missing required fields: classroomId, fileId, ext, base64', + ); + } + + if (!isValidClassroomId(classroomId)) { + return apiError(API_ERROR_CODES.INVALID_REQUEST, 400, 'Invalid classroom id'); + } + + // Idempotent: if already exists, return 200 directly + const existing = await mediaExists(classroomId, fileId); + if (existing.exists) { + return apiSuccess({ fileName: existing.fullName, cached: true }); + } + + const buffer = Buffer.from(base64, 'base64'); + if (buffer.length > MAX_UPLOAD_SIZE) { + return apiError(API_ERROR_CODES.INVALID_REQUEST, 400, 'File too large (max 50MB)'); + } + + const fileName = await saveMedia(classroomId, fileId, buffer, ext); + return apiSuccess({ fileName, cached: false }, 201); + } catch (error) { + return apiError( + API_ERROR_CODES.INTERNAL_ERROR, + 500, + 'Failed to save media', + error instanceof Error ? error.message : String(error), + ); + } +} diff --git a/app/api/classroom/route.ts b/app/api/classroom/route.ts index 1c83ad2b5..b3a1dc892 100644 --- a/app/api/classroom/route.ts +++ b/app/api/classroom/route.ts @@ -4,6 +4,7 @@ import { apiSuccess, apiError, API_ERROR_CODES } from '@/lib/server/api-response import { buildRequestOrigin, isValidClassroomId, + listClassrooms, persistClassroom, readClassroom, } from '@/lib/server/classroom-storage'; @@ -11,7 +12,7 @@ import { export async function POST(request: NextRequest) { try { const body = await request.json(); - const { stage, scenes } = body; + const { stage, scenes, outlines } = body; if (!stage || !scenes) { return apiError( @@ -24,7 +25,10 @@ export async function POST(request: NextRequest) { const id = stage.id || randomUUID(); const baseUrl = buildRequestOrigin(request); - const persisted = await persistClassroom({ id, stage: { ...stage, id }, scenes }, baseUrl); + const persisted = await persistClassroom( + { id, stage: { ...stage, id }, scenes, outlines: outlines || [] }, + baseUrl, + ); return apiSuccess({ id: persisted.id, url: persisted.url }, 201); } catch (error) { @@ -42,11 +46,8 @@ export async function GET(request: NextRequest) { const id = request.nextUrl.searchParams.get('id'); if (!id) { - return apiError( - API_ERROR_CODES.MISSING_REQUIRED_FIELD, - 400, - 'Missing required parameter: id', - ); + const list = await listClassrooms(); + return apiSuccess({ classrooms: list }); } if (!isValidClassroomId(id)) { diff --git a/app/api/course/chapters/route.ts b/app/api/course/chapters/route.ts new file mode 100644 index 000000000..7bb44d6ae --- /dev/null +++ b/app/api/course/chapters/route.ts @@ -0,0 +1,58 @@ +import { type NextRequest, NextResponse } from 'next/server'; +import { addChapter, updateChapter, removeChapter, reorderChapters } from '@/lib/server/course-storage'; +import type { ChapterUpdates } from '@/lib/types/course'; + +export async function POST(req: NextRequest) { + try { + const { courseId, title, description } = await req.json(); + if (!courseId || !title) { + return NextResponse.json({ error: 'Missing required fields' }, { status: 400 }); + } + const chapter = await addChapter(courseId, { title, description }); + return NextResponse.json({ success: true, chapter }); + } catch (error) { + return NextResponse.json({ error: String(error) }, { status: 500 }); + } +} + +export async function PUT(req: NextRequest) { + try { + const body = await req.json(); + const { courseId } = body; + if (!courseId) { + return NextResponse.json({ error: 'Missing courseId' }, { status: 400 }); + } + // Reorder: { courseId, chapterIds: string[] } + if (Array.isArray(body.chapterIds)) { + await reorderChapters(courseId, body.chapterIds); + return NextResponse.json({ success: true }); + } + // Update single chapter: { courseId, chapterId, ...updates } + const { chapterId, title, description, classroomId } = body; + if (!chapterId) { + return NextResponse.json({ error: 'Missing chapterId' }, { status: 400 }); + } + // Build updates; include a key whenever it is explicitly present in body (even if null) + const updates: ChapterUpdates = {}; + if ('title' in body && title !== undefined) updates.title = title; + if ('description' in body) updates.description = description ?? null; + if ('classroomId' in body) updates.classroomId = classroomId ?? null; + await updateChapter(courseId, chapterId, updates); + return NextResponse.json({ success: true }); + } catch (error) { + return NextResponse.json({ error: String(error) }, { status: 500 }); + } +} + +export async function DELETE(req: NextRequest) { + try { + const { courseId, chapterId } = await req.json(); + if (!courseId || !chapterId) { + return NextResponse.json({ error: 'Missing required fields' }, { status: 400 }); + } + await removeChapter(courseId, chapterId); + return NextResponse.json({ success: true }); + } catch (error) { + return NextResponse.json({ error: String(error) }, { status: 500 }); + } +} diff --git a/app/api/course/route.ts b/app/api/course/route.ts new file mode 100644 index 000000000..598a5a3c0 --- /dev/null +++ b/app/api/course/route.ts @@ -0,0 +1,68 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { listCourses, readCourse, persistCourse, deleteCourse } from '@/lib/server/course-storage'; +import { classroomExists } from '@/lib/server/classroom-storage'; +import type { Course } from '@/lib/types/course'; + +export async function GET(req: NextRequest) { + try { + const { searchParams } = new URL(req.url); + const id = searchParams.get('id'); + + if (id) { + const course = await readCourse(id); + if (!course) { + return NextResponse.json({ error: 'Course not found' }, { status: 404 }); + } + return NextResponse.json(course); + } + + const courses = await listCourses(); + const statusFilter = searchParams.get('status'); + const filtered = statusFilter ? courses.filter((c) => c.status === statusFilter) : courses; + return NextResponse.json(filtered); + } catch (error) { + return NextResponse.json({ error: String(error) }, { status: 500 }); + } +} + +export async function POST(req: NextRequest) { + try { + const body = await req.json() as Course; + if (!body.id || !body.name) { + return NextResponse.json({ error: 'Missing required fields' }, { status: 400 }); + } + if (body.status === 'published') { + if (!body.chapters || body.chapters.length === 0) { + return NextResponse.json({ error: 'Course must have at least one chapter to publish' }, { status: 400 }); + } + const bound = body.chapters.filter((ch) => ch.classroomId); + if (bound.length === 0) { + return NextResponse.json({ error: 'At least one chapter must have a bound classroom to publish' }, { status: 400 }); + } + const missing = (await Promise.all(bound.map(async (ch) => ({ ch, exists: await classroomExists(ch.classroomId!) })))) + .filter(({ exists }) => !exists) + .map(({ ch }) => ch.classroomId); + if (missing.length > 0) { + return NextResponse.json({ error: `Bound classrooms not found on server: ${missing.join(', ')}` }, { status: 400 }); + } + } + await persistCourse(body); + return NextResponse.json({ success: true }); + } catch (error) { + return NextResponse.json({ error: String(error) }, { status: 500 }); + } +} + +export async function DELETE(req: NextRequest) { + try { + const { searchParams } = new URL(req.url); + const id = searchParams.get('id'); + if (!id) { + return NextResponse.json({ error: 'Missing id' }, { status: 400 }); + } + await deleteCourse(id); + return NextResponse.json({ success: true }); + } catch (error) { + return NextResponse.json({ error: String(error) }, { status: 500 }); + } +} diff --git a/app/api/generate-classroom/route.ts b/app/api/generate-classroom/route.ts index b35448e7b..9a46288da 100644 --- a/app/api/generate-classroom/route.ts +++ b/app/api/generate-classroom/route.ts @@ -8,6 +8,12 @@ import { buildRequestOrigin } from '@/lib/server/classroom-storage'; export const maxDuration = 30; +function parseOptionalNumber(value: string | null): number | undefined { + if (value === null || value.trim() === '') return undefined; + const parsed = Number.parseFloat(value); + return Number.isFinite(parsed) ? parsed : undefined; +} + export async function POST(req: NextRequest) { try { const rawBody = (await req.json()) as Partial; @@ -15,6 +21,90 @@ export async function POST(req: NextRequest) { requirement: rawBody.requirement || '', ...(rawBody.pdfContent ? { pdfContent: rawBody.pdfContent } : {}), ...(rawBody.language ? { language: rawBody.language } : {}), + ...(rawBody.stageName || req.headers.get('x-stage-name') + ? { stageName: rawBody.stageName || req.headers.get('x-stage-name') || undefined } + : {}), + ...(rawBody.modelString || req.headers.get('x-model') + ? { modelString: rawBody.modelString || req.headers.get('x-model') || undefined } + : {}), + ...(rawBody.apiKey || req.headers.get('x-api-key') + ? { apiKey: rawBody.apiKey || req.headers.get('x-api-key') || undefined } + : {}), + ...(rawBody.baseUrl || req.headers.get('x-base-url') + ? { baseUrl: rawBody.baseUrl || req.headers.get('x-base-url') || undefined } + : {}), + ...(rawBody.providerType || req.headers.get('x-provider-type') + ? { providerType: rawBody.providerType || req.headers.get('x-provider-type') || undefined } + : {}), + ...(rawBody.requiresApiKey !== undefined || + req.headers.get('x-requires-api-key') !== null + ? { + requiresApiKey: + rawBody.requiresApiKey ?? + (req.headers.get('x-requires-api-key') === 'true' ? true : false), + } + : {}), + ...(rawBody.imageGenerationEnabled !== undefined || + req.headers.get('x-image-generation-enabled') !== null + ? { + imageGenerationEnabled: + rawBody.imageGenerationEnabled ?? + (req.headers.get('x-image-generation-enabled') === 'true' ? true : false), + } + : {}), + ...(rawBody.imageProviderId || req.headers.get('x-image-provider') + ? { + imageProviderId: + (rawBody.imageProviderId || + req.headers.get('x-image-provider') || + undefined) as GenerateClassroomInput['imageProviderId'], + } + : {}), + ...(rawBody.imageModel || req.headers.get('x-image-model') + ? { imageModel: rawBody.imageModel || req.headers.get('x-image-model') || undefined } + : {}), + ...(rawBody.imageApiKey || req.headers.get('x-image-api-key') + ? { + imageApiKey: + rawBody.imageApiKey || req.headers.get('x-image-api-key') || undefined, + } + : {}), + ...(rawBody.imageBaseUrl || req.headers.get('x-image-base-url') + ? { + imageBaseUrl: + rawBody.imageBaseUrl || req.headers.get('x-image-base-url') || undefined, + } + : {}), + ...(rawBody.ttsEnabled !== undefined || req.headers.get('x-tts-enabled') !== null + ? { + ttsEnabled: + rawBody.ttsEnabled ?? + (req.headers.get('x-tts-enabled') === 'true' ? true : false), + } + : {}), + ...(rawBody.ttsProviderId || req.headers.get('x-tts-provider') + ? { + ttsProviderId: (rawBody.ttsProviderId || + req.headers.get('x-tts-provider') || + undefined) as GenerateClassroomInput['ttsProviderId'], + } + : {}), + ...(rawBody.ttsVoice || req.headers.get('x-tts-voice') + ? { ttsVoice: rawBody.ttsVoice || req.headers.get('x-tts-voice') || undefined } + : {}), + ...(rawBody.ttsSpeed !== undefined || req.headers.get('x-tts-speed') !== null + ? { + ttsSpeed: rawBody.ttsSpeed ?? parseOptionalNumber(req.headers.get('x-tts-speed')), + } + : {}), + ...(rawBody.ttsApiKey || req.headers.get('x-tts-api-key') + ? { ttsApiKey: rawBody.ttsApiKey || req.headers.get('x-tts-api-key') || undefined } + : {}), + ...(rawBody.ttsBaseUrl || req.headers.get('x-tts-base-url') + ? { + ttsBaseUrl: rawBody.ttsBaseUrl || req.headers.get('x-tts-base-url') || undefined, + } + : {}), }; const { requirement } = body; diff --git a/app/classroom/[id]/page.tsx b/app/classroom/[id]/page.tsx index 523e23211..def07632a 100644 --- a/app/classroom/[id]/page.tsx +++ b/app/classroom/[id]/page.tsx @@ -6,15 +6,209 @@ import { useStageStore } from '@/lib/store'; import { loadImageMapping } from '@/lib/utils/image-storage'; import { useEffect, useRef, useState, useCallback } from 'react'; import { useParams } from 'next/navigation'; -import { useSceneGenerator } from '@/lib/hooks/use-scene-generator'; +import { useSceneGenerator, generateAndStoreTTS, type TTSOverrides } from '@/lib/hooks/use-scene-generator'; import { useMediaGenerationStore } from '@/lib/store/media-generation'; import { useWhiteboardHistoryStore } from '@/lib/store/whiteboard-history'; +import { db } from '@/lib/utils/database'; +import { useSettingsStore } from '@/lib/store/settings'; import { createLogger } from '@/lib/logger'; import { MediaStageProvider } from '@/lib/contexts/media-stage-context'; import { generateMediaForOutlines } from '@/lib/media/media-orchestrator'; +import { setPrefillPromise, getPrefillPromise } from '@/lib/media/prefill-state'; const log = createLogger('Classroom'); +/** + * Pre-fill local IndexedDB with cached media from the server. + * Runs async, does not block page rendering. + */ +async function prefillMediaCache(classroomId: string): Promise { + const res = await fetch(`/api/classroom/media?id=${encodeURIComponent(classroomId)}&list=true`); + if (!res.ok) return; + const json = await res.json(); + if (!json.success || !Array.isArray(json.files) || json.files.length === 0) return; + + const files: string[] = json.files; + const mediaStore = useMediaGenerationStore.getState(); + const prefilledTtsIds = new Set(); + + for (const fileName of files) { + const dotIdx = fileName.lastIndexOf('.'); + if (dotIdx <= 0) continue; + const fileId = fileName.slice(0, dotIdx); + const ext = fileName.slice(dotIdx); + + if (fileName.startsWith('tts_')) { + // Check if already in IndexedDB + const existing = await db.audioFiles.get(fileId); + if (existing) { + prefilledTtsIds.add(fileId); + continue; + } + + const mediaUrl = `/api/classroom/media?id=${encodeURIComponent(classroomId)}&file=${encodeURIComponent(fileId)}`; + try { + const blobRes = await fetch(mediaUrl); + if (!blobRes.ok) continue; + const blob = await blobRes.blob(); + const format = ext.replace('.', ''); + await db.audioFiles.put({ + id: fileId, + blob, + format, + createdAt: Date.now(), + }); + prefilledTtsIds.add(fileId); + log.info('Prefilled TTS from cache:', fileId); + } catch { /* best effort */ } + } else if (fileName.startsWith('gen_img_') || fileName.startsWith('gen_vid_')) { + const elementId = fileId; + const task = mediaStore.getTask(elementId); + if (task?.status === 'done') continue; + + const mediaUrl = `/api/classroom/media?id=${encodeURIComponent(classroomId)}&file=${encodeURIComponent(fileId)}`; + const type = fileName.startsWith('gen_img_') ? 'image' : 'video'; + // Directly set task as done with API URL — works even if task wasn't enqueued yet + useMediaGenerationStore.setState((s) => ({ + tasks: { + ...s.tasks, + [elementId]: { + elementId, + type: type as 'image' | 'video', + status: 'done' as const, + prompt: '', + params: {}, + retryCount: 0, + stageId: classroomId, + objectUrl: mediaUrl, + }, + }, + })); + log.info('Prefilled media from cache:', elementId); + } + } + + // Patch audioId on speech actions whose TTS was prefilled from server cache + if (prefilledTtsIds.size > 0) { + const scenes = useStageStore.getState().scenes; + let anyUpdated = false; + const updatedScenes = scenes.map(scene => { + const actions = scene.actions || []; + let sceneUpdated = false; + const newActions = actions.map(action => { + if (action.type !== 'speech' || !('text' in action) || action.audioId) return action; + const candidateId = `tts_${action.id}`; + if (prefilledTtsIds.has(candidateId)) { + sceneUpdated = true; + return { ...action, audioId: candidateId }; + } + return action; + }); + if (!sceneUpdated) return scene; + anyUpdated = true; + return { ...scene, actions: newActions }; + }); + if (anyUpdated) { + useStageStore.getState().setScenes(updatedScenes); + log.info('Patched audioId on scenes from prefilled TTS cache'); + } + } +} + +/** + * Backfill TTS for completed scenes whose speech actions lack audio. + * API-generated classrooms have speech actions but no TTS — this fills the gap. + * + * Key fixes over the original: + * 1. Deep-copies scenes/actions so Zustand detects the change + * 2. Immediately persists to IndexedDB (not relying on 500ms debounce) + * 3. Updates server-side classroom.json so audioIds survive across browsers + */ +async function backfillMissingTTS(): Promise { + const settings = useSettingsStore.getState(); + if (!settings.ttsEnabled || settings.ttsProviderId === 'browser-native-tts') return; + + // Wait for prefill to finish so we don't regenerate already-cached TTS + await getPrefillPromise(); + + const scenes = useStageStore.getState().scenes; + if (!scenes || scenes.length === 0) return; + + // Phase 1: collect all speech actions missing TTS + const missing: { sceneIdx: number; actionIdx: number; audioId: string; text: string }[] = []; + for (let si = 0; si < scenes.length; si++) { + const actions = scenes[si].actions || []; + for (let ai = 0; ai < actions.length; ai++) { + const action = actions[ai]; + if (action.type !== 'speech' || !('text' in action) || !action.text) continue; + const audioId = action.audioId || `tts_${action.id}`; + const existing = await db.audioFiles.get(audioId); + if (existing) continue; + missing.push({ sceneIdx: si, actionIdx: ai, audioId, text: action.text }); + } + } + + if (missing.length === 0) return; + log.info(`Backfilling ${missing.length} missing TTS...`); + + // Snapshot TTS settings once so all items use the same voice + const ttsProviderConfig = settings.ttsProvidersConfig?.[settings.ttsProviderId]; + const ttsSnapshot: TTSOverrides = { + providerId: settings.ttsProviderId, + voice: settings.ttsVoice, + speed: settings.ttsSpeed, + apiKey: ttsProviderConfig?.apiKey, + baseUrl: ttsProviderConfig?.baseUrl, + }; + + // Phase 2: generate TTS one by one (skip failures) + const generated: typeof missing = []; + for (const item of missing) { + try { + await generateAndStoreTTS(item.audioId, item.text, undefined, ttsSnapshot); + generated.push(item); + log.info('Backfilled TTS:', item.audioId); + } catch (err) { + log.warn('Backfill TTS failed:', item.audioId, err); + } + } + + if (generated.length === 0) return; + + // Phase 3: deep-copy scenes and assign audioId (new object refs for Zustand) + const updatedScenes = scenes.map((scene, si) => { + const updates = generated.filter(g => g.sceneIdx === si); + if (updates.length === 0) return scene; + return { + ...scene, + actions: scene.actions?.map((action, ai) => { + const upd = updates.find(u => u.actionIdx === ai); + return upd ? { ...action, audioId: upd.audioId } : action; + }), + }; + }); + + // Phase 4: update store + persist immediately + useStageStore.getState().setScenes(updatedScenes); + await useStageStore.getState().saveToStorage(); + log.info(`Backfilled ${generated.length} TTS, saved to IndexedDB`); + + // Phase 5: update server-side classroom.json (best effort) + try { + const state = useStageStore.getState(); + await fetch('/api/classroom', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + stage: state.stage, + scenes: state.scenes, + outlines: state.outlines, + }), + }); + log.info('Updated server-side classroom.json with audioIds'); + } catch { /* best effort */ } +} + export default function ClassroomDetailPage() { const params = useParams(); const classroomId = params?.id as string; @@ -25,6 +219,7 @@ export default function ClassroomDetailPage() { const [error, setError] = useState(null); const generationStartedRef = useRef(false); + const backfillStartedRef = useRef(false); const { generateRemaining, retrySingleOutline, stop } = useSceneGenerator({ onComplete: () => { @@ -34,32 +229,48 @@ export default function ClassroomDetailPage() { const loadClassroom = useCallback(async () => { try { - await loadFromStorage(classroomId); - - // If IndexedDB had no data, try server-side storage (API-generated classrooms) - if (!useStageStore.getState().stage) { - log.info('No IndexedDB data, trying server-side storage for:', classroomId); - try { - const res = await fetch(`/api/classroom?id=${encodeURIComponent(classroomId)}`); - if (res.ok) { - const json = await res.json(); - if (json.success && json.classroom) { - const { stage, scenes } = json.classroom; - useStageStore.getState().setStage(stage); - useStageStore.setState({ - scenes, - currentSceneId: scenes[0]?.id ?? null, - }); - log.info('Loaded from server-side storage:', classroomId); - } + let loadedFromServer = false; + + // Published classrooms should prefer the latest server copy. + // IndexedDB stays as an offline/cache fallback instead of being authoritative. + try { + const res = await fetch(`/api/classroom?id=${encodeURIComponent(classroomId)}`, { + cache: 'no-store', + }); + if (res.ok) { + const json = await res.json(); + if (json.success && json.classroom) { + const { stage, scenes, outlines } = json.classroom; + useStageStore.getState().setStage(stage); + useStageStore.setState({ + scenes, + outlines: outlines || [], + currentSceneId: scenes[0]?.id ?? null, + }); + await useStageStore.getState().saveToStorage(); + loadedFromServer = true; + log.info('Loaded latest classroom from server-side storage:', classroomId); } - } catch (fetchErr) { - log.warn('Server-side storage fetch failed:', fetchErr); + } + } catch (fetchErr) { + log.warn('Server-side storage fetch failed, falling back to IndexedDB:', fetchErr); + } + + if (!loadedFromServer) { + await loadFromStorage(classroomId); + if (useStageStore.getState().stage) { + log.info('Loaded classroom from IndexedDB fallback:', classroomId); } } // Restore completed media generation tasks from IndexedDB await useMediaGenerationStore.getState().restoreFromDB(classroomId); + + // Pre-fill media cache from server (fire-and-forget, backfill awaits this promise) + setPrefillPromise(prefillMediaCache(classroomId).catch((err) => { + log.warn('Media cache prefill failed:', err); + })); + // Restore generated agents for this stage const { loadGeneratedAgentsForStage } = await import('@/lib/orchestration/registry/store'); const agentIds = await loadGeneratedAgentsForStage(classroomId); @@ -81,6 +292,7 @@ export default function ClassroomDetailPage() { setLoading(true); setError(null); generationStartedRef.current = false; + backfillStartedRef.current = false; // Clear previous classroom's media tasks to prevent cross-classroom contamination. // Placeholder IDs (gen_img_1, gen_vid_1) are NOT globally unique across stages, @@ -142,8 +354,18 @@ export default function ClassroomDetailPage() { // Resume media generation for any tasks not yet in IndexedDB. // generateMediaForOutlines skips already-completed tasks automatically. generationStartedRef.current = true; - generateMediaForOutlines(outlines, stage.id).catch((err) => { - log.warn('[Classroom] Media generation resume error:', err); + getPrefillPromise().then(() => { + generateMediaForOutlines(outlines, stage.id).catch((err) => { + log.warn('[Classroom] Media generation resume error:', err); + }); + }); + } + + // Backfill TTS for completed scenes that lack audio (e.g. API-generated classrooms) + if (stage && !backfillStartedRef.current) { + backfillStartedRef.current = true; + backfillMissingTTS().catch((err) => { + log.warn('[Classroom] TTS backfill error:', err); }); } }, [loading, error, generateRemaining]); diff --git a/app/course/[id]/page.tsx b/app/course/[id]/page.tsx new file mode 100644 index 000000000..7c06b17cb --- /dev/null +++ b/app/course/[id]/page.tsx @@ -0,0 +1,381 @@ +'use client'; + +import { useEffect, useState, useCallback, useMemo, Suspense } from 'react'; +import { useRouter, useSearchParams } from 'next/navigation'; +import { motion } from 'motion/react'; +import { useI18n } from '@/lib/hooks/use-i18n'; +import { useCourseStore } from '@/lib/store/course'; +import { CourseForm } from '@/components/course/course-form'; +import { ChapterList } from '@/components/course/chapter-list'; +import { ClassroomPickerDialog } from '@/components/course/classroom-picker-dialog'; +import type { ClassroomMeta, CourseChapterContext, CourseFormData } from '@/lib/types/course'; +import { COURSE_CHAPTER_CONTEXT_KEY } from '@/lib/types/course'; +import { Card, CardHeader, CardTitle, CardContent, CardAction } from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import { Badge } from '@/components/ui/badge'; +import { Separator } from '@/components/ui/separator'; +import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'; +import { + AlertDialog, + AlertDialogContent, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogAction, + AlertDialogCancel, +} from '@/components/ui/alert-dialog'; +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; +import { ArrowLeft, Pencil, BookOpen, Building2, GraduationCap, User, Globe, GlobeLock } from 'lucide-react'; +import { toast } from 'sonner'; + +type PublishDialogMode = 'publish' | 'unpublish' | null; + +function CourseDetailContent({ params }: { params: Promise<{ id: string }> }) { + const { t } = useI18n(); + const router = useRouter(); + const searchParams = useSearchParams(); + const isViewMode = searchParams.get('mode') === 'view'; + const { currentCourse, fetchCourse, updateCourse, addChapter, updateChapter, removeChapter, reorderChapters, publishCourse, unpublishCourse } = + useCourseStore(); + const [editing, setEditing] = useState(false); + const [courseId, setCourseId] = useState(null); + const [classroomMeta, setClassroomMeta] = useState>({}); + const [bindingChapterId, setBindingChapterId] = useState(null); + const [publishDialog, setPublishDialog] = useState(null); + + useEffect(() => { + params.then(({ id }) => { + setCourseId(id); + fetchCourse(id); + }); + }, [params, fetchCourse]); + + // Stable dep: comma-joined sorted classroomIds — only refetch when bound classrooms change + const boundClassroomIdsKey = currentCourse?.chapters + .map((c) => c.classroomId) + .filter((id): id is string => !!id) + .sort() + .join(',') ?? ''; + + const loadClassroomMeta = useCallback(async (classroomIds: string[]) => { + try { + const res = await fetch('/api/classroom'); + if (!res.ok) return; + const { classrooms } = await res.json() as { classrooms: Array<{ id: string; name?: string; sceneCount: number }> }; + const idSet = new Set(classroomIds); + setClassroomMeta( + Object.fromEntries( + classrooms + .filter((c) => idSet.has(c.id)) + .map((c): [string, ClassroomMeta] => [ + c.id, + { name: c.name || c.id, sceneCount: c.sceneCount ?? 0, ready: true }, + ]), + ), + ); + } catch { + // non-fatal + } + }, []); + + useEffect(() => { + const ids = boundClassroomIdsKey ? boundClassroomIdsKey.split(',') : []; + if (ids.length > 0) loadClassroomMeta(ids); + }, [boundClassroomIdsKey, loadClassroomMeta]); + + const usedClassroomIds = useMemo( + () => isViewMode ? new Set() : new Set(currentCourse?.chapters.map((c) => c.classroomId).filter((id): id is string => !!id) ?? []), + // eslint-disable-next-line react-hooks/exhaustive-deps + [isViewMode, boundClassroomIdsKey], + ); + + if (!courseId || !currentCourse) { + return ( +
+
+
+
+
+
+
+
+ ); + } + + const canPublish = + currentCourse.chapters.length > 0 && + currentCourse.chapters.some((ch) => !!ch.classroomId); + const boundCount = currentCourse.chapters.filter((ch) => !!ch.classroomId).length; + const totalCount = currentCourse.chapters.length; + const isPublished = currentCourse.status === 'published'; + + const handleUpdate = async (data: CourseFormData) => { + await updateCourse(courseId, data); + setEditing(false); + }; + + const handleMove = async (chapterId: string, direction: 'up' | 'down') => { + const chapters = currentCourse.chapters; + const idx = chapters.findIndex((c) => c.id === chapterId); + if (idx === -1) return; + const newIdx = direction === 'up' ? idx - 1 : idx + 1; + if (newIdx < 0 || newIdx >= chapters.length) return; + const ids = chapters.map((c) => c.id); + [ids[idx], ids[newIdx]] = [ids[newIdx], ids[idx]]; + await reorderChapters(courseId, ids); + }; + + const handleUnbind = async (chapterId: string) => { + await updateChapter(courseId, chapterId, { classroomId: null }); + }; + + const handlePublishConfirm = async () => { + if (!courseId) return; + try { + if (publishDialog === 'publish') { + await publishCourse(courseId); + toast.success(t('course.publishSuccess')); + } else if (publishDialog === 'unpublish') { + await unpublishCourse(courseId); + toast.success(t('course.unpublishSuccess')); + } + } catch { + toast.error(publishDialog === 'publish' ? t('course.publishFailed') : t('course.unpublishFailed')); + } + setPublishDialog(null); + }; + + const handleBind = (chapterId: string) => { + setBindingChapterId(chapterId); + }; + + const handlePickerSelect = async (classroomId: string) => { + if (!bindingChapterId) return; + await updateChapter(courseId, bindingChapterId, { classroomId }); + setBindingChapterId(null); + }; + + const handleCreateAndBind = (chapterId: string) => { + sessionStorage.setItem( + COURSE_CHAPTER_CONTEXT_KEY, + JSON.stringify({ courseId, chapterId, stageId: null, createdAt: Date.now() } satisfies CourseChapterContext), + ); + router.push('/'); + }; + + const handleOpenClassroom = (classroomId: string) => { + router.push(`/classroom/${classroomId}`); + }; + + return ( +
+ {/* Decorative blobs */} +
+
+
+
+ +
+ {/* Back nav */} + + + + + {/* Course info card */} + + + + {currentCourse.name} + +
+ {(isViewMode || isPublished) && ( + + + {t('course.publishedStatus')} + + )} + {!isViewMode && ( + <> + {isPublished ? ( + + ) : canPublish ? ( + + ) : ( + + + + + + + {t('course.cannotPublish')} + + )} + + + )} +
+
+
+ +
+
+ +
+

+ {t('course.college')} +

+

{currentCourse.college}

+
+
+
+ +
+

+ {t('course.major')} +

+

{currentCourse.major}

+
+
+
+ +
+

+ {t('course.teacherName')} +

+

{currentCourse.teacherName}

+
+
+ {currentCourse.description && ( +
+

+ {t('course.description')} +

+

{currentCourse.description}

+
+ )} +
+
+
+
+ + {/* Chapters section */} + + +
+ +

{t('course.chapters')}

+ + {currentCourse.chapters.length} + + {!isViewMode && totalCount > 0 && ( + + {boundCount}/{totalCount} {t('course.chaptersReady')} + + )} +
+ + addChapter(courseId, title, description), + onEdit: (chapterId, title, description) => updateChapter(courseId, chapterId, { title, description }), + onRemove: (chapterId) => removeChapter(courseId, chapterId), + onMove: handleMove, + onBind: handleBind, + onUnbind: handleUnbind, + onCreateAndBind: handleCreateAndBind, + })} + /> +
+
+ + {!isViewMode && ( + <> + {/* Edit course dialog */} + { if (!open) setEditing(false); }}> + + + {t('course.edit')} + + setEditing(false)} /> + + + + {/* Classroom picker dialog */} + {bindingChapterId && ( + setBindingChapterId(null)} + /> + )} + + {/* Publish / Unpublish confirmation dialog */} + { if (!open) setPublishDialog(null); }}> + + + + {publishDialog === 'publish' ? t('course.publish') : t('course.unpublish')} + + + {publishDialog === 'publish' ? t('course.confirmPublish') : t('course.confirmUnpublish')} + + + + {t('course.cancel')} + + {publishDialog === 'publish' ? t('course.publish') : t('course.unpublish')} + + + + + + )} +
+ ); +} + +export default function CourseDetailPage({ params }: { params: Promise<{ id: string }> }) { + return ( + + + + ); +} diff --git a/app/course/page.tsx b/app/course/page.tsx new file mode 100644 index 000000000..23b3306e5 --- /dev/null +++ b/app/course/page.tsx @@ -0,0 +1,244 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { useRouter } from 'next/navigation'; +import { motion } from 'motion/react'; +import { useI18n } from '@/lib/hooks/use-i18n'; +import { useCourseStore } from '@/lib/store/course'; +import type { CourseListItem } from '@/lib/types/course'; +import { CourseForm } from '@/components/course/course-form'; +import { Button } from '@/components/ui/button'; +import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'; +import { + AlertDialog, + AlertDialogContent, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogAction, + AlertDialogCancel, +} from '@/components/ui/alert-dialog'; +import { ArrowLeft, Plus, BookOpen, Trash2, GraduationCap, User } from 'lucide-react'; +import { cn } from '@/lib/utils'; + +const COVERS = [ + { from: '#7c3aed', to: '#a78bfa' }, + { from: '#0ea5e9', to: '#38bdf8' }, + { from: '#059669', to: '#34d399' }, + { from: '#e11d48', to: '#fb7185' }, + { from: '#d97706', to: '#fbbf24' }, + { from: '#0d9488', to: '#2dd4bf' }, + { from: '#c026d3', to: '#e879f9' }, + { from: '#ea580c', to: '#fb923c' }, +]; + +function coverFor(id: string) { + const hash = id.split('').reduce((acc, c) => acc + c.charCodeAt(0), 0); + return COVERS[hash % COVERS.length]; +} + +interface CourseCardProps { + course: CourseListItem; + index: number; + onOpen: () => void; + onDelete: () => void; + chapterLabel: string; +} + +function CourseCard({ course, index, onOpen, onDelete, chapterLabel }: CourseCardProps) { + const cover = coverFor(course.id); + + return ( + +
e.key === 'Enter' && onOpen()} + className={cn( + 'group rounded-2xl overflow-hidden cursor-pointer flex flex-col', + 'border border-white/10 shadow-md hover:shadow-2xl', + 'hover:-translate-y-1.5 transition-all duration-300', + 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2', + )} + style={{ minHeight: '220px' }} + > + {/* Cover area — compact, holds course name */} +
+ {/* Geometric decorations */} +
+
+ {/* Top row: chapter badge + delete */} +
+ + + {course.chapterCount} {chapterLabel} + + +
+ {/* Course name on gradient */} +

+ {course.name} +

+
+ + {/* Info area — taller now, 3 lines of description */} +
+ {course.description ? ( +

+ {course.description} +

+ ) : ( +
+ )} +
+
+ + + {course.college} · {course.major} + +
+
+ + {course.teacherName} +
+
+
+
+ + ); +} + +export default function CoursePage() { + const { t } = useI18n(); + const router = useRouter(); + const { courses, fetchCourses, createCourse, deleteCourse } = useCourseStore(); + const [showForm, setShowForm] = useState(false); + const [deletingId, setDeletingId] = useState(null); + + useEffect(() => { + fetchCourses(); + }, [fetchCourses]); + + const handleCreate = async (data: Parameters[0]) => { + await createCourse(data); + setShowForm(false); + }; + + const handleConfirmDelete = async () => { + if (!deletingId) return; + await deleteCourse(deletingId); + setDeletingId(null); + }; + + return ( +
+ {/* Decorative blobs */} +
+
+
+
+ +
+ {/* Page header */} + +
+ +

{t('course.myCourses')}

+
+ +
+ + {/* Empty state */} + {courses.length === 0 ? ( + +
+ +
+

{t('course.emptyStateTitle')}

+

{t('course.emptyStateDesc')}

+ +
+ ) : ( +
+ {courses.map((course, i) => ( + router.push(`/course/${course.id}`)} + onDelete={() => setDeletingId(course.id)} + chapterLabel={t('course.chapter')} + /> + ))} +
+ )} +
+ + {/* Create course dialog */} + { if (!open) setShowForm(false); }}> + + + {t('course.create')} + + setShowForm(false)} /> + + + + {/* Delete confirmation */} + { if (!open) setDeletingId(null); }}> + + + {t('course.confirmDeleteTitle')} + {t('course.confirmDelete')} + + + {t('course.cancel')} + + {t('course.delete')} + + + + +
+ ); +} diff --git a/app/generation-preview/page.tsx b/app/generation-preview/page.tsx index 213a51409..6d0b01788 100644 --- a/app/generation-preview/page.tsx +++ b/app/generation-preview/page.tsx @@ -23,6 +23,7 @@ import { db } from '@/lib/utils/database'; import { MAX_PDF_CONTENT_CHARS, MAX_VISION_IMAGES } from '@/lib/constants/generation'; import { nanoid } from 'nanoid'; import type { Stage } from '@/lib/types/stage'; +import { COURSE_CHAPTER_CONTEXT_KEY, type CourseChapterContext } from '@/lib/types/course'; import type { SceneOutline, PdfImage, ImageMapping } from '@/lib/types/generation'; import { AgentRevealModal } from '@/components/agent/agent-reveal-modal'; import { createLogger } from '@/lib/logger'; @@ -373,6 +374,18 @@ function GenerationPreviewContent() { updatedAt: Date.now(), }; + // Write stageId into courseChapterContext so publish can do the binding + try { + const ctxStr = sessionStorage.getItem(COURSE_CHAPTER_CONTEXT_KEY); + if (ctxStr) { + const ctx = JSON.parse(ctxStr) as CourseChapterContext; + ctx.stageId = stageId; + sessionStorage.setItem(COURSE_CHAPTER_CONTEXT_KEY, JSON.stringify(ctx)); + } + } catch { + // non-fatal + } + if (settings.agentMode === 'auto') { const agentStepIdx = activeSteps.findIndex((s) => s.id === 'agent-generation'); if (agentStepIdx >= 0) setCurrentStepIndex(agentStepIdx); @@ -745,6 +758,7 @@ function GenerationPreviewContent() { const goBackToHome = () => { abortControllerRef.current?.abort(); sessionStorage.removeItem('generationSession'); + sessionStorage.removeItem(COURSE_CHAPTER_CONTEXT_KEY); router.push('/'); }; diff --git a/app/page.tsx b/app/page.tsx index 80dfbd850..2a224933c 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -9,6 +9,7 @@ import { ChevronDown, Clock, Copy, + Globe, ImagePlus, Pencil, Trash2, @@ -18,6 +19,8 @@ import { Monitor, BotOff, ChevronUp, + BookOpen, + GraduationCap, } from 'lucide-react'; import { useI18n } from '@/lib/hooks/use-i18n'; import { createLogger } from '@/lib/logger'; @@ -31,6 +34,7 @@ import { useTheme } from '@/lib/hooks/use-theme'; import { nanoid } from 'nanoid'; import { storePdfBlob } from '@/lib/utils/image-storage'; import type { UserRequirements } from '@/lib/types/generation'; +import { COURSE_CHAPTER_CONTEXT_KEY, type CourseChapterContext, type CourseListItem } from '@/lib/types/course'; import { useSettingsStore } from '@/lib/store/settings'; import { useUserProfileStore, AVATAR_OPTIONS } from '@/lib/store/user-profile'; import { @@ -46,6 +50,7 @@ import { toast } from 'sonner'; import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; import { useDraftCache } from '@/lib/hooks/use-draft-cache'; import { SpeechButton } from '@/components/audio/speech-button'; +import { useCourseStore } from '@/lib/store/course'; const log = createLogger('Home'); @@ -67,6 +72,22 @@ const initialFormState: FormState = { webSearch: false, }; +const COURSE_COVERS = [ + { from: '#7c3aed', to: '#a78bfa', dot: 'rgba(255,255,255,0.15)' }, + { from: '#0ea5e9', to: '#38bdf8', dot: 'rgba(255,255,255,0.15)' }, + { from: '#059669', to: '#34d399', dot: 'rgba(255,255,255,0.15)' }, + { from: '#e11d48', to: '#fb7185', dot: 'rgba(255,255,255,0.15)' }, + { from: '#d97706', to: '#fbbf24', dot: 'rgba(255,255,255,0.15)' }, + { from: '#0d9488', to: '#2dd4bf', dot: 'rgba(255,255,255,0.15)' }, + { from: '#c026d3', to: '#e879f9', dot: 'rgba(255,255,255,0.15)' }, + { from: '#ea580c', to: '#fb923c', dot: 'rgba(255,255,255,0.15)' }, +] as const; + +function getCourseGradient(courseId: string) { + const hash = courseId.split('').reduce((a, c) => a + c.charCodeAt(0), 0); + return COURSE_COVERS[hash % COURSE_COVERS.length]; +} + function HomePage() { const { t, locale, setLocale } = useI18n(); const { theme, setTheme } = useTheme(); @@ -87,7 +108,6 @@ function HomePage() { const [recentOpen, setRecentOpen] = useState(true); // Hydrate client-only state after mount (avoids SSR mismatch) - /* eslint-disable react-hooks/set-state-in-effect -- Hydration from localStorage must happen in effect */ useEffect(() => { setStoreHydrated(true); try { @@ -114,7 +134,6 @@ function HomePage() { /* localStorage unavailable */ } }, []); - /* eslint-enable react-hooks/set-state-in-effect */ // Restore requirement draft from cache (derived state pattern — no effect needed) const [prevCachedRequirement, setPrevCachedRequirement] = useState(cachedRequirement); @@ -131,9 +150,19 @@ function HomePage() { const [error, setError] = useState(null); const [classrooms, setClassrooms] = useState([]); const [thumbnails, setThumbnails] = useState>({}); + const [publishedCourses, setPublishedCourses] = useState([]); const [pendingDeleteId, setPendingDeleteId] = useState(null); const toolbarRef = useRef(null); const textareaRef = useRef(null); + const [activeTab, setActiveTab] = useState<'courses' | 'standalone'>('standalone'); + const { courses, fetchCourses } = useCourseStore(); + + // Default to 'courses' tab when courses exist but no local classrooms + useEffect(() => { + if (courses.length > 0 && classrooms.length === 0) { + setActiveTab('courses'); + } + }, [courses.length, classrooms.length]); // Close dropdowns when clicking outside useEffect(() => { @@ -169,8 +198,16 @@ function HomePage() { useMediaGenerationStore.getState().revokeObjectUrls(); useMediaGenerationStore.setState({ tasks: {} }); - // eslint-disable-next-line react-hooks/set-state-in-effect -- Store hydration on mount - loadClassrooms(); + Promise.all([ + loadClassrooms(), + fetchCourses(), + fetch('/api/course?status=published').then(res => res.json()), + ]).then(([, , publishedCourseData]) => { + if (Array.isArray(publishedCourseData)) { + setPublishedCourses(publishedCourseData); + } + }).catch(() => {}); + // eslint-disable-next-line react-hooks/exhaustive-deps -- mount-only: intentionally run once }, []); const handleDelete = (id: string, e: React.MouseEvent) => { @@ -293,6 +330,18 @@ function HomePage() { currentStep: 'generating' as const, }; sessionStorage.setItem('generationSession', JSON.stringify(sessionState)); + // Clear stale chapter binding context: if stageId is set (prior generation ran) OR + // if it's older than 5 minutes (user abandoned the "create & bind" flow without generating). + try { + const ctxStr = sessionStorage.getItem(COURSE_CHAPTER_CONTEXT_KEY); + if (ctxStr) { + const ctx = JSON.parse(ctxStr) as CourseChapterContext; + const stale = ctx.stageId !== null || Date.now() - (ctx.createdAt ?? 0) > 5 * 60 * 1000; + if (stale) sessionStorage.removeItem(COURSE_CHAPTER_CONTEXT_KEY); + } + } catch { + sessionStorage.removeItem(COURSE_CHAPTER_CONTEXT_KEY); + } router.push('/generation-preview'); } catch (err) { @@ -309,6 +358,7 @@ function HomePage() { if (diffDays === 0) return t('classroom.today'); if (diffDays === 1) return t('classroom.yesterday'); + if (diffDays === 2) return t('classroom.twoDaysAgo'); if (diffDays < 7) return `${diffDays} ${t('classroom.daysAgo')}`; return date.toLocaleDateString(); }; @@ -607,16 +657,175 @@ function HomePage() { + {/* ═══ Published courses ═══ */} + {publishedCourses.length > 0 && ( + +
+
+ + + {t('course.publishedCourses')} + {publishedCourses.length} + +
+
+ +
+ {publishedCourses.map((course, i) => { + const cover = getCourseGradient(course.id); + return ( + router.push(`/course/${course.id}?mode=view`)} + className="group rounded-2xl overflow-hidden cursor-pointer border border-white/10 shadow-md hover:shadow-xl hover:-translate-y-1 transition-all duration-250 flex flex-col" + style={{ minHeight: '200px' }} + > +
+
+
+ + + {course.chapterCount} {t('course.chapter')} + +

+ {course.name} +

+
+
+ {course.description ? ( +

+ {course.description} +

+ ) : ( +
+ )} +
+ + + {course.college} · {course.major} + +
+
+ + ); + })} +
+ + )} + {/* ═══ Recent classrooms — collapsible ═══ */} - {classrooms.length > 0 && ( + {(classrooms.length > 0 || courses.length > 0) && ( - {/* Trigger — divider-line with centered text */} - + +
+ + {/* Courses tab */} + {activeTab === 'courses' && ( +
+
+ +
+ {courses.length === 0 ? ( +

{t('course.noCourses')}

+ ) : ( +
+ {courses.map((course) => { + const cover = getCourseGradient(course.id); + return ( +
router.push(`/course/${course.id}`)} + className="group rounded-2xl overflow-hidden cursor-pointer border border-white/10 shadow-md hover:shadow-xl hover:-translate-y-1 transition-all duration-250 flex flex-col" + style={{ minHeight: '200px' }} + > +
+
+
+ + + {course.chapterCount} {t('course.chapter')} + +

+ {course.name} +

+
+ +
+ {course.description ? ( +

+ {course.description} +

+ ) : ( +
+ )} +
+ + + {course.college} · {course.major} + +
+
+
+ ); + })} +
+ )} +
+ )} + + {/* Standalone classrooms tab */} + {activeTab === 'standalone' && ( + <> + {/* Browser-local hint */} +

+ {t('course.localClassroomHint')} +

+ {/* Trigger — divider-line with centered text */} + + +
+ + ); +} diff --git a/components/course/chapter-list.tsx b/components/course/chapter-list.tsx new file mode 100644 index 000000000..330cd2a34 --- /dev/null +++ b/components/course/chapter-list.tsx @@ -0,0 +1,304 @@ +'use client'; + +import { useState } from 'react'; +import { motion, AnimatePresence } from 'motion/react'; +import { useI18n } from '@/lib/hooks/use-i18n'; +import type { ClassroomMeta, CourseChapter } from '@/lib/types/course'; +import { ChapterForm } from './chapter-form'; +import { Card, CardContent } from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import { Badge } from '@/components/ui/badge'; +import { + DropdownMenu, + DropdownMenuTrigger, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, +} from '@/components/ui/dropdown-menu'; +import { + AlertDialog, + AlertDialogContent, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogAction, + AlertDialogCancel, +} from '@/components/ui/alert-dialog'; +import { + ChevronUp, + ChevronDown, + Pencil, + Link, + Unlink, + Trash2, + Plus, + ExternalLink, + MoreHorizontal, + Sparkles, + BookOpen, +} from 'lucide-react'; +import { cn } from '@/lib/utils'; + +interface ChapterListProps { + courseId: string; + chapters: CourseChapter[]; + classroomMeta: Record; + readOnly?: boolean; + onAdd?: (title: string, description: string | null) => Promise; + onEdit?: (chapterId: string, title: string, description: string | null) => Promise; + onRemove?: (chapterId: string) => Promise; + onMove?: (chapterId: string, direction: 'up' | 'down') => Promise; + onBind?: (chapterId: string) => void; + onUnbind?: (chapterId: string) => Promise; + onCreateAndBind?: (chapterId: string) => void; + onOpenClassroom: (classroomId: string) => void; +} + +export function ChapterList({ + courseId: _courseId, + chapters, + classroomMeta, + readOnly = false, + onAdd, + onEdit, + onRemove, + onMove, + onBind, + onUnbind, + onCreateAndBind, + onOpenClassroom, +}: ChapterListProps) { + const { t } = useI18n(); + const [showAddForm, setShowAddForm] = useState(false); + const [editingId, setEditingId] = useState(null); + const [removingChapterId, setRemovingChapterId] = useState(null); + + const handleAdd = async (title: string, description: string | null) => { + await onAdd!(title, description); + setShowAddForm(false); + }; + + const handleEdit = async (chapterId: string, title: string, description: string | null) => { + await onEdit!(chapterId, title, description); + setEditingId(null); + }; + + const handleConfirmRemove = async () => { + if (!removingChapterId) return; + await onRemove!(removingChapterId); + setRemovingChapterId(null); + }; + + if (chapters.length === 0 && !showAddForm) { + return ( +
+
+ +
+

+ {readOnly ? t('course.noChaptersPublic') : t('course.noChapters')} +

+ {!readOnly && ( + + )} +
+ ); + } + + return ( +
+ + {chapters.map((chapter, idx) => { + const meta = chapter.classroomId ? classroomMeta[chapter.classroomId] : undefined; + const isReady = !!chapter.classroomId && (meta?.ready ?? false); + const isEditing = editingId === chapter.id; + + return ( + + + + {isEditing ? ( + handleEdit(chapter.id, title, description)} + onCancel={() => setEditingId(null)} + /> + ) : ( +
+ {/* Order number */} + + {idx + 1} + + + {/* Content */} +
+
+ {chapter.title} + + {isReady ? t('course.published') : readOnly ? t('course.comingSoon') : t('course.unpublished')} + +
+ {chapter.description && ( +

{chapter.description}

+ )} + {chapter.classroomId && meta && ( + + )} +
+ + {/* Actions — editor only */} + {!readOnly && ( +
+ + + + + + + + + setEditingId(chapter.id)}> + + {t('course.editChapter')} + + {chapter.classroomId ? ( + <> + onOpenClassroom(chapter.classroomId!)}> + + {t('course.openClassroom')} + + onUnbind!(chapter.id)}> + + {t('course.unbindClassroom')} + + + ) : ( + <> + onBind!(chapter.id)}> + + {t('course.bindClassroom')} + + onCreateAndBind!(chapter.id)}> + + {t('course.createAndBindClassroom')} + + + )} + + setRemovingChapterId(chapter.id)} + > + + {t('course.removeChapter')} + + + +
+ )} +
+ )} +
+
+
+ ); + })} +
+ + {/* Add form — editor only */} + {!readOnly && ( + + {showAddForm ? ( + + + + setShowAddForm(false)} /> + + + + ) : ( + setShowAddForm(true)} + className="w-full py-3.5 border-2 border-dashed border-border/50 rounded-xl text-sm text-muted-foreground hover:border-primary/40 hover:text-primary hover:bg-primary/5 transition-all duration-200 flex items-center justify-center gap-2" + > + + {t('course.addChapter')} + + )} + + )} + + {/* Delete confirmation */} + { if (!open) setRemovingChapterId(null); }}> + + + {t('course.confirmDeleteChapterTitle')} + {t('course.confirmRemoveChapter')} + + + {t('course.cancel')} + + {t('course.removeChapter')} + + + + +
+ ); +} diff --git a/components/course/classroom-picker-dialog.tsx b/components/course/classroom-picker-dialog.tsx new file mode 100644 index 000000000..328837036 --- /dev/null +++ b/components/course/classroom-picker-dialog.tsx @@ -0,0 +1,111 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { motion } from 'motion/react'; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, +} from '@/components/ui/dialog'; +import { ScrollArea } from '@/components/ui/scroll-area'; +import { useI18n } from '@/lib/hooks/use-i18n'; +import { cn } from '@/lib/utils'; + +interface PublishedClassroom { + id: string; + name: string; + sceneCount: number; +} + +interface ClassroomPickerDialogProps { + /** Chapter IDs already bound in the course — exclude from picker */ + usedClassroomIds: Set; + onSelect: (classroomId: string) => Promise; + onClose: () => void; +} + +export function ClassroomPickerDialog({ usedClassroomIds, onSelect, onClose }: ClassroomPickerDialogProps) { + const { t } = useI18n(); + const [classrooms, setClassrooms] = useState([]); + const [loading, setLoading] = useState(true); + const [submitting, setSubmitting] = useState(false); + + useEffect(() => { + fetch('/api/classroom') + .then((r) => r.json()) + .then(({ classrooms }: { classrooms: Array<{ id: string; name?: string; sceneCount: number }> }) => { + const items: PublishedClassroom[] = classrooms + .filter((c) => !usedClassroomIds.has(c.id)) + .map((c) => ({ + id: c.id, + name: c.name || c.id, + sceneCount: c.sceneCount ?? 0, + })); + setClassrooms(items); + }) + .catch(console.error) + .finally(() => setLoading(false)); + }, [usedClassroomIds]); + + const handleSelect = async (id: string) => { + setSubmitting(true); + try { + await onSelect(id); + onClose(); + } finally { + setSubmitting(false); + } + }; + + return ( + { if (!open) onClose(); }}> + + + {t('course.selectClassroom')} + + {t('course.noAvailableClassrooms')} + + + + + {loading ? ( +
+ {[0, 1, 2].map((i) => ( +
+ ))} +
+ ) : classrooms.length === 0 ? ( +

+ {t('course.noAvailableClassrooms')} +

+ ) : ( +
+ {classrooms.map((c, i) => ( + handleSelect(c.id)} + className={cn( + 'w-full text-left px-4 py-3 rounded-lg border border-border/40', + 'hover:border-primary/40 hover:bg-primary/5 transition-all duration-150', + 'disabled:opacity-50 disabled:cursor-not-allowed', + )} + > +
{c.name}
+
+ {c.sceneCount} {t('course.scenes')} +
+
+ ))} +
+ )} + + +
+ ); +} diff --git a/components/course/course-form.tsx b/components/course/course-form.tsx new file mode 100644 index 000000000..0c454d2e9 --- /dev/null +++ b/components/course/course-form.tsx @@ -0,0 +1,105 @@ +'use client'; + +import { useId, useState } from 'react'; +import { useI18n } from '@/lib/hooks/use-i18n'; +import type { CourseFormData } from '@/lib/types/course'; +import { Input } from '@/components/ui/input'; +import { Textarea } from '@/components/ui/textarea'; +import { Label } from '@/components/ui/label'; +import { Button } from '@/components/ui/button'; + +interface CourseFormProps { + initialData?: Partial; + onSubmit: (data: CourseFormData) => void; + onCancel: () => void; +} + +export function CourseForm({ initialData, onSubmit, onCancel }: CourseFormProps) { + const { t } = useI18n(); + const nameId = useId(); + const collegeId = useId(); + const majorId = useId(); + const descriptionId = useId(); + const teacherNameId = useId(); + const [formData, setFormData] = useState({ + name: initialData?.name || '', + college: initialData?.college || '', + major: initialData?.major || '', + description: initialData?.description || '', + teacherName: initialData?.teacherName || '', + }); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + onSubmit(formData); + }; + + return ( +
+
+ + setFormData({ ...formData, name: e.target.value })} + required + /> +
+
+ + setFormData({ ...formData, college: e.target.value })} + required + /> +
+
+ + setFormData({ ...formData, major: e.target.value })} + required + /> +
+
+ +