feat: enhance textbook reader with anchor text support and improve knowledge point management

This commit is contained in:
SpecialX
2026-01-16 10:22:16 +08:00
parent 9bfc621d3f
commit bb4555f611
44 changed files with 6284 additions and 2090 deletions

View File

@@ -201,102 +201,42 @@ Seed 脚本已覆盖班级相关数据,以便在开发环境快速验证页面
---
## 7. 教师仪表盘体验优化 (2026-01-12)
## 7. 班级管理重构与角色分离 (2026-01-14)
**目标**: 提升教师仪表盘的信息密度与易用性,优化核心指标展示,调整布局以符合教师工作流。
**日期**: 2026-01-14
**范围**: 班级创建权限收归管理端,教师端仅保留查看与加入
### 7.1 核心指标卡片重构 (TeacherStats)
- **原有问题**: 展示的总学生数、总课程数等静态指标对日常教学决策帮助有限。
- **优化方案**: 替换为高频动态指标,并增强视觉提示。
- **Needs Grading (待批改)**: 高亮显示待处理事项,使用 Amber 色彩引起注意。
- **Active Assignments (活跃作业)**: 显示当前发布的作业数量,反映教学负载。
- **Average Score (平均分)**: 展示近期作业平均分,快速了解学情。
- **Submission Rate (提交率)**: 展示整体作业完成度,反映学生参与度。
### 7.1 职责分离 (Role Separation)
### 7.2 布局调整 (Layout Restructuring)
- **原有问题**: "Needs Grading" 位于侧边栏,空间受限;"Homework" 列表占据主栏,信息密度低。
- **优化方案**:
- **Needs Grading 移至主栏**: 给予更多宽幅空间,展示详细的学生、作业信息及操作按钮。
- **Homework 移至侧边栏**: 改为紧凑列表视图,作为快速导航入口。
- **Schedule 优化**: 引入时间轴 (Timeline) 视图,支持滚动提示与当前状态指示。
- **管理端 (Management)**:
- 新增 `src/app/(dashboard)/management/grade/classes/page.tsx`
- 供年级组长 (Grade Head) 与管理员创建、编辑、删除班级
- 引入 `GradeClassesView` 组件,支持按年级管理班级
- **教师端 (Teacher)**:
- 移除创建班级入口
- 新增「通过邀请码加入班级」功能 (`JoinClassDialog`)
- `MyClassesGrid` 样式优化,移除硬编码渐变,使用标准 `bg-card`
### 7.3 组件功能增强
- **RecentSubmissions (Needs Grading)**:
- 升级为 Table 视图,展示头像、作业名、提交时间。
- 增加 "Grade" 快捷按钮,一键进入批改页面。
- 增加 "Late" 状态标记。
- **TeacherSchedule**:
- 采用垂直时间轴设计。
- 增加滚动提示 (Scroll Hint) 与 "No more classes" 状态提示。
- **TeacherHomeworkCard**:
- 优化为紧凑型列表,显示发布状态 (Published/Draft) 与截止日期。
### 7.2 数据访问与权限
### 7.4 技术细节
- 引入 `recharts` 替换手写 SVG 图表,统一图表风格。
- 优化 Grid 布局响应式表现 (`lg:grid-cols-12`)。
- **New Components**:
- `TeacherGradeTrends`: 基于 Recharts 的趋势图组件。
- `Chart`: 基于 Shadcn/UI 规范的通用图表包装器 (`src/shared/components/ui/chart.tsx`)。
- 新增 `getGradeManagedClasses`: 仅返回用户作为 Grade Head 或 Teaching Head 管理的年级下的班级
- Server Actions (`createGradeClassAction` 等) 增加严格的 RBAC 校验,确保操作者对目标年级有管理权限
### 7.5 代码管理
- **Branch**: `ui_opt`
- **Scope**: `src/modules/dashboard`, `src/shared/components/ui/chart.tsx`
- **Commit**: "feat(dashboard): optimize teacher dashboard ui and layout"
## 8. 课表模块视觉升级与架构优化 (2026-01-15)
---
**日期**: 2026-01-15
**范围**: 课表视图 (Schedule View) 视觉重构、Insights 模块移除
## 8. 班级详情页与学生管理优化 (2026-01-14)
### 8.1 课表视图重构 (Schedule Optimization)
**目标**: 提升班级管理效率与信息可视化程度,优化大班级场景下的性能与体验
- **视觉对齐**: 重构 `ScheduleView` (`src/modules/classes/components/schedule-view.tsx`) 以完全匹配 `ClassScheduleGrid` 组件的视觉风格
- **无边框设计**: 移除网格线与外边框,采用更现代的洁净布局。
- **时间轴定位**: 废弃 Grid 布局,改用基于时间的绝对定位 (`top`, `height` 百分比计算),支持 8:00 - 18:00 时间段。
- **语义化配色**: 新增 `getSubjectColor` 工具函数,根据课程名称 (Math, Physics, etc.) 自动映射语义化背景色与边框色。
- **过滤器优化**: `ScheduleFilters` 移除边框与阴影,居中显示当前选中的班级名称 (`{Class Name} Schedule`),移除冗余的 Reset 按钮。
### 8.1 学生管理列表优化 (Students Table)
- **分页 (Pagination)**: 引入客户端分页(每页 10 条),解决大班级列表渲染性能问题。
- **信息增强**:
- 增加学生头像 (Avatar)、性别、加入时间展示。
- 增加可视化状态徽章 (Status Badge)Active (Emerald) / Inactive (Muted)。
- **筛选能力**:
- 新增状态筛选器 (Active/Inactive),支持服务端过滤。
- **涉及组件**:
- `src/modules/classes/components/students-table.tsx`
- `src/modules/classes/components/students-filters.tsx`
### 8.2 架构精简 (Insights Removal)
### 8.2 班级详情页重构 (Class Detail Dashboard)
- **布局重构**: 采用响应式双栏布局 (Main Content + Sidebar),提升空间利用率
- **核心指标 (Key Metrics)**: 顶部增加 4 卡片统计网格:
- **Total Students**: 活跃/非活跃人数细分。
- **Schedule Items**: 每周课程数。
- **Active Assignments**: 活跃作业数与逾期数。
- **Class Average**: 基于已评分作业的平均分。
- **侧边栏小部件**:
- **Class Schedule**: 快速查看近期课程。
- **Homework History**: 快速查看历史作业状态。
- **涉及页面**:
- `src/app/(dashboard)/teacher/classes/my/[id]/page.tsx`
### 8.3 数据访问层更新
- **getClassStudents**: 扩展查询字段(头像、性别、加入时间),支持 `status` 过滤参数。
### 8.4 权限与流程调整 (Role Separation)
- **教师端变更**:
- 移除了“创建班级”入口,教师不再直接创建班级。
- 新增“加入班级” (Join Class) 功能,通过 6 位邀请码加入已由管理员创建的班级。
- 涉及组件:`src/modules/classes/components/my-classes-grid.tsx`
## 9. 年级管理端班级模块 (2026-01-14)
**目标**: 实现年级维度的班级集中管理,支持年级组长与管理员统一创建与维护班级。
### 9.1 路由与入口
- **路由**: `src/app/(dashboard)/management/grade/classes/page.tsx`
- **权限**: 仅限拥有年级管理权限的角色Grade Director / Teaching Head / Admin
### 9.2 功能特性
- **GradeClassesView**:
- 展示用户所管理年级的所有班级列表。
- 支持按年级筛选。
- **CRUD**: 提供创建、编辑、删除班级的完整能力。
- **RBAC**: 操作前校验用户对目标年级的管理权限。
### 9.3 核心变更
- **Data Access**: 新增 `getManagedGrades``getGradeManagedClasses`
- **Actions**: 新增 `createGradeClassAction` 等带权限校验的 Server Actions。
- **移除 Insights**: 经评估,`src/app/(dashboard)/teacher/classes/insights` 模块功能冗余,已全量移除。
- **保留核心数据**: 保留 `data-access.ts` 中的 `getClassHomeworkInsights` 函数,继续服务于班级详情页的统计卡片与图表
- **导航更新**: 从 `NAV_CONFIG` 中移除 Insights 入口。

View File

@@ -1,5 +1,34 @@
# Work Log
## 2026-01-15
### 1. Schedule Module Optimization
* **Visual Overhaul (`schedule-view.tsx`)**:
* Refactored the schedule grid to match the exact design of the `ClassScheduleGrid` widget.
* Implemented a clean, borderless layout with no grid lines for a modern look.
* **Time-Based Positioning**: Replaced grid-row logic with absolute positioning based on time (8:00 - 18:00 range) using percentage calculations (`getPositionStyle`).
* **Color Coding**: Added `getSubjectColor` to auto-assign thematic colors (blue for Math, purple for Physics, etc.) based on course names.
* **Card Design**: Refined course cards with vertical centering, better spacing, and removed unnecessary UI elements (like the "+" button in headers).
* **Filter Bar Refinement (`schedule-filters.tsx`)**:
* **Minimalist Design**: Removed borders and shadows from the class selector and buttons to integrate seamlessly with the background.
* **Center Label**: Added a dynamic, absolute-centered text label that updates based on selection:
* Shows "All Classes" when no filter is active.
* Shows "{Class Name}" when a specific class is selected.
* **Simplified Controls**: Removed the "Reset" button (X icon) entirely for a cleaner interface.
* **Ghost Buttons**: Styled the "Add Event" button as a ghost variant with muted colors.
### 2. Architecture & Cleanup
* **Insights Module Removal**:
* Deleted the entire `src/app/(dashboard)/teacher/classes/insights` directory as the feature was deemed redundant.
* Removed `insights-filters.tsx` component.
* Updated `navigation.ts` to remove the "Insights" link from the sidebar.
* *Note*: Preserved `getClassHomeworkInsights` in `data-access.ts` as it's still used by the Class Detail dashboard widgets.
### 3. Verification
* **Type Safety**: Ran `npm run typecheck` multiple times during refactoring to ensure no regressions (Passed).
* **Build**: Attempted to clear build cache to resolve a chunk loading error (Windows permission issue encountered but workaround applied).
## 2026-01-14
### 1. Class Management Refactoring (Role Separation)

View File

@@ -0,0 +1 @@
ALTER TABLE `knowledge_points` ADD `anchor_text` varchar(255);

File diff suppressed because it is too large Load Diff

View File

@@ -57,6 +57,13 @@
"when": 1768205524480,
"tag": "0007_talented_bromley",
"breakpoints": true
},
{
"idx": 8,
"version": "5",
"when": 1768470966367,
"tag": "0008_thin_madrox",
"breakpoints": true
}
]
}

173
package-lock.json generated
View File

@@ -17,8 +17,10 @@
"@radix-ui/react-avatar": "^1.1.11",
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-collapsible": "^1.1.12",
"@radix-ui/react-context-menu": "^2.2.16",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-hover-card": "^1.1.15",
"@radix-ui/react-progress": "^1.1.8",
"@radix-ui/react-radio-group": "^1.3.8",
"@radix-ui/react-scroll-area": "^1.2.10",
@@ -2817,6 +2819,90 @@
}
}
},
"node_modules/@radix-ui/react-context-menu": {
"version": "2.2.16",
"resolved": "https://registry.npmjs.org/@radix-ui/react-context-menu/-/react-context-menu-2.2.16.tgz",
"integrity": "sha512-O8morBEW+HsVG28gYDZPTrT9UUovQUlJue5YO836tiTJhuIWBm/zQHc7j388sHWtdH/xUZurK9olD2+pcqx5ww==",
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.3",
"@radix-ui/react-context": "1.1.2",
"@radix-ui/react-menu": "2.1.16",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-use-callback-ref": "1.1.1",
"@radix-ui/react-use-controllable-state": "1.2.2"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-context-menu/node_modules/@radix-ui/react-context": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz",
"integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-context-menu/node_modules/@radix-ui/react-primitive": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz",
"integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-slot": "1.2.3"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-context-menu/node_modules/@radix-ui/react-slot": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
"integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.2"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-dialog": {
"version": "1.1.15",
"resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.15.tgz",
@@ -3158,6 +3244,93 @@
}
}
},
"node_modules/@radix-ui/react-hover-card": {
"version": "1.1.15",
"resolved": "https://registry.npmjs.org/@radix-ui/react-hover-card/-/react-hover-card-1.1.15.tgz",
"integrity": "sha512-qgTkjNT1CfKMoP0rcasmlH2r1DAiYicWsDsufxl940sT2wHNEWWv6FMWIQXWhVdmC1d/HYfbhQx60KYyAtKxjg==",
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.3",
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-context": "1.1.2",
"@radix-ui/react-dismissable-layer": "1.1.11",
"@radix-ui/react-popper": "1.2.8",
"@radix-ui/react-portal": "1.1.9",
"@radix-ui/react-presence": "1.1.5",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-use-controllable-state": "1.2.2"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-hover-card/node_modules/@radix-ui/react-context": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz",
"integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-hover-card/node_modules/@radix-ui/react-primitive": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz",
"integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-slot": "1.2.3"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-hover-card/node_modules/@radix-ui/react-slot": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
"integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.2"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-id": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.1.tgz",

View File

@@ -22,8 +22,10 @@
"@radix-ui/react-avatar": "^1.1.11",
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-collapsible": "^1.1.12",
"@radix-ui/react-context-menu": "^2.2.16",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-hover-card": "^1.1.15",
"@radix-ui/react-progress": "^1.1.8",
"@radix-ui/react-radio-group": "^1.3.8",
"@radix-ui/react-scroll-area": "^1.2.10",

View File

@@ -1,12 +1,11 @@
import Link from "next/link"
import { notFound } from "next/navigation"
import { ArrowLeft, BookOpen, Inbox } from "lucide-react"
import { BookOpen, Inbox } from "lucide-react"
import { getTextbookById, getChaptersByTextbookId } from "@/modules/textbooks/data-access"
import { getTextbookById, getChaptersByTextbookId, getKnowledgePointsByTextbookId } from "@/modules/textbooks/data-access"
import { TextbookReader } from "@/modules/textbooks/components/textbook-reader"
import { Badge } from "@/shared/components/ui/badge"
import { Button } from "@/shared/components/ui/button"
import { EmptyState } from "@/shared/components/ui/empty-state"
import { getDemoStudentUser } from "@/modules/homework/data-access"
@@ -34,28 +33,24 @@ export default async function StudentTextbookDetailPage({
const { id } = await params
const [textbook, chapters] = await Promise.all([getTextbookById(id), getChaptersByTextbookId(id)])
const [textbook, chapters, knowledgePoints] = await Promise.all([
getTextbookById(id),
getChaptersByTextbookId(id),
getKnowledgePointsByTextbookId(id)
])
if (!textbook) notFound()
return (
<div className="flex h-[calc(100vh-4rem)] flex-col overflow-hidden bg-muted/5">
<div className="flex items-center gap-4 border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60 py-3 px-6 shrink-0 z-10">
<Button variant="ghost" size="sm" className="gap-2 text-muted-foreground hover:text-foreground" asChild>
<Link href="/student/learning/textbooks">
<ArrowLeft className="h-4 w-4" />
Back
</Link>
</Button>
<div className="w-px h-8 bg-border mx-2" />
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<h1 className="text-lg font-bold tracking-tight truncate mr-2">{textbook.title}</h1>
<Badge variant="secondary" className="font-normal text-xs">{textbook.subject}</Badge>
<div className="flex items-center justify-between border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60 py-3 px-6 shrink-0 z-10">
<div className="flex items-center gap-3 min-w-0">
<h1 className="text-lg font-bold tracking-tight truncate">{textbook.title}</h1>
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<span className="hidden sm:inline-block w-px h-4 bg-border" />
<Badge variant="outline" className="font-normal text-xs">{textbook.subject}</Badge>
{textbook.grade && (
<span className="text-xs text-muted-foreground border px-1.5 py-0.5 rounded">
{textbook.grade}
</span>
<Badge variant="secondary" className="font-normal text-xs">{textbook.grade}</Badge>
)}
</div>
</div>
@@ -73,7 +68,7 @@ export default async function StudentTextbookDetailPage({
</div>
) : (
<div className="h-full min-h-0 max-w-[1600px] mx-auto w-full">
<TextbookReader chapters={chapters} />
<TextbookReader chapters={chapters} knowledgePoints={knowledgePoints} />
</div>
)}
</div>

View File

@@ -1,259 +0,0 @@
import Link from "next/link"
import { Suspense } from "react"
import { BarChart3 } from "lucide-react"
import { getClassHomeworkInsights, getTeacherClasses } from "@/modules/classes/data-access"
import { InsightsFilters } from "@/modules/classes/components/insights-filters"
import { EmptyState } from "@/shared/components/ui/empty-state"
import { Skeleton } from "@/shared/components/ui/skeleton"
import { Badge } from "@/shared/components/ui/badge"
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
import { Button } from "@/shared/components/ui/button"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/shared/components/ui/table"
import { formatDate } from "@/shared/lib/utils"
export const dynamic = "force-dynamic"
type SearchParams = { [key: string]: string | string[] | undefined }
const getParam = (params: SearchParams, key: string) => {
const v = params[key]
return Array.isArray(v) ? v[0] : v
}
const formatNumber = (v: number | null, digits = 1) => {
if (typeof v !== "number" || Number.isNaN(v)) return "-"
return v.toFixed(digits)
}
function InsightsResultsFallback() {
return (
<div className="space-y-4">
<div className="grid gap-4 md:grid-cols-3">
{Array.from({ length: 3 }).map((_, idx) => (
<div key={idx} className="rounded-lg border bg-card">
<div className="p-6">
<Skeleton className="h-5 w-28" />
<Skeleton className="mt-3 h-8 w-20" />
</div>
</div>
))}
</div>
<div className="rounded-md border bg-card">
<div className="p-4">
<Skeleton className="h-8 w-full" />
</div>
<div className="space-y-2 p-4 pt-0">
{Array.from({ length: 8 }).map((_, idx) => (
<Skeleton key={idx} className="h-10 w-full" />
))}
</div>
</div>
</div>
)
}
async function InsightsResults({ searchParams }: { searchParams: Promise<SearchParams> }) {
const params = await searchParams
const classId = getParam(params, "classId")
if (!classId || classId === "all") {
return (
<EmptyState
icon={BarChart3}
title="Select a class to view insights"
description="Pick a class to see latest homework and historical score statistics."
className="h-[360px] bg-card"
/>
)
}
const insights = await getClassHomeworkInsights({ classId, limit: 50 })
if (!insights) {
return (
<EmptyState
icon={BarChart3}
title="Class not found"
description="This class may not exist or is not accessible."
className="h-[360px] bg-card"
/>
)
}
const hasAssignments = insights.assignments.length > 0
if (!hasAssignments) {
return (
<EmptyState
icon={BarChart3}
title="No homework data for this class"
description="No homework assignments were targeted to students in this class yet."
className="h-[360px] bg-card"
/>
)
}
const latest = insights.latest
return (
<div className="space-y-6">
<div className="grid gap-4 md:grid-cols-3">
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium">Students</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{insights.studentCounts.total}</div>
<div className="text-xs text-muted-foreground">
Active {insights.studentCounts.active} · Inactive {insights.studentCounts.inactive}
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium">Assignments</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{insights.assignments.length}</div>
<div className="text-xs text-muted-foreground">Latest: {latest ? formatDate(latest.createdAt) : "-"}</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium">Overall scores</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{formatNumber(insights.overallScores.avg, 1)}</div>
<div className="text-xs text-muted-foreground">
Median {formatNumber(insights.overallScores.median, 1)} · Count {insights.overallScores.count}
</div>
</CardContent>
</Card>
</div>
{latest && (
<Card>
<CardHeader className="flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
<div>
<CardTitle className="text-base">Latest assignment</CardTitle>
<div className="mt-1 flex items-center gap-2 text-sm text-muted-foreground">
<span className="font-medium text-foreground">{latest.title}</span>
<Badge variant="outline" className="capitalize">
{latest.status}
</Badge>
<span>·</span>
<span>{formatDate(latest.createdAt)}</span>
{latest.dueAt ? (
<>
<span>·</span>
<span>Due {formatDate(latest.dueAt)}</span>
</>
) : null}
</div>
</div>
<div className="flex items-center gap-2">
<Button asChild variant="outline" size="sm">
<Link href={`/teacher/homework/assignments/${latest.assignmentId}`}>Open</Link>
</Button>
<Button asChild variant="outline" size="sm">
<Link href={`/teacher/homework/assignments/${latest.assignmentId}/submissions`}>Submissions</Link>
</Button>
</div>
</CardHeader>
<CardContent className="grid gap-4 md:grid-cols-5">
<div>
<div className="text-sm text-muted-foreground">Targeted</div>
<div className="text-lg font-semibold">{latest.targetCount}</div>
</div>
<div>
<div className="text-sm text-muted-foreground">Submitted</div>
<div className="text-lg font-semibold">{latest.submittedCount}</div>
</div>
<div>
<div className="text-sm text-muted-foreground">Graded</div>
<div className="text-lg font-semibold">{latest.gradedCount}</div>
</div>
<div>
<div className="text-sm text-muted-foreground">Average</div>
<div className="text-lg font-semibold">{formatNumber(latest.scoreStats.avg, 1)}</div>
</div>
<div>
<div className="text-sm text-muted-foreground">Median</div>
<div className="text-lg font-semibold">{formatNumber(latest.scoreStats.median, 1)}</div>
</div>
</CardContent>
</Card>
)}
<div className="rounded-md border bg-card">
<Table>
<TableHeader>
<TableRow>
<TableHead>Assignment</TableHead>
<TableHead>Status</TableHead>
<TableHead>Due</TableHead>
<TableHead className="text-right">Targeted</TableHead>
<TableHead className="text-right">Submitted</TableHead>
<TableHead className="text-right">Graded</TableHead>
<TableHead className="text-right">Avg</TableHead>
<TableHead className="text-right">Median</TableHead>
<TableHead className="text-right">Min</TableHead>
<TableHead className="text-right">Max</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{insights.assignments.map((a) => (
<TableRow key={a.assignmentId}>
<TableCell className="font-medium">
<Link href={`/teacher/homework/assignments/${a.assignmentId}`} className="hover:underline">
{a.title}
</Link>
<div className="text-xs text-muted-foreground">Created {formatDate(a.createdAt)}</div>
</TableCell>
<TableCell>
<Badge variant="outline" className="capitalize">
{a.status}
</Badge>
</TableCell>
<TableCell>{a.dueAt ? formatDate(a.dueAt) : "-"}</TableCell>
<TableCell className="text-right">{a.targetCount}</TableCell>
<TableCell className="text-right">{a.submittedCount}</TableCell>
<TableCell className="text-right">{a.gradedCount}</TableCell>
<TableCell className="text-right">{formatNumber(a.scoreStats.avg, 1)}</TableCell>
<TableCell className="text-right">{formatNumber(a.scoreStats.median, 1)}</TableCell>
<TableCell className="text-right">{formatNumber(a.scoreStats.min, 0)}</TableCell>
<TableCell className="text-right">{formatNumber(a.scoreStats.max, 0)}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
)
}
export default async function ClassInsightsPage({ searchParams }: { searchParams: Promise<SearchParams> }) {
const classes = await getTeacherClasses()
return (
<div className="flex h-full flex-col space-y-8 p-8">
<div className="flex flex-col justify-between space-y-4 md:flex-row md:items-center md:space-y-0">
<div>
<h2 className="text-2xl font-bold tracking-tight">Class Insights</h2>
<p className="text-muted-foreground">Latest homework and historical score statistics for a class.</p>
</div>
</div>
<div className="space-y-4">
<Suspense fallback={<div className="h-10 w-full animate-pulse rounded-md bg-muted" />}>
<InsightsFilters classes={classes} />
</Suspense>
<Suspense fallback={<InsightsResultsFallback />}>
<InsightsResults searchParams={searchParams} />
</Suspense>
</div>
</div>
)
}

View File

@@ -1,39 +1,18 @@
import Link from "next/link"
import { notFound } from "next/navigation"
import { BookOpen, Calendar, ChevronRight, Clock, Users } from "lucide-react"
import { getClassHomeworkInsights, getClassSchedule, getClassStudents } from "@/modules/classes/data-access"
import { ScheduleView } from "@/modules/classes/components/schedule-view"
import { Badge } from "@/shared/components/ui/badge"
import { Button } from "@/shared/components/ui/button"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/shared/components/ui/card"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/shared/components/ui/table"
import { Avatar, AvatarFallback, AvatarImage } from "@/shared/components/ui/avatar"
import { cn, formatDate } from "@/shared/lib/utils"
import { getClassHomeworkInsights, getClassSchedule, getClassStudentSubjectScoresV2, getClassStudents } from "@/modules/classes/data-access"
import { ClassAssignmentsWidget } from "@/modules/classes/components/class-detail/class-assignments-widget"
import { ClassTrendsWidget } from "@/modules/classes/components/class-detail/class-trends-widget"
import { ClassHeader } from "@/modules/classes/components/class-detail/class-header"
import { ClassOverviewStats } from "@/modules/classes/components/class-detail/class-overview-stats"
import { ClassQuickActions } from "@/modules/classes/components/class-detail/class-quick-actions"
import { ClassScheduleWidget } from "@/modules/classes/components/class-detail/class-schedule-widget"
import { ClassStudentsWidget } from "@/modules/classes/components/class-detail/class-students-widget"
export const dynamic = "force-dynamic"
type SearchParams = { [key: string]: string | string[] | undefined }
const getParam = (params: SearchParams, key: string) => {
const v = params[key]
return Array.isArray(v) ? v[0] : v
}
const formatNumber = (v: number | null, digits = 1) => {
if (typeof v !== "number" || Number.isNaN(v)) return "-"
return v.toFixed(digits)
}
const getInitials = (name: string) => {
return name
.split(" ")
.map((n) => n[0])
.join("")
.toUpperCase()
.slice(0, 2)
}
export default async function ClassDetailPage({
params,
searchParams,
@@ -42,335 +21,96 @@ export default async function ClassDetailPage({
searchParams: Promise<SearchParams>
}) {
const { id } = await params
const sp = await searchParams
const hw = getParam(sp, "hw")
const hwFilter = hw === "active" || hw === "overdue" ? hw : "all"
// Parallel data fetching
const [insights, students, schedule] = await Promise.all([
getClassHomeworkInsights({ classId: id, limit: 50 }),
getClassHomeworkInsights({ classId: id, limit: 20 }), // Limit increased to 20 for better list view
getClassStudents({ classId: id }),
getClassSchedule({ classId: id }),
])
if (!insights) return notFound()
const latest = insights.latest
const filteredAssignments = insights.assignments.filter((a) => {
if (hwFilter === "all") return true
if (hwFilter === "overdue") return a.isOverdue
if (hwFilter === "active") return a.isActive
return true
})
const hasAssignments = filteredAssignments.length > 0
const scheduleBuilderClasses = [
{
id: insights.class.id,
name: insights.class.name,
grade: insights.class.grade,
homeroom: insights.class.homeroom ?? null,
room: insights.class.room ?? null,
studentCount: insights.studentCounts.total,
},
]
// Fetch subject scores
const studentScores = await getClassStudentSubjectScoresV2(id)
// Data mapping for widgets
const assignmentSummaries = insights.assignments.map(a => ({
id: a.assignmentId,
title: a.title,
status: a.status,
subject: a.subject,
isActive: a.isActive,
isOverdue: a.isOverdue,
dueAt: a.dueAt ? new Date(a.dueAt) : null,
submittedCount: a.submittedCount,
targetCount: a.targetCount,
avgScore: a.scoreStats.avg,
medianScore: a.scoreStats.median
}))
const studentSummaries = students.map(s => ({
id: s.id,
name: s.name,
email: s.email,
image: s.image,
status: s.status,
subjectScores: studentScores.get(s.id) ?? {}
}))
// Calculate advanced stats
const activeAssignments = insights.assignments.filter(a => a.isActive)
const papersToGrade = activeAssignments.reduce((acc, a) => acc + (a.submittedCount - a.gradedCount), 0)
const overdueCount = activeAssignments.filter(a => a.isOverdue).length
const totalSubmissionRate = activeAssignments.length > 0
? activeAssignments.reduce((acc, a) => acc + (a.targetCount > 0 ? a.submittedCount / a.targetCount : 0), 0) / activeAssignments.length
: 0
return (
<div className="flex flex-col min-h-full space-y-8 p-8">
{/* Header */}
<div className="flex flex-col gap-4 md:flex-row md:items-start md:justify-between">
<div className="space-y-1.5">
<div className="flex items-center gap-2 text-muted-foreground mb-2">
<Link href="/teacher/classes/my" className="hover:text-foreground transition-colors">
My Classes
</Link>
<ChevronRight className="h-4 w-4" />
<span className="text-foreground font-medium">{insights.class.name}</span>
</div>
<h2 className="text-3xl font-bold tracking-tight">{insights.class.name}</h2>
<div className="flex items-center gap-3 text-sm text-muted-foreground">
<Badge variant="secondary" className="rounded-sm font-normal">
{insights.class.grade}
</Badge>
{insights.class.homeroom && (
<>
<span className="w-1 h-1 rounded-full bg-border" />
<span>Homeroom: {insights.class.homeroom}</span>
</>
)}
{insights.class.room && (
<>
<span className="w-1 h-1 rounded-full bg-border" />
<span>Room: {insights.class.room}</span>
</>
)}
</div>
<div className="flex min-h-screen flex-col bg-muted/10">
<ClassHeader
classId={insights.class.id}
name={insights.class.name}
grade={insights.class.grade}
homeroom={insights.class.homeroom}
room={insights.class.room}
schoolName={insights.class.schoolName}
studentCount={insights.studentCounts.total}
/>
<div className="flex-1 space-y-6 p-6">
{/* Key Metrics */}
<ClassOverviewStats
averageScore={insights.overallScores.avg}
submissionRate={totalSubmissionRate * 100}
papersToGrade={papersToGrade}
overdueCount={overdueCount}
/>
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
{/* Main Content Area (Left 2/3) */}
<div className="space-y-6 lg:col-span-2">
<ClassTrendsWidget
classId={insights.class.id}
assignments={assignmentSummaries}
/>
<ClassStudentsWidget
classId={insights.class.id}
students={studentSummaries}
/>
</div>
<div className="flex flex-wrap items-center gap-2">
<Button asChild variant="outline">
<Link href={`/teacher/classes/students?classId=${encodeURIComponent(insights.class.id)}`}>
<Users className="mr-2 h-4 w-4" />
Students
</Link>
</Button>
<Button asChild variant="outline">
<Link href={`/teacher/classes/schedule?classId=${encodeURIComponent(insights.class.id)}`}>
<Calendar className="mr-2 h-4 w-4" />
Schedule
</Link>
</Button>
<Button asChild>
<Link href={`/teacher/homework/assignments/create?classId=${encodeURIComponent(insights.class.id)}`}>
Create Homework
</Link>
</Button>
{/* Sidebar Area (Right 1/3) */}
<div className="space-y-6">
{/* <ClassQuickActions classId={insights.class.id} /> */}
<ClassScheduleWidget classId={insights.class.id} schedule={schedule} />
<ClassAssignmentsWidget
classId={insights.class.id}
assignments={assignmentSummaries}
/>
</div>
</div>
{/* Stats Grid */}
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Total Students</CardTitle>
<Users className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{insights.studentCounts.total}</div>
<div className="text-xs text-muted-foreground">
{insights.studentCounts.active} active · {insights.studentCounts.inactive} inactive
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Schedule Items</CardTitle>
<Calendar className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{schedule.length}</div>
<div className="text-xs text-muted-foreground">Weekly sessions</div>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Active Assignments</CardTitle>
<Clock className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{insights.assignments.filter((a) => a.isActive).length}
</div>
<div className="text-xs text-muted-foreground">
{insights.assignments.filter((a) => a.isOverdue).length} overdue
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Class Average</CardTitle>
<BookOpen className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{formatNumber(insights.overallScores.avg, 1)}%</div>
<div className="text-xs text-muted-foreground">
Based on {insights.overallScores.count} graded submissions
</div>
</CardContent>
</Card>
</div>
<div className="grid gap-6 lg:grid-cols-7">
{/* Main Content Area */}
<div className="lg:col-span-4 space-y-6">
{/* Latest Homework */}
{latest && (
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div className="space-y-1">
<CardTitle>Latest Homework</CardTitle>
<CardDescription>Most recent assignment activity</CardDescription>
</div>
<Badge variant={latest.isActive ? "default" : "secondary"}>
{latest.status}
</Badge>
</div>
</CardHeader>
<CardContent className="space-y-6">
<div className="flex flex-col gap-4 rounded-lg border p-4">
<div className="flex items-start justify-between gap-4">
<div className="space-y-1">
<Link
href={`/teacher/homework/assignments/${latest.assignmentId}`}
className="font-semibold hover:underline"
>
{latest.title}
</Link>
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<span>Due {latest.dueAt ? formatDate(latest.dueAt) : "No due date"}</span>
<span>·</span>
<span>{latest.submittedCount}/{latest.targetCount} Submitted</span>
</div>
</div>
<Button variant="outline" size="sm" asChild>
<Link href={`/teacher/homework/assignments/${latest.assignmentId}/submissions`}>
Grade
</Link>
</Button>
</div>
<div className="grid grid-cols-3 gap-4 border-t pt-4">
<div className="text-center">
<div className="text-2xl font-bold">{latest.gradedCount}</div>
<div className="text-xs text-muted-foreground">Graded</div>
</div>
<div className="text-center border-l border-r">
<div className="text-2xl font-bold">{formatNumber(latest.scoreStats.avg, 1)}</div>
<div className="text-xs text-muted-foreground">Average</div>
</div>
<div className="text-center">
<div className="text-2xl font-bold">{formatNumber(latest.scoreStats.median, 1)}</div>
<div className="text-xs text-muted-foreground">Median</div>
</div>
</div>
</div>
</CardContent>
</Card>
)}
{/* Students Preview */}
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<div className="space-y-1">
<CardTitle>Students</CardTitle>
<CardDescription>Recently active students</CardDescription>
</div>
<Button variant="ghost" size="sm" asChild>
<Link href={`/teacher/classes/students?classId=${encodeURIComponent(insights.class.id)}`}>
View All
<ChevronRight className="ml-2 h-4 w-4" />
</Link>
</Button>
</CardHeader>
<CardContent>
{students.length === 0 ? (
<div className="text-center py-8 text-muted-foreground">
No students enrolled yet.
</div>
) : (
<div className="space-y-4">
{students.slice(0, 5).map((s) => (
<div key={s.id} className="flex items-center justify-between">
<div className="flex items-center gap-3">
<Avatar className="h-9 w-9">
<AvatarImage src={s.image || undefined} />
<AvatarFallback>{getInitials(s.name)}</AvatarFallback>
</Avatar>
<div>
<div className="font-medium text-sm">{s.name}</div>
<div className="text-xs text-muted-foreground">{s.email}</div>
</div>
</div>
<Badge variant={s.status === "active" ? "outline" : "secondary"} className="text-xs font-normal">
{s.status}
</Badge>
</div>
))}
</div>
)}
</CardContent>
</Card>
</div>
{/* Sidebar Area */}
<div className="lg:col-span-3 space-y-6">
{/* Schedule Widget */}
<Card className="h-fit">
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle>Schedule</CardTitle>
<Button variant="ghost" size="icon" asChild>
<Link href={`/teacher/classes/schedule?classId=${encodeURIComponent(insights.class.id)}`}>
<ChevronRight className="h-4 w-4" />
</Link>
</Button>
</CardHeader>
<CardContent>
<ScheduleView schedule={schedule} classes={scheduleBuilderClasses} />
</CardContent>
</Card>
{/* Homework History */}
<Card>
<CardHeader>
<CardTitle>History</CardTitle>
<div className="flex gap-2 mt-2">
<Button
size="sm"
variant={hwFilter === "all" ? "secondary" : "ghost"}
asChild
className="h-7 px-2 text-xs"
>
<Link href={`/teacher/classes/my/${encodeURIComponent(insights.class.id)}`}>All</Link>
</Button>
<Button
size="sm"
variant={hwFilter === "active" ? "secondary" : "ghost"}
asChild
className="h-7 px-2 text-xs"
>
<Link href={`/teacher/classes/my/${encodeURIComponent(insights.class.id)}?hw=active`}>Active</Link>
</Button>
<Button
size="sm"
variant={hwFilter === "overdue" ? "secondary" : "ghost"}
asChild
className="h-7 px-2 text-xs"
>
<Link href={`/teacher/classes/my/${encodeURIComponent(insights.class.id)}?hw=overdue`}>Overdue</Link>
</Button>
</div>
</CardHeader>
<CardContent className="p-0">
<div className="divide-y">
{filteredAssignments.slice(0, 5).map((a) => (
<div key={a.assignmentId} className="p-4 hover:bg-muted/50 transition-colors">
<div className="flex items-start justify-between gap-4 mb-2">
<Link
href={`/teacher/homework/assignments/${a.assignmentId}`}
className="text-sm font-medium hover:underline line-clamp-1"
>
{a.title}
</Link>
<Badge variant={a.isActive ? "default" : "secondary"} className="shrink-0 text-[10px] h-5">
{a.status}
</Badge>
</div>
<div className="flex items-center justify-between text-xs text-muted-foreground">
<span>Due {a.dueAt ? formatDate(a.dueAt) : "-"}</span>
<div className="flex gap-3">
<span>{a.submittedCount} submitted</span>
<span>{formatNumber(a.scoreStats.avg, 0)}% avg</span>
</div>
</div>
</div>
))}
{filteredAssignments.length === 0 && (
<div className="p-8 text-center text-sm text-muted-foreground">
No assignments found
</div>
)}
</div>
{filteredAssignments.length > 5 && (
<div className="p-2 border-t text-center">
<Button variant="ghost" size="sm" className="w-full text-muted-foreground" asChild>
<Link href={`/teacher/homework/assignments?classId=${encodeURIComponent(insights.class.id)}`}>
View All Assignments
</Link>
</Button>
</div>
)}
</CardContent>
</Card>
</div>
</div>
</div>
)

View File

@@ -15,16 +15,7 @@ async function MyClassesPageImpl() {
const classes = await getTeacherClasses()
return (
<div className="flex h-full flex-col space-y-8 p-8">
<div className="flex flex-col justify-between space-y-4 md:flex-row md:items-center md:space-y-0">
<div>
<h2 className="text-2xl font-bold tracking-tight">My Classes</h2>
<p className="text-muted-foreground">
Overview of your classes.
</p>
</div>
</div>
<div className="flex h-full flex-col space-y-4 p-8">
<MyClassesGrid classes={classes} canCreateClass={false} />
</div>
)

View File

@@ -67,16 +67,7 @@ export default async function SchedulePage({ searchParams }: { searchParams: Pro
return (
<div className="flex h-full flex-col space-y-8 p-8">
<div className="flex flex-col justify-between space-y-4 md:flex-row md:items-center md:space-y-0">
<div>
<h2 className="text-2xl font-bold tracking-tight">Schedule</h2>
<p className="text-muted-foreground">
View class schedule.
</p>
</div>
</div>
<div className="space-y-4">
<div className="space-y-6">
<Suspense fallback={<div className="h-10 w-full animate-pulse rounded-md bg-muted" />}>
<ScheduleFilters classes={classes} />
</Suspense>

View File

@@ -1,7 +1,7 @@
import { Suspense } from "react"
import { User } from "lucide-react"
import { getClassStudents, getTeacherClasses } from "@/modules/classes/data-access"
import { getClassStudents, getTeacherClasses, getStudentsSubjectScores } from "@/modules/classes/data-access"
import { StudentsFilters } from "@/modules/classes/components/students-filters"
import { StudentsTable } from "@/modules/classes/components/students-table"
import { EmptyState } from "@/shared/components/ui/empty-state"
@@ -16,19 +16,34 @@ const getParam = (params: SearchParams, key: string) => {
return Array.isArray(v) ? v[0] : v
}
async function StudentsResults({ searchParams }: { searchParams: Promise<SearchParams> }) {
async function StudentsResults({ searchParams, defaultClassId }: { searchParams: Promise<SearchParams>, defaultClassId?: string }) {
const params = await searchParams
const q = getParam(params, "q") || undefined
const classId = getParam(params, "classId")
const status = getParam(params, "status")
// If classId is explicit in URL, use it (unless "all"). If not, use defaultClassId.
// If user explicitly selects "all", classId will be "all".
// However, the requirement is "Default to showing the first class".
// If classId param is missing, we use defaultClassId.
const targetClassId = classId ? (classId !== "all" ? classId : undefined) : defaultClassId
const filteredStudents = await getClassStudents({
q,
classId: classId && classId !== "all" ? classId : undefined,
classId: targetClassId,
status: status && status !== "all" ? status : undefined,
})
// Fetch subject scores for all filtered students
if (filteredStudents.length > 0) {
const studentIds = filteredStudents.map(s => s.id)
const scores = await getStudentsSubjectScores(studentIds)
for (const student of filteredStudents) {
student.subjectScores = scores.get(student.id)
}
}
const hasFilters = Boolean(q || (classId && classId !== "all") || (status && status !== "all"))
if (filteredStudents.length === 0) {
@@ -67,25 +82,20 @@ function StudentsResultsFallback() {
export default async function StudentsPage({ searchParams }: { searchParams: Promise<SearchParams> }) {
const classes = await getTeacherClasses()
const params = await searchParams
// Logic to determine default class (first one available)
const defaultClassId = classes.length > 0 ? classes[0].id : undefined
return (
<div className="flex h-full flex-col space-y-8 p-8">
<div className="flex flex-col justify-between space-y-4 md:flex-row md:items-center md:space-y-0">
<div>
<h2 className="text-2xl font-bold tracking-tight">Students</h2>
<p className="text-muted-foreground">
Manage student list.
</p>
</div>
</div>
<div className="flex h-full flex-col space-y-4 p-8">
<div className="space-y-4">
<Suspense fallback={<div className="h-10 w-full animate-pulse rounded-md bg-muted" />}>
<StudentsFilters classes={classes} />
<StudentsFilters classes={classes} defaultClassId={defaultClassId} />
</Suspense>
<Suspense fallback={<StudentsResultsFallback />}>
<StudentsResults searchParams={searchParams} />
<StudentsResults searchParams={searchParams} defaultClassId={defaultClassId} />
</Suspense>
</div>
</div>

View File

@@ -4,7 +4,7 @@ import Link from "next/link";
import { Button } from "@/shared/components/ui/button";
import { Badge } from "@/shared/components/ui/badge";
import { getTextbookById, getChaptersByTextbookId, getKnowledgePointsByTextbookId } from "@/modules/textbooks/data-access";
import { TextbookContentLayout } from "@/modules/textbooks/components/textbook-content-layout";
import { TextbookReader } from "@/modules/textbooks/components/textbook-reader";
import { TextbookSettingsDialog } from "@/modules/textbooks/components/textbook-settings-dialog";
export const dynamic = "force-dynamic"
@@ -51,10 +51,11 @@ export default async function TextbookDetailPage({
{/* Main Content Layout (Flex grow) */}
<div className="flex-1 overflow-hidden pt-6">
<TextbookContentLayout
<TextbookReader
chapters={chapters}
knowledgePoints={knowledgePoints}
textbookId={id}
canEdit={true}
/>
</div>
</div>

View File

@@ -0,0 +1,109 @@
import Link from "next/link"
import { ChevronRight, FileText } from "lucide-react"
import { Badge } from "@/shared/components/ui/badge"
import { Button } from "@/shared/components/ui/button"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/shared/components/ui/card"
import { formatDate } from "@/shared/lib/utils"
interface AssignmentSummary {
id: string
title: string
status: string
isActive: boolean
isOverdue: boolean
dueAt: Date | null
submittedCount: number
targetCount: number
avgScore: number | null
medianScore: number | null
}
interface ClassAssignmentsWidgetProps {
classId: string
assignments: AssignmentSummary[]
}
export function ClassAssignmentsWidget({ classId, assignments }: ClassAssignmentsWidgetProps) {
const activeAssignments = assignments.filter((a) => a.isActive)
return (
<Card className="h-fit">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<div className="space-y-1">
<CardTitle className="text-base font-semibold">Recent Homework</CardTitle>
<CardDescription>
{activeAssignments.length} active assignments
</CardDescription>
</div>
<Button variant="ghost" size="sm" asChild>
<Link href={`/teacher/homework/assignments?classId=${encodeURIComponent(classId)}`}>
View All
<ChevronRight className="ml-2 h-4 w-4" />
</Link>
</Button>
</CardHeader>
<CardContent className="pt-4">
{assignments.length === 0 ? (
<div className="flex flex-col items-center justify-center space-y-3 py-8 text-center">
<div className="rounded-full bg-muted p-3">
<FileText className="h-6 w-6 text-muted-foreground" />
</div>
<div className="space-y-1">
<p className="text-sm font-medium">No homework yet</p>
<p className="text-xs text-muted-foreground">
Create an assignment to get started.
</p>
</div>
<Button size="sm" asChild>
<Link href={`/teacher/homework/assignments/create?classId=${encodeURIComponent(classId)}`}>
Create Homework
</Link>
</Button>
</div>
) : (
<div className="space-y-4">
{assignments.slice(0, 5).map((assignment) => (
<div
key={assignment.id}
className="flex items-start justify-between space-x-4 rounded-md border p-3 transition-all hover:bg-muted/50"
>
<div className="space-y-1">
<Link
href={`/teacher/homework/assignments/${assignment.id}`}
className="block font-medium hover:underline line-clamp-1"
>
{assignment.title}
</Link>
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<span className={assignment.isOverdue ? "text-destructive font-medium" : ""}>
Due {assignment.dueAt ? formatDate(assignment.dueAt) : "No due date"}
</span>
<span></span>
<span>
{assignment.submittedCount}/{assignment.targetCount} Submitted
</span>
</div>
</div>
<div className="flex flex-col items-end gap-2">
<Badge
variant={assignment.isActive ? "default" : "secondary"}
className="rounded-sm px-1.5 py-0.5 text-[10px] uppercase"
>
{assignment.status}
</Badge>
{typeof assignment.avgScore === "number" && (
<span className="text-xs font-medium tabular-nums">
Avg: {assignment.avgScore.toFixed(0)}%
</span>
)}
</div>
</div>
))}
</div>
)}
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,119 @@
"use client"
import { useState } from "react"
import { MoreHorizontal, Pencil, Settings, Share2 } from "lucide-react"
import { Badge } from "@/shared/components/ui/badge"
import { Button } from "@/shared/components/ui/button"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/shared/components/ui/dropdown-menu"
import { EditClassDialog } from "./edit-class-dialog"
interface ClassHeaderProps {
classId: string
name: string
grade: string
homeroom?: string | null
room?: string | null
schoolName?: string | null
studentCount: number
}
export function ClassHeader({
classId,
name,
grade,
homeroom,
room,
schoolName,
studentCount,
}: ClassHeaderProps) {
const [showEdit, setShowEdit] = useState(false)
return (
<>
<div className="flex flex-col gap-4 border-b bg-background px-6 py-4">
<div className="flex items-start justify-between">
<div className="space-y-1">
<h1 className="text-2xl font-bold tracking-tight text-foreground sm:text-3xl">
{name}
</h1>
<div className="flex flex-wrap items-center gap-2 text-sm text-muted-foreground">
{schoolName && (
<>
<span>{schoolName}</span>
<span className="text-muted-foreground/40"></span>
</>
)}
<Badge variant="secondary" className="font-medium">
{grade}
</Badge>
{homeroom && (
<>
<span className="text-muted-foreground/40"></span>
<span>Homeroom {homeroom}</span>
</>
)}
{room && (
<>
<span className="text-muted-foreground/40"></span>
<span>Room {room}</span>
</>
)}
<span className="text-muted-foreground/40"></span>
<span>{studentCount} Students</span>
</div>
</div>
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" className="hidden sm:flex">
<Share2 className="mr-2 h-4 w-4" />
Invite
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="icon" className="h-8 w-8">
<MoreHorizontal className="h-4 w-4" />
<span className="sr-only">More actions</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => setShowEdit(true)}>
<Pencil className="mr-2 h-4 w-4" />
Edit details
</DropdownMenuItem>
<DropdownMenuItem>
<Share2 className="mr-2 h-4 w-4" />
Invite students
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem className="text-destructive focus:text-destructive">
<Settings className="mr-2 h-4 w-4" />
Class settings
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
</div>
<EditClassDialog
open={showEdit}
onOpenChange={setShowEdit}
classId={classId}
initialData={{
name,
grade,
homeroom,
room,
schoolName
}}
/>
</>
)
}

View File

@@ -0,0 +1,74 @@
import { AlertCircle, BarChart3, CheckCircle2, PenTool } from "lucide-react"
import { Card, CardContent } from "@/shared/components/ui/card"
interface ClassOverviewStatsProps {
averageScore: number | null
submissionRate: number
papersToGrade: number
overdueCount: number
}
export function ClassOverviewStats({
averageScore,
submissionRate,
papersToGrade,
overdueCount,
}: ClassOverviewStatsProps) {
return (
<div className="grid grid-cols-2 gap-4 lg:grid-cols-4">
<StatsCard
title="Class Average"
value={averageScore ? `${averageScore.toFixed(1)}%` : "-"}
subValue="Overall performance"
icon={BarChart3}
/>
<StatsCard
title="Submission Rate"
value={`${submissionRate.toFixed(0)}%`}
subValue="Average turn-in rate"
icon={CheckCircle2}
/>
<StatsCard
title="To Grade"
value={papersToGrade.toString()}
subValue="Pending reviews"
icon={PenTool}
/>
<StatsCard
title="Missed Deadlines"
value={overdueCount.toString()}
subValue="Active assignments past due"
icon={AlertCircle}
/>
</div>
)
}
function StatsCard({
title,
value,
subValue,
icon: Icon,
}: {
title: string
value: string
subValue: string
icon: React.ElementType
}) {
return (
<Card>
<CardContent className="p-6">
<div className="flex items-center justify-between space-y-0 pb-2">
<p className="text-sm font-medium text-muted-foreground">{title}</p>
<Icon className="h-4 w-4 text-muted-foreground" />
</div>
<div className="flex flex-col gap-1">
<div className="text-2xl font-bold">{value}</div>
<p className="text-xs text-muted-foreground">{subValue}</p>
</div>
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,42 @@
import Link from "next/link"
import { Calendar, FilePlus, Mail, MessageSquare, Settings } from "lucide-react"
import { Button } from "@/shared/components/ui/button"
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
interface ClassQuickActionsProps {
classId: string
}
export function ClassQuickActions({ classId }: ClassQuickActionsProps) {
return (
<Card>
<CardHeader>
<CardTitle className="text-base font-semibold">Quick Actions</CardTitle>
</CardHeader>
<CardContent className="grid gap-2">
<Button asChild className="w-full justify-start" size="sm">
<Link href={`/teacher/homework/assignments/create?classId=${encodeURIComponent(classId)}`}>
<FilePlus className="mr-2 h-4 w-4" />
Create Homework
</Link>
</Button>
<Button asChild variant="outline" className="w-full justify-start" size="sm">
<Link href={`/teacher/classes/schedule?classId=${encodeURIComponent(classId)}`}>
<Calendar className="mr-2 h-4 w-4" />
Manage Schedule
</Link>
</Button>
<Button variant="outline" className="w-full justify-start" size="sm" disabled>
<MessageSquare className="mr-2 h-4 w-4" />
Message Class (Coming soon)
</Button>
<Button variant="outline" className="w-full justify-start" size="sm" disabled>
<Settings className="mr-2 h-4 w-4" />
Class Settings
</Button>
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,111 @@
import Link from "next/link"
import { Calendar, ChevronRight, Clock, MapPin } from "lucide-react"
import { Button } from "@/shared/components/ui/button"
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
import { HoverCard, HoverCardContent, HoverCardTrigger } from "@/shared/components/ui/hover-card"
import type { ClassScheduleItem } from "@/modules/classes/types"
interface ClassScheduleWidgetProps {
classId: string
schedule: ClassScheduleItem[]
}
const WEEKDAYS = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]
const WEEKDAY_INDICES = [1, 2, 3, 4, 5, 6, 7] // 1=Mon, 7=Sun
export function ClassScheduleGrid({ schedule, compact = false }: { schedule: ClassScheduleItem[], compact?: boolean }) {
// Group by weekday
const groupedSchedule = schedule.reduce((acc, item) => {
const day = item.weekday
if (!acc[day]) acc[day] = []
acc[day].push(item)
return acc
}, {} as Record<number, ClassScheduleItem[]>)
// Sort items within each day by start time
Object.keys(groupedSchedule).forEach(key => {
groupedSchedule[Number(key)].sort((a, b) => a.startTime.localeCompare(b.startTime))
})
if (schedule.length === 0) {
return (
<div className="flex flex-col items-center justify-center space-y-3 py-6 text-center">
<div className="rounded-full bg-muted p-3">
<Calendar className="h-6 w-6 text-muted-foreground" />
</div>
<p className="text-sm text-muted-foreground">No sessions scheduled.</p>
</div>
)
}
return (
<div className="grid grid-cols-5 gap-1 text-center h-full grid-rows-[auto_1fr]">
{WEEKDAYS.slice(0, 5).map((day, i) => (
<div key={day} className="text-[10px] font-medium text-muted-foreground uppercase py-0.5 border-b bg-muted/20 h-fit">
{day}
</div>
))}
{WEEKDAY_INDICES.slice(0, 5).map((dayNum) => {
const items = groupedSchedule[dayNum] || []
return (
<div key={dayNum} className={`flex flex-col gap-1 py-1 border-r last:border-r-0 border-muted/30 ${compact ? 'max-h-[140px]' : 'min-h-[100px]'}`}>
{items.length === 0 ? (
<div className="flex-1" />
) : (
items.map(item => (
<HoverCard key={item.id}>
<HoverCardTrigger asChild>
<div className="bg-primary/5 text-primary rounded-[2px] p-1 text-[10px] text-left relative hover:bg-primary/10 transition-colors cursor-default leading-tight shrink-0">
<div className="font-semibold truncate">{item.course}</div>
<div className="opacity-70 scale-90 origin-left mt-0.5 whitespace-nowrap">{item.startTime}-{item.endTime}</div>
</div>
</HoverCardTrigger>
<HoverCardContent className="w-48 p-3" align="start" side="top">
<div className="flex flex-col gap-2">
<div className="font-semibold text-sm border-b pb-1 mb-1">{item.course}</div>
<div className="flex items-center gap-2 text-muted-foreground text-xs">
<Clock className="h-3.5 w-3.5 shrink-0" />
<span>{item.startTime} - {item.endTime}</span>
</div>
{item.location && (
<div className="flex items-center gap-2 text-muted-foreground text-xs">
<MapPin className="h-3.5 w-3.5 shrink-0" />
<span>{item.location}</span>
</div>
)}
</div>
</HoverCardContent>
</HoverCard>
))
)}
</div>
)
})}
</div>
)
}
export function ClassScheduleWidget({ classId, schedule }: ClassScheduleWidgetProps) {
return (
<Card className="h-fit">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-base font-semibold">Weekly Schedule</CardTitle>
<Button variant="ghost" size="sm" asChild>
<Link href={`/teacher/classes/schedule?classId=${encodeURIComponent(classId)}`}>
Manage
<ChevronRight className="ml-2 h-4 w-4" />
</Link>
</Button>
</CardHeader>
<CardContent className="pt-4">
<ClassScheduleGrid schedule={schedule} />
<div className="mt-2 text-[10px] text-muted-foreground text-center">
* Showing Mon-Fri schedule
</div>
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,106 @@
import Link from "next/link"
import { ChevronRight, Users } from "lucide-react"
import { Avatar, AvatarFallback, AvatarImage } from "@/shared/components/ui/avatar"
import { Badge } from "@/shared/components/ui/badge"
import { Button } from "@/shared/components/ui/button"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/shared/components/ui/card"
import { formatDate } from "@/shared/lib/utils"
interface StudentSummary {
id: string
name: string
email: string
image?: string | null
status: string
subjectScores?: Record<string, number | null>
}
interface ClassStudentsWidgetProps {
classId: string
students: StudentSummary[]
}
export function ClassStudentsWidget({ classId, students }: ClassStudentsWidgetProps) {
const activeCount = students.filter(s => s.status === "active").length
return (
<Card className="h-fit">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<div className="space-y-1">
<CardTitle className="text-base font-semibold">Students</CardTitle>
<CardDescription>
{activeCount} active students
</CardDescription>
</div>
<Button variant="ghost" size="sm" asChild>
<Link href={`/teacher/classes/students?classId=${encodeURIComponent(classId)}`}>
View All
<ChevronRight className="ml-2 h-4 w-4" />
</Link>
</Button>
</CardHeader>
<CardContent className="pt-4">
{students.length === 0 ? (
<div className="flex flex-col items-center justify-center space-y-3 py-8 text-center">
<div className="rounded-full bg-muted p-3">
<Users className="h-6 w-6 text-muted-foreground" />
</div>
<p className="text-sm text-muted-foreground">No students enrolled yet.</p>
</div>
) : (
<div className="space-y-4">
{students.slice(0, 6).map((student) => (
<div key={student.id} className="flex flex-col gap-2 rounded-lg border p-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<Avatar className="h-8 w-8">
<AvatarImage src={student.image || undefined} alt={student.name} />
<AvatarFallback className="text-xs">
{student.name
.split(" ")
.map((n) => n[0])
.join("")
.toUpperCase()
.slice(0, 2)}
</AvatarFallback>
</Avatar>
<div className="space-y-0.5">
<div className="text-sm font-medium leading-none">{student.name}</div>
<div className="text-xs text-muted-foreground line-clamp-1">{student.email}</div>
</div>
</div>
<Badge
variant={student.status === "active" ? "outline" : "secondary"}
className="text-[10px] capitalize"
>
{student.status}
</Badge>
</div>
{/* Subject Scores */}
{student.subjectScores && Object.keys(student.subjectScores).length > 0 && (
<div className="flex flex-wrap gap-2 pt-1">
{Object.entries(student.subjectScores).map(([subject, score]) => (
<div key={subject} className="flex items-center gap-1.5 rounded bg-muted/50 px-2 py-1 text-[10px]">
<span className="font-medium text-muted-foreground">{subject}</span>
{score !== null ? (
<span className={score >= 60 ? "font-semibold text-primary" : "font-semibold text-destructive"}>
{score}
</span>
) : (
<span className="text-muted-foreground">-</span>
)}
</div>
))}
</div>
)}
</div>
))}
</div>
)}
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,398 @@
"use client"
import { useState, useMemo } from "react"
import { Area, AreaChart, CartesianGrid, Line, LineChart, XAxis, YAxis } from "recharts"
import { ChevronDown } from "lucide-react"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/shared/components/ui/card"
import { ChartConfig, ChartContainer, ChartTooltip, ChartTooltipContent } from "@/shared/components/ui/chart"
import { Tabs, TabsList, TabsTrigger } from "@/shared/components/ui/tabs"
import { cn } from "@/shared/lib/utils"
import { Button } from "@/shared/components/ui/button"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/shared/components/ui/dropdown-menu"
interface AssignmentSummary {
id: string
title: string
status: string
subject?: string | null
isActive: boolean
isOverdue: boolean
dueAt: Date | null
submittedCount: number
targetCount: number
avgScore: number | null
medianScore: number | null
}
interface ClassTrendsWidgetProps {
classId: string
assignments: AssignmentSummary[]
compact?: boolean
className?: string
}
const chartConfig = {
submitted: {
label: "Submitted",
color: "hsl(var(--primary))",
},
target: {
label: "Total Students",
color: "hsl(var(--muted-foreground))",
},
avg: {
label: "Average Score",
color: "hsl(var(--chart-2))",
},
median: {
label: "Median Score",
color: "hsl(var(--chart-4))",
},
} satisfies ChartConfig
export function transformAssignmentsToChartData(assignments: AssignmentSummary[], limit?: number) {
const data = [...assignments].reverse().map(a => ({
title: a.title.length > 10 ? a.title.substring(0, 10) + "..." : a.title,
fullTitle: a.title,
submitted: a.submittedCount,
target: a.targetCount,
avg: a.avgScore ? Math.round(a.avgScore) : null,
median: a.medianScore ? Math.round(a.medianScore) : null,
}))
if (limit) {
return data.slice(-limit)
}
return data
}
export function ClassSubmissionTrendChart({
data,
className
}: {
data: any[]
className?: string
}) {
return (
<ChartContainer config={chartConfig} className={className}>
<LineChart accessibilityLayer data={data} margin={{ top: 5, right: 5, bottom: 0, left: 0 }}>
<CartesianGrid vertical={false} strokeDasharray="3 3" />
<XAxis
dataKey="title"
tickLine={false}
tickMargin={10}
axisLine={false}
fontSize={10}
hide
/>
<YAxis
tickLine={false}
axisLine={false}
fontSize={10}
domain={[0, 'auto']}
tickFormatter={(value) => `${value}`}
hide
/>
<ChartTooltip content={<ChartTooltipContent />} />
<Line
type="monotone"
dataKey="target"
stroke="var(--color-target)"
strokeWidth={2}
strokeDasharray="4 4"
dot={false}
/>
<Line
type="monotone"
dataKey="submitted"
stroke="var(--color-submitted)"
strokeWidth={2}
activeDot={{ r: 6 }}
/>
</LineChart>
</ChartContainer>
)
}
export function ClassTrendsWidget({ classId, assignments, compact, className }: ClassTrendsWidgetProps) {
const [chartTab, setChartTab] = useState<"submission" | "score">("submission")
const [selectedSubject, setSelectedSubject] = useState<string>("all")
// Extract unique subjects
const subjects = Array.from(new Set(assignments.map(a => a.subject).filter(Boolean))) as string[]
const activeAssignments = assignments.filter((a) => {
if (selectedSubject !== "all" && a.subject !== selectedSubject) return false
return a.isActive || a.status === "published" // Include published even if not "active" in terms of due date
})
const chartData = transformAssignmentsToChartData(activeAssignments, 7)
if (chartData.length === 0 && selectedSubject === "all") return null
if (compact) {
// Calculate simple stats for compact view
const lastAssignment = chartData[chartData.length - 1]
let metricValue = "0%"
let metricLabel = "Latest"
if (lastAssignment) {
if (chartTab === "submission") {
metricValue = lastAssignment.target > 0
? `${Math.round((lastAssignment.submitted / lastAssignment.target) * 100)}%`
: "0%"
} else {
metricValue = lastAssignment.avg ? `${lastAssignment.avg}` : "-"
}
}
return (
<div className={`flex flex-col h-full ${className || ""}`}>
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm" className="h-6 gap-1 px-2 text-xs font-semibold text-foreground/80 hover:bg-muted">
{chartTab === "submission" ? "Submission" : "Score"}
<ChevronDown className="h-3 w-3 opacity-50" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
<DropdownMenuItem onClick={() => setChartTab("submission")} className="text-xs">
Submission Trends
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setChartTab("score")} className="text-xs">
Score Trends
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
{subjects.length > 0 && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm" className="h-6 gap-1 px-2 text-xs text-muted-foreground hover:text-foreground">
{selectedSubject === "all" ? "All Subjects" : selectedSubject}
<ChevronDown className="h-3 w-3 opacity-50" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
<DropdownMenuItem onClick={() => setSelectedSubject("all")} className="text-xs">
All Subjects
</DropdownMenuItem>
{subjects.map(s => (
<DropdownMenuItem key={s} onClick={() => setSelectedSubject(s)} className="text-xs">
{s}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
<div className="text-xs font-medium text-muted-foreground">
{metricLabel}: <span className="text-foreground">{metricValue}</span>
</div>
</div>
{/* Compact Sparkline Chart */}
<div className="flex-1 w-full min-h-0">
<ChartContainer config={chartConfig} className="h-full w-full">
{chartTab === "submission" ? (
<AreaChart accessibilityLayer data={chartData} margin={{ top: 5, right: 0, bottom: 0, left: 0 }}>
<defs>
<linearGradient id="fillSubmitted" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="var(--color-submitted)" stopOpacity={0.3}/>
<stop offset="95%" stopColor="var(--color-submitted)" stopOpacity={0.05}/>
</linearGradient>
</defs>
<CartesianGrid vertical={false} strokeDasharray="3 3" horizontal={false} />
<XAxis dataKey="title" hide />
<YAxis hide domain={[0, 'auto']} />
<ChartTooltip
cursor={false}
content={<ChartTooltipContent indicator="dot" hideLabel />}
/>
<Area
type="monotone"
dataKey="submitted"
stroke="var(--color-submitted)"
fill="url(#fillSubmitted)"
strokeWidth={2}
/>
<Line
type="monotone"
dataKey="target"
stroke="var(--color-target)"
strokeWidth={1}
strokeDasharray="2 2"
dot={false}
activeDot={false}
/>
</AreaChart>
) : (
<LineChart accessibilityLayer data={chartData} margin={{ top: 5, right: 0, bottom: 0, left: 0 }}>
<CartesianGrid vertical={false} strokeDasharray="3 3" horizontal={false} />
<XAxis dataKey="title" hide />
<YAxis hide domain={[0, 100]} />
<ChartTooltip
cursor={false}
content={<ChartTooltipContent indicator="dot" />}
/>
<Line
type="monotone"
dataKey="avg"
stroke="var(--color-avg)"
strokeWidth={2}
dot={false}
activeDot={{ r: 4 }}
/>
<Line
type="monotone"
dataKey="median"
stroke="var(--color-median)"
strokeWidth={2}
strokeDasharray="4 4"
dot={false}
activeDot={{ r: 4 }}
/>
</LineChart>
)}
</ChartContainer>
</div>
</div>
)
}
return (
<Card>
<CardHeader>
<div className="flex flex-col gap-4">
<div className="flex items-center justify-between">
<div className="space-y-1">
<CardTitle className="text-base font-semibold">
{chartTab === "submission" ? "Submission Trends" : "Score Trends"}
</CardTitle>
<CardDescription>
{chartTab === "submission" ? "Recent assignment turn-in rates" : "Average vs Median performance"}
</CardDescription>
</div>
<Tabs value={chartTab} onValueChange={(v) => setChartTab(v as "submission" | "score")} className="w-auto">
<TabsList className="grid w-full grid-cols-2 h-8">
<TabsTrigger value="submission" className="text-xs">Submission</TabsTrigger>
<TabsTrigger value="score" className="text-xs">Score</TabsTrigger>
</TabsList>
</Tabs>
</div>
{subjects.length > 0 && (
<Tabs value={selectedSubject} onValueChange={setSelectedSubject} className="w-full">
<TabsList className="h-8 w-auto flex-wrap justify-start bg-transparent p-0">
<TabsTrigger
value="all"
className="h-7 rounded-md border bg-background px-3 text-xs data-[state=active]:bg-muted data-[state=active]:text-foreground"
>
All Subjects
</TabsTrigger>
{subjects.map(s => (
<TabsTrigger
key={s}
value={s}
className="ml-2 h-7 rounded-md border bg-background px-3 text-xs data-[state=active]:bg-muted data-[state=active]:text-foreground"
>
{s}
</TabsTrigger>
))}
</TabsList>
</Tabs>
)}
</div>
</CardHeader>
<CardContent>
{chartData.length > 0 ? (
<ChartContainer config={chartConfig} className="h-[250px] w-full">
{chartTab === "submission" ? (
<LineChart accessibilityLayer data={chartData} margin={{ top: 20, right: 20, bottom: 0, left: 0 }}>
<CartesianGrid vertical={false} strokeDasharray="3 3" />
<XAxis
dataKey="title"
tickLine={false}
tickMargin={10}
axisLine={false}
fontSize={12}
/>
<YAxis
tickLine={false}
axisLine={false}
fontSize={12}
domain={[0, 'auto']}
tickFormatter={(value) => `${value}`}
/>
<ChartTooltip content={<ChartTooltipContent />} />
<Line
type="monotone"
dataKey="target"
stroke="var(--color-target)"
strokeWidth={2}
strokeDasharray="4 4"
dot={false}
/>
<Line
type="monotone"
dataKey="submitted"
stroke="var(--color-submitted)"
strokeWidth={2}
activeDot={{ r: 6 }}
/>
</LineChart>
) : (
<LineChart accessibilityLayer data={chartData} margin={{ top: 20, right: 20, bottom: 0, left: 0 }}>
<CartesianGrid vertical={false} strokeDasharray="3 3" />
<XAxis
dataKey="title"
tickLine={false}
tickMargin={10}
axisLine={false}
fontSize={12}
/>
<YAxis
tickLine={false}
axisLine={false}
fontSize={12}
domain={[0, 100]}
tickFormatter={(value) => `${value}%`}
/>
<ChartTooltip content={<ChartTooltipContent />} />
<Line
type="monotone"
dataKey="avg"
stroke="var(--color-avg)"
strokeWidth={2}
activeDot={{ r: 6 }}
/>
<Line
type="monotone"
dataKey="median"
stroke="var(--color-median)"
strokeWidth={2}
strokeDasharray="4 4"
activeDot={{ r: 6 }}
/>
</LineChart>
)}
</ChartContainer>
) : (
<div className="flex h-[250px] items-center justify-center text-sm text-muted-foreground">
No data for this subject
</div>
)}
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,143 @@
"use client"
import { useState } from "react"
import { useRouter } from "next/navigation"
import { toast } from "sonner"
import { Button } from "@/shared/components/ui/button"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/shared/components/ui/dialog"
import { Input } from "@/shared/components/ui/input"
import { Label } from "@/shared/components/ui/label"
import { updateTeacherClassAction } from "../../actions"
interface EditClassDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
classId: string
initialData: {
name: string
grade: string
homeroom?: string | null
room?: string | null
schoolName?: string | null
}
}
export function EditClassDialog({
open,
onOpenChange,
classId,
initialData,
}: EditClassDialogProps) {
const router = useRouter()
const [isWorking, setIsWorking] = useState(false)
const handleEdit = async (formData: FormData) => {
setIsWorking(true)
try {
const res = await updateTeacherClassAction(classId, null, formData)
if (res.success) {
toast.success(res.message)
onOpenChange(false)
router.refresh()
} else {
toast.error(res.message || "Failed to update class")
}
} catch {
toast.error("Failed to update class")
} finally {
setIsWorking(false)
}
}
return (
<Dialog
open={open}
onOpenChange={(val) => {
if (isWorking) return
onOpenChange(val)
}}
>
<DialogContent className="sm:max-w-[480px]">
<DialogHeader>
<DialogTitle>Edit class</DialogTitle>
<DialogDescription>Update basic class information.</DialogDescription>
</DialogHeader>
<form action={handleEdit}>
<div className="grid gap-4 py-4">
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="schoolName" className="text-right">
School
</Label>
<Input
id="schoolName"
name="schoolName"
className="col-span-3"
defaultValue={initialData.schoolName ?? ""}
placeholder="Optional"
/>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="name" className="text-right">
Name
</Label>
<Input
id="name"
name="name"
className="col-span-3"
defaultValue={initialData.name}
required
/>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="grade" className="text-right">
Grade
</Label>
<Input
id="grade"
name="grade"
className="col-span-3"
defaultValue={initialData.grade}
required
/>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="homeroom" className="text-right">
Homeroom
</Label>
<Input
id="homeroom"
name="homeroom"
className="col-span-3"
defaultValue={initialData.homeroom ?? ""}
/>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="room" className="text-right">
Room
</Label>
<Input
id="room"
name="room"
className="col-span-3"
defaultValue={initialData.room ?? ""}
/>
</div>
</div>
<DialogFooter>
<Button type="submit" disabled={isWorking}>
{isWorking ? "Saving..." : "Save Changes"}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
)
}

View File

@@ -1,40 +0,0 @@
"use client"
import { useQueryState, parseAsString } from "nuqs"
import { X } from "lucide-react"
import { Button } from "@/shared/components/ui/button"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/shared/components/ui/select"
import type { TeacherClass } from "../types"
export function InsightsFilters({ classes }: { classes: TeacherClass[] }) {
const [classId, setClassId] = useQueryState("classId", parseAsString.withDefault("all"))
return (
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
<div className="flex items-center gap-2">
<Select value={classId} onValueChange={(val) => setClassId(val === "all" ? null : val)}>
<SelectTrigger className="w-[240px]">
<SelectValue placeholder="Class" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Select a class</SelectItem>
{classes.map((c) => (
<SelectItem key={c.id} value={c.id}>
{c.name}
</SelectItem>
))}
</SelectContent>
</Select>
{classId !== "all" && (
<Button variant="ghost" onClick={() => setClassId(null)} className="h-8 px-2 lg:px-3">
Reset
<X className="ml-2 h-4 w-4" />
</Button>
)}
</div>
</div>
)
}

View File

@@ -4,44 +4,21 @@ import Link from "next/link"
import { useMemo, useState } from "react"
import { useRouter } from "next/navigation"
import {
Calendar,
Copy,
MoreHorizontal,
Pencil,
Plus,
RefreshCw,
Search,
Trash2,
Copy,
Users,
GraduationCap,
MapPin,
ChartBar,
GraduationCap,
Search,
} from "lucide-react"
import { toast } from "sonner"
import { parseAsString, useQueryState } from "nuqs"
import { Card, CardContent, CardFooter, CardHeader, CardTitle } from "@/shared/components/ui/card"
import { Button } from "@/shared/components/ui/button"
import { Badge } from "@/shared/components/ui/badge"
import { EmptyState } from "@/shared/components/ui/empty-state"
import { cn } from "@/shared/lib/utils"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/shared/components/ui/dropdown-menu"
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/shared/components/ui/alert-dialog"
import {
Dialog,
DialogContent,
@@ -53,15 +30,11 @@ import {
} from "@/shared/components/ui/dialog"
import { Input } from "@/shared/components/ui/input"
import { Label } from "@/shared/components/ui/label"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/shared/components/ui/select"
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/shared/components/ui/tooltip"
import type { TeacherClass } from "../types"
import type { TeacherClass, ClassScheduleItem } from "../types"
import {
createTeacherClassAction,
deleteTeacherClassAction,
ensureClassInvitationCodeAction,
regenerateClassInvitationCodeAction,
updateTeacherClassAction,
joinClassByInvitationCodeAction,
} from "../actions"
@@ -82,26 +55,6 @@ export function MyClassesGrid({ classes, canCreateClass }: { classes: TeacherCla
const [isWorking, setIsWorking] = useState(false)
const [joinOpen, setJoinOpen] = useState(false)
const [q, setQ] = useQueryState("q", parseAsString.withDefault(""))
const [grade, setGrade] = useQueryState("grade", parseAsString.withDefault("all"))
const gradeOptions = useMemo(() => {
const set = new Set<string>()
for (const c of classes) set.add(c.grade)
return Array.from(set).sort((a, b) => a.localeCompare(b))
}, [classes])
const filteredClasses = useMemo(() => {
const needle = q.trim().toLowerCase()
return classes.filter((c) => {
const gradeOk = grade === "all" ? true : c.grade === grade
const qOk = needle.length === 0 ? true : c.name.toLowerCase().includes(needle)
return gradeOk && qOk
})
}, [classes, grade, q])
const defaultGrade = useMemo(() => (grade !== "all" ? grade : classes[0]?.grade ?? ""), [classes, grade])
const handleJoin = async (formData: FormData) => {
setIsWorking(true)
try {
@@ -123,45 +76,8 @@ export function MyClassesGrid({ classes, canCreateClass }: { classes: TeacherCla
return (
<div className="space-y-6">
{/* Filter Bar */}
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div className="flex flex-1 items-center gap-2">
<div className="relative flex-1 md:max-w-[320px]">
<Search className="absolute left-2.5 top-2.5 size-4 text-muted-foreground" />
<Input
placeholder="Search classes..."
value={q}
onChange={(e) => setQ(e.target.value || null)}
className="pl-9 bg-background"
/>
</div>
<Select value={grade} onValueChange={(v) => setGrade(v === "all" ? null : v)}>
<SelectTrigger className="w-[160px] bg-background">
<SelectValue placeholder="All Grades" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Grades</SelectItem>
{gradeOptions.map((g) => (
<SelectItem key={g} value={g}>
{g}
</SelectItem>
))}
</SelectContent>
</Select>
{(q || grade !== "all") && (
<Button
variant="ghost"
size="icon"
onClick={() => {
setQ(null)
setGrade(null)
}}
title="Clear filters"
>
<RefreshCw className="size-4" />
</Button>
)}
</div>
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-end">
<div className="relative">
<Dialog
open={joinOpen}
onOpenChange={(open) => {
@@ -170,35 +86,66 @@ export function MyClassesGrid({ classes, canCreateClass }: { classes: TeacherCla
}}
>
<DialogTrigger asChild>
<Button className="gap-2 shadow-sm" disabled={isWorking}>
<Plus className="size-4" />
Join Class
<div className="group relative">
{/* Decorative Ticket Stub Effect */}
<div className="absolute -inset-0.5 bg-gradient-to-r from-primary/20 to-secondary/20 rounded-lg blur opacity-30 group-hover:opacity-60 transition duration-500"></div>
<Button className="relative gap-2 h-10 px-5 shadow-sm border border-primary/10 hover:shadow-md transition-all bg-background text-foreground hover:bg-muted/50" disabled={isWorking} variant="outline">
<div className="flex items-center justify-center w-5 h-5 rounded-full bg-primary/10 text-primary group-hover:scale-110 transition-transform duration-300">
<Plus className="size-3.5" strokeWidth={3} />
</div>
<span className="font-semibold tracking-tight">Join New Class</span>
</Button>
</div>
</DialogTrigger>
<DialogContent className="sm:max-w-[480px]">
<DialogHeader>
<DialogTitle>Join Class</DialogTitle>
<DialogDescription>Enter the invitation code to join a class.</DialogDescription>
<DialogContent className="sm:max-w-[480px] p-0 overflow-hidden gap-0 border-none shadow-2xl">
{/* Header with Pattern */}
<div className="relative bg-primary/5 p-6 border-b border-border/50">
<div className="absolute inset-0 opacity-[0.03]" style={{ backgroundImage: 'radial-gradient(#000 1px, transparent 1px)', backgroundSize: '12px 12px' }}></div>
<DialogHeader className="relative z-10">
<DialogTitle className="text-xl font-bold flex items-center gap-2">
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-primary text-primary-foreground shadow-sm">
<Plus className="size-5" />
</div>
Join a Class
</DialogTitle>
<DialogDescription className="text-muted-foreground mt-1.5">
Enter the 6-digit invitation code provided by your administrator.
</DialogDescription>
</DialogHeader>
<form action={handleJoin}>
<div className="grid gap-4 py-4">
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="join-code" className="text-right">
Code
</div>
<form action={handleJoin} className="bg-card">
<div className="p-6 space-y-6">
<div className="space-y-3">
<Label htmlFor="join-code" className="text-sm font-medium">
Invitation Code
</Label>
<div className="relative">
<Input
id="join-code"
name="code"
className="col-span-3"
className="h-12 text-center text-2xl font-mono tracking-[0.5em] font-bold uppercase placeholder:tracking-normal placeholder:font-sans placeholder:text-base placeholder:font-normal"
placeholder="e.g. 123456"
required
maxLength={6}
pattern="\d{6}"
autoComplete="off"
autoFocus
/>
<div className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground/30 pointer-events-none">
<Users className="size-5" />
</div>
</div>
<DialogFooter>
<Button type="submit" disabled={isWorking}>
<p className="text-xs text-muted-foreground">
Ask your administrator for the code if you don't have one.
</p>
</div>
</div>
<DialogFooter className="p-6 pt-2 bg-muted/5 border-t border-border/50">
<Button type="button" variant="ghost" onClick={() => setJoinOpen(false)} disabled={isWorking}>
Cancel
</Button>
<Button type="submit" disabled={isWorking} className="min-w-[100px]">
{isWorking ? "Joining..." : "Join Class"}
</Button>
</DialogFooter>
@@ -206,34 +153,21 @@ export function MyClassesGrid({ classes, canCreateClass }: { classes: TeacherCla
</DialogContent>
</Dialog>
</div>
</div>
{/* Grid */}
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{/* List */}
<div className="flex flex-col gap-4">
{classes.length === 0 ? (
<EmptyState
title="No classes yet"
description="Join a class to start managing students and schedules."
icon={Users}
action={{ label: "Join class", onClick: () => setJoinOpen(true) }}
className="h-[360px] bg-card border-dashed sm:col-span-2 lg:col-span-3 xl:col-span-4"
/>
) : filteredClasses.length === 0 ? (
<EmptyState
title="No classes match your filters"
description="Try clearing filters or adjusting keywords."
icon={Search}
action={{
label: "Clear filters",
onClick: () => {
setQ(null)
setGrade(null)
},
}}
className="h-[360px] bg-card border-dashed sm:col-span-2 lg:col-span-3 xl:col-span-4"
className="h-[360px] bg-card border-dashed"
/>
) : (
filteredClasses.map((c) => (
<ClassCard key={c.id} c={c} onWorkingChange={setIsWorking} isWorking={isWorking} />
classes.map((c) => (
<ClassTicket key={c.id} c={c} onWorkingChange={setIsWorking} isWorking={isWorking} />
))
)}
</div>
@@ -241,7 +175,12 @@ export function MyClassesGrid({ classes, canCreateClass }: { classes: TeacherCla
)
}
function ClassCard({
import { ClassScheduleGrid } from "./class-detail/class-schedule-widget"
import { ClassTrendsWidget } from "./class-detail/class-trends-widget"
// Removed MiniSchedule since we're using ClassScheduleGrid now
function ClassTicket({
c,
isWorking,
onWorkingChange,
@@ -251,8 +190,6 @@ function ClassCard({
onWorkingChange: (v: boolean) => void
}) {
const router = useRouter()
const [showEdit, setShowEdit] = useState(false)
const [showDelete, setShowDelete] = useState(false)
const handleEnsureCode = async () => {
onWorkingChange(true)
@@ -299,277 +236,160 @@ function ClassCard({
}
}
const handleEdit = async (formData: FormData) => {
onWorkingChange(true)
try {
const res = await updateTeacherClassAction(c.id, null, formData)
if (res.success) {
toast.success(res.message)
setShowEdit(false)
router.refresh()
} else {
toast.error(res.message || "Failed to update class")
}
} catch {
toast.error("Failed to update class")
} finally {
onWorkingChange(false)
}
}
// Real data for chart
const recentAssignments = c.recentAssignments ?? []
const handleDelete = async () => {
onWorkingChange(true)
try {
const res = await deleteTeacherClassAction(c.id)
if (res.success) {
toast.success(res.message)
setShowDelete(false)
router.refresh()
} else {
toast.error(res.message || "Failed to delete class")
}
} catch {
toast.error("Failed to delete class")
} finally {
onWorkingChange(false)
}
}
// Calculate performance change for indicator (still needed for the top indicator)
// We can't reuse chart data easily here without recalculating, but ClassTrendsWidget handles its own data now
const lastTwoAssignments = [...recentAssignments].reverse().slice(-2)
const performanceChange = lastTwoAssignments.length === 2 && lastTwoAssignments[0].submittedCount > 0
? ((lastTwoAssignments[1].submittedCount - lastTwoAssignments[0].submittedCount) / lastTwoAssignments[0].submittedCount) * 100
: 0
const isPositive = performanceChange >= 0
return (
<Card className={cn("group flex flex-col transition-all hover:shadow-md", getClassGradient(c.id))}>
<CardHeader className="relative pb-3">
<div className="flex items-start justify-between gap-3">
<div className="space-y-1">
<CardTitle className="line-clamp-1 text-lg font-bold leading-none tracking-tight">
<Link href={`/teacher/classes/my/${encodeURIComponent(c.id)}`} className="hover:underline">
<div className="group relative flex w-full overflow-hidden rounded-xl border bg-card shadow-sm transition-all hover:shadow-md">
{/* Realistic Paper Texture & Noise */}
<div className="absolute inset-0 pointer-events-none opacity-[0.02]" style={{ backgroundImage: 'url("data:image/svg+xml,%3Csvg viewBox=\'0 0 200 200\' xmlns=\'http://www.w3.org/2000/svg\'%3E%3Cfilter id=\'noiseFilter\'%3E%3CfeTurbulence type=\'fractalNoise\' baseFrequency=\'0.65\' numOctaves=\'3\' stitchTiles=\'stitch\'/%3E%3C/filter%3E%3Crect width=\'100%25\' height=\'100%25\' filter=\'url(%23noiseFilter)\'/%3E%3C/svg%3E")' }}></div>
<div className="absolute inset-0 pointer-events-none opacity-[0.03]" style={{ backgroundImage: 'radial-gradient(#000 1px, transparent 1px)', backgroundSize: '16px 16px' }}></div>
{/* Decorative Barcode Strip */}
<div className="absolute left-0 top-0 bottom-0 w-1.5 bg-primary/10 flex flex-col justify-between py-2 pointer-events-none">
{Array.from({ length: 20 }).map((_, i) => (
<div key={i} className="w-full h-px bg-primary/20" style={{ marginBottom: Math.random() * 8 + 2 + 'px' }}></div>
))}
</div>
{/* Left Section: Basic Info (Narrower) */}
<div className="flex w-full flex-col justify-between p-5 pl-7 sm:w-[320px] sm:flex-shrink-0 relative z-10 border-r border-dashed border-muted-foreground/20">
{/* Punch Hole Effect Top-Left */}
<div className="absolute -left-2 -top-2 h-6 w-6 rounded-full bg-background border border-border shadow-[inset_1px_1px_2px_rgba(0,0,0,0.1)] z-20"></div>
<div className="flex flex-col gap-4">
<div className="flex items-center gap-3">
<div className="flex h-12 w-12 items-center justify-center rounded-lg bg-primary/5 text-xl font-bold text-primary shadow-sm border border-primary/10">
{c.grade.replace(/[^0-9]/g, '')}
</div>
<div className="flex flex-col">
<Link href={`/teacher/classes/my/${encodeURIComponent(c.id)}`} className="text-lg font-bold hover:underline tracking-tight line-clamp-1">
{c.name}
</Link>
</CardTitle>
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Badge variant="secondary" className="h-5 px-1.5 font-medium">
{c.grade}
</Badge>
{c.homeroom && (
<Badge variant="outline" className="h-5 border-dashed bg-transparent px-1.5 font-normal">
{c.homeroom}
<Badge variant="secondary" className="w-fit font-normal text-xs bg-muted/50 font-mono tracking-tight">
{c.grade} {c.id.slice(-4).toUpperCase()}
</Badge>
</div>
</div>
<div className="space-y-2 text-sm text-muted-foreground">
<div className="flex items-center gap-2">
<Users className="size-4 text-muted-foreground/70" />
<span className="font-medium text-foreground/80">{c.studentCount}</span> Students
</div>
<div className="flex items-center gap-2">
<MapPin className="size-4 text-muted-foreground/70" />
<span className="font-medium text-foreground/80">{c.room || "No Room"}</span>
</div>
{c.schoolName && (
<div className="flex items-center gap-2">
<GraduationCap className="size-4 text-muted-foreground/70" />
<span className="line-clamp-1">{c.schoolName}</span>
</div>
)}
</div>
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-8 w-8 -mr-2" disabled={isWorking}>
<MoreHorizontal className="size-4" />
<span className="sr-only">Actions</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => setShowEdit(true)}>
<Pencil className="mr-2 size-4" />
Edit Class
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
className="text-destructive focus:text-destructive"
onClick={() => setShowDelete(true)}
>
<Trash2 className="mr-2 size-4" />
Delete Class
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</CardHeader>
<CardContent className="flex-1 pb-4">
<div className="grid grid-cols-2 gap-4 text-sm">
<div className="flex flex-col gap-1">
<span className="text-xs text-muted-foreground">Students</span>
<div className="flex items-center gap-1.5 font-medium">
<Users className="size-3.5 text-muted-foreground" />
{c.studentCount}
{/* Invitation Code Section */}
<div className="mt-6 pt-4 border-t border-dashed border-border relative">
{/* Tiny Cut marks */}
<div className="absolute -left-5 top-[-1px] w-2 h-[2px] bg-border"></div>
<div className="absolute -right-5 top-[-1px] w-2 h-[2px] bg-border"></div>
<div className="flex flex-col gap-1.5">
<div className="flex items-center justify-between">
<span className="text-[10px] uppercase text-muted-foreground font-semibold tracking-wider">Entry Pass</span>
<div className="flex gap-0.5">
{Array.from({ length: 5 }).map((_, i) => (
<div key={i} className="w-0.5 h-2 bg-muted-foreground/20"></div>
))}
</div>
</div>
<div className="flex flex-col gap-1">
<span className="text-xs text-muted-foreground">Room</span>
<div className="flex items-center gap-1.5 font-medium">
<MapPin className="size-3.5 text-muted-foreground" />
{c.room || "—"}
</div>
<div className="flex items-center justify-between gap-2 bg-muted/30 px-3 py-1.5 rounded-sm border border-dashed border-muted-foreground/30 relative overflow-hidden">
<span className="font-mono text-lg font-bold tracking-widest text-foreground z-10">{c.invitationCode || "—"}</span>
{/* Faint QR Code Placeholder Background */}
<div className="absolute right-10 top-1/2 -translate-y-1/2 opacity-[0.03]">
<div className="w-8 h-8 bg-current grid grid-cols-4 grid-rows-4 gap-px">
{Array.from({ length: 16 }).map((_, i) => (
<div key={i} className={cn("bg-transparent", Math.random() > 0.5 && "bg-black")}></div>
))}
</div>
</div>
<div className="mt-4 flex items-center justify-between rounded-md border bg-background/50 p-2">
<div className="flex flex-col">
<span className="text-[10px] uppercase text-muted-foreground">Invite Code</span>
<span className="font-mono text-sm font-medium tracking-wider">{c.invitationCode || "—"}</span>
</div>
<div className="flex items-center gap-1">
{c.invitationCode ? (
<TooltipProvider delayDuration={0}>
<Tooltip>
<TooltipTrigger asChild>
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={handleCopyCode} disabled={isWorking}>
<div className="flex gap-1 z-10">
<Button variant="ghost" size="icon" className="h-7 w-7 hover:bg-muted" onClick={handleCopyCode} title="Copy">
<Copy className="size-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent>Copy Code</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={handleRegenerateCode}
disabled={isWorking}
>
<Button variant="ghost" size="icon" className="h-7 w-7 hover:bg-muted" onClick={handleRegenerateCode} title="Regenerate">
<RefreshCw className="size-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent>Regenerate</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
) : (
<Button variant="outline" size="sm" className="h-7 text-xs" onClick={handleEnsureCode} disabled={isWorking}>
<Button variant="outline" size="sm" className="h-7 text-xs z-10" onClick={handleEnsureCode}>
Generate
</Button>
)}
</div>
</div>
</CardContent>
</div>
</div>
<CardFooter className="grid grid-cols-3 gap-2 border-t p-2">
<Button asChild variant="ghost" size="sm" className="h-8 w-full justify-center px-0 text-xs">
<Link href={`/teacher/classes/students?classId=${encodeURIComponent(c.id)}`}>
<Users className="mr-1.5 size-3.5" />
Students
</Link>
</Button>
<Button asChild variant="ghost" size="sm" className="h-8 w-full justify-center px-0 text-xs">
<Link href={`/teacher/classes/schedule?classId=${encodeURIComponent(c.id)}`}>
<Calendar className="mr-1.5 size-3.5" />
Schedule
</Link>
</Button>
<Button asChild variant="ghost" size="sm" className="h-8 w-full justify-center px-0 text-xs">
<Link href={`/teacher/classes/insights?classId=${encodeURIComponent(c.id)}`}>
<ChartBar className="mr-1.5 size-3.5" />
Insights
</Link>
</Button>
</CardFooter>
{/* Dashed Divider (Ticket perforation) */}
<div className="relative hidden w-4 flex-col items-center justify-center sm:flex -ml-2 z-20">
<div className="absolute -top-2 h-4 w-4 rounded-full bg-background border border-border shadow-[inset_0_-1px_1px_rgba(0,0,0,0.05)]" />
<div className="h-full w-px border-l-2 border-dashed border-muted-foreground/20 relative">
{/* Scissor Icon */}
<div className="absolute top-1/2 -left-[5px] -translate-y-1/2 text-muted-foreground/20 -rotate-90 text-[10px]"></div>
</div>
<div className="absolute -bottom-2 h-4 w-4 rounded-full bg-background border border-border shadow-[inset_0_1px_1px_rgba(0,0,0,0.05)]" />
</div>
{/* Dialogs */}
<Dialog
open={showEdit}
onOpenChange={(open) => {
if (isWorking) return
setShowEdit(open)
}}
>
<DialogContent className="sm:max-w-[480px]">
<DialogHeader>
<DialogTitle>Edit class</DialogTitle>
<DialogDescription>Update basic class information.</DialogDescription>
</DialogHeader>
<form action={handleEdit}>
<div className="grid gap-4 py-4">
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor={`edit-school-name-${c.id}`} className="text-right">
School
</Label>
<Input
id={`edit-school-name-${c.id}`}
name="schoolName"
className="col-span-3"
defaultValue={c.schoolName ?? ""}
placeholder="Optional"
/>
{/* Right Section: Stats & Actions (Wider) */}
<div className="flex flex-1 flex-col bg-muted/5 p-6 relative z-10">
<div className="flex flex-1 gap-6">
{/* Left: Submission Trends */}
<div className="flex-1 flex flex-col gap-4 min-w-0">
<div className="flex items-center justify-between">
<h4 className="text-sm font-semibold text-foreground/80">Submission Trends</h4>
<span className={cn(
"text-xs font-bold px-2 py-0.5 rounded-full border flex items-center gap-1",
isPositive
? "text-emerald-600 bg-emerald-50 border-emerald-100"
: "text-red-600 bg-red-50 border-red-100"
)}>
{isPositive ? "+" : ""}{Math.round(performanceChange)}% <span className={cn("font-normal opacity-70 hidden sm:inline")}>vs last week</span>
</span>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor={`edit-name-${c.id}`} className="text-right">
Name
</Label>
<Input
id={`edit-name-${c.id}`}
name="name"
className="col-span-3"
defaultValue={c.name}
required
/>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor={`edit-grade-${c.id}`} className="text-right">
Grade
</Label>
<Input
id={`edit-grade-${c.id}`}
name="grade"
className="col-span-3"
defaultValue={c.grade}
required
/>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor={`edit-homeroom-${c.id}`} className="text-right">
Homeroom
</Label>
<Input
id={`edit-homeroom-${c.id}`}
name="homeroom"
className="col-span-3"
defaultValue={c.homeroom ?? ""}
/>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor={`edit-room-${c.id}`} className="text-right">
Room
</Label>
<Input
id={`edit-room-${c.id}`}
name="room"
className="col-span-3"
defaultValue={c.room ?? ""}
/>
</div>
</div>
<DialogFooter>
<Button type="submit" disabled={isWorking}>
{isWorking ? "Saving..." : "Save Changes"}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
<AlertDialog
open={showDelete}
onOpenChange={(open) => {
if (isWorking) return
setShowDelete(open)
}}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete class?</AlertDialogTitle>
<AlertDialogDescription>
This will permanently delete <span className="font-medium text-foreground">{c.name}</span> and remove all
enrollments.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isWorking}>Cancel</AlertDialogCancel>
<AlertDialogAction
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
onClick={handleDelete}
disabled={isWorking}
>
{isWorking ? "Deleting..." : "Delete Class"}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</Card>
{/* Real Chart */}
<div className="h-[140px] w-full">
<ClassTrendsWidget
classId={c.id}
assignments={recentAssignments}
compact
className="h-full w-full"
/>
</div>
</div>
{/* Right: Weekly Schedule */}
<div className="flex-1 flex flex-col gap-4 border-l border-dashed border-muted-foreground/20 pl-6 min-w-0">
<div className="h-[170px] w-full overflow-y-auto pr-1">
<ClassScheduleGrid schedule={c.schedule ?? []} compact />
</div>
</div>
</div>
</div>
</div>
)
}

View File

@@ -3,7 +3,7 @@
import { useEffect, useMemo, useState } from "react"
import { useRouter } from "next/navigation"
import { useQueryState, parseAsString } from "nuqs"
import { Plus, X } from "lucide-react"
import { Plus } from "lucide-react"
import { toast } from "sonner"
import { Button } from "@/shared/components/ui/button"
@@ -29,7 +29,7 @@ import type { TeacherClass } from "../types"
import { createClassScheduleItemAction } from "../actions"
export function ScheduleFilters({ classes }: { classes: TeacherClass[] }) {
const [classId, setClassId] = useQueryState("classId", parseAsString.withDefault("all"))
const [classId, setClassId] = useQueryState("classId", parseAsString.withDefault("all").withOptions({ shallow: false }))
const router = useRouter()
const [open, setOpen] = useState(false)
@@ -64,33 +64,29 @@ export function ScheduleFilters({ classes }: { classes: TeacherClass[] }) {
}
}
const selectedClass = classes.find((c) => c.id === classId)
const title = selectedClass ? selectedClass.name : "All Classes"
return (
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
<div className="relative flex items-center justify-between py-2">
<div className="flex items-center gap-2">
<Select value={classId} onValueChange={(val) => setClassId(val === "all" ? null : val)}>
<SelectTrigger className="w-[240px]">
<SelectValue placeholder="Class" />
<Select value={classId} onValueChange={(val) => setClassId(val === "all" ? "all" : val)}>
<SelectTrigger className="h-8 w-[180px] text-xs bg-transparent border-none shadow-none hover:bg-muted/50 focus:ring-0 text-muted-foreground hover:text-foreground">
<SelectValue placeholder="All Classes" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Classes</SelectItem>
<SelectItem value="all" className="text-xs">All Classes</SelectItem>
{classes.map((c) => (
<SelectItem key={c.id} value={c.id}>
<SelectItem key={c.id} value={c.id} className="text-xs">
{c.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{classId !== "all" && (
<Button
variant="ghost"
onClick={() => setClassId(null)}
className="h-8 px-2 lg:px-3"
>
Reset
<X className="ml-2 h-4 w-4" />
</Button>
)}
<div className="absolute left-1/2 -translate-x-1/2 text-sm font-medium text-muted-foreground">
{title}
</div>
<Dialog
@@ -101,9 +97,13 @@ export function ScheduleFilters({ classes }: { classes: TeacherClass[] }) {
}}
>
<DialogTrigger asChild>
<Button className="gap-2" disabled={classes.length === 0}>
<Plus className="size-4" />
Add item
<Button
className="h-8 gap-1.5 text-xs px-3 shadow-none border-transparent bg-transparent text-muted-foreground hover:text-foreground hover:bg-muted/50"
disabled={classes.length === 0}
variant="ghost"
>
<Plus className="size-3.5" />
Add Event
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-[560px]">

View File

@@ -151,88 +151,145 @@ export function ScheduleView({
}
}
const getPositionStyle = (startTime: string, endTime: string) => {
// Range 8:00 (480 min) -> 18:00 (1080 min)
// Total duration: 600 min
const startParts = startTime.split(':').map(Number)
const endParts = endTime.split(':').map(Number)
const startMinutes = startParts[0] * 60 + startParts[1]
const endMinutes = endParts[0] * 60 + endParts[1]
const minTime = 8 * 60
const maxTime = 18 * 60
const totalDuration = maxTime - minTime
// Calculate percentage positions
const top = Math.max(0, ((startMinutes - minTime) / totalDuration) * 100)
const height = Math.min(100 - top, ((endMinutes - startMinutes) / totalDuration) * 100)
return {
top: `${top}%`,
height: `${height}%`,
}
}
const HOURS = Array.from({ length: 11 }, (_, i) => 8 + i) // 8, 9, ..., 18
// Predefined colors for different subjects to add visual variety
const getSubjectColor = (subject: string) => {
const s = subject.toLowerCase()
if (s.includes('math')) return 'bg-blue-500/10 text-blue-700 border-blue-500/20 hover:bg-blue-500/20'
if (s.includes('physics') || s.includes('science')) return 'bg-purple-500/10 text-purple-700 border-purple-500/20 hover:bg-purple-500/20'
if (s.includes('english') || s.includes('lit')) return 'bg-amber-500/10 text-amber-700 border-amber-500/20 hover:bg-amber-500/20'
if (s.includes('history') || s.includes('geo')) return 'bg-orange-500/10 text-orange-700 border-orange-500/20 hover:bg-orange-500/20'
if (s.includes('art') || s.includes('music')) return 'bg-pink-500/10 text-pink-700 border-pink-500/20 hover:bg-pink-500/20'
if (s.includes('sport') || s.includes('pe')) return 'bg-emerald-500/10 text-emerald-700 border-emerald-500/20 hover:bg-emerald-500/20'
return 'bg-primary/10 text-primary border-primary/20 hover:bg-primary/20'
}
return (
<div className="grid gap-6 md:grid-cols-2 xl:grid-cols-3">
{WEEKDAYS.map((d) => {
const items = byDay.get(d.key) ?? []
return (
<Card key={d.key} className="shadow-none">
<CardHeader className="flex flex-row items-center justify-between space-y-0">
<div className="flex items-center gap-2">
<CardTitle className="text-base">{d.label}</CardTitle>
<Badge variant="secondary" className={cn(items.length === 0 && "opacity-60")}>
{items.length} items
</Badge>
</div>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
disabled={classes.length === 0}
onClick={() => {
setCreateWeekday(d.key)
setCreateOpen(true)
}}
<div className="h-[600px] flex flex-col">
<div className="flex h-full">
{/* Time Axis */}
<div className="w-14 flex-shrink-0 flex flex-col">
<div className="h-10" /> {/* Header spacer */}
<div className="flex-1 relative">
{HOURS.map((h, i) => (
<div
key={h}
className="absolute w-full text-right pr-3 text-[11px] text-muted-foreground/60 font-medium -translate-y-1/2 font-mono"
style={{ top: `${(i / 10) * 100}%` }}
>
<Plus className="size-4" />
</Button>
</CardHeader>
<CardContent>
{items.length === 0 ? (
<div className="text-muted-foreground text-sm">No classes scheduled.</div>
) : (
<div className="space-y-4">
{items.map((item) => (
<div key={item.id} className="space-y-1 border-b pb-4 last:border-0 last:pb-0">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<div className="font-medium leading-none">{item.course}</div>
{h}:00
</div>
<div className="flex items-center gap-2">
<Badge variant="outline">{classNameById.get(item.classId) ?? "Class"}</Badge>
))}
</div>
</div>
{/* Days Columns */}
<div className="flex-1 grid grid-cols-5">
{WEEKDAYS.slice(0, 5).map((d) => (
<div key={d.key} className="flex flex-col h-full min-w-0">
<div className="flex items-center justify-center py-2 h-10 group">
<span className="text-xs font-semibold text-muted-foreground group-hover:text-foreground transition-colors uppercase tracking-wider">{d.label}</span>
</div>
<div className="relative h-full mx-1">
{/* Subtle vertical guideline */}
<div className="absolute left-0 top-0 bottom-0 w-px bg-border/30" />
{(byDay.get(d.key) ?? []).map((item) => (
<div
key={item.id}
className="group absolute w-full px-1 z-10"
style={getPositionStyle(item.startTime, item.endTime)}
>
<div className={cn(
"rounded-md p-2 text-xs text-left relative transition-all cursor-default leading-tight h-full border overflow-hidden shadow-sm hover:shadow-md flex flex-col justify-center",
getSubjectColor(item.course)
)}>
<div className="flex justify-between items-start gap-1">
<div className="min-w-0 flex-1 flex flex-col gap-0.5">
<div className="font-bold truncate text-[11px] leading-none tracking-tight">{item.course}</div>
<div className="opacity-80 scale-95 origin-left whitespace-nowrap tabular-nums text-[10px] font-medium leading-none font-mono">
{item.startTime} - {item.endTime}
</div>
<div className="opacity-70 scale-95 origin-left truncate text-[9px] leading-none mt-0.5 font-medium">
{classNameById.get(item.classId)}
</div>
</div>
<div className="opacity-0 group-hover:opacity-100 transition-opacity absolute top-1 right-1">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-8 w-8" disabled={isWorking}>
<MoreHorizontal className="size-4" />
<Button variant="ghost" size="icon" className="h-5 w-5 hover:bg-background/20 p-0" disabled={isWorking}>
<MoreHorizontal className="h-3.5 w-3.5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => setEditItem(item)}>
<Pencil className="mr-2 size-4" />
<DropdownMenuContent align="end" className="w-32">
<DropdownMenuItem onClick={() => setEditItem(item)} className="text-xs">
<Pencil className="mr-2 h-3 w-3" />
Edit
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
className="text-destructive focus:text-destructive"
className="text-xs text-destructive focus:text-destructive"
onClick={() => setDeleteItem(item)}
>
<Trash2 className="mr-2 size-4" />
<Trash2 className="mr-2 h-3 w-3" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
<div className="text-muted-foreground flex items-center gap-3 text-sm">
<span className="inline-flex items-center gap-1 tabular-nums">
<Clock className="h-3.5 w-3.5" />
{item.startTime}{item.endTime}
</span>
{item.location ? (
<span className="inline-flex items-center gap-1">
<MapPin className="h-3.5 w-3.5" />
{item.location}
</span>
) : null}
</div>
</div>
))}
{/* Add Button Overlay - Only visible on hover of the column */}
<div className="absolute inset-0 opacity-0 hover:opacity-100 transition-opacity pointer-events-none">
<div className="absolute top-2 right-2 pointer-events-auto">
<Button
variant="secondary"
size="icon"
className="h-6 w-6 rounded-full shadow-sm bg-background/80 backdrop-blur-sm hover:bg-primary hover:text-primary-foreground transition-all"
disabled={classes.length === 0}
onClick={() => {
setCreateWeekday(d.key)
setCreateOpen(true)
}}
>
<Plus className="h-3 w-3" />
</Button>
</div>
</div>
</div>
</div>
))}
</div>
)}
</CardContent>
</Card>
)
})}
</div>
<Dialog
open={createOpen}
@@ -311,7 +368,7 @@ export function ScheduleView({
</Dialog>
<Dialog
open={Boolean(editItem)}
open={!!editItem}
onOpenChange={(v) => {
if (isWorking) return
if (!v) setEditItem(null)
@@ -320,9 +377,8 @@ export function ScheduleView({
<DialogContent className="sm:max-w-[560px]">
<DialogHeader>
<DialogTitle>Edit schedule item</DialogTitle>
<DialogDescription>Update this schedule entry.</DialogDescription>
<DialogDescription>Update class schedule entry.</DialogDescription>
</DialogHeader>
{editItem ? (
<form action={handleUpdate}>
<div className="grid gap-4 py-4">
<div className="grid grid-cols-4 items-center gap-4">
@@ -345,7 +401,9 @@ export function ScheduleView({
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label className="text-right">Weekday</Label>
<Label htmlFor="edit-weekday" className="text-right">
Weekday
</Label>
<div className="col-span-3">
<Select value={editWeekday} onValueChange={setEditWeekday}>
<SelectTrigger>
@@ -374,7 +432,7 @@ export function ScheduleView({
name="startTime"
type="time"
className="col-span-3"
defaultValue={editItem.startTime}
defaultValue={editItem?.startTime}
required
/>
</div>
@@ -388,7 +446,7 @@ export function ScheduleView({
name="endTime"
type="time"
className="col-span-3"
defaultValue={editItem.endTime}
defaultValue={editItem?.endTime}
required
/>
</div>
@@ -401,7 +459,8 @@ export function ScheduleView({
id="edit-course"
name="course"
className="col-span-3"
defaultValue={editItem.course}
defaultValue={editItem?.course}
placeholder="e.g. Math"
required
/>
</div>
@@ -414,22 +473,22 @@ export function ScheduleView({
id="edit-location"
name="location"
className="col-span-3"
defaultValue={editItem.location ?? ""}
defaultValue={editItem?.location ?? ""}
placeholder="Optional"
/>
</div>
</div>
<DialogFooter>
<Button type="submit" disabled={isWorking}>
{isWorking ? "Saving..." : "Save"}
<Button type="submit" disabled={isWorking || !editClassId}>
{isWorking ? "Saving..." : "Save changes"}
</Button>
</DialogFooter>
</form>
) : null}
</DialogContent>
</Dialog>
<AlertDialog
open={Boolean(deleteItem)}
open={!!deleteItem}
onOpenChange={(v) => {
if (isWorking) return
if (!v) setDeleteItem(null)
@@ -437,22 +496,20 @@ export function ScheduleView({
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete schedule item?</AlertDialogTitle>
<AlertDialogTitle>Are you sure?</AlertDialogTitle>
<AlertDialogDescription>
{deleteItem ? (
<>
This will permanently delete <span className="font-medium text-foreground">{deleteItem.course}</span>{" "}
({deleteItem.startTime}{deleteItem.endTime}).
</>
) : null}
This will permanently delete this schedule item.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isWorking}>Cancel</AlertDialogCancel>
<AlertDialogAction
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
onClick={handleDelete}
onClick={(e) => {
e.preventDefault()
handleDelete()
}}
disabled={isWorking}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
{isWorking ? "Deleting..." : "Delete"}
</AlertDialogAction>
@@ -462,4 +519,3 @@ export function ScheduleView({
</div>
)
}

View File

@@ -3,18 +3,19 @@
import { useEffect, useMemo, useState } from "react"
import { useRouter } from "next/navigation"
import { useQueryState, parseAsString } from "nuqs"
import { Search, UserPlus, X } from "lucide-react"
import { Search, UserPlus, X, ChevronDown, Check } from "lucide-react"
import { toast } from "sonner"
import { Input } from "@/shared/components/ui/input"
import { Button } from "@/shared/components/ui/button"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/shared/components/ui/select"
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
DropdownMenuSeparator,
DropdownMenuLabel,
} from "@/shared/components/ui/dropdown-menu"
import {
Dialog,
DialogContent,
@@ -24,26 +25,35 @@ import {
DialogTitle,
DialogTrigger,
} from "@/shared/components/ui/dialog"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/shared/components/ui/select"
import { Label } from "@/shared/components/ui/label"
import { cn } from "@/shared/lib/utils"
import type { TeacherClass } from "../types"
import { enrollStudentByEmailAction } from "../actions"
export function StudentsFilters({ classes }: { classes: TeacherClass[] }) {
const [search, setSearch] = useQueryState("q", parseAsString.withDefault(""))
const [classId, setClassId] = useQueryState("classId", parseAsString.withDefault("all"))
const [status, setStatus] = useQueryState("status", parseAsString.withDefault("all"))
export function StudentsFilters({ classes, defaultClassId }: { classes: TeacherClass[], defaultClassId?: string }) {
const [search, setSearch] = useQueryState("q", parseAsString.withDefault("").withOptions({ shallow: false, throttleMs: 500 }))
const [classId, setClassId] = useQueryState("classId", parseAsString.withDefault(defaultClassId || "all").withOptions({ shallow: false }))
const [status, setStatus] = useQueryState("status", parseAsString.withDefault("all").withOptions({ shallow: false }))
const router = useRouter()
const [open, setOpen] = useState(false)
const [isWorking, setIsWorking] = useState(false)
const defaultClassId = useMemo(() => (classId !== "all" ? classId : classes[0]?.id ?? ""), [classId, classes])
const [enrollClassId, setEnrollClassId] = useState(defaultClassId)
const effectiveClassId = classId === "all" && defaultClassId ? defaultClassId : classId
const [enrollClassId, setEnrollClassId] = useState(effectiveClassId !== "all" ? effectiveClassId : (classes[0]?.id ?? ""))
useEffect(() => {
if (!open) return
setEnrollClassId(defaultClassId)
}, [open, defaultClassId])
setEnrollClassId(effectiveClassId !== "all" ? effectiveClassId : (classes[0]?.id ?? ""))
}, [open, effectiveClassId, classes])
const handleEnroll = async (formData: FormData) => {
setIsWorking(true)
@@ -63,58 +73,84 @@ export function StudentsFilters({ classes }: { classes: TeacherClass[] }) {
}
}
const selectedClass = classes.find(c => c.id === classId)
const classLabel = classId === "all" ? "All Classes" : (selectedClass?.name || "Unknown Class")
const statusLabel = status === "all" ? "All Status" : (status === "active" ? "Active" : "Inactive")
const hasFilters = search || classId !== "all" || status !== "all"
return (
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
<div className="flex flex-1 items-center gap-2">
<div className="relative flex-1 md:max-w-sm">
<Search className="text-muted-foreground absolute left-2.5 top-2.5 h-4 w-4" />
<div className="flex items-center justify-between py-2">
<div className="flex items-center gap-2">
{/* Search - Minimal */}
<div className="relative group">
<Search className="text-muted-foreground absolute left-2 top-1/2 -translate-y-1/2 h-3.5 w-3.5 group-hover:text-foreground transition-colors" />
<Input
placeholder="Search students..."
className="pl-8"
className="pl-8 h-8 w-[180px] text-xs bg-transparent border-transparent hover:bg-muted/50 focus-visible:bg-background focus-visible:ring-1 focus-visible:ring-ring focus-visible:border-input transition-all"
value={search}
onChange={(e) => setSearch(e.target.value || null)}
/>
</div>
<Select value={classId} onValueChange={(val) => setClassId(val === "all" ? null : val)}>
<SelectTrigger className="w-[180px]">
<SelectValue placeholder="Class" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Classes</SelectItem>
{classes.map((c) => (
<SelectItem key={c.id} value={c.id}>
{c.name}
</SelectItem>
))}
</SelectContent>
</Select>
<div className="h-4 w-[1px] bg-border mx-1" />
<Select value={status} onValueChange={(val) => setStatus(val === "all" ? null : val)}>
<SelectTrigger className="w-[140px]">
<SelectValue placeholder="Status" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Status</SelectItem>
<SelectItem value="active">Active</SelectItem>
<SelectItem value="inactive">Inactive</SelectItem>
</SelectContent>
</Select>
{(search || classId !== "all" || status !== "all") && (
<Button
variant="ghost"
onClick={() => {
setSearch(null)
setClassId(null)
setStatus(null)
}}
className="h-8 px-2 lg:px-3"
>
Reset
<X className="ml-2 h-4 w-4" />
{/* Class Filter - Compact */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm" className="h-8 gap-1 px-2 text-xs font-medium text-muted-foreground hover:text-foreground hover:bg-muted/50">
<span className="truncate max-w-[120px]">{classLabel}</span>
<ChevronDown className="h-3 w-3 opacity-50" />
</Button>
)}
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-[200px]">
<DropdownMenuLabel className="text-xs text-muted-foreground font-normal">Filter by Class</DropdownMenuLabel>
<DropdownMenuItem
onClick={() => setClassId("all")}
className="text-xs flex items-center justify-between"
>
All Classes
{classId === "all" && <Check className="h-3 w-3" />}
</DropdownMenuItem>
<DropdownMenuSeparator />
{classes.map((c) => (
<DropdownMenuItem
key={c.id}
onClick={() => setClassId(c.id)}
className="text-xs flex items-center justify-between"
>
<span className="truncate">{c.name}</span>
{classId === c.id && <Check className="h-3 w-3" />}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
{/* Status Filter - Compact */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm" className="h-8 gap-1 px-2 text-xs font-medium text-muted-foreground hover:text-foreground hover:bg-muted/50">
{statusLabel}
<ChevronDown className="h-3 w-3 opacity-50" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
<DropdownMenuLabel className="text-xs text-muted-foreground font-normal">Filter by Status</DropdownMenuLabel>
<DropdownMenuItem onClick={() => setStatus(null)} className="text-xs flex items-center justify-between">
All Status
{status === "all" && <Check className="h-3 w-3" />}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setStatus("active")} className="text-xs flex items-center justify-between">
Active
{status === "active" && <Check className="h-3 w-3" />}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setStatus("inactive")} className="text-xs flex items-center justify-between">
Inactive
{status === "inactive" && <Check className="h-3 w-3" />}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
<Dialog
@@ -125,8 +161,8 @@ export function StudentsFilters({ classes }: { classes: TeacherClass[] }) {
}}
>
<DialogTrigger asChild>
<Button className="gap-2" disabled={classes.length === 0}>
<UserPlus className="size-4" />
<Button size="sm" className="h-8 gap-1.5 text-xs px-3" disabled={classes.length === 0}>
<UserPlus className="size-3.5" />
Add student
</Button>
</DialogTrigger>

View File

@@ -2,13 +2,13 @@
import { useState } from "react"
import { useRouter } from "next/navigation"
import { MoreHorizontal, UserCheck, UserX, ChevronLeft, ChevronRight } from "lucide-react"
import { MoreHorizontal, UserCheck, UserX } from "lucide-react"
import { toast } from "sonner"
import { Badge } from "@/shared/components/ui/badge"
import { Button } from "@/shared/components/ui/button"
import { Avatar, AvatarFallback, AvatarImage } from "@/shared/components/ui/avatar"
import { Card, CardContent, CardFooter, CardHeader, CardTitle } from "@/shared/components/ui/card"
import { Card, CardContent, CardFooter, CardHeader } from "@/shared/components/ui/card"
import { cn, formatDate } from "@/shared/lib/utils"
import {
DropdownMenu,
@@ -27,31 +27,16 @@ import {
AlertDialogHeader,
AlertDialogTitle,
} from "@/shared/components/ui/alert-dialog"
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/shared/components/ui/table"
import type { ClassStudent } from "../types"
import { setStudentEnrollmentStatusAction } from "../actions"
const ITEMS_PER_PAGE = 10
export function StudentsTable({ students }: { students: ClassStudent[] }) {
const router = useRouter()
const [workingKey, setWorkingKey] = useState<string | null>(null)
const [removeTarget, setRemoveTarget] = useState<ClassStudent | null>(null)
const [page, setPage] = useState(1)
const totalPages = Math.ceil(students.length / ITEMS_PER_PAGE)
const startIndex = (page - 1) * ITEMS_PER_PAGE
const paginatedStudents = students.slice(startIndex, startIndex + ITEMS_PER_PAGE)
const setStatus = async (student: ClassStudent, status: "active" | "inactive") => {
const key = `${student.classId}:${student.id}:${status}`
const key = `${student.classId}:${student.id}`
setWorkingKey(key)
try {
const res = await setStudentEnrollmentStatusAction(student.classId, student.id, status)
@@ -59,10 +44,10 @@ export function StudentsTable({ students }: { students: ClassStudent[] }) {
toast.success(res.message)
router.refresh()
} else {
toast.error(res.message || "Failed to update student")
toast.error(res.message)
}
} catch {
toast.error("Failed to update student")
toast.error("Failed to update status")
} finally {
setWorkingKey(null)
}
@@ -79,69 +64,83 @@ export function StudentsTable({ students }: { students: ClassStudent[] }) {
return (
<>
<Card className="shadow-none">
<CardHeader className="border-b px-6 py-4">
<div className="flex items-center justify-between">
<CardTitle className="text-base font-semibold">All Students</CardTitle>
<Badge variant="secondary" className="rounded-sm px-1.5 font-normal">
{students.length} total
</Badge>
</div>
</CardHeader>
<CardContent className="p-0">
<Table>
<TableHeader>
<TableRow className="bg-muted/50 hover:bg-muted/50">
<TableHead className="pl-6 text-xs font-medium uppercase text-muted-foreground">Student</TableHead>
<TableHead className="text-xs font-medium uppercase text-muted-foreground">Class</TableHead>
<TableHead className="text-xs font-medium uppercase text-muted-foreground">Joined</TableHead>
<TableHead className="text-xs font-medium uppercase text-muted-foreground">Status</TableHead>
<TableHead className="pr-6 text-right text-xs font-medium uppercase text-muted-foreground">
Actions
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{paginatedStudents.map((s) => (
<TableRow key={`${s.classId}:${s.id}`} className={cn("h-16", s.status !== "active" && "opacity-70")}>
<TableCell className="pl-6">
<div className="flex items-center gap-3">
<Avatar className="h-9 w-9 border">
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{students.map((s) => (
<Card key={`${s.classId}:${s.id}`} className="overflow-hidden">
<CardHeader className="flex flex-row items-center gap-4 space-y-0 p-4 pb-2">
<div className="relative">
<Avatar className="h-10 w-10 border">
<AvatarImage src={s.image || undefined} alt={s.name} />
<AvatarFallback>{getInitials(s.name)}</AvatarFallback>
</Avatar>
<div className="flex flex-col gap-0.5">
<span className="font-medium leading-none">{s.name}</span>
<span className="text-xs text-muted-foreground">{s.email}</span>
<span className={cn(
"absolute bottom-0 right-0 h-3 w-3 rounded-full border-2 border-background",
s.status === "active" ? "bg-emerald-500" : "bg-muted-foreground"
)} />
</div>
<div className="flex flex-col flex-1 overflow-hidden">
<div className="flex items-start justify-between">
<div className="flex flex-col overflow-hidden mr-2">
<span className="truncate font-semibold text-sm">{s.name}</span>
<span className="truncate text-xs text-muted-foreground">{s.email}</span>
</div>
</TableCell>
<TableCell>
<Badge variant="outline" className="font-normal">
<div className="flex flex-col items-end gap-0.5 text-xs text-muted-foreground shrink-0">
<span className="text-[10px] font-medium text-foreground/80">
{s.className}
</Badge>
</TableCell>
<TableCell className="text-muted-foreground text-sm">
{formatDate(s.joinedAt)}
</TableCell>
<TableCell>
<Badge
variant={s.status === "active" ? "secondary" : "outline"}
className={cn(
"font-medium",
s.status === "active"
? "bg-emerald-500/10 text-emerald-700 dark:bg-emerald-500/20 dark:text-emerald-400 hover:bg-emerald-500/20"
: "text-muted-foreground"
</span>
<span className="text-[10px]">
{new Date(s.joinedAt).toLocaleDateString("en-GB", {
day: "2-digit",
month: "2-digit",
year: "2-digit"
})}
</span>
</div>
</div>
</div>
</CardHeader>
<CardContent className="p-4 pt-0">
{s.subjectScores && Object.keys(s.subjectScores).length > 0 ? (
<div className="flex flex-col gap-2">
<div className="flex flex-wrap gap-1.5">
{Object.entries(s.subjectScores).slice(0, 4).map(([subject, score]) => (
<div key={subject} className="flex items-center gap-1.5 rounded-md bg-muted/50 px-2 py-1 text-xs border border-muted/50">
<span className="font-medium text-muted-foreground/80">{subject}</span>
{score !== null ? (
<span className={cn(
"font-bold",
score >= 90 ? "text-emerald-600" :
score >= 80 ? "text-primary" :
score >= 60 ? "text-yellow-600" : "text-destructive"
)}>
{score}
</span>
) : (
<span className="text-muted-foreground">-</span>
)}
>
{s.status === "active" ? "Active" : "Inactive"}
</Badge>
</TableCell>
<TableCell className="pr-6 text-right">
</div>
))}
{Object.keys(s.subjectScores).length > 4 && (
<div className="flex items-center justify-center rounded-md bg-muted/50 px-2 py-1 text-xs text-muted-foreground font-medium border border-muted/50">
+{Object.keys(s.subjectScores).length - 4}
</div>
)}
</div>
</div>
) : (
<div className="flex items-center justify-center h-[32px] rounded-md bg-muted/20 border border-dashed border-muted">
<span className="text-xs text-muted-foreground/50 italic">No recent scores</span>
</div>
)}
</CardContent>
<CardFooter className="flex items-center justify-between border-t bg-muted/50 p-2">
<Button variant="ghost" size="sm" className="h-7 text-xs text-muted-foreground" asChild>
<a href={`mailto:${s.email}`}>Email</a>
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-8 w-8" disabled={workingKey !== null}>
<MoreHorizontal className="size-4" />
<Button variant="ghost" size="icon" className="h-7 w-7" disabled={workingKey !== null}>
<MoreHorizontal className="size-4 text-muted-foreground" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
@@ -167,45 +166,10 @@ export function StudentsTable({ students }: { students: ClassStudent[] }) {
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
{totalPages > 1 && (
<CardFooter className="flex items-center justify-between border-t px-6 py-4">
<div className="text-xs text-muted-foreground">
Showing <strong>{startIndex + 1}</strong>-
<strong>{Math.min(startIndex + ITEMS_PER_PAGE, students.length)}</strong> of{" "}
<strong>{students.length}</strong> students
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() => setPage((p) => Math.max(1, p - 1))}
disabled={page === 1}
className="h-8 w-8 p-0"
>
<ChevronLeft className="h-4 w-4" />
</Button>
<div className="text-sm font-medium">
{page} / {totalPages}
</div>
<Button
variant="outline"
size="sm"
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
disabled={page === totalPages}
className="h-8 w-8 p-0"
>
<ChevronRight className="h-4 w-4" />
</Button>
</div>
</CardFooter>
)}
</Card>
))}
</div>
<AlertDialog
open={Boolean(removeTarget)}

View File

@@ -2,7 +2,7 @@ import "server-only";
import { randomInt } from "node:crypto"
import { cache } from "react"
import { and, asc, desc, eq, inArray, sql, type SQL } from "drizzle-orm"
import { and, asc, desc, eq, inArray, or, sql, type SQL } from "drizzle-orm"
import { createId } from "@paralleldrive/cuid2"
import { db } from "@/shared/db"
@@ -17,6 +17,8 @@ import {
homeworkAssignments,
homeworkSubmissions,
schools,
subjects,
exams,
users,
} from "@/shared/db/schema"
import { DEFAULT_CLASS_SUBJECTS } from "./types"
@@ -169,7 +171,35 @@ export const getTeacherClasses = cache(async (params?: { teacherId?: string }):
}))
list.sort(compareClassLike)
return list
// Fetch recent assignments for trends and schedule
const listWithTrends = await Promise.all(
list.map(async (c) => {
const [insights, schedule] = await Promise.all([
getClassHomeworkInsights({ classId: c.id, teacherId, limit: 7 }),
getClassSchedule({ classId: c.id, teacherId }),
])
const recentAssignments = insights
? insights.assignments.map((a) => ({
id: a.assignmentId,
title: a.title,
status: a.status,
subject: a.subject,
isActive: a.isActive,
isOverdue: a.isOverdue,
dueAt: a.dueAt ? new Date(a.dueAt) : null,
submittedCount: a.submittedCount,
targetCount: a.targetCount,
avgScore: a.scoreStats.avg,
medianScore: a.scoreStats.median,
}))
: []
return { ...c, recentAssignments, schedule }
})
)
return listWithTrends
})
export const getTeacherOptions = cache(async (): Promise<TeacherOption[]> => {
@@ -752,11 +782,22 @@ export const getClassHomeworkInsights = cache(
}
const limit = typeof params.limit === "number" && params.limit > 0 ? params.limit : 50
const assignments = await db.query.homeworkAssignments.findMany({
where: and(inArray(homeworkAssignments.id, assignmentIds), eq(homeworkAssignments.creatorId, teacherId)),
orderBy: [desc(homeworkAssignments.createdAt)],
limit,
const assignments = await db
.select({
id: homeworkAssignments.id,
title: homeworkAssignments.title,
status: homeworkAssignments.status,
createdAt: homeworkAssignments.createdAt,
dueAt: homeworkAssignments.dueAt,
subjectId: exams.subjectId,
subjectName: subjects.name
})
.from(homeworkAssignments)
.innerJoin(exams, eq(homeworkAssignments.sourceExamId, exams.id))
.leftJoin(subjects, eq(exams.subjectId, subjects.id))
.where(and(inArray(homeworkAssignments.id, assignmentIds), eq(homeworkAssignments.creatorId, teacherId)))
.orderBy(desc(homeworkAssignments.createdAt))
.limit(limit)
const usedAssignmentIds = assignments.map((a) => a.id)
if (usedAssignmentIds.length === 0) {
@@ -845,6 +886,7 @@ export const getClassHomeworkInsights = cache(
assignmentId: a.id,
title: a.title,
status: (a.status as string) ?? "draft",
subject: a.subjectName,
createdAt: a.createdAt.toISOString(),
dueAt: a.dueAt ? a.dueAt.toISOString() : null,
isActive: dueMs === null || dueMs >= nowMs,
@@ -1694,3 +1736,104 @@ export async function deleteClassScheduleItem(scheduleId: string): Promise<void>
await db.delete(classSchedule).where(eq(classSchedule.id, id))
}
export const getStudentsSubjectScores = cache(
async (studentIds: string[]): Promise<Map<string, Record<string, number | null>>> => {
if (studentIds.length === 0) return new Map()
// 1. Find assignments targeted at these students
const assignmentTargets = await db
.select({ assignmentId: homeworkAssignmentTargets.assignmentId })
.from(homeworkAssignmentTargets)
.where(inArray(homeworkAssignmentTargets.studentId, studentIds))
const assignmentIds = Array.from(new Set(assignmentTargets.map(t => t.assignmentId)))
if (assignmentIds.length === 0) return new Map()
// 2. Get assignment details including subject from linked exam
const assignments = await db
.select({
id: homeworkAssignments.id,
createdAt: homeworkAssignments.createdAt,
subjectId: exams.subjectId,
subjectName: subjects.name
})
.from(homeworkAssignments)
.innerJoin(exams, eq(homeworkAssignments.sourceExamId, exams.id))
.leftJoin(subjects, eq(exams.subjectId, subjects.id))
.where(and(
inArray(homeworkAssignments.id, assignmentIds),
eq(homeworkAssignments.status, "published")
))
.orderBy(desc(homeworkAssignments.createdAt))
// 3. Filter subjects (exclude PE, Music, Art)
const excludeSubjects = ["体育", "音乐", "美术"]
const subjectAssignments = new Map<string, string>() // subject -> assignmentId (latest)
for (const a of assignments) {
if (!a.subjectName) continue
if (excludeSubjects.includes(a.subjectName)) continue
if (!subjectAssignments.has(a.subjectName)) {
subjectAssignments.set(a.subjectName, a.id)
}
}
const targetAssignmentIds = Array.from(subjectAssignments.values())
if (targetAssignmentIds.length === 0) return new Map()
// 4. Get submissions for these assignments
const submissions = await db
.select({
studentId: homeworkSubmissions.studentId,
assignmentId: homeworkSubmissions.assignmentId,
score: homeworkSubmissions.score,
createdAt: homeworkSubmissions.createdAt,
})
.from(homeworkSubmissions)
.where(inArray(homeworkSubmissions.assignmentId, targetAssignmentIds))
.orderBy(desc(homeworkSubmissions.createdAt))
// 5. Map back to subject scores per student
const studentScores = new Map<string, Record<string, number | null>>()
// Create reverse map for assignment -> subject
const assignmentSubjectMap = new Map<string, string>()
for (const [subject, id] of subjectAssignments.entries()) {
assignmentSubjectMap.set(id, subject)
}
for (const s of submissions) {
const subject = assignmentSubjectMap.get(s.assignmentId)
if (!subject) continue
if (!studentScores.has(s.studentId)) {
studentScores.set(s.studentId, {})
}
const scores = studentScores.get(s.studentId)!
// Only set if not already set (since we ordered by desc createdAt, first one is latest)
if (scores[subject] === undefined) {
scores[subject] = s.score
}
}
return studentScores
}
)
export const getClassStudentSubjectScoresV2 = cache(
async (classId: string): Promise<Map<string, Record<string, number | null>>> => {
// 1. Get student IDs in the class
const enrollments = await db
.select({ studentId: classEnrollments.studentId })
.from(classEnrollments)
.where(and(
eq(classEnrollments.classId, classId),
eq(classEnrollments.status, "active")
))
const studentIds = enrollments.map(e => e.studentId)
return getStudentsSubjectScores(studentIds)
}
)

View File

@@ -7,6 +7,22 @@ export type TeacherClass = {
room?: string | null
invitationCode?: string | null
studentCount: number
recentAssignments?: AssignmentSummary[]
schedule?: ClassScheduleItem[]
}
export interface AssignmentSummary {
id: string
title: string
status: string
subject?: string | null
isActive: boolean
isOverdue: boolean
dueAt: Date | null
submittedCount: number
targetCount: number
avgScore: number | null
medianScore: number | null
}
export type TeacherOption = {
@@ -71,6 +87,7 @@ export type ClassStudent = {
className: string
status: "active" | "inactive"
joinedAt: Date
subjectScores?: Record<string, number | null>
}
export type ClassScheduleItem = {
@@ -135,6 +152,7 @@ export type ClassHomeworkAssignmentStats = {
assignmentId: string
title: string
status: string
subject?: string | null
createdAt: string
dueAt: string | null
isActive: boolean
@@ -149,6 +167,8 @@ export type ClassHomeworkAssignmentStats = {
export type ClassHomeworkInsights = {
class: {
id: string
schoolName?: string | null
schoolId?: string | null
name: string
grade: string
homeroom?: string | null

View File

@@ -550,6 +550,7 @@ export function StructureEditor({ items, onChange, onScoreChange, onGroupTitleCh
return (
<DndContext
id="structure-editor-dnd"
sensors={sensors}
collisionDetection={customCollisionDetection}
onDragStart={handleDragStart}

View File

@@ -124,7 +124,6 @@ export const NAV_CONFIG: Record<Role, NavItem[]> = {
{ title: "My Classes", href: "/teacher/classes/my" },
{ title: "Students", href: "/teacher/classes/students" },
{ title: "Schedule", href: "/teacher/classes/schedule" },
{ title: "Insights", href: "/teacher/classes/insights" },
]
},
{

View File

@@ -60,6 +60,9 @@ interface CreateQuestionDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
initialData?: Question | null
defaultKnowledgePointIds?: string[]
defaultContent?: string
defaultType?: "single_choice" | "multiple_choice" | "text" | "judgment" | "composite"
}
function getInitialTextFromContent(content: unknown) {
@@ -97,7 +100,14 @@ function getInitialOptionsFromContent(content: unknown) {
return mapped.length > 0 ? mapped : undefined
}
export function CreateQuestionDialog({ open, onOpenChange, initialData }: CreateQuestionDialogProps) {
export function CreateQuestionDialog({
open,
onOpenChange,
initialData,
defaultKnowledgePointIds = [],
defaultContent = "",
defaultType = "single_choice"
}: CreateQuestionDialogProps) {
const router = useRouter()
const [isPending, setIsPending] = useState(false)
const isEdit = !!initialData
@@ -105,9 +115,9 @@ export function CreateQuestionDialog({ open, onOpenChange, initialData }: Create
const form = useForm<QuestionFormValues>({
resolver: zodResolver(QuestionFormSchema),
defaultValues: {
type: initialData?.type || "single_choice",
type: initialData?.type || defaultType,
difficulty: initialData?.difficulty || 1,
content: getInitialTextFromContent(initialData?.content),
content: getInitialTextFromContent(initialData?.content) || defaultContent,
options:
getInitialOptionsFromContent(initialData?.content) ?? [
{ label: "Option A", value: "A", isCorrect: true },
@@ -130,16 +140,16 @@ export function CreateQuestionDialog({ open, onOpenChange, initialData }: Create
})
} else {
form.reset({
type: "single_choice",
type: defaultType,
difficulty: 1,
content: "",
content: defaultContent,
options: [
{ label: "Option A", value: "A", isCorrect: true },
{ label: "Option B", value: "B", isCorrect: false },
],
})
}
}, [initialData, form, open])
}, [initialData, form, open, defaultContent, defaultType])
const questionType = form.watch("type")
@@ -184,7 +194,7 @@ export function CreateQuestionDialog({ open, onOpenChange, initialData }: Create
type: data.type,
difficulty: data.difficulty,
content: buildContent(data),
knowledgePointIds: [],
knowledgePointIds: isEdit ? [] : defaultKnowledgePointIds,
}
const fd = new FormData()
fd.set("json", JSON.stringify(payload))

View File

@@ -8,6 +8,7 @@ import {
deleteChapter,
createKnowledgePoint,
deleteKnowledgePoint,
updateKnowledgePoint,
updateTextbook,
deleteTextbook,
reorderChapters
@@ -185,11 +186,12 @@ export async function createKnowledgePointAction(
): Promise<ActionState> {
const name = formData.get("name") as string;
const description = formData.get("description") as string;
const anchorText = formData.get("anchorText") as string;
if (!name) return { success: false, message: "Name is required" };
try {
await createKnowledgePoint({ name, description, chapterId });
await createKnowledgePoint({ name, description, anchorText, chapterId });
revalidatePath(`/teacher/textbooks/${textbookId}`);
return { success: true, message: "Knowledge point created successfully" };
} catch {
@@ -209,3 +211,24 @@ export async function deleteKnowledgePointAction(
return { success: false, message: "Failed to delete knowledge point" };
}
}
export async function updateKnowledgePointAction(
kpId: string,
textbookId: string,
prevState: ActionState | null,
formData: FormData
): Promise<ActionState> {
const name = formData.get("name") as string;
const description = formData.get("description") as string;
const anchorText = formData.get("anchorText") as string;
if (!name) return { success: false, message: "Name is required" };
try {
await updateKnowledgePoint({ id: kpId, name, description, anchorText });
revalidatePath(`/teacher/textbooks/${textbookId}`);
return { success: true, message: "Knowledge point updated successfully" };
} catch {
return { success: false, message: "Failed to update knowledge point" };
}
}

View File

@@ -1,54 +0,0 @@
"use client"
import ReactMarkdown from "react-markdown"
import remarkBreaks from "remark-breaks"
import remarkGfm from "remark-gfm"
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
} from "@/shared/components/ui/dialog"
import { ScrollArea } from "@/shared/components/ui/scroll-area"
import { Chapter } from "../types"
interface ChapterContentViewerProps {
chapter: Chapter | null
open: boolean
onOpenChange: (open: boolean) => void
}
export function ChapterContentViewer({
chapter,
open,
onOpenChange,
}: ChapterContentViewerProps) {
if (!chapter) return null
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-3xl h-[80vh] flex flex-col">
<DialogHeader>
<DialogTitle>{chapter.title}</DialogTitle>
<DialogDescription>
Reading Mode
</DialogDescription>
</DialogHeader>
<ScrollArea className="flex-1 pr-4 min-h-0">
<div className="prose prose-sm dark:prose-invert max-w-none">
{chapter.content ? (
<ReactMarkdown remarkPlugins={[remarkGfm, remarkBreaks]}>
{chapter.content}
</ReactMarkdown>
) : (
<div className="flex h-40 items-center justify-center text-muted-foreground italic">
No content available for this chapter.
</div>
)}
</div>
</ScrollArea>
</DialogContent>
</Dialog>
)
}

View File

@@ -1,136 +0,0 @@
"use client"
import { useState } from "react"
import { ChevronRight, FileText, Folder, MoreHorizontal, Eye } from "lucide-react"
import { Chapter } from "../types"
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/shared/components/ui/collapsible"
import { Button } from "@/shared/components/ui/button"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/shared/components/ui/dropdown-menu"
import { cn } from "@/shared/lib/utils"
import { ChapterContentViewer } from "./chapter-content-viewer"
interface ChapterItemProps {
chapter: Chapter
level?: number
onView: (chapter: Chapter) => void
showActions?: boolean
}
function ChapterItem({ chapter, level = 0, onView, showActions = true }: ChapterItemProps) {
const [isOpen, setIsOpen] = useState(false)
const hasChildren = chapter.children && chapter.children.length > 0
return (
<div className={cn(level > 0 && "ml-2 border-l pl-2")}>
<Collapsible open={isOpen} onOpenChange={setIsOpen}>
<div className="flex items-center group py-1">
{hasChildren ? (
<CollapsibleTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 shrink-0 p-0 hover:bg-muted"
>
<ChevronRight
className={cn(
"h-4 w-4 transition-transform duration-200 text-muted-foreground",
isOpen && "rotate-90"
)}
/>
<span className="sr-only">Toggle</span>
</Button>
</CollapsibleTrigger>
) : (
<div className="w-6 shrink-0" />
)}
<div className={cn(
"flex flex-1 items-center gap-2 rounded-md px-2 py-1.5 text-sm hover:bg-muted/50 cursor-pointer transition-colors",
level === 0 ? "font-medium text-foreground" : "text-muted-foreground"
)}
onClick={() => !hasChildren && onView(chapter)}
>
{hasChildren ? (
<Folder className={cn("h-4 w-4", isOpen ? "text-primary" : "text-muted-foreground/70")} />
) : (
<FileText className="h-4 w-4 text-muted-foreground/50" />
)}
<span className="truncate">{chapter.title}</span>
{showActions ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className="ml-auto h-6 w-6 opacity-0 group-hover:opacity-100 transition-opacity focus:opacity-100"
onClick={(e) => e.stopPropagation()}
>
<MoreHorizontal className="h-4 w-4 text-muted-foreground" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => onView(chapter)}>
<Eye className="mr-2 h-4 w-4" />
View Content
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
) : null}
</div>
</div>
{hasChildren && (
<CollapsibleContent className="overflow-hidden data-[state=closed]:animate-collapsible-up data-[state=open]:animate-collapsible-down">
<div className="pt-1">
{chapter.children!.map((child) => (
<ChapterItem
key={child.id}
chapter={child}
level={level + 1}
onView={onView}
showActions={showActions}
/>
))}
</div>
</CollapsibleContent>
)}
</Collapsible>
</div>
)
}
export function ChapterList({ chapters, showActions }: { chapters: Chapter[]; showActions?: boolean }) {
const [viewingChapter, setViewingChapter] = useState<Chapter | null>(null)
const [isViewerOpen, setIsViewerOpen] = useState(false)
const handleView = (chapter: Chapter) => {
setViewingChapter(chapter)
setIsViewerOpen(true)
}
return (
<>
<div className="space-y-1">
{chapters.map((chapter) => (
<ChapterItem key={chapter.id} chapter={chapter} onView={handleView} showActions={showActions} />
))}
</div>
<ChapterContentViewer
chapter={viewingChapter}
open={isViewerOpen}
onOpenChange={setIsViewerOpen}
/>
</>
)
}

View File

@@ -35,9 +35,10 @@ interface SortableChapterItemProps {
textbookId: string
onDelete: (chapter: Chapter) => void
onCreateSub: (parentId: string) => void
canEdit?: boolean
}
function SortableChapterItem({ chapter, level, selectedId, onSelect, textbookId, onDelete, onCreateSub }: SortableChapterItemProps) {
function SortableChapterItem({ chapter, level, selectedId, onSelect, textbookId, onDelete, onCreateSub, canEdit = true }: SortableChapterItemProps) {
const [isOpen, setIsOpen] = useState(level === 0)
const hasChildren = chapter.children && chapter.children.length > 0
const isSelected = chapter.id === selectedId
@@ -49,7 +50,7 @@ function SortableChapterItem({ chapter, level, selectedId, onSelect, textbookId,
transform,
transition,
isDragging,
} = useSortable({ id: chapter.id })
} = useSortable({ id: chapter.id, disabled: !canEdit })
const style = {
transform: CSS.Transform.toString(transform),
@@ -66,9 +67,11 @@ function SortableChapterItem({ chapter, level, selectedId, onSelect, textbookId,
isSelected ? "bg-accent text-accent-foreground font-medium" : "hover:bg-muted/50 text-muted-foreground hover:text-foreground",
isDragging && "opacity-50"
)}>
{canEdit && (
<div {...attributes} {...listeners} className="mr-1 cursor-grab opacity-0 group-hover:opacity-100 transition-opacity p-1 hover:bg-muted rounded">
<GripVertical className="h-3.5 w-3.5 text-muted-foreground/50" />
</div>
)}
{hasChildren ? (
<CollapsibleTrigger asChild>
@@ -103,7 +106,7 @@ function SortableChapterItem({ chapter, level, selectedId, onSelect, textbookId,
<span className="truncate text-sm">{chapter.title}</span>
</div>
<div className="flex items-center opacity-0 group-hover:opacity-100 transition-opacity ml-2">
<div className={cn("flex items-center opacity-0 group-hover:opacity-100 transition-opacity ml-2", !canEdit && "hidden")}>
<Button
variant="ghost"
size="icon"
@@ -142,6 +145,7 @@ function SortableChapterItem({ chapter, level, selectedId, onSelect, textbookId,
textbookId={textbookId}
onDelete={onDelete}
onCreateSub={onCreateSub}
canEdit={canEdit}
/>
)}
</div>
@@ -151,14 +155,15 @@ function SortableChapterItem({ chapter, level, selectedId, onSelect, textbookId,
)
}
function RecursiveSortableList({ items, level, selectedId, onSelect, textbookId, onDelete, onCreateSub }: {
function RecursiveSortableList({ items, level, selectedId, onSelect, textbookId, onDelete, onCreateSub, canEdit = true }: {
items: Chapter[],
level: number,
selectedId?: string,
onSelect: (c: Chapter) => void,
textbookId: string,
onDelete: (c: Chapter) => void,
onCreateSub: (pid: string) => void
onCreateSub: (pid: string) => void,
canEdit?: boolean
}) {
return (
<SortableContext items={items.map(i => i.id)} strategy={verticalListSortingStrategy}>
@@ -172,6 +177,7 @@ function RecursiveSortableList({ items, level, selectedId, onSelect, textbookId,
textbookId={textbookId}
onDelete={onDelete}
onCreateSub={onCreateSub}
canEdit={canEdit}
/>
))}
</SortableContext>
@@ -183,9 +189,10 @@ interface ChapterSidebarListProps {
selectedChapterId?: string
onSelectChapter: (chapter: Chapter) => void
textbookId: string
canEdit?: boolean
}
export function ChapterSidebarList({ chapters, selectedChapterId, onSelectChapter, textbookId }: ChapterSidebarListProps) {
export function ChapterSidebarList({ chapters, selectedChapterId, onSelectChapter, textbookId, canEdit = true }: ChapterSidebarListProps) {
const [showCreateDialog, setShowCreateDialog] = useState(false)
const [createParentId, setCreateParentId] = useState<string | undefined>(undefined)
@@ -300,8 +307,9 @@ export function ChapterSidebarList({ chapters, selectedChapterId, onSelectChapte
setShowCreateDialog(true)
}
// If not editable, we can skip dnd logic
if (!canEdit) {
return (
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
<RecursiveSortableList
items={chapters}
level={0}
@@ -310,6 +318,22 @@ export function ChapterSidebarList({ chapters, selectedChapterId, onSelectChapte
textbookId={textbookId}
onDelete={handleDeleteRequest}
onCreateSub={handleCreateSubRequest}
canEdit={false}
/>
)
}
return (
<DndContext id="chapter-sidebar-dnd" sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
<RecursiveSortableList
items={chapters}
level={0}
selectedId={selectedChapterId}
onSelect={onSelectChapter}
textbookId={textbookId}
onDelete={handleDeleteRequest}
onCreateSub={handleCreateSubRequest}
canEdit={true}
/>
<CreateChapterDialog

View File

@@ -1,148 +0,0 @@
"use client"
import { useState } from "react"
import ReactMarkdown from "react-markdown"
import remarkBreaks from "remark-breaks"
import remarkGfm from "remark-gfm"
import { Chapter, KnowledgePoint } from "../types"
import { ChapterSidebarList } from "./chapter-sidebar-list"
import { KnowledgePointPanel } from "./knowledge-point-panel"
import { ScrollArea } from "@/shared/components/ui/scroll-area"
import { Button } from "@/shared/components/ui/button"
import { Edit2, Save, Folder } from "lucide-react"
import { CreateChapterDialog } from "./create-chapter-dialog"
import { updateChapterContentAction } from "../actions"
import { toast } from "sonner"
import { RichTextEditor } from "@/shared/components/ui/rich-text-editor"
interface TextbookContentLayoutProps {
chapters: Chapter[]
knowledgePoints: KnowledgePoint[]
textbookId: string
}
export function TextbookContentLayout({ chapters, knowledgePoints, textbookId }: TextbookContentLayoutProps) {
const [selectedChapter, setSelectedChapter] = useState<Chapter | null>(null)
const [isEditing, setIsEditing] = useState(false)
const [editContent, setEditContent] = useState("")
const [isSaving, setIsSaving] = useState(false)
// Sync edit content when selection changes
const handleSelectChapter = (chapter: Chapter) => {
setSelectedChapter(chapter)
setEditContent(chapter.content || "")
setIsEditing(false)
}
const handleSaveContent = async () => {
if (!selectedChapter) return
setIsSaving(true)
const result = await updateChapterContentAction(selectedChapter.id, editContent, textbookId)
setIsSaving(false)
if (result.success) {
toast.success(result.message)
setIsEditing(false)
setSelectedChapter((prev) => (prev ? { ...prev, content: editContent } : prev))
} else {
toast.error(result.message)
}
}
return (
<div className="grid grid-cols-12 h-[calc(100vh-8rem)]">
{/* Left Sidebar: TOC (3 cols) */}
<div className="col-span-3 border-r flex flex-col h-full bg-muted/10">
<div className="flex items-center justify-between p-4 border-b">
<h3 className="font-semibold text-sm uppercase tracking-wider text-muted-foreground">Contents</h3>
<CreateChapterDialog textbookId={textbookId} />
</div>
<ScrollArea className="flex-1">
<div className="p-3">
<ChapterSidebarList
chapters={chapters}
selectedChapterId={selectedChapter?.id}
onSelectChapter={handleSelectChapter}
textbookId={textbookId}
/>
</div>
</ScrollArea>
</div>
{/* Middle: Content Viewer/Editor (6 cols) */}
<div className="col-span-6 flex flex-col h-full min-h-0 bg-background">
{selectedChapter ? (
<>
<div className="flex items-center justify-between px-8 py-4 border-b sticky top-0 bg-background/95 backdrop-blur z-10">
<h2 className="text-2xl font-bold tracking-tight">{selectedChapter.title}</h2>
<div className="flex gap-2">
{isEditing ? (
<>
<Button size="sm" variant="ghost" onClick={() => setIsEditing(false)} disabled={isSaving}>
Cancel
</Button>
<Button size="sm" onClick={handleSaveContent} disabled={isSaving}>
<Save className="mr-2 h-4 w-4" />
{isSaving ? "Saving..." : "Save"}
</Button>
</>
) : (
<Button size="sm" variant="outline" onClick={() => setIsEditing(true)}>
<Edit2 className="mr-2 h-4 w-4" />
Edit Content
</Button>
)}
</div>
</div>
<ScrollArea className="flex-1">
<div className="max-w-3xl mx-auto px-8 py-8 min-h-full">
{isEditing ? (
<RichTextEditor
value={editContent}
onChange={setEditContent}
className="min-h-[500px] border-none shadow-none"
/>
) : (
<div className="prose prose-zinc dark:prose-invert max-w-none prose-headings:font-bold prose-h1:text-3xl prose-h2:text-2xl prose-h3:text-xl prose-p:leading-relaxed">
{selectedChapter.content ? (
<ReactMarkdown remarkPlugins={[remarkGfm, remarkBreaks]}>
{selectedChapter.content}
</ReactMarkdown>
) : (
<div className="flex flex-col items-center justify-center py-20 text-muted-foreground space-y-4">
<div className="p-4 rounded-full bg-muted">
<Edit2 className="h-8 w-8 opacity-50" />
</div>
<p className="italic">No content available yet.</p>
<Button variant="outline" size="sm" onClick={() => setIsEditing(true)}>
Start Writing
</Button>
</div>
)}
</div>
)}
</div>
</ScrollArea>
</>
) : (
<div className="h-full flex flex-col items-center justify-center text-muted-foreground space-y-4">
<div className="p-6 rounded-full bg-muted/30">
<Folder className="h-12 w-12 opacity-20" />
</div>
<p>Select a chapter to view or edit content</p>
</div>
)}
</div>
{/* Right Sidebar: Knowledge Points (3 cols) */}
<div className="col-span-3 border-l flex flex-col h-full bg-muted/10">
<KnowledgePointPanel
knowledgePoints={knowledgePoints}
selectedChapterId={selectedChapter?.id || null}
textbookId={textbookId}
/>
</div>
</div>
)
}

View File

@@ -1,16 +1,47 @@
"use client"
import { useMemo, useState } from "react"
import { useMemo, useState, useEffect, useRef } from "react"
import ReactMarkdown from "react-markdown"
import remarkBreaks from "remark-breaks"
import remarkGfm from "remark-gfm"
import { useQueryState, parseAsString } from "nuqs"
import { ChevronRight, FileText, Folder } from "lucide-react"
import { Tag, List, Plus, Edit2, Save, Trash2, Pencil, PlusCircle, ChevronDown, ChevronUp } from "lucide-react"
import { toast } from "sonner"
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/shared/components/ui/collapsible"
import type { Chapter } from "../types"
import type { Chapter, KnowledgePoint } from "../types"
import { createKnowledgePointAction, updateChapterContentAction, deleteKnowledgePointAction, updateKnowledgePointAction } from "../actions"
import { CreateQuestionDialog } from "@/modules/questions/components/create-question-dialog"
import { cn } from "@/shared/lib/utils"
import { ScrollArea } from "@/shared/components/ui/scroll-area"
import { Button } from "@/shared/components/ui/button"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/shared/components/ui/tabs"
import { Badge } from "@/shared/components/ui/badge"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/shared/components/ui/dialog"
import { Input } from "@/shared/components/ui/input"
import { Label } from "@/shared/components/ui/label"
import { Textarea } from "@/shared/components/ui/textarea"
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuTrigger,
} from "@/shared/components/ui/context-menu"
import { ChapterSidebarList } from "./chapter-sidebar-list"
import { RichTextEditor } from "@/shared/components/ui/rich-text-editor"
function buildChapterIndex(chapters: Chapter[]) {
const index = new Map<string, Chapter>()
@@ -26,131 +57,543 @@ function buildChapterIndex(chapters: Chapter[]) {
return index
}
function ReaderChapterItem({
chapter,
level = 0,
selectedId,
onSelect,
}: {
chapter: Chapter
level?: number
selectedId: string | null
onSelect: (chapterId: string) => void
}) {
const hasChildren = Boolean(chapter.children && chapter.children.length > 0)
const [open, setOpen] = useState(level === 0)
const isSelected = selectedId === chapter.id
return (
<div className={cn(level > 0 && "ml-2 border-l pl-2")}>
<div
className={cn(
"flex items-center group py-1 rounded-md transition-colors",
isSelected ? "bg-accent text-accent-foreground" : "hover:bg-muted/50"
)}
>
{hasChildren ? (
<Button
variant="ghost"
size="icon"
className="h-6 w-6 shrink-0 p-0 hover:bg-muted"
onClick={() => setOpen((v) => !v)}
>
<ChevronRight className={cn("h-4 w-4 text-muted-foreground transition-transform", open && "rotate-90")} />
</Button>
) : (
<div className="w-6 shrink-0" />
)}
<button
type="button"
className={cn(
"flex flex-1 min-w-0 items-center gap-2 px-2 py-1.5 text-sm text-left cursor-pointer",
level === 0 ? "font-medium" : "text-muted-foreground",
isSelected && "text-accent-foreground font-medium"
)}
onClick={() => onSelect(chapter.id)}
>
{hasChildren ? (
<Folder className={cn("h-4 w-4", open ? "text-primary" : "text-muted-foreground/70")} />
) : (
<FileText className="h-4 w-4 text-muted-foreground/50" />
)}
<span className="truncate flex-1 min-w-0">{chapter.title}</span>
</button>
</div>
{hasChildren && open ? (
<div className="pt-1">
{chapter.children!.map((child) => (
<ReaderChapterItem
key={child.id}
chapter={child}
level={level + 1}
selectedId={selectedId}
onSelect={onSelect}
/>
))}
</div>
) : null}
</div>
)
}
export function TextbookReader({ chapters }: { chapters: Chapter[] }) {
export function TextbookReader({ chapters, knowledgePoints = [], canEdit = false, textbookId }: { chapters: Chapter[]; knowledgePoints?: KnowledgePoint[]; canEdit?: boolean; textbookId?: string }) {
const [chapterId, setChapterId] = useQueryState("chapterId", parseAsString.withDefault(""))
const [activeTab, setActiveTab] = useState("chapters")
const [highlightedKpId, setHighlightedKpId] = useState<string | null>(null)
// Selection & Creation State
const [selectedText, setSelectedText] = useState("")
const selectionRef = useRef("") // Store selection temporarily to avoid re-renders on pointer down
const [createDialogOpen, setCreateDialogOpen] = useState(false)
const [isCreating, setIsCreating] = useState(false)
const contentRef = useRef<HTMLDivElement>(null)
// Editing State
const [isEditing, setIsEditing] = useState(false)
const [editContent, setEditContent] = useState("")
const [isSaving, setIsSaving] = useState(false)
// Knowledge Point Edit State
const [editingKp, setEditingKp] = useState<KnowledgePoint | null>(null)
const [editKpDialogOpen, setEditKpDialogOpen] = useState(false)
const [isUpdatingKp, setIsUpdatingKp] = useState(false)
// Question Creation State
const [questionDialogOpen, setQuestionDialogOpen] = useState(false)
const [targetKpForQuestion, setTargetKpForQuestion] = useState<KnowledgePoint | null>(null)
const index = useMemo(() => buildChapterIndex(chapters), [chapters])
const selected = chapterId ? index.get(chapterId) ?? null : null
const selectedId = selected?.id ?? null
const handleSelect = (id: string) => setChapterId(id)
const handleSelect = (chapter: Chapter) => {
setChapterId(chapter.id)
setIsEditing(false)
}
// Handle Text Selection via Context Menu
// We capture selection on PointerDown (Right Click) to ensure we get the state before any context menu logic runs.
// Using onContextMenu directly caused conflicts with Radix UI's ContextMenuTrigger in some cases.
const handleContentPointerDown = (e: React.PointerEvent) => {
// Only capture on right click (button 2)
if (e.button !== 2) return
const selection = window.getSelection()
if (!selection || selection.isCollapsed) {
selectionRef.current = ""
return
}
// Check if selection is within content area
if (contentRef.current && contentRef.current.contains(selection.anchorNode)) {
// Store in ref, don't trigger re-render yet
selectionRef.current = selection.toString().trim()
} else {
selectionRef.current = ""
}
}
const handleContextMenuChange = (open: boolean) => {
if (!open) return
// When menu opens, sync ref to state to update UI
if (selectionRef.current) {
setSelectedText(selectionRef.current)
} else {
// Fallback: If pointer down didn't capture (e.g. keyboard), try now
const selection = window.getSelection()
if (selection && !selection.isCollapsed && contentRef.current && contentRef.current.contains(selection.anchorNode)) {
const text = selection.toString().trim()
selectionRef.current = text
setSelectedText(text)
} else {
setSelectedText("")
}
}
}
const handleCreateKnowledgePoint = async (formData: FormData) => {
if (!selectedId || !selected) return
setIsCreating(true)
try {
const result = await createKnowledgePointAction(
selectedId,
selected.textbookId,
null,
formData
)
if (result.success) {
toast.success("知识点已创建")
setCreateDialogOpen(false)
setActiveTab("knowledge")
// Clear selection
window.getSelection()?.removeAllRanges()
setSelectedText("")
} else {
toast.error(result.message || "创建知识点失败")
}
} catch {
toast.error("发生错误")
} finally {
setIsCreating(false)
}
}
const handleSaveContent = async () => {
if (!selectedId || !textbookId) return
setIsSaving(true)
const result = await updateChapterContentAction(selectedId, editContent, textbookId)
setIsSaving(false)
if (result.success) {
toast.success(result.message)
setIsEditing(false)
// Optimistic update might be tricky here without full reload, but let's assume parent revalidates or we rely on router refresh
// For now, we manually update the local state if needed, but since we use `chapters` prop which comes from server,
// we ideally want to trigger a refresh.
// However, for this component, we can just let the user see the new content if we render `editContent` or rely on props update.
// But `chapters` prop won't update automatically unless we router.refresh().
// Let's rely on the fact that `selected` comes from `chapters` which might be stale until refresh.
// A full solution would use `router.refresh()`.
// For now, we can update the `selected.content` in place? No, it's a prop.
// We will rely on router refresh in the parent or just simple UI feedback.
// Actually, let's trigger a router refresh if possible, but we don't have router here.
// We'll just exit edit mode. The content might look old until refresh.
// To fix this, we can locally override content.
if (selected) selected.content = editContent
} else {
toast.error(result.message)
}
}
const startEditing = () => {
if (selected) {
setEditContent(selected.content || "")
setIsEditing(true)
}
}
const handleDeleteKnowledgePoint = async (kpId: string, e: React.MouseEvent) => {
e.stopPropagation()
if (!confirm("确定要删除这个知识点吗?")) return
if (!textbookId) return
try {
const result = await deleteKnowledgePointAction(kpId, textbookId)
if (result.success) {
toast.success(result.message)
if (highlightedKpId === kpId) {
setHighlightedKpId(null)
}
} else {
toast.error(result.message)
}
} catch {
toast.error("删除失败")
}
}
const handleUpdateKnowledgePoint = async (formData: FormData) => {
if (!editingKp || !textbookId) return
setIsUpdatingKp(true)
try {
const result = await updateKnowledgePointAction(editingKp.id, textbookId, null, formData)
if (result.success) {
toast.success(result.message)
setEditKpDialogOpen(false)
setEditingKp(null)
} else {
toast.error(result.message)
}
} catch {
toast.error("更新失败")
} finally {
setIsUpdatingKp(false)
}
}
// Filter KPs for the current chapter
const currentChapterKPs = useMemo(() => {
if (!selectedId) return []
return knowledgePoints.filter(kp => kp.chapterId === selectedId)
}, [knowledgePoints, selectedId])
// Pre-process content to mark knowledge points
const processedContent = useMemo(() => {
if (!selected?.content) return ""
let content = selected.content
// Sort KPs by name length descending to handle overlapping names
const sortedKPs = [...currentChapterKPs].sort((a, b) => b.name.length - a.name.length)
// We use a temporary replacement strategy to avoid nested replacements
// This is simple but works for most cases
// We replace "Name" with "[Name](kp://id)"
for (const kp of sortedKPs) {
// Escape regex special characters
const escapedName = kp.name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
// Case insensitive match, but preserve original text casing
// We use a simplified lookahead to avoid replacing inside existing links if possible,
// but perfect markdown parsing is hard with regex.
// For now, we assume KPs don't overlap in a way that breaks things often.
const regex = new RegExp(`(${escapedName})`, 'gi')
// We only replace if not already part of a link (simplified check)
// A robust parser would be better, but regex is acceptable for this level
content = content.replace(regex, `[$1](#kp-${kp.id})`)
}
return content
}, [selected?.content, currentChapterKPs])
// Scroll to highlighted KP
useEffect(() => {
if (highlightedKpId) {
// Find first element by data attribute
const el = document.querySelector(`[data-kp-id="${highlightedKpId}"]`)
if (el) {
el.scrollIntoView({ behavior: "smooth", block: "center" })
// Add temporary highlight effect
el.classList.add("ring-2", "ring-primary", "ring-offset-2")
setTimeout(() => {
el.classList.remove("ring-2", "ring-primary", "ring-offset-2")
}, 2000)
}
}
}, [highlightedKpId])
return (
<div className="grid grid-cols-1 gap-6 lg:grid-cols-12 h-full">
<div className="lg:col-span-4 lg:border-r lg:pr-6 flex flex-col min-h-0">
<Tabs value={activeTab} onValueChange={setActiveTab} className="flex flex-col h-full">
<div className="flex items-center justify-between mb-4 px-2 shrink-0">
<h3 className="font-semibold">Chapters</h3>
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="chapters" className="gap-2">
<List className="h-4 w-4" />
</TabsTrigger>
<TabsTrigger value="knowledge" className="gap-2" disabled={!selectedId}>
<Tag className="h-4 w-4" />
{currentChapterKPs.length > 0 && (
<Badge variant="secondary" className="ml-1 px-1 py-0 h-5 text-[10px]">{currentChapterKPs.length}</Badge>
)}
</TabsTrigger>
</TabsList>
</div>
<ScrollArea className="flex-1 min-h-0 px-2">
<div className="space-y-1">
{chapters.map((chapter) => (
<ReaderChapterItem
key={chapter.id}
chapter={chapter}
selectedId={selectedId}
onSelect={handleSelect}
<TabsContent value="chapters" className="flex-1 min-h-0 mt-0">
<ScrollArea className="flex-1 h-full px-2">
<div className="space-y-1 pb-4">
<ChapterSidebarList
chapters={chapters}
selectedChapterId={selectedId || undefined}
onSelectChapter={handleSelect}
textbookId={textbookId || ""}
canEdit={canEdit}
/>
</div>
</ScrollArea>
</TabsContent>
<TabsContent value="knowledge" className="flex-1 min-h-0 mt-0">
{!selectedId ? (
<div className="flex h-full items-center justify-center text-muted-foreground p-4 text-center text-sm">
</div>
) : currentChapterKPs.length === 0 ? (
<div className="flex h-full items-center justify-center text-muted-foreground p-4 text-center text-sm">
</div>
) : (
<ScrollArea className="flex-1 h-full px-2">
<div className="space-y-2 pb-4">
{currentChapterKPs.map((kp) => (
<div
key={kp.id}
className={cn(
"p-3 rounded-lg border bg-card hover:bg-accent/50 transition-colors cursor-pointer",
highlightedKpId === kp.id && "border-primary bg-primary/5"
)}
onClick={() => setHighlightedKpId(kp.id)}
>
<div className="flex items-start justify-between gap-2">
<h4 className="text-sm font-medium leading-none">{kp.name}</h4>
<div className="flex items-center gap-1">
<Badge variant="outline" className="text-[10px] h-5 px-1">Lv.{kp.level}</Badge>
{canEdit && (
<div className="flex items-center gap-1 ml-1">
<Button
variant="ghost"
size="icon"
className="h-5 w-5 text-muted-foreground hover:text-foreground"
onClick={(e) => {
e.stopPropagation()
setTargetKpForQuestion(kp)
setQuestionDialogOpen(true)
}}
title="创建相关题目"
>
<PlusCircle className="h-3 w-3" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-5 w-5 text-muted-foreground hover:text-foreground"
onClick={(e) => {
e.stopPropagation()
setEditingKp(kp)
setEditKpDialogOpen(true)
}}
title="编辑知识点"
>
<Pencil className="h-3 w-3" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-5 w-5 text-muted-foreground hover:text-destructive"
onClick={(e) => handleDeleteKnowledgePoint(kp.id, e)}
title="删除知识点"
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
)}
</div>
</div>
{kp.description && (
<p className="mt-2 text-xs text-muted-foreground line-clamp-2">
{kp.description}
</p>
)}
</div>
))}
</div>
</ScrollArea>
)}
</TabsContent>
</Tabs>
</div>
<div className="lg:col-span-8 flex flex-col min-h-0">
<div className="lg:col-span-8 flex flex-col min-h-0 relative">
<Dialog open={createDialogOpen} onOpenChange={setCreateDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle></DialogTitle>
<DialogDescription>
</DialogDescription>
</DialogHeader>
<form action={handleCreateKnowledgePoint}>
<div className="grid gap-4 py-4">
<div className="grid gap-2">
<Label htmlFor="name"></Label>
<Input id="name" name="name" defaultValue={selectedText} required />
</div>
<div className="grid gap-2">
<Label htmlFor="description"></Label>
<Textarea id="description" name="description" placeholder="请输入描述..." />
</div>
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => setCreateDialogOpen(false)} disabled={isCreating}>
</Button>
<Button type="submit" disabled={isCreating}>
{isCreating ? "创建中..." : "创建"}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
<Dialog open={editKpDialogOpen} onOpenChange={setEditKpDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle></DialogTitle>
<DialogDescription>
</DialogDescription>
</DialogHeader>
<form action={handleUpdateKnowledgePoint}>
<div className="grid gap-4 py-4">
<div className="grid gap-2">
<Label htmlFor="edit-name"></Label>
<Input id="edit-name" name="name" defaultValue={editingKp?.name} required />
</div>
<div className="grid gap-2">
<Label htmlFor="edit-description"></Label>
<Textarea id="edit-description" name="description" defaultValue={editingKp?.description || ""} placeholder="请输入描述..." />
</div>
<div className="space-y-2 border rounded-md p-3 bg-muted/20">
<div className="flex items-center justify-between">
<Label htmlFor="edit-anchorText" className="text-muted-foreground text-xs flex items-center gap-1">
()
</Label>
</div>
<div className="pt-2">
<Input
key={editingKp?.id} // Force re-render when kp changes
id="edit-anchorText"
name="anchorText"
defaultValue={editingKp?.anchorText || editingKp?.name}
className="text-sm font-mono"
required
/>
<p className="text-[10px] text-muted-foreground mt-1">
</p>
</div>
</div>
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => setEditKpDialogOpen(false)} disabled={isUpdatingKp}>
</Button>
<Button type="submit" disabled={isUpdatingKp}>
{isUpdatingKp ? "保存中..." : "保存"}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
<CreateQuestionDialog
open={questionDialogOpen}
onOpenChange={setQuestionDialogOpen}
defaultKnowledgePointIds={targetKpForQuestion ? [targetKpForQuestion.id] : []}
defaultContent={targetKpForQuestion ? `Please explain the knowledge point: ${targetKpForQuestion.name}` : ""}
defaultType="text"
/>
{selected ? (
<>
<div className="flex items-center justify-between mb-4 pb-2 border-b px-2 shrink-0">
<h2 className="text-xl font-bold tracking-tight line-clamp-1">{selected.title}</h2>
</div>
<ScrollArea className="flex-1 min-h-0 px-2">
<div className="p-4 min-h-full">
{selected.content ? (
<div className="prose prose-sm dark:prose-invert max-w-none">
<ReactMarkdown remarkPlugins={[remarkGfm, remarkBreaks]}>{selected.content}</ReactMarkdown>
</div>
{canEdit && (
<div className="flex gap-2">
{isEditing ? (
<>
<Button size="sm" variant="ghost" onClick={() => setIsEditing(false)} disabled={isSaving}>
</Button>
<Button size="sm" onClick={handleSaveContent} disabled={isSaving}>
<Save className="mr-2 h-4 w-4" />
{isSaving ? "保存中..." : "保存"}
</Button>
</>
) : (
<div className="text-muted-foreground italic py-8 text-center">No content available.</div>
<Button size="sm" variant="outline" onClick={startEditing}>
<Edit2 className="mr-2 h-4 w-4" />
</Button>
)}
</div>
)}
</div>
<ScrollArea className="flex-1 min-h-0 px-2">
{isEditing ? (
<div className="h-full">
<RichTextEditor
value={editContent}
onChange={setEditContent}
className="min-h-[500px] border-none shadow-none"
/>
</div>
) : (
<ContextMenu onOpenChange={handleContextMenuChange}>
<ContextMenuTrigger asChild>
<div
className="p-4 min-h-full"
ref={contentRef}
onPointerDown={handleContentPointerDown}
>
{selected.content ? (
<div className="prose prose-sm dark:prose-invert max-w-none">
<ReactMarkdown
remarkPlugins={[remarkGfm, remarkBreaks]}
components={{
a: ({ href, children, ...props }) => {
if (href?.startsWith("#kp-")) {
const id = href.replace("#kp-", "")
const isHighlighted = highlightedKpId === id
return (
<span
data-kp-id={id}
className={cn(
"font-medium text-primary cursor-pointer hover:underline decoration-dashed underline-offset-4 transition-all duration-300",
isHighlighted && "bg-yellow-300 dark:bg-yellow-600 text-black dark:text-white rounded px-1 py-0.5 shadow-sm scale-110 inline-block mx-0.5 font-bold ring-2 ring-yellow-400/50 dark:ring-yellow-500/50"
)}
onClick={(e) => {
e.preventDefault()
setHighlightedKpId(id)
setActiveTab("knowledge")
}}
title="点击查看知识点详情"
>
{children}
</span>
)
}
return <a href={href} {...props}>{children}</a>
}
}}
>
{processedContent}
</ReactMarkdown>
</div>
) : (
<div className="text-muted-foreground italic py-8 text-center"></div>
)}
</div>
</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuItem
disabled={!selectedText}
onClick={() => setCreateDialogOpen(true)}
>
<Plus className="mr-2 h-4 w-4" />
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
)}
</ScrollArea>
</>
) : (
<div className="flex-1 flex items-center justify-center text-muted-foreground px-2">
Select a chapter to start reading.
</div>
)}
</div>
</div>
)
}

View File

@@ -366,29 +366,28 @@ export const getKnowledgePointsByTextbookId = cache(async (textbookId: string):
}))
})
export async function createKnowledgePoint(data: CreateKnowledgePointInput): Promise<KnowledgePoint> {
const id = createId()
const row = {
id,
name: data.name.trim(),
description: normalizeOptional(data.description ?? null),
export async function createKnowledgePoint(data: { name: string; description?: string; anchorText?: string; chapterId?: string; parentId?: string }): Promise<void> {
await db.insert(knowledgePoints).values({
id: createId(),
name: data.name,
description: data.description,
anchorText: data.anchorText,
chapterId: data.chapterId,
level: 1,
order: 0,
}
parentId: data.parentId,
level: 0, // Default level
order: 0, // Default order
})
}
await db.insert(knowledgePoints).values(row)
return {
id: row.id,
name: row.name,
description: row.description,
parentId: null,
chapterId: row.chapterId,
level: row.level,
order: row.order,
}
export async function updateKnowledgePoint(data: { id: string; name: string; description?: string; anchorText?: string }): Promise<void> {
await db
.update(knowledgePoints)
.set({
name: data.name,
description: data.description,
anchorText: data.anchorText,
})
.where(eq(knowledgePoints.id, data.id))
}
export async function deleteKnowledgePoint(id: string): Promise<void> {

View File

@@ -48,6 +48,7 @@ export type KnowledgePoint = {
id: string;
name: string;
description?: string | null;
anchorText?: string | null;
parentId?: string | null;
chapterId?: string; // Logic link for this module context
level: number;
@@ -69,5 +70,14 @@ export type UpdateChapterContentInput = {
export type CreateKnowledgePointInput = {
name: string;
description?: string;
chapterId: string;
anchorText?: string;
parentId?: string;
chapterId?: string;
};
export type UpdateKnowledgePointInput = {
id: string;
name: string;
description?: string;
anchorText?: string;
};

View File

@@ -0,0 +1,200 @@
"use client"
import * as React from "react"
import * as ContextMenuPrimitive from "@radix-ui/react-context-menu"
import { Check, ChevronRight, Circle } from "lucide-react"
import { cn } from "@/shared/lib/utils"
const ContextMenu = ContextMenuPrimitive.Root
const ContextMenuTrigger = ContextMenuPrimitive.Trigger
const ContextMenuGroup = ContextMenuPrimitive.Group
const ContextMenuPortal = ContextMenuPrimitive.Portal
const ContextMenuSub = ContextMenuPrimitive.Sub
const ContextMenuRadioGroup = ContextMenuPrimitive.RadioGroup
const ContextMenuSubTrigger = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.SubTrigger> & {
inset?: boolean
}
>(({ className, inset, children, ...props }, ref) => (
<ContextMenuPrimitive.SubTrigger
ref={ref}
className={cn(
"flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground",
inset && "pl-8",
className
)}
{...props}
>
{children}
<ChevronRight className="ml-auto h-4 w-4" />
</ContextMenuPrimitive.SubTrigger>
))
ContextMenuSubTrigger.displayName = ContextMenuPrimitive.SubTrigger.displayName
const ContextMenuSubContent = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.SubContent>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.SubContent>
>(({ className, ...props }, ref) => (
<ContextMenuPrimitive.SubContent
ref={ref}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
/>
))
ContextMenuSubContent.displayName = ContextMenuPrimitive.SubContent.displayName
const ContextMenuContent = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Content>
>(({ className, ...props }, ref) => (
<ContextMenuPrimitive.Portal>
<ContextMenuPrimitive.Content
ref={ref}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md animate-in fade-in-80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
/>
</ContextMenuPrimitive.Portal>
))
ContextMenuContent.displayName = ContextMenuPrimitive.Content.displayName
const ContextMenuItem = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Item> & {
inset?: boolean
}
>(({ className, inset, ...props }, ref) => (
<ContextMenuPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
inset && "pl-8",
className
)}
{...props}
/>
))
ContextMenuItem.displayName = ContextMenuPrimitive.Item.displayName
const ContextMenuCheckboxItem = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.CheckboxItem>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.CheckboxItem>
>(({ className, children, checked, ...props }, ref) => (
<ContextMenuPrimitive.CheckboxItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
checked={checked}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<ContextMenuPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</ContextMenuPrimitive.ItemIndicator>
</span>
{children}
</ContextMenuPrimitive.CheckboxItem>
))
ContextMenuCheckboxItem.displayName =
ContextMenuPrimitive.CheckboxItem.displayName
const ContextMenuRadioItem = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.RadioItem>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.RadioItem>
>(({ className, children, ...props }, ref) => (
<ContextMenuPrimitive.RadioItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<ContextMenuPrimitive.ItemIndicator>
<Circle className="h-2 w-2 fill-current" />
</ContextMenuPrimitive.ItemIndicator>
</span>
{children}
</ContextMenuPrimitive.RadioItem>
))
ContextMenuRadioItem.displayName = ContextMenuPrimitive.RadioItem.displayName
const ContextMenuLabel = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Label> & {
inset?: boolean
}
>(({ className, inset, ...props }, ref) => (
<ContextMenuPrimitive.Label
ref={ref}
className={cn(
"px-2 py-1.5 text-sm font-semibold text-foreground",
inset && "pl-8",
className
)}
{...props}
/>
))
ContextMenuLabel.displayName = ContextMenuPrimitive.Label.displayName
const ContextMenuSeparator = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Separator>
>(({ className, ...props }, ref) => (
<ContextMenuPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-border", className)}
{...props}
/>
))
ContextMenuSeparator.displayName = ContextMenuPrimitive.Separator.displayName
const ContextMenuShortcut = ({
className,
...props
}: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span
className={cn(
"ml-auto text-xs tracking-widest text-muted-foreground",
className
)}
{...props}
/>
)
}
ContextMenuShortcut.displayName = "ContextMenuShortcut"
export {
ContextMenu,
ContextMenuTrigger,
ContextMenuContent,
ContextMenuItem,
ContextMenuCheckboxItem,
ContextMenuRadioItem,
ContextMenuLabel,
ContextMenuSeparator,
ContextMenuShortcut,
ContextMenuGroup,
ContextMenuPortal,
ContextMenuSub,
ContextMenuSubContent,
ContextMenuSubTrigger,
ContextMenuRadioGroup,
}

View File

@@ -0,0 +1,29 @@
"use client"
import * as React from "react"
import * as HoverCardPrimitive from "@radix-ui/react-hover-card"
import { cn } from "@/shared/lib/utils"
const HoverCard = HoverCardPrimitive.Root
const HoverCardTrigger = HoverCardPrimitive.Trigger
const HoverCardContent = React.forwardRef<
React.ElementRef<typeof HoverCardPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof HoverCardPrimitive.Content>
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
<HoverCardPrimitive.Content
ref={ref}
align={align}
sideOffset={sideOffset}
className={cn(
"z-50 w-64 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
/>
))
HoverCardContent.displayName = HoverCardPrimitive.Content.displayName
export { HoverCard, HoverCardTrigger, HoverCardContent }

View File

@@ -118,6 +118,7 @@ export const knowledgePoints = mysqlTable("knowledge_points", {
id: id("id").primaryKey(),
name: varchar("name", { length: 255 }).notNull(),
description: text("description"),
anchorText: varchar("anchor_text", { length: 255 }),
// Tree Structure: Parent KP
parentId: varchar("parent_id", { length: 128 }), // Self-reference defined in relations