Module Update
This commit is contained in:
@@ -18,6 +18,16 @@ jobs:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
|
||||
# 1. 增加 Cache 策略,显著加快 npm ci 速度
|
||||
- name: Cache npm dependencies
|
||||
uses: actions/cache@v3
|
||||
id: npm-cache
|
||||
with:
|
||||
path: ~/.npm
|
||||
key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-node-
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
@@ -27,6 +37,18 @@ jobs:
|
||||
- name: Typecheck
|
||||
run: npm run typecheck
|
||||
|
||||
# 2. 增加 Next.js 构建缓存
|
||||
- name: Cache Next.js build
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: |
|
||||
~/.npm
|
||||
${{ github.workspace }}/.next/cache
|
||||
# Generate a new cache whenever packages or source files change.
|
||||
key: ${{ runner.os }}-nextjs-${{ hashFiles('**/package-lock.json') }}-${{ hashFiles('**/*.js', '**/*.jsx', '**/*.ts', '**/*.tsx') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-nextjs-${{ hashFiles('**/package-lock.json') }}-
|
||||
|
||||
- name: Build
|
||||
run: npm run build
|
||||
|
||||
@@ -35,7 +57,7 @@ jobs:
|
||||
# echo "======================="
|
||||
# echo "1. Root directory files:"
|
||||
# ls -la
|
||||
|
||||
#
|
||||
# echo "======================="
|
||||
# echo "2. Checking .next directory:"
|
||||
# if [ -d ".next" ]; then
|
||||
@@ -58,10 +80,10 @@ jobs:
|
||||
cp -r .next/static/* .next/standalone/.next/static/
|
||||
cp Dockerfile .next/standalone/Dockerfile
|
||||
|
||||
- name: 🔍 Debug - List Build Files
|
||||
run: |
|
||||
echo "======================="
|
||||
ls -la .next/standalone
|
||||
# - name: 🔍 Debug - List Build Files
|
||||
# run: |
|
||||
# echo "======================="
|
||||
# ls -la .next/standalone
|
||||
|
||||
- name: Upload production build artifact
|
||||
uses: actions/upload-artifact@v3
|
||||
@@ -83,8 +105,21 @@ jobs:
|
||||
|
||||
- name: Deploy to Docker
|
||||
run: |
|
||||
docker build -t nextjs-app .
|
||||
# 1. 使用 --no-cache 防止使用旧的构建层,确保部署的是最新代码
|
||||
# 2. 使用 --pull 确保基础镜像是最新的
|
||||
docker build --no-cache --pull -t nextjs-app .
|
||||
|
||||
# 3. 优雅停止:先尝试 stop,如果失败则无需处理 (|| true)
|
||||
docker stop nextjs-app || true
|
||||
docker rm nextjs-app || true
|
||||
docker run -d -p 8015:3000 --restart unless-stopped --name nextjs-app nextjs-app
|
||||
|
||||
# 4. 运行容器:
|
||||
# --init: 解决 Node.js PID 1 僵尸进程问题
|
||||
# --restart unless-stopped: 自动重启策略
|
||||
docker run -d \
|
||||
--init \
|
||||
-p 8015:3000 \
|
||||
--restart unless-stopped \
|
||||
--name nextjs-app \
|
||||
nextjs-app
|
||||
|
||||
|
||||
332
ARCHITECTURE.md
Normal file
332
ARCHITECTURE.md
Normal file
@@ -0,0 +1,332 @@
|
||||
# Next_Edu Architecture RFC
|
||||
|
||||
**Status**: PROPOSED
|
||||
**Date**: 2025-12-22
|
||||
**Author**: Principal Software Architect
|
||||
**Version**: 1.0.0
|
||||
|
||||
---
|
||||
|
||||
## 1. 核心原则 (Core Principles)
|
||||
|
||||
本架构设计遵循以下核心原则,旨在构建一个高性能、可扩展、易维护的企业级在线教育平台。
|
||||
|
||||
1. **Vertical Slice Architecture (垂直切片)**: 拒绝传统的按技术分层(Layered Architecture),采用按业务功能分层。代码应根据“它属于哪个功能”而不是“它是什么文件”来组织。
|
||||
2. **Type Safety First (类型安全优先)**: 全链路 TypeScript (Strict Mode)。从数据库 Schema 到 API 再到 UI 组件,必须保持类型一致性。
|
||||
3. **Server-First (服务端优先)**: 充分利用 Next.js 15 App Router 的 RSC (React Server Components) 能力,减少客户端 Bundle 体积。
|
||||
4. **Performance by Default (默认高性能)**: 严禁引入重型动画库,动效优先使用 CSS Native 实现。Web Vitals 指标作为 CI 阻断标准。
|
||||
5. **Strict Engineering (严格工程化)**: CI/CD 流程标准化,代码风格统一,自动化测试覆盖。
|
||||
|
||||
---
|
||||
|
||||
## 2. 技术栈全景图 (Technology Panorama)
|
||||
|
||||
### 核心框架
|
||||
* **Framework**: Next.js 15 (App Router)
|
||||
* **Language**: TypeScript 5.x (Strict Mode enabled)
|
||||
* *Correction*: 鉴于 Next.js App Router 的特性,`.tsx` 仅用于包含 JSX 的组件文件。所有的业务逻辑、Hooks、API 路由、Lib 工具函数必须使用 `.ts` 后缀,以明确区分“渲染层”与“逻辑层”。
|
||||
* **Runtime**: Node.js 20.x (LTS)
|
||||
|
||||
### 数据层
|
||||
* **Database**: MySQL 8.0+
|
||||
* **ORM**: Drizzle ORM (轻量、无运行时开销、类型安全)
|
||||
* **Driver**: `mysql2` (配合连接池) 或 `serverless-mysql` (针对特定 Serverless 环境)
|
||||
* **Validation**: Zod (Schema 定义与运行时验证)
|
||||
|
||||
### UI/UX 层
|
||||
* **Styling**: Tailwind CSS v3.4+
|
||||
* **Components**: Shadcn/UI (基于 Radix UI 的 Headless 组件拷贝)
|
||||
* **Icons**: Lucide React
|
||||
* **Animations**: CSS Transitions / Tailwind `animate-*` / `tailwindcss-animate`
|
||||
* *Complex Interactions*: Framer Motion (仅限按需加载 `LazyMotion`)
|
||||
|
||||
### 身份验证与授权
|
||||
* **Auth**: Auth.js v5 (NextAuth)
|
||||
* *Decision Driver*: 相比 Clerk,Auth.js 提供了完全的**数据所有权**和**无 Vendor Lock-in**。
|
||||
* *Enterprise Needs*: 允许自定义 Session 结构(如注入 `Role` 字段)并直接对接现有 MySQL 数据库,满足复杂的企业级权限管理需求。
|
||||
|
||||
### 状态管理
|
||||
* **Server State**: TanStack Query v5 (仅用于复杂客户端轮询/无限加载)
|
||||
* **URL State (Primary)**: Nuqs (Type-safe search params state manager)
|
||||
* *Principle*: 绝大多数状态(筛选、分页、Tab)应存在 URL 中,以支持分享和书签。
|
||||
* **Global Client State (Secondary)**: Zustand
|
||||
* *Usage*: 仅限极少数全局交互状态(如播放器悬浮窗、全局 Modal)。
|
||||
* *Anti-pattern*: **严禁使用 Redux**。避免不必要的样板代码和 Bundle 体积。
|
||||
|
||||
### 基础设施 & DevOps
|
||||
* **CI/CD**: GitHub Actions (Strictly v3)
|
||||
* **Linting**: ESLint (Next.js config), Prettier
|
||||
* **Package Manager**: pnpm (推荐) 或 npm
|
||||
|
||||
---
|
||||
|
||||
## 3. 项目目录结构规范 (Project Structure)
|
||||
|
||||
采用 **Feature-based / Vertical Slice** 架构。所有业务逻辑应封装在 `src/modules` 中。
|
||||
|
||||
文档存放位置:
|
||||
* 架构设计文档: `docs/architecture/`
|
||||
* API 规范文档: `docs/api/`
|
||||
|
||||
### 目录树 (Directory Tree)
|
||||
|
||||
```
|
||||
Next_Edu/
|
||||
├── .github/
|
||||
│ └── workflows/
|
||||
│ └── ci.yml # GitHub Actions (v3 strict)
|
||||
├── docs/
|
||||
│ └── architecture/ # 架构决策记录 (ADR)
|
||||
├── drizzle/ # 数据库迁移文件 (Generated)
|
||||
├── public/ # 静态资源
|
||||
├── src/
|
||||
│ ├── app/ # [路由层] 极薄,仅负责路由分发和布局
|
||||
│ │ ├── (auth)/ # 路由组
|
||||
│ │ ├── (dashboard)/
|
||||
│ │ ├── api/ # Webhooks / External APIs
|
||||
│ │ ├── layout.tsx
|
||||
│ │ └── page.tsx
|
||||
│ │
|
||||
│ ├── modules/ # [核心业务层] 垂直切片
|
||||
│ │ ├── courses/ # 课程模块
|
||||
│ │ │ ├── components/ # 模块私有组件 (CourseCard, Player)
|
||||
│ │ │ ├── actions.ts # Server Actions (业务逻辑入口)
|
||||
│ │ │ ├── service.ts # 领域服务 (可选,复杂逻辑拆分)
|
||||
│ │ │ ├── data-access.ts # 数据库查询 (DTOs)
|
||||
│ │ │ └── types.ts # 模块私有类型
|
||||
│ │ │
|
||||
│ │ ├── users/ # 用户模块
|
||||
│ │ ├── payments/ # 支付模块
|
||||
│ │ └── community/ # 社区模块
|
||||
│ │
|
||||
│ ├── shared/ # [共享层] 仅存放真正通用的代码
|
||||
│ │ ├── components/ # 通用 UI (Button, Dialog - Shadcn)
|
||||
│ │ ├── lib/ # 通用工具 (utils, date formatting)
|
||||
│ │ ├── db/ # Drizzle Client & Schema
|
||||
│ │ │ ├── index.ts # DB 连接实例
|
||||
│ │ │ └── schema.ts # 全局 Schema 定义 (或按模块拆分导出)
|
||||
│ │ └── hooks/ # 通用 Hooks
|
||||
│ │
|
||||
│ ├── env.mjs # 环境变量类型检查
|
||||
│ └── middleware.ts # 边缘中间件 (Auth check)
|
||||
├── drizzle.config.ts # Drizzle 配置文件
|
||||
├── next.config.mjs
|
||||
└── package.json
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. 数据库层设计 (Database Strategy)
|
||||
|
||||
### 连接配置 (Connection Pooling)
|
||||
在 Next.js 的 Serverless/Edge 环境中,直接连接 MySQL 可能导致连接数耗尽。我们采取以下策略:
|
||||
|
||||
1. **开发环境**: 使用 Global Singleton 模式防止 Hot Reload 导致连接泄露。
|
||||
2. **生产环境**:
|
||||
* 推荐使用支持 HTTP 连接或内置连接池的 Serverless MySQL 方案 (如 PlanetScale)。
|
||||
* 若使用标准 MySQL,必须配置连接池 (`connectionLimit`) 并合理设置空闲超时。
|
||||
|
||||
**代码示例 (`src/shared/db/index.ts`)**:
|
||||
|
||||
```typescript
|
||||
import { drizzle } from "drizzle-orm/mysql2";
|
||||
import mysql from "mysql2/promise";
|
||||
import * as schema from "./schema";
|
||||
|
||||
// Global cache to prevent connection exhaustion in development
|
||||
const globalForDb = globalThis as unknown as {
|
||||
conn: mysql.Pool | undefined;
|
||||
};
|
||||
|
||||
const poolConnection = globalForDb.conn ?? mysql.createPool({
|
||||
uri: process.env.DATABASE_URL,
|
||||
waitForConnections: true,
|
||||
connectionLimit: 10, // 根据数据库规格调整
|
||||
queueLimit: 0
|
||||
});
|
||||
|
||||
if (process.env.NODE_ENV !== "production") globalForDb.conn = poolConnection;
|
||||
|
||||
export const db = drizzle(poolConnection, { schema, mode: "default" });
|
||||
```
|
||||
|
||||
### Migration 策略
|
||||
* 使用 `drizzle-kit` 进行迁移管理。
|
||||
* 严禁在生产环境运行时自动执行 Migration。
|
||||
* **流程**:
|
||||
1. 修改 Schema (`schema.ts`).
|
||||
2. 运行 `pnpm drizzle-kit generate` 生成 SQL 文件。
|
||||
3. Review SQL 文件。
|
||||
4. 在 CI/CD 部署前置步骤或手动运行 `pnpm drizzle-kit migrate`。
|
||||
|
||||
### Server Components 中的数据查询
|
||||
* **Colocation**: 查询逻辑应尽量靠近使用它的组件,或者封装在 `data-access.ts` 中。
|
||||
* **Request Memoization**: 即使在一个请求中多次调用相同的查询函数,Next.js 的 `cache` (或 React `cache`) 也会自动去重。
|
||||
|
||||
```typescript
|
||||
// src/modules/courses/data-access.ts
|
||||
import { cache } from 'react';
|
||||
import { db } from '@/shared/db';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { courses } from '@/shared/db/schema';
|
||||
|
||||
// 使用 React cache 确保单次请求内的去重
|
||||
export const getCourseById = cache(async (id: string) => {
|
||||
return await db.query.courses.findFirst({
|
||||
where: eq(courses.id, id),
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. UI/UX 动效规范 (Animation Guidelines)
|
||||
|
||||
### 核心策略
|
||||
* **CSS Native First**: 90% 的交互通过 CSS `transition` 和 `animation` 实现。
|
||||
* **Hardware Acceleration**: 确保动画属性触发 GPU 加速 (`transform`, `opacity`)。
|
||||
* **Micro-interactions**: 关注 `:hover`, `:active`, `:focus-visible` 状态。
|
||||
|
||||
### 高性能通用组件示例 (Interactive Card)
|
||||
|
||||
这是一个符合规范的卡片组件,使用了 Tailwind 的 `group` 和 `transform` 属性实现丝滑的微交互,且没有 JS 运行时开销。
|
||||
|
||||
```tsx
|
||||
// src/shared/components/ui/interactive-card.tsx
|
||||
import { cn } from "@/shared/lib/utils";
|
||||
|
||||
interface CardProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function InteractiveCard({ className, children, ...props }: CardProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"group relative overflow-hidden rounded-xl border border-border bg-card text-card-foreground shadow-sm",
|
||||
// 核心动效:
|
||||
// 1. duration-300 ease-out: 丝滑的时间函数
|
||||
// 2. hover:shadow-md: 悬浮提升感
|
||||
// 3. hover:-translate-y-1: 物理反馈
|
||||
"transition-all duration-300 ease-out hover:-translate-y-1 hover:shadow-md",
|
||||
// 消除 Safari 上的闪烁
|
||||
"transform-gpu backface-hidden",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{/* 光泽效果 (Shimmer Effect) - 仅 CSS */}
|
||||
<div
|
||||
className="absolute inset-0 -translate-x-full bg-gradient-to-r from-transparent via-white/5 to-transparent transition-transform duration-700 group-hover:translate-x-full"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
|
||||
<div className="relative p-6">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. CI/CD 配置文件模板 (GitHub Actions)
|
||||
|
||||
**警告**: 必须严格遵守 `v3` 版本限制。严禁使用 `v4`。
|
||||
|
||||
文件路径: `.github/workflows/ci.yml`
|
||||
|
||||
```yaml
|
||||
name: CI/CD Pipeline
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ "main", "develop" ]
|
||||
pull_request:
|
||||
branches: [ "main", "develop" ]
|
||||
|
||||
env:
|
||||
NODE_VERSION: '20.x'
|
||||
|
||||
jobs:
|
||||
quality-check:
|
||||
name: Quality & Type Check
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
# 强制使用 v3
|
||||
- name: Checkout Code
|
||||
uses: actions/checkout@v3
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
cache: 'npm' # 或 'pnpm'
|
||||
|
||||
- name: Install Dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Linting (ESLint)
|
||||
run: npm run lint
|
||||
|
||||
- name: Type Checking (TSC)
|
||||
# 确保没有 TS 错误
|
||||
run: npx tsc --noEmit
|
||||
|
||||
test:
|
||||
name: Unit Tests
|
||||
needs: quality-check
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout Code
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
cache: 'npm'
|
||||
|
||||
- name: Install Dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Run Tests
|
||||
run: npm run test
|
||||
|
||||
build-check:
|
||||
name: Production Build Check
|
||||
needs: test
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout Code
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
cache: 'npm'
|
||||
|
||||
- name: Install Dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Cache Next.js build
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: |
|
||||
~/.npm
|
||||
${{ github.workspace }}/.next/cache
|
||||
# Generate a new cache whenever packages or source files change.
|
||||
key: ${{ runner.os }}-nextjs-${{ hashFiles('**/package-lock.json') }}-${{ hashFiles('**/*.js', '**/*.jsx', '**/*.ts', '**/*.tsx') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-nextjs-${{ hashFiles('**/package-lock.json') }}-
|
||||
|
||||
- name: Build Application
|
||||
run: npm run build
|
||||
env:
|
||||
# 构建时跳过 ESLint/TS 检查 (因为已经在 quality-check job 做过了,加速构建)
|
||||
NEXT_TELEMETRY_DISABLED: 1
|
||||
```
|
||||
112
docs/architecture/001_database_schema_design.md
Normal file
112
docs/architecture/001_database_schema_design.md
Normal file
@@ -0,0 +1,112 @@
|
||||
# 架构决策记录 (ADR): 数据库 Schema 设计方案 v1.0
|
||||
|
||||
**状态**: 已实施 (IMPLEMENTED)
|
||||
**日期**: 2025-12-23
|
||||
**作者**: 首席系统架构师
|
||||
**背景**: Next_Edu 平台 - K12 智慧教育管理系统
|
||||
|
||||
---
|
||||
|
||||
## 1. 概述 (Overview)
|
||||
|
||||
本文档详细记录了 Next_Edu 平台的数据库 Schema 架构设计。本设计优先考虑 **可扩展性 (Scalability)**、**灵活性 (Flexibility)**(针对复杂的嵌套内容)以及 **严格的类型安全 (Strict Type Safety)**,并完全符合 PRD 中规定的领域驱动设计 (DDD) 原则。
|
||||
|
||||
## 2. 技术栈决策 (Technology Stack Decisions)
|
||||
|
||||
| 组件 | 选择 | 理由 |
|
||||
| :--- | :--- | :--- |
|
||||
| **数据库** | MySQL 8.0+ | 强大的关系型支持,完善的 JSON 能力,行业标准。 |
|
||||
| **ORM** | Drizzle ORM | 轻量级,零运行时开销 (Zero-runtime overhead),业界一流的 TypeScript 类型推断。 |
|
||||
| **ID 策略** | CUID2 | 分布式友好 (k-sortable),安全(防连续猜测攻击),比 UUID 更短。 |
|
||||
| **认证方案** | Auth.js v5 | 标准化的 OAuth 流程,支持自定义数据库适配器。 |
|
||||
|
||||
---
|
||||
|
||||
## 3. 核心 Schema 领域模型 (Core Schema Domains)
|
||||
|
||||
物理文件位于 `src/shared/db/schema.ts`,逻辑上分为三大领域。
|
||||
|
||||
### 3.1 身份与访问管理 (IAM)
|
||||
|
||||
我们采用了 **Auth.js 标准表** 与 **自定义 RBAC** 相结合的混合模式。
|
||||
|
||||
* **标准表**: `users`, `accounts` (OAuth), `sessions`, `verificationTokens`。
|
||||
* **RBAC 扩展**:
|
||||
* `roles`: 定义系统角色(例如:`grade_head` 年级主任, `teacher` 老师)。
|
||||
* `users_to_roles`: 多对多关联表。
|
||||
* **设计目标**: 解决“一人多职”问题(例如:一个老师同时也是年级主任),避免在 `users` 表中堆砌字段。
|
||||
|
||||
### 3.2 智能题库中心 (Intelligent Question Bank) - 核心
|
||||
|
||||
这是最复杂的领域,需要支持无限层级嵌套和富文本内容。
|
||||
|
||||
#### 实体定义:
|
||||
1. **`questions` (题目表)**:
|
||||
* `id`: CUID2。
|
||||
* `content`: **JSON 类型**。存储结构化内容(如 SlateJS 节点),支持富文本、图片和公式混排。
|
||||
* `parentId`: **自引用 (Self-Reference)**。
|
||||
* *若为 NULL*: 独立题目 或 “大题干” (Parent)。
|
||||
* *若有值*: 子题目 (Child)(例如:一篇阅读理解下的第1小题)。
|
||||
* `type`: 枚举 (`single_choice`, `text`, `composite` 等)。
|
||||
2. **`knowledge_points` (知识点表)**:
|
||||
* 通过 `parentId` 实现树状结构。
|
||||
* 支持无限层级 (学科 -> 章 -> 节 -> 知识点)。
|
||||
3. **`questions_to_knowledge_points`**:
|
||||
* 多对多关联。一道题可考察多个知识点;一个知识点可关联数千道题。
|
||||
|
||||
### 3.3 教务教学流 (Academic Teaching Flow)
|
||||
|
||||
将物理世界的教学过程映射为数字实体。
|
||||
|
||||
* **`textbooks` & `chapters`**: 标准的教材大纲映射。`chapters` 同样支持通过 `parentId` 进行嵌套。
|
||||
* **`exams`**: 考试/作业的聚合实体。
|
||||
* **`exam_submissions`**: 代表一名学生的单次答题记录。
|
||||
* **`submission_answers`**: 细粒度的答题详情,记录每道题的答案,支持自动评分 (`score`) 和人工反馈 (`feedback`)。
|
||||
|
||||
---
|
||||
|
||||
## 4. 关键设计模式 (Key Design Patterns)
|
||||
|
||||
### 4.1 无限嵌套 ("Composite" Pattern)
|
||||
我们没有为“题干”和“题目”创建单独的表,而是在 `questions` 表上使用 **自引用 (Self-Referencing)** 模式。
|
||||
|
||||
* **优点**:
|
||||
* 统一的查询接口 (`db.query.questions.findFirst({ with: { children: true } })`)。
|
||||
* 递归逻辑可统一应用。
|
||||
* 当内容结构变化时,迁移更简单。
|
||||
* **缺点**:
|
||||
* 需要处理递归查询逻辑(已通过 Drizzle Relations 解决)。
|
||||
|
||||
### 4.2 CUID2 优于 自增 ID
|
||||
* **安全性**: 防止 ID 枚举攻击(猜测下一个用户 ID)。
|
||||
* **分布式**: 支持在客户端或多服务器节点生成,无碰撞风险。
|
||||
* **性能**: `k-sortable` 特性保证了比随机 UUID v4 更好的索引局部性。
|
||||
|
||||
### 4.3 JSON 存储内容
|
||||
* 教育内容不仅仅是“文本”。它包含格式、LaTeX 公式和图片引用。
|
||||
* 使用 `JSON` 存储允许前端 (Next.js) 直接渲染富组件,无需解析复杂的 HTML 字符串。
|
||||
|
||||
---
|
||||
|
||||
## 5. 安全与索引策略 (Security & Indexing Strategy)
|
||||
|
||||
### 索引 (Indexes)
|
||||
* **外键**: 所有外键列 (`author_id`, `parent_id` 等) 均显式建立索引。
|
||||
* **性能**:
|
||||
* `parent_id_idx`: 对树形结构的遍历性能至关重要。
|
||||
* `email_idx`: 登录查询的核心索引。
|
||||
|
||||
### 类型安全 (Type Safety)
|
||||
* 严格的 TypeScript 定义直接从 `src/shared/db/schema.ts` 导出。
|
||||
* Zod Schema (待生成) 将与这些 Drizzle 定义保持 1:1 对齐。
|
||||
|
||||
---
|
||||
|
||||
## 6. 目录结构 (Directory Structure)
|
||||
|
||||
```bash
|
||||
src/shared/db/
|
||||
├── index.ts # 单例数据库连接池
|
||||
├── schema.ts # 物理表结构定义
|
||||
└── relations.ts # 逻辑 Drizzle 关系定义
|
||||
```
|
||||
52
docs/architecture/002_exam_structure_migration.md
Normal file
52
docs/architecture/002_exam_structure_migration.md
Normal file
@@ -0,0 +1,52 @@
|
||||
# Database Schema Change Request: Exam Structure Support
|
||||
|
||||
## 1. Table: `exams`
|
||||
|
||||
### Change
|
||||
**Add Column**: `structure`
|
||||
|
||||
### Details
|
||||
- **Type**: `JSON`
|
||||
- **Nullable**: `TRUE` (Default: `NULL`)
|
||||
|
||||
### Reason
|
||||
To support hierarchical exam structures (e.g., Sections/Groups containing Questions). The existing flat `exam_questions` table only supports a simple list of questions and is insufficient for complex exam layouts (e.g., "Part A: Reading", "Part B: Writing").
|
||||
|
||||
### Before vs After
|
||||
|
||||
**Before**:
|
||||
`exams` table only stores metadata (`title`, `description`, etc.). Question ordering relies solely on `exam_questions.order`.
|
||||
|
||||
**After**:
|
||||
`exams` table includes `structure` column to store the full tree representation:
|
||||
```json
|
||||
[
|
||||
{ "id": "uuid-1", "type": "group", "title": "Section A", "children": [...] },
|
||||
{ "id": "uuid-2", "type": "question", "questionId": "q1", "score": 10 }
|
||||
]
|
||||
```
|
||||
*Note: `exam_questions` table is retained for relational integrity and efficient querying of question usage, but the presentation order/structure is now driven by this new JSON column.*
|
||||
|
||||
---
|
||||
|
||||
## 2. Table: `questions_to_knowledge_points`
|
||||
|
||||
### Change
|
||||
**Rename Foreign Key Constraints**
|
||||
|
||||
### Details
|
||||
- Rename constraint for `question_id` to `q_kp_qid_fk`
|
||||
- Rename constraint for `knowledge_point_id` to `q_kp_kpid_fk`
|
||||
|
||||
### Reason
|
||||
The default generated foreign key names (e.g., `questions_to_knowledge_points_knowledge_point_id_knowledge_points_id_fk`) exceed the MySQL identifier length limit (64 characters), causing migration failures.
|
||||
|
||||
### Before vs After
|
||||
|
||||
**Before**:
|
||||
- FK Name: `questions_to_knowledge_points_question_id_questions_id_fk` (Too long)
|
||||
- FK Name: `questions_to_knowledge_points_knowledge_point_id_knowledge_points_id_fk` (Too long)
|
||||
|
||||
**After**:
|
||||
- FK Name: `q_kp_qid_fk`
|
||||
- FK Name: `q_kp_kpid_fk`
|
||||
115
docs/architecture/002_role_based_routing.md
Normal file
115
docs/architecture/002_role_based_routing.md
Normal file
@@ -0,0 +1,115 @@
|
||||
# Architecture RFC: Role-Based Routing & Directory Structure
|
||||
|
||||
**Status**: PROPOSED
|
||||
**Date**: 2025-12-23
|
||||
**Context**: Next_Edu supports multiple roles (Admin, Teacher, Student, Parent) with distinct UI/UX requirements.
|
||||
|
||||
## 1. 核心问题 (The Problem)
|
||||
目前项目结构中,页面与角色耦合不清。
|
||||
* `/dashboard` 目前硬编码为教师视图。
|
||||
* 不同角色的功能模块(如“课程”)可能有完全不同的视图和逻辑(教师管理课程 vs 学生学习课程)。
|
||||
* 缺乏统一的路由规范指导开发人员“把页面放哪里”。
|
||||
|
||||
## 2. 解决方案:基于角色的路由策略 (Role-Based Routing Strategy)
|
||||
|
||||
我们采用 **"Hybrid Routing" (混合路由)** 策略:
|
||||
1. **Explicit Dashboard Routing**: Dashboards are separated by role in the file structure (e.g., `/teacher/dashboard`).
|
||||
2. **Explicit Role Scopes (显式角色域)**: 具体的业务功能页面放入 `/teacher`, `/student`, `/admin` 专属路径下。
|
||||
|
||||
### 2.1 目录结构 (Directory Structure)
|
||||
|
||||
```
|
||||
src/app/(dashboard)/
|
||||
├── layout.tsx # Shared App Shell (Sidebar, Header)
|
||||
├── dashboard/
|
||||
│ └── page.tsx # [Redirector] Redirects to role-specific dashboard
|
||||
│
|
||||
├── teacher/ # 教师专属路由域
|
||||
│ ├── dashboard/ # Teacher Dashboard
|
||||
│ │ └── page.tsx
|
||||
│ ├── classes/
|
||||
│ │ └── page.tsx
|
||||
│ ├── exams/
|
||||
│ │ └── page.tsx
|
||||
│ └── textbooks/
|
||||
│ └── page.tsx
|
||||
│
|
||||
├── student/ # 学生专属路由域
|
||||
│ ├── dashboard/ # Student Dashboard
|
||||
│ │ └── page.tsx
|
||||
│ ├── learning/
|
||||
│ │ └── page.tsx
|
||||
│ └── schedule/
|
||||
│ └── page.tsx
|
||||
│
|
||||
└── admin/ # 管理员专属路由域
|
||||
├── dashboard/ # Admin Dashboard
|
||||
│ └── page.tsx
|
||||
├── users/
|
||||
└── school/
|
||||
```
|
||||
|
||||
### 2.2 Dashboard Routing Logic
|
||||
|
||||
`/dashboard` 页面现在作为一个 **Portal (入口)** 或 **Redirector (重定向器)**,而不是直接渲染内容。
|
||||
|
||||
**Example: `src/app/(dashboard)/dashboard/page.tsx`**
|
||||
|
||||
```tsx
|
||||
import { redirect } from "next/navigation";
|
||||
import { auth } from "@/auth";
|
||||
|
||||
export default async function DashboardPage() {
|
||||
const session = await auth();
|
||||
const role = session?.user?.role;
|
||||
|
||||
switch (role) {
|
||||
case "teacher":
|
||||
redirect("/teacher/dashboard");
|
||||
case "student":
|
||||
redirect("/student/dashboard");
|
||||
case "admin":
|
||||
redirect("/admin/dashboard");
|
||||
default:
|
||||
redirect("/login");
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 3. 模块化组织 (Module Organization)
|
||||
|
||||
为了避免 `src/app` 变得臃肿,具体的**业务组件**必须存放在 `src/modules` 中。
|
||||
|
||||
```
|
||||
src/modules/
|
||||
├── teacher/ # 教师业务领域
|
||||
│ ├── components/ # 仅教师使用的组件 (e.g., GradebookTable)
|
||||
│ ├── hooks/
|
||||
│ └── actions.ts
|
||||
│
|
||||
├── student/ # 学生业务领域
|
||||
│ ├── components/ # (e.g., CourseProgressCard)
|
||||
│ └── ...
|
||||
│
|
||||
├── shared/ # 跨角色共享
|
||||
│ ├── components/ # (e.g., CourseCard - if generic)
|
||||
```
|
||||
|
||||
## 4. 路由与导航配置 (Navigation Config)
|
||||
|
||||
`src/modules/layout/config/navigation.ts` 已经配置好了基于角色的菜单。
|
||||
* 当用户访问 `/dashboard` 时,根据角色看到不同的 Dashboard 组件。
|
||||
* 点击侧边栏菜单(如 "Exams")时,跳转到显式路径 `/teacher/exams`。
|
||||
|
||||
## 5. 优势 (Benefits)
|
||||
1. **Security**: 可以在 Middleware 或 Layout 层级轻松对 `/admin/*` 路径实施权限控制。
|
||||
2. **Clarity**: 开发者清楚知道“教师的试卷列表页”应该放在 `src/app/(dashboard)/teacher/exams/page.tsx`。
|
||||
3. **Decoupling**: 教师端和学生端的逻辑完全解耦,互不影响。
|
||||
|
||||
---
|
||||
|
||||
## 6. Action Items (执行计划)
|
||||
|
||||
1. **Refactor Dashboard**: 将 `src/app/(dashboard)/dashboard/page.tsx` 重构为 Dispatcher。
|
||||
2. **Create Role Directories**: 在 `src/app/(dashboard)` 下创建 `teacher`, `student`, `admin` 目录。
|
||||
3. **Move Components**: 确保 `src/modules` 结构清晰。
|
||||
39
docs/db/schema-changelog.md
Normal file
39
docs/db/schema-changelog.md
Normal file
@@ -0,0 +1,39 @@
|
||||
# Database Schema Changelog
|
||||
|
||||
## v1.1.0 - Exam Structure & Performance Optimization
|
||||
**Date:** 2025-12-29
|
||||
**Migration ID:** `0001_flawless_texas_twister`
|
||||
**Author:** Principal Database Architect
|
||||
|
||||
### 1. Summary
|
||||
This release introduces support for hierarchical exam structures (Sectioning/Grouping) and optimizes database constraint naming for better compatibility with MySQL environments.
|
||||
|
||||
### 2. Changes
|
||||
|
||||
#### 2.1 Table: `exams`
|
||||
* **Action**: `ADD COLUMN`
|
||||
* **Field**: `structure` (JSON)
|
||||
* **Reason**: To support nested exam layouts (e.g., "Part I: Listening", "Section A").
|
||||
* *Architecture Note*: This JSON field is strictly for **Presentation Layer** ordering and grouping. The `exam_questions` table remains the **Source of Truth** for relational integrity and scoring logic.
|
||||
* **Schema Definition**:
|
||||
```typescript
|
||||
type ExamStructure = Array<
|
||||
| { type: 'group', title: string, children: ExamStructure }
|
||||
| { type: 'question', questionId: string, score: number }
|
||||
>
|
||||
```
|
||||
|
||||
#### 2.2 Table: `questions_to_knowledge_points`
|
||||
* **Action**: `RENAME FOREIGN KEY`
|
||||
* **Details**:
|
||||
* Old: `questions_to_knowledge_points_question_id_questions_id_fk` -> New: `q_kp_qid_fk`
|
||||
* Old: `questions_to_knowledge_points_knowledge_point_id_knowledge_points_id_fk` -> New: `q_kp_kpid_fk`
|
||||
* **Reason**: Previous names exceeded MySQL's 64-character identifier limit, causing potential migration failures in production environments.
|
||||
|
||||
### 3. Migration Strategy
|
||||
* **Up**: Run standard Drizzle migration. The script includes `ALTER TABLE ... DROP FOREIGN KEY` followed by `ADD CONSTRAINT`.
|
||||
* **Down**: Revert `structure` column. Note that FK names can be kept short as they are implementation details.
|
||||
|
||||
### 4. Impact Analysis
|
||||
* **Performance**: Negligible. JSON parsing is done client-side or at application layer.
|
||||
* **Data Integrity**: High. Existing data is unaffected. New `structure` field defaults to `NULL`.
|
||||
75
docs/db/seed-data.md
Normal file
75
docs/db/seed-data.md
Normal file
@@ -0,0 +1,75 @@
|
||||
# Database Seeding Strategy
|
||||
|
||||
**Status**: Implemented
|
||||
**Script Location**: [`scripts/seed.ts`](../../scripts/seed.ts)
|
||||
**Command**: `npm run db:seed`
|
||||
|
||||
---
|
||||
|
||||
## 1. Overview
|
||||
The seed script is designed to populate the database with a **representative set of data** that covers all core business scenarios. It serves two purposes:
|
||||
1. **Development**: Provides a consistent baseline for developers.
|
||||
2. **Validation**: Verifies complex schema relationships (e.g., recursive trees, JSON structures).
|
||||
|
||||
## 2. Seed Data Topology
|
||||
|
||||
### 2.1 Identity & Access Management (IAM)
|
||||
We strictly follow the RBAC model defined in `docs/architecture/001_database_schema_design.md`.
|
||||
|
||||
* **Roles**:
|
||||
* `admin`: System Administrator.
|
||||
* `teacher`: Academic Instructor.
|
||||
* `student`: Learner.
|
||||
* `grade_head`: Head of Grade Year (Demonstrates multi-role capability).
|
||||
* **Users**:
|
||||
* `admin@next-edu.com` (Role: Admin)
|
||||
* `math@next-edu.com` (Role: Teacher + Grade Head)
|
||||
* `alice@next-edu.com` (Role: Student)
|
||||
|
||||
### 2.2 Knowledge Graph
|
||||
Generates a hierarchical structure to test recursive queries (`parentId`).
|
||||
* **Math** (Level 0)
|
||||
* └── **Algebra** (Level 1)
|
||||
* └── **Linear Equations** (Level 2)
|
||||
|
||||
### 2.3 Question Bank
|
||||
Includes rich content and nested structures.
|
||||
1. **Simple Single Choice**: "What is 2 + 2?"
|
||||
2. **Composite Question (Reading Comprehension)**:
|
||||
* **Parent**: A reading passage.
|
||||
* **Child 1**: Single Choice question about the passage.
|
||||
* **Child 2**: Open-ended text question.
|
||||
|
||||
### 2.4 Exams
|
||||
Demonstrates the new **JSON Structure** field (`exams.structure`).
|
||||
* **Title**: "Algebra Mid-Term 2025"
|
||||
* **Structure**:
|
||||
```json
|
||||
[
|
||||
{
|
||||
"type": "group",
|
||||
"title": "Part 1: Basics",
|
||||
"children": [{ "type": "question", "questionId": "...", "score": 10 }]
|
||||
},
|
||||
{
|
||||
"type": "group",
|
||||
"title": "Part 2: Reading",
|
||||
"children": [{ "type": "question", "questionId": "...", "score": 20 }]
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
## 3. How to Run
|
||||
|
||||
### Prerequisites
|
||||
Ensure your `.env` file contains a valid `DATABASE_URL`.
|
||||
|
||||
### Execution
|
||||
Run the following command in the project root:
|
||||
|
||||
```bash
|
||||
npm run db:seed
|
||||
```
|
||||
|
||||
### Reset Behavior
|
||||
**WARNING**: The script currently performs a **TRUNCATE** on all core tables before seeding. This ensures a clean state but will **WIPE EXISTING DATA**.
|
||||
76
docs/design/001_auth_ui_implementation.md
Normal file
76
docs/design/001_auth_ui_implementation.md
Normal file
@@ -0,0 +1,76 @@
|
||||
# Auth UI Implementation Details
|
||||
|
||||
**Date**: 2025-12-23
|
||||
**Author**: Senior Frontend Engineer
|
||||
**Module**: Auth (`src/modules/auth`)
|
||||
|
||||
---
|
||||
|
||||
## 1. 概述 (Overview)
|
||||
|
||||
本文档记录了登录 (`/login`) 和注册 (`/register`) 页面的前端实现细节。遵循 Vertical Slice Architecture 和 Pixel-Perfect UI 规范。
|
||||
|
||||
## 2. 架构设计 (Architecture)
|
||||
|
||||
### 2.1 目录结构
|
||||
所有认证相关的业务逻辑和组件均封装在 `src/modules/auth` 下,保持了高内聚。
|
||||
|
||||
```
|
||||
src/
|
||||
├── app/
|
||||
│ └── (auth)/ # 路由层 (Server Components)
|
||||
│ ├── layout.tsx # 统一的 AuthLayout 容器
|
||||
│ ├── login/page.tsx
|
||||
│ └── register/page.tsx
|
||||
│
|
||||
├── modules/
|
||||
│ └── auth/ # 业务模块
|
||||
│ └── components/ # 模块私有组件
|
||||
│ ├── auth-layout.tsx # 左右分屏布局
|
||||
│ ├── login-form.tsx # 登录表单 (Client Component)
|
||||
│ └── register-form.tsx # 注册表单 (Client Component)
|
||||
```
|
||||
|
||||
### 2.2 渲染策略
|
||||
* **Server Components**: 页面入口 (`page.tsx`) 和布局 (`layout.tsx`) 默认为服务端组件,负责元数据 (`Metadata`) 和静态结构渲染。
|
||||
* **Client Components**: 表单组件 (`*-form.tsx`) 标记为 `'use client'`,处理交互逻辑(状态管理、表单提交、Loading 状态)。
|
||||
|
||||
## 3. UI/UX 细节
|
||||
|
||||
### 3.1 布局 (Layout)
|
||||
采用 **Split Screen (分屏)** 设计:
|
||||
* **左侧 (Desktop Only)**:
|
||||
* 深色背景 (`bg-zinc-900`),强调品牌沉浸感。
|
||||
* 包含 Logo (`Next_Edu`) 和用户证言 (`Blockquote`)。
|
||||
* 使用 `hidden lg:flex` 实现响应式显隐。
|
||||
* **右侧**:
|
||||
* 居中对齐的表单容器。
|
||||
* 移动端优先 (`w-full`),桌面端限制最大宽度 (`sm:w-[350px]`)。
|
||||
|
||||
### 3.2 交互 (Interactions)
|
||||
* **Loading State**: 表单提交时按钮进入 `disabled` 状态并显示 `Loader2` 旋转动画。
|
||||
* **Micro-animations**:
|
||||
* 按钮 Hover 效果。
|
||||
* 链接 Hover 下划线 (`hover:underline`).
|
||||
* **Feedback**: 模拟了 3 秒的异步请求延迟,用于演示加载状态。
|
||||
|
||||
## 4. 错误处理 (Error Handling)
|
||||
|
||||
### 4.1 模块级错误边界
|
||||
* **Scoped Error Boundary**: `src/app/(auth)/error.tsx` 仅处理 Auth 模块内的运行时错误。
|
||||
* 显示友好的 "Authentication Error" 提示。
|
||||
* 提供 "Try again" 按钮重置状态。
|
||||
|
||||
### 4.2 模块级 404
|
||||
* **Scoped Not Found**: `src/app/(auth)/not-found.tsx` 处理 Auth 模块内的无效路径。
|
||||
* 引导用户返回 `/login` 页面,防止用户迷失。
|
||||
|
||||
## 5. 组件复用
|
||||
* 使用了 `src/shared/components/ui` 中的标准 Shadcn 组件:
|
||||
* `Button`, `Input`, `Label` (新增).
|
||||
* 图标库统一使用 `lucide-react`.
|
||||
|
||||
## 5. 后续计划 (Next Steps)
|
||||
* [ ] 集成 `next-auth` (Auth.js) 进行实际的身份验证。
|
||||
* [ ] 添加 Zod Schema 进行前端表单验证。
|
||||
* [ ] 对接后端 API (`src/modules/auth/actions.ts`).
|
||||
100
docs/design/002_teacher_dashboard_implementation.md
Normal file
100
docs/design/002_teacher_dashboard_implementation.md
Normal file
@@ -0,0 +1,100 @@
|
||||
# 教师仪表盘实现与 Hydration 修复记录
|
||||
|
||||
**日期**: 2025-12-23
|
||||
**作者**: 资深前端工程师 (Senior Frontend Engineer)
|
||||
**状态**: 已实现
|
||||
|
||||
## 1. 概述
|
||||
|
||||
本文档详细说明了教师仪表盘 (Teacher Dashboard) 的实现细节,该实现严格遵循 Next_Edu 设计系统 v1.3.0。文档还记录了开发过程中遇到的 Hydration 错误及其解决方案。
|
||||
|
||||
## 2. 组件架构
|
||||
|
||||
仪表盘采用垂直切片架构 (Vertical Slice Architecture),代码位于 `src/modules/dashboard`。
|
||||
|
||||
### 2.1 文件结构
|
||||
```
|
||||
src/modules/dashboard/
|
||||
└── components/
|
||||
├── teacher-stats.tsx # 核心指标 (学生数, 课程数, 待批改作业)
|
||||
├── teacher-schedule.tsx # 今日日程列表
|
||||
├── recent-submissions.tsx # 最近的学生提交记录
|
||||
└── teacher-quick-actions.tsx # 常用操作 (创建作业等)
|
||||
```
|
||||
|
||||
### 2.2 设计系统集成
|
||||
所有组件严格遵循 v1.3.0 规范:
|
||||
- **排版 (Typography)**: 使用 `Geist Sans`,数据展示开启 `tabular-nums`。
|
||||
- **色彩 (Colors)**: 使用语义化 HSL 变量 (`muted`, `primary`, `destructive`)。
|
||||
- **图标 (Icons)**: 使用 `lucide-react` (如 `Users`, `BookOpen`, `Inbox`)。
|
||||
- **状态 (States)**:
|
||||
- **Loading**: 使用自定义骨架屏 (Skeleton),拒绝全屏 Spinner。
|
||||
- **Empty**: 使用 `EmptyState` 组件处理无数据场景。
|
||||
|
||||
## 3. 组件详情
|
||||
|
||||
### 3.1 TeacherStats (教师统计)
|
||||
- **用途**: 展示教师当前状态的高层概览。
|
||||
- **特性**:
|
||||
- 在响应式网格中展示 4 个关键指标。
|
||||
- 支持 `isLoading` 属性以渲染骨架屏。
|
||||
- 使用 `Card` 组件作为容器。
|
||||
|
||||
### 3.2 TeacherSchedule (教师日程)
|
||||
- **用途**: 展示今日课程安排。
|
||||
- **特性**:
|
||||
- 列出课程时间及地点。
|
||||
- 使用 Badge 区分 "Lecture" (讲座) 和 "Workshop" (研讨会)。
|
||||
- **空状态**: 当无日程时显示 "No Classes Today"。
|
||||
|
||||
### 3.3 RecentSubmissions (最近提交)
|
||||
- **用途**: 追踪最新的学生活动。
|
||||
- **特性**:
|
||||
- 展示学生头像、姓名、作业名称及时间。
|
||||
- "Late" (迟交) 状态指示器。
|
||||
- **空状态**: 当列表为空时显示 "No New Submissions"。
|
||||
|
||||
### 3.4 EmptyState Component (空状态组件)
|
||||
- **位置**: `src/shared/components/ui/empty-state.tsx`
|
||||
- **规范**:
|
||||
- 虚线边框容器。
|
||||
- 居中图标 (Muted 背景)。
|
||||
- 清晰的标题和描述。
|
||||
- 可选的操作按钮插槽。
|
||||
|
||||
## 4. Hydration 错误修复
|
||||
|
||||
### 4.1 问题描述
|
||||
开发过程中观察到 "Hydration failed" 错误,原因是 HTML 嵌套无效。具体来说,是 `p` 标签内包含了块级元素(或 React 在 hydration 检查期间视为块级的元素)。
|
||||
|
||||
### 4.2 根本原因分析
|
||||
React 的 hydration 过程对 HTML 有效性要求极高。将 `div` 放入 `p` 标签中违反了 HTML5 标准,但浏览器通常会自动修正 DOM 结构,导致实际 DOM 与 React 基于虚拟 DOM 预期的结构不一致。
|
||||
|
||||
### 4.3 实施的修复
|
||||
将所有仪表盘组件中存在风险的 `p` 标签替换为 `div` 标签,以确保嵌套结构的健壮性。
|
||||
|
||||
**示例 (RecentSubmissions):**
|
||||
|
||||
*修改前 (有风险):*
|
||||
```tsx
|
||||
<p className="text-sm font-medium leading-none">
|
||||
{item.studentName}
|
||||
</p>
|
||||
```
|
||||
|
||||
*修改后 (安全):*
|
||||
```tsx
|
||||
<div className="text-sm font-medium leading-none">
|
||||
{item.studentName}
|
||||
</div>
|
||||
```
|
||||
|
||||
**受影响的组件:**
|
||||
1. `recent-submissions.tsx`
|
||||
2. `teacher-stats.tsx`
|
||||
3. `teacher-schedule.tsx`
|
||||
|
||||
## 5. 下一步计划
|
||||
- 将 Mock Data 对接到真实的 API 端点 (React Server Actions)。
|
||||
- 实现 "Quick Actions" (快捷操作) 的具体功能。
|
||||
- 为 Submissions 和 Schedule 添加 "View All" (查看全部) 跳转导航。
|
||||
98
docs/design/003_textbooks_module_implementation.md
Normal file
98
docs/design/003_textbooks_module_implementation.md
Normal file
@@ -0,0 +1,98 @@
|
||||
# Textbooks Module Implementation Details
|
||||
|
||||
**Date**: 2025-12-23
|
||||
**Author**: DevOps Architect
|
||||
**Module**: Textbooks (`src/modules/textbooks`)
|
||||
|
||||
---
|
||||
|
||||
## 1. 概述 (Overview)
|
||||
|
||||
本文档记录了教材模块 (`Textbooks Module`) 的全栈实现细节。该模块负责教材、章节结构及知识点映射的数字化管理,采用了 **Vertical Slice Architecture** 和 **Immersive Workbench** 交互设计。
|
||||
|
||||
## 2. 架构设计 (Architecture)
|
||||
|
||||
### 2.1 目录结构
|
||||
所有教材相关的业务逻辑、数据访问和组件均封装在 `src/modules/textbooks` 下,实现了高度的模块化和隔离。
|
||||
|
||||
```
|
||||
src/
|
||||
├── app/
|
||||
│ └── (dashboard)/
|
||||
│ └── teacher/
|
||||
│ └── textbooks/ # 路由层 (Server Components)
|
||||
│ ├── page.tsx # 列表页
|
||||
│ ├── loading.tsx # 列表骨架屏
|
||||
│ └── [id]/ # 详情页
|
||||
│ ├── page.tsx
|
||||
│ └── loading.tsx
|
||||
│
|
||||
├── modules/
|
||||
│ └── textbooks/ # 业务模块
|
||||
│ ├── actions.ts # Server Actions (增删改)
|
||||
│ ├── data-access.ts # 数据访问层 (Mock/DB)
|
||||
│ ├── types.ts # 类型定义 (Schema-aligned)
|
||||
│ └── components/ # 模块私有组件
|
||||
│ ├── textbook-content-layout.tsx # [核心] 三栏布局工作台
|
||||
│ ├── chapter-sidebar-list.tsx # 递归章节树
|
||||
│ ├── knowledge-point-panel.tsx # 知识点管理面板
|
||||
│ ├── create-chapter-dialog.tsx # 章节创建弹窗
|
||||
│ └── ... (其他交互组件)
|
||||
```
|
||||
|
||||
### 2.2 渲染策略
|
||||
* **Server Components**: 页面入口 (`page.tsx`) 负责初始数据获取 (Data Fetching),利用 `Promise.all` 并行拉取教材、章节和知识点数据,实现 "Render-as-you-fetch"。
|
||||
* **Client Components**: 工作台布局 (`textbook-content-layout.tsx`) 标记为 `'use client'`,接管后续的所有交互逻辑(状态管理、局部更新、弹窗控制),提供类似 SPA 的流畅体验。
|
||||
|
||||
## 3. UI/UX 细节
|
||||
|
||||
### 3.1 布局 (Layout)
|
||||
采用 **Immersive Workbench (沉浸式工作台)** 三栏设计:
|
||||
* **左侧 (Navigation)**:
|
||||
* 展示递归的章节树 (`Recursive Tree`)。
|
||||
* 支持折叠/展开,清晰展示教材结构。
|
||||
* 顶部提供 "+" 按钮快速创建新章节。
|
||||
* **中间 (Content)**:
|
||||
* **阅读模式**: 渲染 Markdown 格式的章节正文。
|
||||
* **编辑模式**: 提供 `Textarea` 进行内容创作,支持实时保存。
|
||||
* **右侧 (Context)**:
|
||||
* **上下文感知**: 仅显示当前选中章节的关联知识点。
|
||||
* 提供知识点的快速添加 (`Dialog`) 和删除操作。
|
||||
|
||||
### 3.2 交互 (Interactions)
|
||||
* **Selection State**: 点击左侧章节,中间和右侧区域即时更新,无需页面跳转。
|
||||
* **Optimistic UI**: 虽然使用 Server Actions,但通过本地状态 (`useState`) 实现了操作的即时反馈(如保存正文后立即退出编辑模式)。
|
||||
* **Feedback**: 使用 `sonner` (`toast`) 提供操作成功或失败的提示。
|
||||
|
||||
## 4. 数据流与逻辑 (Data Flow)
|
||||
|
||||
### 4.1 Server Actions
|
||||
所有数据变更操作均通过 `src/modules/textbooks/actions.ts` 定义的 Server Actions 处理:
|
||||
* `createChapterAction`: 创建章节(支持嵌套)。
|
||||
* `updateChapterContentAction`: 更新正文内容。
|
||||
* `createKnowledgePointAction`: 创建知识点并自动关联当前章节。
|
||||
* `updateTextbookAction`: 更新教材元数据(Title, Subject, Grade, Publisher)。
|
||||
* `deleteTextbookAction`: 删除教材及其关联数据。
|
||||
* `delete...Action`: 处理删除逻辑。
|
||||
|
||||
### 4.2 数据访问层 (Data Access)
|
||||
* **Mock Implementation**: 目前在 `data-access.ts` 中使用内存数组模拟数据库操作,并人为增加了延迟 (`setTimeout`) 以测试 Loading 状态。
|
||||
* **Type Safety**: 定义了严格的 TypeScript 类型 (`Chapter`, `KnowledgePoint`, `UpdateTextbookInput`),确保前后端数据契约一致。
|
||||
|
||||
## 5. 组件复用
|
||||
* 使用了 `src/shared/components/ui` 中的 Shadcn 组件:
|
||||
* `Dialog`, `ScrollArea`, `Card`, `Button`, `Input`, `Textarea`, `Select`.
|
||||
* `Collapsible` 用于实现递归章节树。
|
||||
* 图标库统一使用 `lucide-react`.
|
||||
|
||||
## 6. Settings 功能实现 (New)
|
||||
* **入口**: 详情页右上角的 "Settings" 按钮。
|
||||
* **组件**: `TextbookSettingsDialog`。
|
||||
* **功能**:
|
||||
* **Edit**: 修改教材的基本信息。
|
||||
* **Delete**: 提供红色删除按钮,二次确认后执行删除并跳转回列表页。
|
||||
|
||||
## 7. 后续计划 (Next Steps)
|
||||
* [ ] **富文本编辑器**: 集成 Tiptap 替换现有的 Markdown Textarea,支持更丰富的格式。
|
||||
* [ ] **拖拽排序**: 实现章节树的拖拽排序 (`dnd-kit`)。
|
||||
* [ ] **数据库对接**: 将 `data-access.ts` 中的 Mock 逻辑替换为真实的 `drizzle-orm` 数据库调用。
|
||||
87
docs/design/004_question_bank_implementation.md
Normal file
87
docs/design/004_question_bank_implementation.md
Normal file
@@ -0,0 +1,87 @@
|
||||
# Question Bank Module Implementation
|
||||
|
||||
## 1. Overview
|
||||
The Question Bank module (`src/modules/questions`) is a core component for teachers to manage their examination resources. It implements a comprehensive CRUD interface with advanced filtering and batch operations.
|
||||
|
||||
**Status**: IMPLEMENTED
|
||||
**Date**: 2025-12-23
|
||||
**Author**: Senior Frontend Engineer
|
||||
|
||||
---
|
||||
|
||||
## 2. Architecture & Tech Stack
|
||||
|
||||
### 2.1 Vertical Slice Architecture
|
||||
Following the project's architectural guidelines, all question-related logic is encapsulated within `src/modules/questions`:
|
||||
- `components/`: UI components (Data Table, Dialogs, Filters)
|
||||
- `actions.ts`: Server Actions for data mutation
|
||||
- `data-access.ts`: Database query logic
|
||||
- `schema.ts`: Zod schemas for validation
|
||||
- `types.ts`: TypeScript interfaces
|
||||
|
||||
### 2.2 Key Technologies
|
||||
- **Data Grid**: `@tanstack/react-table` for high-performance rendering.
|
||||
- **State Management**: `nuqs` for URL-based state (filters, search).
|
||||
- **Forms**: `react-hook-form` + `zod` + `shadcn/ui` form components.
|
||||
- **Validation**: Strict server-side and client-side validation using Zod schemas.
|
||||
|
||||
---
|
||||
|
||||
## 3. Component Design
|
||||
|
||||
### 3.1 QuestionDataTable (`question-data-table.tsx`)
|
||||
- **Features**: Pagination, Sorting, Row Selection.
|
||||
- **Performance**: Uses `React.memo` compatible patterns where possible (though `useReactTable` itself is not memoized).
|
||||
- **Responsiveness**: Mobile-first design with horizontal scroll for complex columns.
|
||||
|
||||
### 3.2 QuestionColumns (`question-columns.tsx`)
|
||||
Custom cell renderers for rich data display:
|
||||
- **Type Badge**: Color-coded badges for different question types (Single Choice, Multiple Choice, etc.).
|
||||
- **Difficulty**: Visual indicator with color (Green -> Red) and numerical value.
|
||||
- **Actions**: Dropdown menu for Edit, Delete, View Details, and Copy ID.
|
||||
|
||||
### 3.3 Create/Edit Dialog (`create-question-dialog.tsx`)
|
||||
A unified dialog component for both creating and editing questions.
|
||||
- **Dynamic Fields**: Shows/hides "Options" field based on question type.
|
||||
- **Interactive Options**: Allows adding/removing/reordering options for choice questions.
|
||||
- **Optimistic UI**: Shows loading states during submission.
|
||||
|
||||
### 3.4 Filters (`question-filters.tsx`)
|
||||
- **URL Sync**: All filter states (Search, Type, Difficulty) are synced to URL parameters.
|
||||
- **Debounce**: Search input uses debounce to prevent excessive requests.
|
||||
- **Server Filtering**: Filtering logic is executed on the server side (currently simulated in `page.tsx`, ready for DB integration).
|
||||
|
||||
---
|
||||
|
||||
## 4. Implementation Details
|
||||
|
||||
### 4.1 Data Flow
|
||||
1. **Read**: `page.tsx` (Server Component) fetches data based on `searchParams`.
|
||||
2. **Write**: Client components invoke Server Actions (simulated) -> Revalidate Path -> UI Updates.
|
||||
3. **Filter**: User interaction -> Update URL -> Server Component Re-render -> New Data.
|
||||
|
||||
### 4.2 Type Safety
|
||||
A shared `Question` interface ensures consistency across the stack:
|
||||
```typescript
|
||||
export interface Question {
|
||||
id: string;
|
||||
content: any; // Rich text structure
|
||||
type: QuestionType;
|
||||
difficulty: number;
|
||||
// ... relations
|
||||
}
|
||||
```
|
||||
|
||||
### 4.3 UI/UX Standards
|
||||
- **Empty States**: Custom `EmptyState` component when no data matches.
|
||||
- **Loading States**: Skeleton screens for table loading.
|
||||
- **Feedback**: `Sonner` toasts for success/error notifications.
|
||||
- **Confirmation**: `AlertDialog` for destructive actions (Delete).
|
||||
|
||||
---
|
||||
|
||||
## 5. Next Steps
|
||||
- [ ] Integrate with real Database (replace Mock Data).
|
||||
- [ ] Implement Rich Text Editor (Slate.js / Tiptap) for question content.
|
||||
- [ ] Add "Batch Import" functionality.
|
||||
- [ ] Implement "Tags" management for Knowledge Points.
|
||||
90
docs/design/005_exam_module_implementation.md
Normal file
90
docs/design/005_exam_module_implementation.md
Normal file
@@ -0,0 +1,90 @@
|
||||
# 考试模块实现设计文档
|
||||
|
||||
## 1. 概述
|
||||
考试模块提供了一个完整的评估管理生命周期,使教师能够创建考试、组卷(支持嵌套分组)、发布评估以及对学生的提交进行评分。
|
||||
|
||||
## 2. 数据架构
|
||||
|
||||
### 2.1 核心实体
|
||||
- **Exams**: 根实体,包含元数据(标题、时间安排)和结构信息。
|
||||
- **ExamQuestions**: 关系链接,用于查询题目的使用情况(扁平化表示)。
|
||||
- **ExamSubmissions**: 学生的考试尝试记录。
|
||||
- **SubmissionAnswers**: 链接到特定题目的单个答案。
|
||||
|
||||
### 2.2 `structure` 字段
|
||||
为了支持层级布局(如章节/分组),我们在 `exams` 表中引入了一个 JSON 列 `structure`。这作为考试布局的“单一事实来源”(Source of Truth)。
|
||||
|
||||
**JSON Schema:**
|
||||
```typescript
|
||||
type ExamNode = {
|
||||
id: string; // 节点的唯一 UUID
|
||||
type: 'group' | 'question';
|
||||
title?: string; // 'group' 类型必填
|
||||
questionId?: string; // 'question' 类型必填
|
||||
score?: number; // 在此考试上下文中的分值
|
||||
children?: ExamNode[]; // 'group' 类型的递归子节点
|
||||
}
|
||||
```
|
||||
|
||||
## 3. 组件架构
|
||||
|
||||
### 3.1 组卷(构建器)
|
||||
位于 `/teacher/exams/[id]/build`。
|
||||
|
||||
- **`ExamAssembly` (客户端组件)**
|
||||
- 管理 `structure` 状态树。
|
||||
- 处理“添加题目”、“添加章节”、“移除”和“重新排序”操作。
|
||||
- 实时计算总分和进度。
|
||||
- **`StructureEditor` (客户端组件)**
|
||||
- 基于 `@dnd-kit` 构建。
|
||||
- 提供嵌套的可排序(Sortable)界面。
|
||||
- 支持在组内/组间拖拽题目(当前优化为 2 层深度)。
|
||||
- **`QuestionBankList`**
|
||||
- 可搜索/筛选的可用题目列表。
|
||||
- “添加”操作将节点追加到结构树中。
|
||||
|
||||
### 3.2 阅卷界面
|
||||
位于 `/teacher/exams/grading/[submissionId]`。
|
||||
|
||||
- **`GradingView` (客户端组件)**
|
||||
- **左侧面板**: 只读视图,显示学生的答案与题目内容。
|
||||
- **右侧面板**: 评分和反馈的输入字段。
|
||||
- **状态**: 在提交前管理本地更改。
|
||||
- **Actions**: `gradeSubmissionAction` 更新 `submissionAnswers` 并将总分聚合到 `examSubmissions`。
|
||||
|
||||
## 4. 关键工作流
|
||||
|
||||
### 4.1 创建与构建考试
|
||||
1. **创建**: 教师输入基本信息(标题、科目)。数据库创建记录(草稿状态)。
|
||||
2. **构建**:
|
||||
- 教师打开“构建”页面。
|
||||
- 服务器从数据库 Hydrate(注水)`initialStructure`。
|
||||
- 教师从题库拖拽题目到结构树。
|
||||
- 教师创建章节(分组)。
|
||||
- **保存**: 同时提交 `questionsJson`(扁平化,用于索引)和 `structureJson`(树状,用于布局)到 `updateExamAction`。
|
||||
3. **发布**: 状态变更为 `published`。
|
||||
|
||||
### 4.2 阅卷流程
|
||||
1. **列表**: 教师查看 `submission-data-table`。
|
||||
2. **评分**: 打开特定提交。
|
||||
3. **审查**: 遍历题目。
|
||||
- 系统显示学生答案。
|
||||
- 教师输入分数(上限为满分)和反馈。
|
||||
4. **提交**: 服务器更新单个答案记录并重新计算提交总分。
|
||||
|
||||
## 5. 技术决策
|
||||
|
||||
### 5.1 混合存储策略
|
||||
我们在存储考试题目时采用了 **混合方法**:
|
||||
- **关系型 (`exam_questions`)**: 用于“查找所有使用题目 X 的考试”查询和外键约束。
|
||||
- **文档型 (`exams.structure`)**: 用于渲染嵌套 UI 和保留任意排序/分组。
|
||||
*理由*: 这结合了 SQL 的完整性和 NoSQL 在 UI 布局上的灵活性。
|
||||
|
||||
### 5.2 拖拽功能
|
||||
使用 `@dnd-kit` 代替旧库,因为:
|
||||
- 更好的无障碍支持(键盘支持)。
|
||||
- 模块化架构(Sensors, Modifiers)。
|
||||
- 面向未来(现代 React Hooks 模式)。
|
||||
|
||||
### 5.3 Server Actions
|
||||
所有变更操作(保存草稿、发布、评分)均使用 Next.js Server Actions,以确保类型安全并自动重新验证缓存。
|
||||
258
docs/design/design_system.md
Normal file
258
docs/design/design_system.md
Normal file
@@ -0,0 +1,258 @@
|
||||
# Next_Edu Design System Specs
|
||||
|
||||
**Version**: 1.4.0 (Updated)
|
||||
**Status**: ACTIVE
|
||||
**Role**: Chief Creative Director
|
||||
**Philosophy**: "Data as Art" - Clean, Minimalist, Information-Dense.
|
||||
|
||||
---
|
||||
|
||||
## 1. 核心理念 (Core Philosophy)
|
||||
|
||||
Next_Edu 旨在对抗教育系统常见的信息过载。我们的设计风格深受 **International Typographic Style (国际主义设计风格)** 影响。
|
||||
|
||||
* **Precision (精准)**: 每一个像素的留白都有其目的。
|
||||
* **Clarity (清晰)**: 通过排版和对比度区分层级,而非装饰性的色块。
|
||||
* **Efficiency (效率)**: 专为高密度数据操作优化,减少视觉噪音。
|
||||
|
||||
---
|
||||
|
||||
## 2. 视觉基础 (Visual Foundation)
|
||||
|
||||
### 2.1 色彩系统 (Color System)
|
||||
|
||||
我们放弃 Hex 直选,全面采用 **HSL 语义化变量** 以支持完美的多主题适配。
|
||||
|
||||
#### **Base (基调)**
|
||||
* **Neutral**: Zinc (锌色). 冷峻、纯净,无偏色。
|
||||
* **Brand**: Deep Indigo (深靛蓝).
|
||||
* `Primary`: 专业、权威,避免幼稚的高饱和蓝。
|
||||
* 语义: `hsl(var(--primary))`
|
||||
|
||||
#### **Functional (功能色)**
|
||||
| 语义 | 色系 | 用途 |
|
||||
| :--- | :--- | :--- |
|
||||
| **Destructive** | Red | 删除、危险操作、系统错误 |
|
||||
| **Warning** | Amber | 需注意的状态、非阻断性警告 |
|
||||
| **Success** | Emerald | 操作成功、状态正常 |
|
||||
| **Info** | Blue | 一般性提示、帮助信息 |
|
||||
|
||||
#### **Surface Hierarchy (层级)**
|
||||
1. **Background**: 应用底层背景。
|
||||
2. **Card**: 内容承载容器,轻微提升层级。
|
||||
3. **Popover**: 悬浮层、下拉菜单,最高层级。
|
||||
4. **Muted**: 用于次级信息或禁用状态背景。
|
||||
|
||||
### 2.2 排版 (Typography)
|
||||
|
||||
* **Font Family**: `Geist Sans` > `Inter` > `System UI`.
|
||||
* *Requirement*: 必须开启 `tabular-nums` (等宽数字) 特性,确保表格数据对齐。
|
||||
* **Scale**:
|
||||
* **Base Body**: 14px (0.875rem) - 提升信息密度。
|
||||
* **H1 (Page Title)**: 24px, Tracking -0.02em, Weight 600.
|
||||
* **H2 (Section Title)**: 20px, Tracking -0.01em, Weight 600.
|
||||
* **H3 (Card Title)**: 16px, Weight 600.
|
||||
* **Tiny/Caption**: 12px, Text-Muted-Foreground.
|
||||
|
||||
### 2.3 质感 (Look & Feel)
|
||||
|
||||
* **Borders**: `1px solid var(--border)`. 界面骨架,以此分割区域而非背景色块。
|
||||
* **Radius**:
|
||||
* `sm` (4px): Badges, Checkboxes.
|
||||
* `md` (8px): **Default**. Buttons, Inputs, Cards.
|
||||
* `lg` (12px): Modals, Dialogs.
|
||||
* **Shadows**:
|
||||
* Default: None (Flat).
|
||||
* Hover: `shadow-sm` (仅用于可交互元素).
|
||||
* Dropdown/Popover: `shadow-md`.
|
||||
* *Ban*: 禁止使用大面积弥散阴影。
|
||||
|
||||
---
|
||||
|
||||
## 3. 核心布局 (App Shell)
|
||||
|
||||
### 3.1 架构 (Architecture)
|
||||
我们采用了 `SidebarProvider` + `AppSidebar` 的组合模式,确保了布局的灵活性和移动端的完美适配。
|
||||
|
||||
* **Provider**: `SidebarProvider` (src/modules/layout/components/sidebar-provider.tsx)
|
||||
* 管理侧边栏状态 (`expanded`, `isMobile`).
|
||||
* 负责在移动端渲染 Sheet (Drawer)。
|
||||
* 负责在桌面端渲染 Sticky Sidebar。
|
||||
* **Key Prop**: `sidebar` (显式传递侧边栏组件)。
|
||||
|
||||
### 3.2 布局结构
|
||||
```
|
||||
+-------------------------------------------------------+
|
||||
| Sidebar | Header (Sticky) |
|
||||
| |-------------------------------------------|
|
||||
| (Collap- | Main Content |
|
||||
| sible) | |
|
||||
| | +-------------------------------------+ |
|
||||
| | | Card | |
|
||||
| | | +---------------------------------+ | |
|
||||
| | | | Data Table | | |
|
||||
| | | +---------------------------------+ | |
|
||||
| | +-------------------------------------+ |
|
||||
| | |
|
||||
+-------------------------------------------------------+
|
||||
```
|
||||
|
||||
### 3.3 详细规范
|
||||
|
||||
#### **Sidebar (侧边栏)**
|
||||
* **Width**: Expanded `260px` | Collapsed `64px` | Mobile `Sheet (Drawer)`.
|
||||
* **Behavior**:
|
||||
* Desktop: 固定左侧,支持折叠。
|
||||
* Mobile: 默认隐藏,点击汉堡菜单从左侧滑出。
|
||||
* **Navigation Item**:
|
||||
* Height: `36px` (Compact).
|
||||
* State:
|
||||
* `Inactive`: `text-muted-foreground hover:text-foreground`.
|
||||
* `Active`: `bg-sidebar-accent text-sidebar-accent-foreground font-medium`.
|
||||
|
||||
#### **Header (顶栏)**
|
||||
* **Height**: `64px` (h-16).
|
||||
* **Layout**: `flex items-center justify-between px-6 border-b`.
|
||||
* **Components**:
|
||||
1. **Breadcrumb**: 显示当前路径,层级清晰。
|
||||
2. **Global Search**: `Cmd+K` 触发,居中或靠右。
|
||||
3. **User Nav**: 头像 + 下拉菜单。
|
||||
|
||||
#### **Main Content (内容区)**
|
||||
* **Padding**: `p-6` (Desktop) / `p-4` (Mobile).
|
||||
* **Max Width**: `max-w-[1600px]` (默认) 或 `w-full` (针对超宽报表)。
|
||||
|
||||
---
|
||||
|
||||
## 4. 导航与角色系统 (Navigation & Roles)
|
||||
|
||||
Next_Edu 支持多角色(Multi-Tenant / Role-Based)导航系统。
|
||||
|
||||
### 4.1 配置文件
|
||||
导航结构已从 UI 组件中解耦,统一配置在:
|
||||
`src/modules/layout/config/navigation.ts`
|
||||
|
||||
### 4.2 支持的角色 (Roles)
|
||||
系统内置支持以下角色,每个角色拥有独立的侧边栏菜单结构:
|
||||
* **Admin**: 系统管理员,拥有所有管理权限 (School Management, User Management, Finance)。
|
||||
* **Teacher**: 教师,关注班级管理 (My Classes)、成绩录入 (Gradebook) 和日程。
|
||||
* **Student**: 学生,关注课程学习 (My Learning) 和作业提交。
|
||||
* **Parent**: 家长,关注子女动态和学费缴纳。
|
||||
|
||||
### 4.3 开发与调试
|
||||
* **View As (Dev Mode)**: 在开发环境下,侧边栏顶部提供 "View As" 下拉菜单,允许开发者实时切换角色视角,预览不同角色的导航结构。
|
||||
* **Implementation**: `AppSidebar` 组件通过读取 `NAV_CONFIG[currentRole]` 动态渲染菜单项。
|
||||
|
||||
---
|
||||
|
||||
## 5. 错误处理与边界情况 (Error Handling & Boundaries)
|
||||
|
||||
系统必须优雅地处理错误和边缘情况,避免白屏或无反馈。
|
||||
|
||||
### 5.1 全局错误边界 (Global Error Boundary)
|
||||
* **Scope**: 捕获渲染期间的未处理异常。
|
||||
* **UI**: 显示友好的错误页面(非技术堆栈信息),提供 "Try Again" 按钮重置状态。
|
||||
* **Implementation**: 使用 React `ErrorBoundary` 或 Next.js `error.tsx`。
|
||||
|
||||
### 5.2 404 Not Found
|
||||
* **Design**: 必须保留 App Shell (Sidebar + Header),仅在 Main Content 区域显示 404 提示。
|
||||
* **Content**: "Page not found" 文案 + 返回 Dashboard 的主操作按钮。
|
||||
|
||||
### 5.3 空状态 (Empty States)
|
||||
当列表或表格无数据时,**严禁**只显示空白。
|
||||
* **Component**: `EmptyState`。
|
||||
* **Composition**:
|
||||
1. **Icon**: 线性风格图标 (muted foreground).
|
||||
2. **Title**: 简短说明 (e.g., "No students found").
|
||||
3. **Description**: 解释原因或下一步操作 (e.g., "Add a student to get started").
|
||||
4. **Action**: (可选) "Create New" 按钮。
|
||||
|
||||
### 5.4 加载状态 (Loading States)
|
||||
* **Initial Load**: 使用 `Skeleton` 骨架屏,模拟内容布局,避免 CLS (Content Layout Shift)。禁止使用全屏 Spinner。
|
||||
* **Action Loading**: 按钮点击后进入 `disabled` + `spinner` 状态。
|
||||
* **Table Loading**: 表格内容区域显示 3-5 行 Skeleton Rows。
|
||||
|
||||
### 5.5 表单验证 (Form Validation)
|
||||
* **Style**: 错误信息显示在输入框下方,字号 `text-xs`,颜色 `text-destructive`。
|
||||
* **Input**: 边框变红 (`border-destructive`)。
|
||||
|
||||
---
|
||||
|
||||
## 6. 职责边界与协作 (Responsibility Boundaries)
|
||||
|
||||
**[IMPORTANT] 严禁越界修改 (Strict No-Modification Policy)**
|
||||
|
||||
为了维护大型项目的可维护性,UI 工程师和开发人员必须遵守以下边界规则:
|
||||
|
||||
### 6.1 模块化原则 (Modularity)
|
||||
* **Scope**: 开发者仅应对分配给自己的模块负责。例如,负责 "Dashboard" 的开发者**不应**修改 "Sidebar" 或 "Auth" 模块的代码。
|
||||
* **Dependencies**: 如果你的模块依赖于其他模块的变更,**必须**先与该模块的负责人沟通,或在 PR 中明确标注。
|
||||
|
||||
### 6.2 共享组件 (Shared Components)
|
||||
* **Immutable Core**: `src/shared/components/ui` 下的基础组件(如 Button, Card)视为**核心库**。
|
||||
* **Extension**: 如果基础组件不能满足需求,优先考虑组合(Composition)或创建新的业务组件,而不是修改核心组件的源码。
|
||||
* **Modification Request**: 只有在发现严重 Bug 或需要全局样式调整时,才允许修改核心组件,且必须经过 Design Lead 审批。
|
||||
|
||||
### 6.3 样式一致性 (Consistency)
|
||||
* **Global CSS**: `globals.css` 定义了系统的物理法则。严禁在局部组件中随意覆盖全局 CSS 变量。
|
||||
* **Tailwind Config**: 禁止随意在组件中添加任意值(Arbitrary Values, e.g., `w-[123px]`),必须使用 Design Token。
|
||||
|
||||
---
|
||||
|
||||
## 7. 组件设计规范 (Component Specs)
|
||||
|
||||
### 7.1 Card (卡片)
|
||||
卡片是信息组织的基本单元。
|
||||
* **Class**: `bg-card text-card-foreground border rounded-lg shadow-none`.
|
||||
* **Header**: `p-6 pb-2`. Title (`font-semibold leading-none tracking-tight`).
|
||||
* **Content**: `p-6 pt-0`.
|
||||
|
||||
### 7.2 Data Table (数据表格)
|
||||
教务系统的核心组件。
|
||||
* **Density**:
|
||||
* `Default`: Row Height `48px` (h-12).
|
||||
* `Compact`: Row Height `36px` (h-9).
|
||||
* **Header**: `bg-muted/50 text-muted-foreground text-xs uppercase font-medium`.
|
||||
* **Stripes**: 默认关闭。仅在列数 > 8 时开启 `even:bg-muted/50`。
|
||||
* **Actions**: 行操作按钮应默认隐形 (`opacity-0`),Hover 时显示 (`group-hover:opacity-100`),减少视觉干扰。
|
||||
|
||||
### 7.3 Feedback (反馈与通知)
|
||||
* **Toast**: 使用 `Sonner` 组件。
|
||||
* 位置: 默认右下角 (Bottom Right).
|
||||
* 样式: 极简黑白风格 (跟随主题),支持撤销操作。
|
||||
* 调用: `toast("Event has been created", { description: "Sunday, December 03, 2023 at 9:00 AM" })`.
|
||||
* **Skeleton**: 加载状态必须使用 Skeleton 骨架屏,禁止使用全屏 Spinner。
|
||||
* **Badge**: 状态指示器。
|
||||
* `default`: 主要状态 (Primary).
|
||||
* `secondary`: 次要状态 (Neutral).
|
||||
* `destructive`: 错误/警告状态 (Error).
|
||||
* `outline`: 描边风格 (Subtle).
|
||||
|
||||
---
|
||||
|
||||
## 8. 开发指南 (Developer Guide)
|
||||
|
||||
### 8.1 CSS Variables
|
||||
所有颜色和圆角均通过 CSS 变量控制,定义在 `globals.css` 中。禁止在代码中 Hardcode 颜色值 (如 `#FFFFFF`, `rgb(0,0,0)` )。
|
||||
|
||||
### 8.2 Tailwind Utility 优先
|
||||
优先使用 Tailwind Utility Classes。
|
||||
* ✅ `text-sm text-muted-foreground`
|
||||
* ❌ `.custom-text-class { font-size: 14px; color: #666; }`
|
||||
|
||||
### 8.3 Dark Mode
|
||||
设计系统原生支持深色模式。只要正确使用语义化颜色变量(如 `bg-background`, `text-foreground`),Dark Mode 将自动完美适配,无需额外编写 `dark:` 修饰符(除非为了特殊调整)。
|
||||
|
||||
### 8.4 组件库引用
|
||||
所有 UI 组件位于 `src/shared/components/ui`。
|
||||
* `Button`: 基础按钮
|
||||
* `Input`: 输入框
|
||||
* `Select`: 下拉选择器 (New)
|
||||
* `Sheet`: 侧边栏/抽屉
|
||||
* `Sonner`: Toast 通知
|
||||
* `Badge`: 徽章/标签
|
||||
* `Skeleton`: 加载占位符
|
||||
* `DropdownMenu`: 下拉菜单
|
||||
* `Avatar`: 头像
|
||||
* `Label`: 表单标签
|
||||
* `EmptyState`: 空状态占位 (New)
|
||||
180
docs/product_requirements.md
Normal file
180
docs/product_requirements.md
Normal file
@@ -0,0 +1,180 @@
|
||||
# Next_Edu 产品需求文档 (PRD) - K12 智慧教学管理系统
|
||||
|
||||
**版本**: 2.0.0 (K12 Enterprise Edition)
|
||||
**状态**: 规划中
|
||||
**最后更新**: 2025-12-22
|
||||
**作者**: Senior EdTech Product Manager
|
||||
**适用范围**: 全校级教学管理 (教-考-练-评)
|
||||
|
||||
---
|
||||
|
||||
## 1. 角色与权限矩阵 (Complex Role Matrix)
|
||||
|
||||
本系统采用基于 RBAC (Role-Based Access Control) 的多维权限设计,并结合 **行级安全 (Row-Level Security, RLS)** 策略,确保数据隔离与行政管理的精确匹配。
|
||||
|
||||
### 1.1 角色定义与核心职责
|
||||
|
||||
| 角色 | 核心职责 | 权限特征 (Scope) |
|
||||
| :--- | :--- | :--- |
|
||||
| **系统管理员 (Admin)** | 基础数据维护、账号管理、学期设置 | 全局系统配置,不可触碰教学业务数据内容(隐私保护)。 |
|
||||
| **校长 (Principal)** | 全校教学概况监控、宏观统计报表 | **全校可见**。查看所有年级、学科的统计数据(平均分、作业完成率),无修改具体的题目/作业权限。 |
|
||||
| **年级主任 (Grade Head)** | 本年级行政管理、班级均衡度分析 | **年级可见**。管理本年级所有行政班级;查看本年级跨学科对比;无权干涉其他年级。 |
|
||||
| **教研组长 (Subject Head)** | 学科资源建设、命题质量把控 | **学科可见**。管理本学科公共题库、教案模板;查看全校该学科教学质量;无权查看其他学科详情。 |
|
||||
| **班主任 (Class Teacher)** | 班级学生管理、家校通知、综合评价 | **行政班可见**。查看本班所有学生的跨学科成绩、考勤;发布班级公告。 |
|
||||
| **任课老师 (Teacher)** | 备课、出卷、批改、个别辅导 | **教学班可见**。仅能操作自己所教班级的该学科作业/考试;私有题库管理。 |
|
||||
| **学生 (Student)** | 完成作业、参加考试、查看错题本 | **个人可见**。仅能访问分配给自己的任务;查看个人成长档案。 |
|
||||
|
||||
### 1.2 关键权限辨析:年级主任 vs 教研组长
|
||||
|
||||
* **维度差异**:
|
||||
* **年级主任 (横向管理)**: 关注的是 **"人" (People & Administration)**。例如:高一(3)班的整体纪律如何?高一年级整体是否在期中考试中达标?他们需要跨学科的数据视图(如:某学生是否偏科)。
|
||||
* **教研组长 (纵向管理)**: 关注的是 **"内容" (Content & Pedagogy)**。例如:英语科目的“阅读理解”题型得分率全校是否偏低?公共题库的题目质量如何?他们需要跨年级但单学科的深度视图。
|
||||
|
||||
* **数据可见性 (RLS 策略)**:
|
||||
* `GradeHead_View`: `WHERE class.grade_id = :current_user_grade_id`
|
||||
* `SubjectHead_View`: `WHERE course.subject_id = :current_user_subject_id` (可能跨所有年级)
|
||||
|
||||
---
|
||||
|
||||
## 2. 核心功能模块深度拆解
|
||||
|
||||
### 2.1 智能题库中心 (Smart Question Bank)
|
||||
|
||||
这是系统的核心资产库,必须支持高复杂度的题目结构。
|
||||
|
||||
* **多层嵌套题目结构 (Nested Questions)**:
|
||||
* **场景**: 英语完形填空、语文现代文阅读、理综大题。
|
||||
* **逻辑**: 引入 **"题干 (Stem)"** 与 **"子题 (Sub-question)"** 的概念。
|
||||
* **父题 (Parent)**: 承载公共题干(如一篇 500 字的文章、一张物理实验图表)。通常不直接设分,或者设总分。
|
||||
* **子题 (Child)**: 依附于父题,是具体的答题点(选择、填空、简答)。每个子题有独立的分值、答案和解析。
|
||||
* **交互**: 组卷时,拖动“父题”,所有“子题”必须作为一个原子整体跟随移动,不可拆分。
|
||||
|
||||
* **知识点图谱 (Knowledge Graph)**:
|
||||
* **结构**: 树状结构 (Tree)。例如:`数学 -> 代数 -> 函数 -> 二次函数 -> 二次函数的图像`。
|
||||
* **关联**:
|
||||
* **多对多 (Many-to-Many)**: 一道题可能考察多个知识点(综合题)。
|
||||
* **权重**: (可选高级需求) 标记主要考点与次要考点。
|
||||
|
||||
### 2.2 课本与大纲映射 (Textbook & Curriculum)
|
||||
|
||||
* **课本数字化**:
|
||||
* 系统预置主流教材版本 (如人教版、北师大版)。
|
||||
* **核心映射**: `Textbook Chapter` (课本章节) <--> `Knowledge Point` (知识点)。
|
||||
* **价值**: 老师备课时,只需选择“必修一 第一章”,系统自动推荐关联的“集合”相关题目,无需手动去海量题库搜索。
|
||||
|
||||
### 2.3 试卷/作业组装引擎 (Assembly Engine)
|
||||
|
||||
* **智能筛选**: 支持交集筛选(同时包含“力学”和“三角函数”的题目)。
|
||||
* **AB 卷生成**: 针对防作弊场景,支持题目乱序或选项乱序(Shuffle)。
|
||||
* **作业分层**: 支持“必做题”与“选做题”设置,满足分层教学需求。
|
||||
|
||||
### 2.4 消息通知中心 (Notification System)
|
||||
|
||||
分级分策略的消息分发:
|
||||
|
||||
* **强提醒 (High Priority)**: 系统公告、考试开始提醒。通过站内信 + 弹窗 + (集成)短信/微信模板消息。
|
||||
* **业务流 (Medium Priority)**: 作业发布、成绩出炉。站内红点 + 列表推送。
|
||||
* **弱提醒 (Low Priority)**: 错题本更新、周报生成。仅在进入相关模块时提示。
|
||||
|
||||
---
|
||||
|
||||
## 3. 数据实体关系推演 (Data Entity Relationships)
|
||||
|
||||
基于 MySQL 关系型数据库的设计方案。
|
||||
|
||||
### 3.1 核心实体模型 (ER Draft)
|
||||
|
||||
1. **SysUser**: `id`, `username`, `role`, `school_id`
|
||||
2. **TeacherProfile**: `user_id`, `is_grade_head`, `is_subject_head`
|
||||
3. **Class**: `id`, `grade_level` (e.g., 10), `class_name` (e.g., "3班"), `homeroom_teacher_id`
|
||||
4. **Subject**: `id`, `name` (e.g., "Math")
|
||||
5. **Course**: `id`, `class_id`, `subject_id`, `teacher_id` (核心教学关系表: 谁教哪个班的哪门课)
|
||||
|
||||
### 3.2 题库与知识点设计 (关键难点)
|
||||
|
||||
#### Table: `knowledge_points` (知识点树)
|
||||
* `id`: UUID
|
||||
* `subject_id`: FK
|
||||
* `name`: VARCHAR
|
||||
* `parent_id`: UUID (Self-reference, Root is NULL)
|
||||
* `level`: INT (1, 2, 3...)
|
||||
* `code`: VARCHAR (e.g., "M-ALG-01-02" 用于快速检索)
|
||||
|
||||
#### Table: `questions` (支持嵌套)
|
||||
* `id`: UUID
|
||||
* `content`: TEXT (HTML/Markdown, store images as URLs)
|
||||
* `type`: ENUM ('SINGLE', 'MULTI', 'FILL', 'ESSAY', 'COMPOSITE')
|
||||
* `parent_id`: UUID (Self-reference, **核心设计**)
|
||||
* If `NULL`: 这是一道独立题目 OR 复合题的大题干。
|
||||
* If `NOT NULL`: 这是一个子题目,属于 `parent_id` 对应的题干。
|
||||
* `difficulty`: INT (1-5)
|
||||
* `answer`: TEXT (JSON structure for structured answers)
|
||||
* `analysis`: TEXT (解析)
|
||||
* `created_by`: FK (Teacher)
|
||||
* `scope`: ENUM ('PUBLIC', 'PRIVATE')
|
||||
|
||||
#### Table: `question_knowledge` (题目-知识点关联)
|
||||
* `question_id`: FK
|
||||
* `knowledge_point_id`: FK
|
||||
* **Primary Key**: (`question_id`, `knowledge_point_id`)
|
||||
|
||||
### 3.3 课本映射设计
|
||||
|
||||
#### Table: `textbooks`
|
||||
* `id`: UUID
|
||||
* `name`: VARCHAR
|
||||
* `grade_level`: INT
|
||||
* `subject_id`: FK
|
||||
|
||||
#### Table: `textbook_chapters`
|
||||
* `id`: UUID
|
||||
* `textbook_id`: FK
|
||||
* `name`: VARCHAR
|
||||
* `parent_id`: UUID (Sections within Chapters)
|
||||
* `content`: TEXT (Rich text content of the chapter/section) -- [ADDED for Content Viewing]
|
||||
* `order`: INT
|
||||
|
||||
#### Table: `chapter_knowledge_mapping`
|
||||
* `chapter_id`: FK
|
||||
* `knowledge_point_id`: FK
|
||||
* *解释*: 这张表是连接“教学进度”与“底层知识”的桥梁。
|
||||
|
||||
---
|
||||
|
||||
## 4. 关键业务流程 (User Flows)
|
||||
|
||||
### 4.1 智能组卷与发布流程 (Exam Creation Flow)
|
||||
|
||||
这是一个高频且复杂的路径,需要极高的流畅度。
|
||||
|
||||
1. **启动组卷**:
|
||||
* 老师进入 [教学工作台] -> 点击 [新建试卷/作业]。
|
||||
* 输入基本信息(名称、考试时长、总分)。
|
||||
|
||||
2. **设定范围 (锚定课本)**:
|
||||
* 老师选择教材版本:`人教版高中数学必修一`。
|
||||
* 选择章节:勾选 `第一章 集合` 和 `第二章 函数概念`。
|
||||
* *系统动作*: 后台查询 `chapter_knowledge_mapping`,提取出这几章对应的所有 `knowledge_points`。
|
||||
|
||||
3. **筛选题目**:
|
||||
* 系统展示题目列表,默认过滤条件为上述提取的知识点。
|
||||
* 老师增加筛选:`难度: 中等`, `题型: 选择题`。
|
||||
* **处理嵌套题**: 如果筛选结果包含一个“完形填空”的子题,系统在 UI 上必须**强制展示**其对应的父题干,并提示老师“需整体添加”。
|
||||
|
||||
4. **加入试题篮 (Cart)**:
|
||||
* 老师点击“+”号。
|
||||
* 试题篮动态更新:`当前题目数: 15, 预计总分: 85`。
|
||||
|
||||
5. **试卷精修 (Refine)**:
|
||||
* 进入“试卷预览”模式。
|
||||
* 调整题目顺序 (Drag & drop)。
|
||||
* 修改某道题的分值(覆盖默认分值)。
|
||||
|
||||
6. **发布设置**:
|
||||
* 选择发布对象:`高一(3)班`, `高一(5)班` (基于 `Course` 表权限)。
|
||||
* 设置时间:`开始时间`, `截止时间`。
|
||||
* 发布模式:`在线作答` 或 `线下答题卡` (若线下,系统生成 PDF 和答题卡样式)。
|
||||
|
||||
7. **完成**:
|
||||
* 学生端收到 `Notifications` 推送。
|
||||
* `Exams` 表生成记录,`ExamAllocations` 表为每个班级/学生生成状态记录。
|
||||
35
docs/scripts/reset-db.ts
Normal file
35
docs/scripts/reset-db.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import "dotenv/config"
|
||||
import { db } from "@/shared/db"
|
||||
import { sql } from "drizzle-orm"
|
||||
|
||||
async function reset() {
|
||||
console.log("🔥 Resetting database...")
|
||||
|
||||
// Disable foreign key checks
|
||||
await db.execute(sql`SET FOREIGN_KEY_CHECKS = 0;`)
|
||||
|
||||
// Get all table names
|
||||
const tables = await db.execute(sql`
|
||||
SELECT table_name
|
||||
FROM information_schema.tables
|
||||
WHERE table_schema = DATABASE();
|
||||
`)
|
||||
|
||||
// Drop each table
|
||||
for (const row of (tables[0] as unknown as any[])) {
|
||||
const tableName = row.TABLE_NAME || row.table_name
|
||||
console.log(`Dropping table: ${tableName}`)
|
||||
await db.execute(sql.raw(`DROP TABLE IF EXISTS \`${tableName}\`;`))
|
||||
}
|
||||
|
||||
// Re-enable foreign key checks
|
||||
await db.execute(sql`SET FOREIGN_KEY_CHECKS = 1;`)
|
||||
|
||||
console.log("✅ Database reset complete.")
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
reset().catch((err) => {
|
||||
console.error("❌ Reset failed:", err)
|
||||
process.exit(1)
|
||||
})
|
||||
346
docs/scripts/seed-exams.ts
Normal file
346
docs/scripts/seed-exams.ts
Normal file
@@ -0,0 +1,346 @@
|
||||
import "dotenv/config"
|
||||
import { db } from "@/shared/db"
|
||||
import { users, exams, questions, knowledgePoints, examSubmissions, examQuestions, submissionAnswers } from "@/shared/db/schema"
|
||||
import { createId } from "@paralleldrive/cuid2"
|
||||
import { faker } from "@faker-js/faker"
|
||||
import { eq } from "drizzle-orm"
|
||||
|
||||
/**
|
||||
* Seed Script for Next_Edu
|
||||
*
|
||||
* Usage:
|
||||
* 1. Ensure DATABASE_URL is set in .env
|
||||
* 2. Run with tsx: npx tsx docs/scripts/seed-exams.ts
|
||||
*/
|
||||
|
||||
const SUBJECTS = ["Mathematics", "Physics", "English", "Chemistry", "Biology"]
|
||||
const GRADES = ["Grade 10", "Grade 11", "Grade 12"]
|
||||
const DIFFICULTY = [1, 2, 3, 4, 5]
|
||||
|
||||
async function seed() {
|
||||
console.log("🌱 Starting seed process...")
|
||||
|
||||
// 1. Create a Teacher User if not exists
|
||||
const teacherEmail = "teacher@example.com"
|
||||
let teacherId = "user_teacher_123"
|
||||
|
||||
const existingTeacher = await db.query.users.findFirst({
|
||||
where: eq(users.email, teacherEmail)
|
||||
})
|
||||
|
||||
if (!existingTeacher) {
|
||||
console.log("Creating teacher user...")
|
||||
await db.insert(users).values({
|
||||
id: teacherId,
|
||||
name: "Senior Teacher",
|
||||
email: teacherEmail,
|
||||
role: "teacher",
|
||||
image: "https://api.dicebear.com/7.x/avataaars/svg?seed=Teacher",
|
||||
})
|
||||
} else {
|
||||
teacherId = existingTeacher.id
|
||||
console.log("Teacher user exists:", teacherId)
|
||||
}
|
||||
|
||||
// 1b. Create Students
|
||||
console.log("Creating students...")
|
||||
const studentIds: string[] = []
|
||||
for (let i = 0; i < 5; i++) {
|
||||
const sId = createId()
|
||||
studentIds.push(sId)
|
||||
await db.insert(users).values({
|
||||
id: sId,
|
||||
name: faker.person.fullName(),
|
||||
email: faker.internet.email(),
|
||||
role: "student",
|
||||
image: `https://api.dicebear.com/7.x/avataaars/svg?seed=${sId}`,
|
||||
})
|
||||
}
|
||||
|
||||
// 2. Create Knowledge Points
|
||||
console.log("Creating knowledge points...")
|
||||
const kpIds: string[] = []
|
||||
for (const subject of SUBJECTS) {
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const kpId = createId()
|
||||
kpIds.push(kpId)
|
||||
await db.insert(knowledgePoints).values({
|
||||
id: kpId,
|
||||
name: `${subject} - ${faker.science.unit()}`,
|
||||
description: faker.lorem.sentence(),
|
||||
level: 1,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Create Questions
|
||||
console.log("Creating questions...")
|
||||
const questionIds: string[] = []
|
||||
for (let i = 0; i < 50; i++) {
|
||||
const qId = createId()
|
||||
questionIds.push(qId)
|
||||
|
||||
const type = faker.helpers.arrayElement(["single_choice", "multiple_choice", "text", "judgment"])
|
||||
|
||||
await db.insert(questions).values({
|
||||
id: qId,
|
||||
content: {
|
||||
text: faker.lorem.paragraph(),
|
||||
options: type.includes("choice") ? [
|
||||
{ id: "A", text: faker.lorem.sentence(), isCorrect: true },
|
||||
{ id: "B", text: faker.lorem.sentence(), isCorrect: false },
|
||||
{ id: "C", text: faker.lorem.sentence(), isCorrect: false },
|
||||
{ id: "D", text: faker.lorem.sentence(), isCorrect: false },
|
||||
] : undefined
|
||||
},
|
||||
type: type as any,
|
||||
difficulty: faker.helpers.arrayElement(DIFFICULTY),
|
||||
authorId: teacherId,
|
||||
})
|
||||
}
|
||||
|
||||
// 4. Create Exams & Submissions
|
||||
console.log("Creating exams and submissions...")
|
||||
for (let i = 0; i < 15; i++) {
|
||||
const examId = createId()
|
||||
const subject = faker.helpers.arrayElement(SUBJECTS)
|
||||
const grade = faker.helpers.arrayElement(GRADES)
|
||||
const status = faker.helpers.arrayElement(["draft", "published", "archived"])
|
||||
|
||||
const scheduledAt = faker.date.soon({ days: 30 })
|
||||
|
||||
const meta = {
|
||||
subject,
|
||||
grade,
|
||||
difficulty: faker.helpers.arrayElement(DIFFICULTY),
|
||||
totalScore: 100,
|
||||
durationMin: faker.helpers.arrayElement([45, 60, 90, 120]),
|
||||
questionCount: faker.number.int({ min: 10, max: 30 }),
|
||||
tags: [faker.word.sample(), faker.word.sample()],
|
||||
scheduledAt: scheduledAt.toISOString()
|
||||
}
|
||||
|
||||
await db.insert(exams).values({
|
||||
id: examId,
|
||||
title: `${subject} ${faker.helpers.arrayElement(["Midterm", "Final", "Quiz", "Unit Test"])}`,
|
||||
description: JSON.stringify(meta),
|
||||
creatorId: teacherId,
|
||||
startTime: scheduledAt,
|
||||
status: status as any,
|
||||
})
|
||||
|
||||
// Link some questions to this exam (random 5 questions)
|
||||
const selectedQuestions = faker.helpers.arrayElements(questionIds, 5)
|
||||
await db.insert(examQuestions).values(
|
||||
selectedQuestions.map((qId, idx) => ({
|
||||
examId,
|
||||
questionId: qId,
|
||||
score: 20, // 5 * 20 = 100
|
||||
order: idx
|
||||
}))
|
||||
)
|
||||
|
||||
// Create submissions for published exams
|
||||
if (status === "published") {
|
||||
const submittingStudents = faker.helpers.arrayElements(studentIds, faker.number.int({ min: 1, max: 3 }))
|
||||
for (const studentId of submittingStudents) {
|
||||
const submissionId = createId()
|
||||
const submissionStatus = faker.helpers.arrayElement(["submitted", "graded"])
|
||||
|
||||
await db.insert(examSubmissions).values({
|
||||
id: submissionId,
|
||||
examId,
|
||||
studentId,
|
||||
score: submissionStatus === "graded" ? faker.number.int({ min: 60, max: 100 }) : null,
|
||||
status: submissionStatus,
|
||||
submittedAt: faker.date.recent(),
|
||||
})
|
||||
|
||||
// Generate answers for this submission
|
||||
for (const qId of selectedQuestions) {
|
||||
await db.insert(submissionAnswers).values({
|
||||
id: createId(),
|
||||
submissionId: submissionId,
|
||||
questionId: qId,
|
||||
answerContent: { answer: faker.lorem.sentence() }, // Mock answer
|
||||
score: submissionStatus === "graded" ? faker.number.int({ min: 0, max: 20 }) : null,
|
||||
feedback: submissionStatus === "graded" ? faker.lorem.sentence() : null,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Create a specific Primary School Chinese Exam (小学语文)
|
||||
console.log("Creating Primary School Chinese Exam...")
|
||||
const chineseExamId = createId()
|
||||
const chineseQuestions = []
|
||||
|
||||
// 5a. Pinyin Questions
|
||||
const pinyinQ1 = createId()
|
||||
const pinyinQ2 = createId()
|
||||
chineseQuestions.push({ id: pinyinQ1, score: 5 }, { id: pinyinQ2, score: 5 })
|
||||
|
||||
await db.insert(questions).values([
|
||||
{
|
||||
id: pinyinQ1,
|
||||
content: { text: "看拼音写词语:chūn tiān ( )" },
|
||||
type: "text",
|
||||
difficulty: 1,
|
||||
authorId: teacherId,
|
||||
},
|
||||
{
|
||||
id: pinyinQ2,
|
||||
content: { text: "看拼音写词语:huā duǒ ( )" },
|
||||
type: "text",
|
||||
difficulty: 1,
|
||||
authorId: teacherId,
|
||||
}
|
||||
])
|
||||
|
||||
// 5b. Vocabulary Questions
|
||||
const vocabQ1 = createId()
|
||||
const vocabQ2 = createId()
|
||||
chineseQuestions.push({ id: vocabQ1, score: 5 }, { id: vocabQ2, score: 5 })
|
||||
|
||||
await db.insert(questions).values([
|
||||
{
|
||||
id: vocabQ1,
|
||||
content: {
|
||||
text: "选词填空:今天天气真( )。",
|
||||
options: [
|
||||
{ id: "A", text: "美好", isCorrect: false },
|
||||
{ id: "B", text: "晴朗", isCorrect: true },
|
||||
{ id: "C", text: "快乐", isCorrect: false }
|
||||
]
|
||||
},
|
||||
type: "single_choice",
|
||||
difficulty: 2,
|
||||
authorId: teacherId,
|
||||
},
|
||||
{
|
||||
id: vocabQ2,
|
||||
content: {
|
||||
text: "下列词语中,书写正确的是( )。",
|
||||
options: [
|
||||
{ id: "A", text: "漂扬", isCorrect: false },
|
||||
{ id: "B", text: "飘扬", isCorrect: true },
|
||||
{ id: "C", text: "票扬", isCorrect: false }
|
||||
]
|
||||
},
|
||||
type: "single_choice",
|
||||
difficulty: 2,
|
||||
authorId: teacherId,
|
||||
}
|
||||
])
|
||||
|
||||
// 5c. Reading Comprehension Questions
|
||||
const readingQ1 = createId()
|
||||
const readingQ2 = createId()
|
||||
chineseQuestions.push({ id: readingQ1, score: 10 }, { id: readingQ2, score: 10 })
|
||||
|
||||
await db.insert(questions).values([
|
||||
{
|
||||
id: readingQ1,
|
||||
content: {
|
||||
text: "阅读短文《小兔子乖乖》,回答问题:\n\n小兔子乖乖,把门儿开开...\n\n文中提到的动物是?",
|
||||
options: [
|
||||
{ id: "A", text: "大灰狼", isCorrect: false },
|
||||
{ id: "B", text: "小兔子", isCorrect: true },
|
||||
{ id: "C", text: "小花猫", isCorrect: false }
|
||||
]
|
||||
},
|
||||
type: "single_choice",
|
||||
difficulty: 3,
|
||||
authorId: teacherId,
|
||||
},
|
||||
{
|
||||
id: readingQ2,
|
||||
content: { text: "请用一句话形容小兔子。" },
|
||||
type: "text",
|
||||
difficulty: 3,
|
||||
authorId: teacherId,
|
||||
}
|
||||
])
|
||||
|
||||
// 5d. Construct Exam Structure
|
||||
const chineseExamStructure = [
|
||||
{
|
||||
id: createId(),
|
||||
type: "group",
|
||||
title: "第一部分:基础知识",
|
||||
children: [
|
||||
{
|
||||
id: createId(),
|
||||
type: "group",
|
||||
title: "一、看拼音写词语",
|
||||
children: [
|
||||
{ id: createId(), type: "question", questionId: pinyinQ1, score: 5 },
|
||||
{ id: createId(), type: "question", questionId: pinyinQ2, score: 5 }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: createId(),
|
||||
type: "group",
|
||||
title: "二、词语积累",
|
||||
children: [
|
||||
{ id: createId(), type: "question", questionId: vocabQ1, score: 5 },
|
||||
{ id: createId(), type: "question", questionId: vocabQ2, score: 5 }
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: createId(),
|
||||
type: "group",
|
||||
title: "第二部分:阅读理解",
|
||||
children: [
|
||||
{
|
||||
id: createId(),
|
||||
type: "group",
|
||||
title: "三、短文阅读",
|
||||
children: [
|
||||
{ id: createId(), type: "question", questionId: readingQ1, score: 10 },
|
||||
{ id: createId(), type: "question", questionId: readingQ2, score: 10 }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
await db.insert(exams).values({
|
||||
id: chineseExamId,
|
||||
title: "小学语文三年级上册期末考试",
|
||||
description: JSON.stringify({
|
||||
subject: "Chinese",
|
||||
grade: "Grade 3",
|
||||
difficulty: 3,
|
||||
totalScore: 40,
|
||||
durationMin: 90,
|
||||
questionCount: 6,
|
||||
tags: ["期末", "语文", "三年级"]
|
||||
}),
|
||||
structure: chineseExamStructure,
|
||||
creatorId: teacherId,
|
||||
status: "published",
|
||||
startTime: new Date(),
|
||||
})
|
||||
|
||||
// Link questions to exam
|
||||
await db.insert(examQuestions).values(
|
||||
chineseQuestions.map((q, idx) => ({
|
||||
examId: chineseExamId,
|
||||
questionId: q.id,
|
||||
score: q.score,
|
||||
order: idx
|
||||
}))
|
||||
)
|
||||
|
||||
console.log("✅ Seed completed successfully!")
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
seed().catch((err) => {
|
||||
console.error("❌ Seed failed:", err)
|
||||
process.exit(1)
|
||||
})
|
||||
10
drizzle.config.ts
Normal file
10
drizzle.config.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { defineConfig } from "drizzle-kit";
|
||||
|
||||
export default defineConfig({
|
||||
schema: "./src/shared/db/schema.ts",
|
||||
out: "./drizzle",
|
||||
dialect: "mysql",
|
||||
dbCredentials: {
|
||||
url: process.env.DATABASE_URL!,
|
||||
},
|
||||
});
|
||||
183
drizzle/0000_aberrant_cobalt_man.sql
Normal file
183
drizzle/0000_aberrant_cobalt_man.sql
Normal file
@@ -0,0 +1,183 @@
|
||||
CREATE TABLE `accounts` (
|
||||
`userId` varchar(128) NOT NULL,
|
||||
`type` varchar(255) NOT NULL,
|
||||
`provider` varchar(255) NOT NULL,
|
||||
`providerAccountId` varchar(255) NOT NULL,
|
||||
`refresh_token` text,
|
||||
`access_token` text,
|
||||
`expires_at` int,
|
||||
`token_type` varchar(255),
|
||||
`scope` varchar(255),
|
||||
`id_token` text,
|
||||
`session_state` varchar(255),
|
||||
CONSTRAINT `accounts_provider_providerAccountId_pk` PRIMARY KEY(`provider`,`providerAccountId`)
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `chapters` (
|
||||
`id` varchar(128) NOT NULL,
|
||||
`textbook_id` varchar(128) NOT NULL,
|
||||
`title` varchar(255) NOT NULL,
|
||||
`order` int DEFAULT 0,
|
||||
`parent_id` varchar(128),
|
||||
`created_at` timestamp NOT NULL DEFAULT (now()),
|
||||
`updated_at` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP,
|
||||
CONSTRAINT `chapters_id` PRIMARY KEY(`id`)
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `exam_questions` (
|
||||
`exam_id` varchar(128) NOT NULL,
|
||||
`question_id` varchar(128) NOT NULL,
|
||||
`score` int DEFAULT 0,
|
||||
`order` int DEFAULT 0,
|
||||
CONSTRAINT `exam_questions_exam_id_question_id_pk` PRIMARY KEY(`exam_id`,`question_id`)
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `exam_submissions` (
|
||||
`id` varchar(128) NOT NULL,
|
||||
`exam_id` varchar(128) NOT NULL,
|
||||
`student_id` varchar(128) NOT NULL,
|
||||
`score` int,
|
||||
`status` varchar(50) DEFAULT 'started',
|
||||
`submitted_at` timestamp,
|
||||
`created_at` timestamp NOT NULL DEFAULT (now()),
|
||||
`updated_at` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP,
|
||||
CONSTRAINT `exam_submissions_id` PRIMARY KEY(`id`)
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `exams` (
|
||||
`id` varchar(128) NOT NULL,
|
||||
`title` varchar(255) NOT NULL,
|
||||
`description` text,
|
||||
`creator_id` varchar(128) NOT NULL,
|
||||
`start_time` timestamp,
|
||||
`end_time` timestamp,
|
||||
`status` varchar(50) DEFAULT 'draft',
|
||||
`created_at` timestamp NOT NULL DEFAULT (now()),
|
||||
`updated_at` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP,
|
||||
CONSTRAINT `exams_id` PRIMARY KEY(`id`)
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `knowledge_points` (
|
||||
`id` varchar(128) NOT NULL,
|
||||
`name` varchar(255) NOT NULL,
|
||||
`description` text,
|
||||
`parent_id` varchar(128),
|
||||
`level` int DEFAULT 0,
|
||||
`order` int DEFAULT 0,
|
||||
`created_at` timestamp NOT NULL DEFAULT (now()),
|
||||
`updated_at` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP,
|
||||
CONSTRAINT `knowledge_points_id` PRIMARY KEY(`id`)
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `questions` (
|
||||
`id` varchar(128) NOT NULL,
|
||||
`content` json NOT NULL,
|
||||
`type` enum('single_choice','multiple_choice','text','judgment','composite') NOT NULL,
|
||||
`difficulty` int DEFAULT 1,
|
||||
`parent_id` varchar(128),
|
||||
`author_id` varchar(128) NOT NULL,
|
||||
`created_at` timestamp NOT NULL DEFAULT (now()),
|
||||
`updated_at` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP,
|
||||
CONSTRAINT `questions_id` PRIMARY KEY(`id`)
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `questions_to_knowledge_points` (
|
||||
`question_id` varchar(128) NOT NULL,
|
||||
`knowledge_point_id` varchar(128) NOT NULL,
|
||||
CONSTRAINT `questions_to_knowledge_points_question_id_knowledge_point_id_pk` PRIMARY KEY(`question_id`,`knowledge_point_id`)
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `roles` (
|
||||
`id` varchar(128) NOT NULL,
|
||||
`name` varchar(50) NOT NULL,
|
||||
`description` varchar(255),
|
||||
`created_at` timestamp NOT NULL DEFAULT (now()),
|
||||
`updated_at` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP,
|
||||
CONSTRAINT `roles_id` PRIMARY KEY(`id`),
|
||||
CONSTRAINT `roles_name_unique` UNIQUE(`name`)
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `sessions` (
|
||||
`sessionToken` varchar(255) NOT NULL,
|
||||
`userId` varchar(128) NOT NULL,
|
||||
`expires` timestamp NOT NULL,
|
||||
CONSTRAINT `sessions_sessionToken` PRIMARY KEY(`sessionToken`)
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `submission_answers` (
|
||||
`id` varchar(128) NOT NULL,
|
||||
`submission_id` varchar(128) NOT NULL,
|
||||
`question_id` varchar(128) NOT NULL,
|
||||
`answer_content` json,
|
||||
`score` int,
|
||||
`feedback` text,
|
||||
`created_at` timestamp NOT NULL DEFAULT (now()),
|
||||
`updated_at` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP,
|
||||
CONSTRAINT `submission_answers_id` PRIMARY KEY(`id`)
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `textbooks` (
|
||||
`id` varchar(128) NOT NULL,
|
||||
`title` varchar(255) NOT NULL,
|
||||
`subject` varchar(100) NOT NULL,
|
||||
`grade` varchar(50),
|
||||
`publisher` varchar(100),
|
||||
`created_at` timestamp NOT NULL DEFAULT (now()),
|
||||
`updated_at` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP,
|
||||
CONSTRAINT `textbooks_id` PRIMARY KEY(`id`)
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `users` (
|
||||
`id` varchar(128) NOT NULL,
|
||||
`name` varchar(255),
|
||||
`email` varchar(255) NOT NULL,
|
||||
`emailVerified` timestamp,
|
||||
`image` varchar(255),
|
||||
`role` varchar(50) DEFAULT 'student',
|
||||
`password` varchar(255),
|
||||
`created_at` timestamp NOT NULL DEFAULT (now()),
|
||||
`updated_at` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP,
|
||||
CONSTRAINT `users_id` PRIMARY KEY(`id`),
|
||||
CONSTRAINT `users_email_unique` UNIQUE(`email`)
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `users_to_roles` (
|
||||
`user_id` varchar(128) NOT NULL,
|
||||
`role_id` varchar(128) NOT NULL,
|
||||
CONSTRAINT `users_to_roles_user_id_role_id_pk` PRIMARY KEY(`user_id`,`role_id`)
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `verificationTokens` (
|
||||
`identifier` varchar(255) NOT NULL,
|
||||
`token` varchar(255) NOT NULL,
|
||||
`expires` timestamp NOT NULL,
|
||||
CONSTRAINT `verificationTokens_identifier_token_pk` PRIMARY KEY(`identifier`,`token`)
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE `accounts` ADD CONSTRAINT `accounts_userId_users_id_fk` FOREIGN KEY (`userId`) REFERENCES `users`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE `chapters` ADD CONSTRAINT `chapters_textbook_id_textbooks_id_fk` FOREIGN KEY (`textbook_id`) REFERENCES `textbooks`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE `exam_questions` ADD CONSTRAINT `exam_questions_exam_id_exams_id_fk` FOREIGN KEY (`exam_id`) REFERENCES `exams`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE `exam_questions` ADD CONSTRAINT `exam_questions_question_id_questions_id_fk` FOREIGN KEY (`question_id`) REFERENCES `questions`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE `exam_submissions` ADD CONSTRAINT `exam_submissions_exam_id_exams_id_fk` FOREIGN KEY (`exam_id`) REFERENCES `exams`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE `exam_submissions` ADD CONSTRAINT `exam_submissions_student_id_users_id_fk` FOREIGN KEY (`student_id`) REFERENCES `users`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE `exams` ADD CONSTRAINT `exams_creator_id_users_id_fk` FOREIGN KEY (`creator_id`) REFERENCES `users`(`id`) ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE `questions` ADD CONSTRAINT `questions_author_id_users_id_fk` FOREIGN KEY (`author_id`) REFERENCES `users`(`id`) ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE `questions_to_knowledge_points` ADD CONSTRAINT `questions_to_knowledge_points_question_id_questions_id_fk` FOREIGN KEY (`question_id`) REFERENCES `questions`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE `questions_to_knowledge_points` ADD CONSTRAINT `questions_to_knowledge_points_knowledge_point_id_knowledge_points_id_fk` FOREIGN KEY (`knowledge_point_id`) REFERENCES `knowledge_points`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE `sessions` ADD CONSTRAINT `sessions_userId_users_id_fk` FOREIGN KEY (`userId`) REFERENCES `users`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE `submission_answers` ADD CONSTRAINT `submission_answers_submission_id_exam_submissions_id_fk` FOREIGN KEY (`submission_id`) REFERENCES `exam_submissions`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE `submission_answers` ADD CONSTRAINT `submission_answers_question_id_questions_id_fk` FOREIGN KEY (`question_id`) REFERENCES `questions`(`id`) ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE `users_to_roles` ADD CONSTRAINT `users_to_roles_user_id_users_id_fk` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE `users_to_roles` ADD CONSTRAINT `users_to_roles_role_id_roles_id_fk` FOREIGN KEY (`role_id`) REFERENCES `roles`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
CREATE INDEX `account_userId_idx` ON `accounts` (`userId`);--> statement-breakpoint
|
||||
CREATE INDEX `textbook_idx` ON `chapters` (`textbook_id`);--> statement-breakpoint
|
||||
CREATE INDEX `parent_id_idx` ON `chapters` (`parent_id`);--> statement-breakpoint
|
||||
CREATE INDEX `exam_student_idx` ON `exam_submissions` (`exam_id`,`student_id`);--> statement-breakpoint
|
||||
CREATE INDEX `parent_id_idx` ON `knowledge_points` (`parent_id`);--> statement-breakpoint
|
||||
CREATE INDEX `parent_id_idx` ON `questions` (`parent_id`);--> statement-breakpoint
|
||||
CREATE INDEX `author_id_idx` ON `questions` (`author_id`);--> statement-breakpoint
|
||||
CREATE INDEX `kp_idx` ON `questions_to_knowledge_points` (`knowledge_point_id`);--> statement-breakpoint
|
||||
CREATE INDEX `session_userId_idx` ON `sessions` (`userId`);--> statement-breakpoint
|
||||
CREATE INDEX `submission_idx` ON `submission_answers` (`submission_id`);--> statement-breakpoint
|
||||
CREATE INDEX `email_idx` ON `users` (`email`);--> statement-breakpoint
|
||||
CREATE INDEX `user_id_idx` ON `users_to_roles` (`user_id`);
|
||||
5
drizzle/0001_flawless_texas_twister.sql
Normal file
5
drizzle/0001_flawless_texas_twister.sql
Normal file
@@ -0,0 +1,5 @@
|
||||
ALTER TABLE `exams` ADD `structure` json;--> statement-breakpoint
|
||||
ALTER TABLE `questions_to_knowledge_points` DROP FOREIGN KEY `questions_to_knowledge_points_question_id_questions_id_fk`;--> statement-breakpoint
|
||||
ALTER TABLE `questions_to_knowledge_points` DROP FOREIGN KEY `questions_to_knowledge_points_knowledge_point_id_knowledge_points_id_fk`;--> statement-breakpoint
|
||||
ALTER TABLE `questions_to_knowledge_points` ADD CONSTRAINT `q_kp_qid_fk` FOREIGN KEY (`question_id`) REFERENCES `questions`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE `questions_to_knowledge_points` ADD CONSTRAINT `q_kp_kpid_fk` FOREIGN KEY (`knowledge_point_id`) REFERENCES `knowledge_points`(`id`) ON DELETE cascade ON UPDATE no action;
|
||||
1286
drizzle/meta/0000_snapshot.json
Normal file
1286
drizzle/meta/0000_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
1293
drizzle/meta/0001_snapshot.json
Normal file
1293
drizzle/meta/0001_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
20
drizzle/meta/_journal.json
Normal file
20
drizzle/meta/_journal.json
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"version": "7",
|
||||
"dialect": "mysql",
|
||||
"entries": [
|
||||
{
|
||||
"idx": 0,
|
||||
"version": "5",
|
||||
"when": 1766460456274,
|
||||
"tag": "0000_aberrant_cobalt_man",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 1,
|
||||
"version": "5",
|
||||
"when": 1767004087964,
|
||||
"tag": "0001_flawless_texas_twister",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
3924
package-lock.json
generated
3924
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
44
package.json
44
package.json
@@ -7,20 +7,60 @@
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "eslint",
|
||||
"typecheck": "tsc --noEmit"
|
||||
"typecheck": "tsc --noEmit",
|
||||
"db:seed": "npx tsx scripts/seed.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@hookform/resolvers": "^5.2.2",
|
||||
"@paralleldrive/cuid2": "^3.0.4",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.15",
|
||||
"@radix-ui/react-avatar": "^1.1.11",
|
||||
"@radix-ui/react-checkbox": "^1.3.3",
|
||||
"@radix-ui/react-collapsible": "^1.1.12",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||
"@radix-ui/react-scroll-area": "^1.2.10",
|
||||
"@radix-ui/react-select": "^2.2.6",
|
||||
"@radix-ui/react-separator": "^1.1.8",
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@t3-oss/env-nextjs": "^0.13.10",
|
||||
"@tanstack/react-query": "^5.90.12",
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"drizzle-orm": "^0.45.1",
|
||||
"lucide-react": "^0.562.0",
|
||||
"mysql2": "^3.16.0",
|
||||
"next": "16.0.10",
|
||||
"next-auth": "^5.0.0-beta.30",
|
||||
"next-themes": "^0.4.6",
|
||||
"nuqs": "^2.8.5",
|
||||
"react": "19.2.1",
|
||||
"react-dom": "19.2.1"
|
||||
"react-dom": "19.2.1",
|
||||
"react-hook-form": "^7.69.0",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"zod": "^4.2.1",
|
||||
"zustand": "^5.0.9"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@faker-js/faker": "^10.1.0",
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"dotenv": "^17.2.3",
|
||||
"drizzle-kit": "^0.31.8",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "16.0.10",
|
||||
"prettier": "^3.7.4",
|
||||
"prettier-plugin-tailwindcss": "^0.7.2",
|
||||
"tailwindcss": "^4",
|
||||
"typescript": "^5"
|
||||
}
|
||||
|
||||
236
scripts/seed.ts
Normal file
236
scripts/seed.ts
Normal file
@@ -0,0 +1,236 @@
|
||||
import "dotenv/config";
|
||||
import { db } from "../src/shared/db";
|
||||
import {
|
||||
users, roles, usersToRoles,
|
||||
questions, knowledgePoints, questionsToKnowledgePoints,
|
||||
exams, examQuestions, examSubmissions, submissionAnswers,
|
||||
textbooks, chapters
|
||||
} from "../src/shared/db/schema";
|
||||
import { createId } from "@paralleldrive/cuid2";
|
||||
import { faker } from "@faker-js/faker";
|
||||
import { sql } from "drizzle-orm";
|
||||
|
||||
/**
|
||||
* Enterprise-Grade Seed Script for Next_Edu
|
||||
*
|
||||
* Scenarios Covered:
|
||||
* 1. IAM: RBAC with multiple roles (Teacher & Grade Head).
|
||||
* 2. Knowledge Graph: Nested Knowledge Points (Math -> Algebra -> Linear Equations).
|
||||
* 3. Question Bank: Rich Text Content & Nested Questions (Reading Comprehension).
|
||||
* 4. Exams: JSON Structure for Sectioning.
|
||||
*/
|
||||
|
||||
async function seed() {
|
||||
console.log("🌱 Starting Database Seed...");
|
||||
const start = performance.now();
|
||||
|
||||
// --- 0. Cleanup (Optional: Truncate tables for fresh start) ---
|
||||
// Note: Order matters due to foreign keys if checks are enabled.
|
||||
// Ideally, use: SET FOREIGN_KEY_CHECKS = 0;
|
||||
try {
|
||||
await db.execute(sql`SET FOREIGN_KEY_CHECKS = 0;`);
|
||||
const tables = [
|
||||
"submission_answers", "exam_submissions", "exam_questions", "exams",
|
||||
"questions_to_knowledge_points", "questions", "knowledge_points",
|
||||
"chapters", "textbooks",
|
||||
"users_to_roles", "roles", "users", "accounts", "sessions"
|
||||
];
|
||||
for (const table of tables) {
|
||||
await db.execute(sql.raw(`TRUNCATE TABLE \`${table}\`;`));
|
||||
}
|
||||
await db.execute(sql`SET FOREIGN_KEY_CHECKS = 1;`);
|
||||
console.log("🧹 Cleaned up existing data.");
|
||||
} catch (e) {
|
||||
console.warn("⚠️ Cleanup warning (might be fresh DB):", e);
|
||||
}
|
||||
|
||||
// --- 1. IAM & Roles ---
|
||||
console.log("👤 Seeding IAM...");
|
||||
|
||||
// Roles
|
||||
const roleMap = {
|
||||
admin: "role_admin",
|
||||
teacher: "role_teacher",
|
||||
student: "role_student",
|
||||
grade_head: "role_grade_head"
|
||||
};
|
||||
|
||||
await db.insert(roles).values([
|
||||
{ id: roleMap.admin, name: "admin", description: "System Administrator" },
|
||||
{ id: roleMap.teacher, name: "teacher", description: "Academic Instructor" },
|
||||
{ id: roleMap.student, name: "student", description: "Learner" },
|
||||
{ id: roleMap.grade_head, name: "grade_head", description: "Head of Grade Year" }
|
||||
]);
|
||||
|
||||
// Users
|
||||
const usersData = [
|
||||
{
|
||||
id: "user_admin",
|
||||
name: "Admin User",
|
||||
email: "admin@next-edu.com",
|
||||
role: "admin", // Legacy field
|
||||
image: "https://api.dicebear.com/7.x/avataaars/svg?seed=Admin"
|
||||
},
|
||||
{
|
||||
id: "user_teacher_math",
|
||||
name: "Mr. Math",
|
||||
email: "math@next-edu.com",
|
||||
role: "teacher",
|
||||
image: "https://api.dicebear.com/7.x/avataaars/svg?seed=Math"
|
||||
},
|
||||
{
|
||||
id: "user_student_1",
|
||||
name: "Alice Student",
|
||||
email: "alice@next-edu.com",
|
||||
role: "student",
|
||||
image: "https://api.dicebear.com/7.x/avataaars/svg?seed=Alice"
|
||||
}
|
||||
];
|
||||
|
||||
await db.insert(users).values(usersData);
|
||||
|
||||
// Assign Roles (RBAC)
|
||||
await db.insert(usersToRoles).values([
|
||||
{ userId: "user_admin", roleId: roleMap.admin },
|
||||
{ userId: "user_teacher_math", roleId: roleMap.teacher },
|
||||
// Math teacher is also a Grade Head
|
||||
{ userId: "user_teacher_math", roleId: roleMap.grade_head },
|
||||
{ userId: "user_student_1", roleId: roleMap.student },
|
||||
]);
|
||||
|
||||
// --- 2. Knowledge Graph (Tree) ---
|
||||
console.log("🧠 Seeding Knowledge Graph...");
|
||||
|
||||
const kpMathId = createId();
|
||||
const kpAlgebraId = createId();
|
||||
const kpLinearId = createId();
|
||||
|
||||
await db.insert(knowledgePoints).values([
|
||||
{ id: kpMathId, name: "Mathematics", level: 0 },
|
||||
{ id: kpAlgebraId, name: "Algebra", parentId: kpMathId, level: 1 },
|
||||
{ id: kpLinearId, name: "Linear Equations", parentId: kpAlgebraId, level: 2 },
|
||||
]);
|
||||
|
||||
// --- 3. Question Bank (Rich Content) ---
|
||||
console.log("📚 Seeding Question Bank...");
|
||||
|
||||
// 3.1 Simple Single Choice
|
||||
const qSimpleId = createId();
|
||||
await db.insert(questions).values({
|
||||
id: qSimpleId,
|
||||
authorId: "user_teacher_math",
|
||||
type: "single_choice",
|
||||
difficulty: 1,
|
||||
content: {
|
||||
text: "What is 2 + 2?",
|
||||
options: [
|
||||
{ id: "A", text: "3", isCorrect: false },
|
||||
{ id: "B", text: "4", isCorrect: true },
|
||||
{ id: "C", text: "5", isCorrect: false }
|
||||
]
|
||||
}
|
||||
});
|
||||
|
||||
// Link to KP
|
||||
await db.insert(questionsToKnowledgePoints).values({
|
||||
questionId: qSimpleId,
|
||||
knowledgePointId: kpLinearId // Just for demo
|
||||
});
|
||||
|
||||
// 3.2 Composite Question (Reading Comprehension)
|
||||
const qParentId = createId();
|
||||
const qChild1Id = createId();
|
||||
const qChild2Id = createId();
|
||||
|
||||
// Parent (Passage)
|
||||
await db.insert(questions).values({
|
||||
id: qParentId,
|
||||
authorId: "user_teacher_math",
|
||||
type: "composite",
|
||||
difficulty: 3,
|
||||
content: {
|
||||
text: "Read the following passage about Algebra...\n(Long text here)...",
|
||||
assets: []
|
||||
}
|
||||
});
|
||||
|
||||
// Children
|
||||
await db.insert(questions).values([
|
||||
{
|
||||
id: qChild1Id,
|
||||
authorId: "user_teacher_math",
|
||||
parentId: qParentId, // <--- Key: Nested
|
||||
type: "single_choice",
|
||||
difficulty: 2,
|
||||
content: {
|
||||
text: "What is the main topic?",
|
||||
options: [
|
||||
{ id: "A", text: "Geometry", isCorrect: false },
|
||||
{ id: "B", text: "Algebra", isCorrect: true }
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
id: qChild2Id,
|
||||
authorId: "user_teacher_math",
|
||||
parentId: qParentId,
|
||||
type: "text",
|
||||
difficulty: 4,
|
||||
content: {
|
||||
text: "Explain the concept of variables.",
|
||||
}
|
||||
}
|
||||
]);
|
||||
|
||||
// --- 4. Exams (New Structure) ---
|
||||
console.log("📝 Seeding Exams...");
|
||||
|
||||
const examId = createId();
|
||||
const examStructure = [
|
||||
{
|
||||
type: "group",
|
||||
title: "Part 1: Basics",
|
||||
children: [
|
||||
{ type: "question", questionId: qSimpleId, score: 10 }
|
||||
]
|
||||
},
|
||||
{
|
||||
type: "group",
|
||||
title: "Part 2: Reading",
|
||||
children: [
|
||||
// For composite questions, we usually add the parent, and the system fetches children
|
||||
{ type: "question", questionId: qParentId, score: 20 }
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
await db.insert(exams).values({
|
||||
id: examId,
|
||||
title: "Algebra Mid-Term 2025",
|
||||
description: "Comprehensive assessment",
|
||||
creatorId: "user_teacher_math",
|
||||
status: "published",
|
||||
startTime: new Date(),
|
||||
structure: examStructure as any // Bypass strict typing for seed
|
||||
});
|
||||
|
||||
// Link questions physically (Source of Truth)
|
||||
await db.insert(examQuestions).values([
|
||||
{ examId, questionId: qSimpleId, score: 10, order: 0 },
|
||||
{ examId, questionId: qParentId, score: 20, order: 1 },
|
||||
// Note: Child questions are often implicitly included or explicitly added depending on logic.
|
||||
// For this seed, we assume linking Parent is enough for the relation,
|
||||
// but let's link children too for completeness if the query strategy requires it.
|
||||
{ examId, questionId: qChild1Id, score: 0, order: 2 },
|
||||
{ examId, questionId: qChild2Id, score: 0, order: 3 },
|
||||
]);
|
||||
|
||||
const end = performance.now();
|
||||
console.log(`✅ Seed completed in ${(end - start).toFixed(2)}ms`);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
seed().catch((err) => {
|
||||
console.error("❌ Seed failed:", err);
|
||||
process.exit(1);
|
||||
});
|
||||
34
src/app/(auth)/error.tsx
Normal file
34
src/app/(auth)/error.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect } from "react"
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import { AlertCircle } from "lucide-react"
|
||||
|
||||
export default function AuthError({
|
||||
error,
|
||||
reset,
|
||||
}: {
|
||||
error: Error & { digest?: string }
|
||||
reset: () => void
|
||||
}) {
|
||||
useEffect(() => {
|
||||
console.error(error)
|
||||
}, [error])
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col items-center justify-center gap-4 p-4 text-center">
|
||||
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-destructive/10">
|
||||
<AlertCircle className="h-8 w-8 text-destructive" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<h2 className="text-xl font-bold tracking-tight">Authentication Error</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
There was a problem signing you in. Please try again.
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={() => reset()} variant="default" size="sm">
|
||||
Try again
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
5
src/app/(auth)/layout.tsx
Normal file
5
src/app/(auth)/layout.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { AuthLayout } from "@/modules/auth/components/auth-layout"
|
||||
|
||||
export default function Layout({ children }: { children: React.ReactNode }) {
|
||||
return <AuthLayout>{children}</AuthLayout>
|
||||
}
|
||||
11
src/app/(auth)/login/page.tsx
Normal file
11
src/app/(auth)/login/page.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
import { Metadata } from "next"
|
||||
import { LoginForm } from "@/modules/auth/components/login-form"
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Login - Next_Edu",
|
||||
description: "Login to your account",
|
||||
}
|
||||
|
||||
export default function LoginPage() {
|
||||
return <LoginForm />
|
||||
}
|
||||
22
src/app/(auth)/not-found.tsx
Normal file
22
src/app/(auth)/not-found.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import Link from "next/link"
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import { FileQuestion } from "lucide-react"
|
||||
|
||||
export default function AuthNotFound() {
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col items-center justify-center gap-4 p-4 text-center">
|
||||
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-muted">
|
||||
<FileQuestion className="h-8 w-8 text-muted-foreground" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<h2 className="text-xl font-bold tracking-tight">Page Not Found</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
The authentication page you are looking for does not exist.
|
||||
</p>
|
||||
</div>
|
||||
<Button asChild variant="outline" size="sm">
|
||||
<Link href="/login">Return to Login</Link>
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
11
src/app/(auth)/register/page.tsx
Normal file
11
src/app/(auth)/register/page.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
import { Metadata } from "next"
|
||||
import { RegisterForm } from "@/modules/auth/components/register-form"
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Register - Next_Edu",
|
||||
description: "Create an account",
|
||||
}
|
||||
|
||||
export default function RegisterPage() {
|
||||
return <RegisterForm />
|
||||
}
|
||||
5
src/app/(dashboard)/admin/dashboard/page.tsx
Normal file
5
src/app/(dashboard)/admin/dashboard/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { AdminDashboard } from "@/modules/dashboard/components/admin-view"
|
||||
|
||||
export default function AdminDashboardPage() {
|
||||
return <AdminDashboard />
|
||||
}
|
||||
72
src/app/(dashboard)/dashboard/page.tsx
Normal file
72
src/app/(dashboard)/dashboard/page.tsx
Normal file
@@ -0,0 +1,72 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import Link from "next/link"
|
||||
import { Shield, GraduationCap, Users, User } from "lucide-react"
|
||||
|
||||
// In a real app, this would be a server component that redirects based on session
|
||||
// But for this demo/dev environment, we keep the manual selection or add auto-redirect logic if we had auth state.
|
||||
|
||||
export default function DashboardPage() {
|
||||
// Mock Auth Logic (Optional: Uncomment to test auto-redirect)
|
||||
/*
|
||||
const router = useRouter();
|
||||
useEffect(() => {
|
||||
// const role = "teacher"; // Fetch from auth hook
|
||||
// if (role) router.push(`/${role}/dashboard`);
|
||||
}, []);
|
||||
*/
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-[50vh] space-y-8">
|
||||
<div className="text-center space-y-2">
|
||||
<h1 className="text-3xl font-bold">Welcome to Next_Edu</h1>
|
||||
<p className="text-muted-foreground">Select your role to view the corresponding dashboard.</p>
|
||||
<p className="text-xs text-muted-foreground bg-muted p-2 rounded inline-block">
|
||||
[DEV MODE] In production, you would be redirected automatically based on your login session.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
<Link href="/admin/dashboard">
|
||||
<Button variant="outline" className="h-40 w-40 flex flex-col gap-4 hover:border-primary hover:bg-primary/5 transition-all">
|
||||
<Shield className="h-10 w-10 text-primary" />
|
||||
<div className="space-y-1">
|
||||
<span className="font-semibold text-lg block">Admin</span>
|
||||
<span className="text-xs text-muted-foreground font-normal">System Management</span>
|
||||
</div>
|
||||
</Button>
|
||||
</Link>
|
||||
<Link href="/teacher/dashboard">
|
||||
<Button variant="outline" className="h-40 w-40 flex flex-col gap-4 hover:border-indigo-500 hover:bg-indigo-50 transition-all">
|
||||
<GraduationCap className="h-10 w-10 text-indigo-600" />
|
||||
<div className="space-y-1">
|
||||
<span className="font-semibold text-lg block">Teacher</span>
|
||||
<span className="text-xs text-muted-foreground font-normal">Class & Exams</span>
|
||||
</div>
|
||||
</Button>
|
||||
</Link>
|
||||
<Link href="/student/dashboard">
|
||||
<Button variant="outline" className="h-40 w-40 flex flex-col gap-4 hover:border-emerald-500 hover:bg-emerald-50 transition-all">
|
||||
<Users className="h-10 w-10 text-emerald-600" />
|
||||
<div className="space-y-1">
|
||||
<span className="font-semibold text-lg block">Student</span>
|
||||
<span className="text-xs text-muted-foreground font-normal">My Learning</span>
|
||||
</div>
|
||||
</Button>
|
||||
</Link>
|
||||
<Link href="/parent/dashboard">
|
||||
<Button variant="outline" className="h-40 w-40 flex flex-col gap-4 hover:border-amber-500 hover:bg-amber-50 transition-all">
|
||||
<User className="h-10 w-10 text-amber-600" />
|
||||
<div className="space-y-1">
|
||||
<span className="font-semibold text-lg block">Parent</span>
|
||||
<span className="text-xs text-muted-foreground font-normal">Family Overview</span>
|
||||
</div>
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
35
src/app/(dashboard)/error.tsx
Normal file
35
src/app/(dashboard)/error.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect } from "react"
|
||||
import { AlertCircle } from "lucide-react"
|
||||
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||
|
||||
export default function Error({
|
||||
error,
|
||||
reset,
|
||||
}: {
|
||||
error: Error & { digest?: string }
|
||||
reset: () => void
|
||||
}) {
|
||||
useEffect(() => {
|
||||
// Log the error to an error reporting service
|
||||
console.error(error)
|
||||
}, [error])
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col items-center justify-center space-y-4">
|
||||
<EmptyState
|
||||
icon={AlertCircle}
|
||||
title="Something went wrong!"
|
||||
description="We apologize for the inconvenience. An unexpected error occurred."
|
||||
action={{
|
||||
label: "Try Again",
|
||||
onClick: () => reset()
|
||||
}}
|
||||
className="border-none shadow-none h-auto"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
18
src/app/(dashboard)/layout.tsx
Normal file
18
src/app/(dashboard)/layout.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import { AppSidebar } from "@/modules/layout/components/app-sidebar"
|
||||
import { SidebarProvider } from "@/modules/layout/components/sidebar-provider"
|
||||
import { SiteHeader } from "@/modules/layout/components/site-header"
|
||||
|
||||
export default function DashboardLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<SidebarProvider sidebar={<AppSidebar />}>
|
||||
<SiteHeader />
|
||||
<main className="flex-1 overflow-auto p-6">
|
||||
{children}
|
||||
</main>
|
||||
</SidebarProvider>
|
||||
)
|
||||
}
|
||||
23
src/app/(dashboard)/not-found.tsx
Normal file
23
src/app/(dashboard)/not-found.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import Link from "next/link"
|
||||
import { FileQuestion } from "lucide-react"
|
||||
|
||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||
|
||||
export default function NotFound() {
|
||||
return (
|
||||
<div className="flex h-full flex-col items-center justify-center space-y-4">
|
||||
<EmptyState
|
||||
icon={FileQuestion}
|
||||
title="Page Not Found"
|
||||
description="The page you are looking for does not exist or has been moved."
|
||||
className="border-none shadow-none h-auto"
|
||||
/>
|
||||
<Link
|
||||
href="/dashboard"
|
||||
className="bg-primary text-primary-foreground hover:bg-primary/90 inline-flex h-9 items-center justify-center rounded-md px-4 text-sm font-medium transition-colors"
|
||||
>
|
||||
Return to Dashboard
|
||||
</Link>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
8
src/app/(dashboard)/parent/dashboard/page.tsx
Normal file
8
src/app/(dashboard)/parent/dashboard/page.tsx
Normal file
@@ -0,0 +1,8 @@
|
||||
export default function ParentDashboardPage() {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<h1 className="text-2xl font-bold">Parent Dashboard</h1>
|
||||
<p className="text-muted-foreground">Welcome, Parent!</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
5
src/app/(dashboard)/student/dashboard/page.tsx
Normal file
5
src/app/(dashboard)/student/dashboard/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { StudentDashboard } from "@/modules/dashboard/components/student-view"
|
||||
|
||||
export default function StudentDashboardPage() {
|
||||
return <StudentDashboard />
|
||||
}
|
||||
22
src/app/(dashboard)/teacher/classes/my/page.tsx
Normal file
22
src/app/(dashboard)/teacher/classes/my/page.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||
import { Users } from "lucide-react"
|
||||
|
||||
export default function MyClassesPage() {
|
||||
return (
|
||||
<div className="h-full flex-1 flex-col space-y-8 p-8 md:flex">
|
||||
<div className="flex items-center justify-between space-y-2">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold tracking-tight">My Classes</h2>
|
||||
<p className="text-muted-foreground">
|
||||
Overview of your classes.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<EmptyState
|
||||
title="No classes found"
|
||||
description="You are not assigned to any classes yet."
|
||||
icon={Users}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
5
src/app/(dashboard)/teacher/classes/page.tsx
Normal file
5
src/app/(dashboard)/teacher/classes/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { redirect } from "next/navigation"
|
||||
|
||||
export default function ClassesPage() {
|
||||
redirect("/teacher/classes/my")
|
||||
}
|
||||
22
src/app/(dashboard)/teacher/classes/schedule/page.tsx
Normal file
22
src/app/(dashboard)/teacher/classes/schedule/page.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||
import { Calendar } from "lucide-react"
|
||||
|
||||
export default function SchedulePage() {
|
||||
return (
|
||||
<div className="h-full flex-1 flex-col space-y-8 p-8 md:flex">
|
||||
<div className="flex items-center justify-between space-y-2">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold tracking-tight">Schedule</h2>
|
||||
<p className="text-muted-foreground">
|
||||
View class schedule.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<EmptyState
|
||||
title="No schedule available"
|
||||
description="Your class schedule has not been set up yet."
|
||||
icon={Calendar}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
22
src/app/(dashboard)/teacher/classes/students/page.tsx
Normal file
22
src/app/(dashboard)/teacher/classes/students/page.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||
import { User } from "lucide-react"
|
||||
|
||||
export default function StudentsPage() {
|
||||
return (
|
||||
<div className="h-full flex-1 flex-col space-y-8 p-8 md:flex">
|
||||
<div className="flex items-center justify-between space-y-2">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold tracking-tight">Students</h2>
|
||||
<p className="text-muted-foreground">
|
||||
Manage student list.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<EmptyState
|
||||
title="No students found"
|
||||
description="There are no students in your classes yet."
|
||||
icon={User}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
28
src/app/(dashboard)/teacher/dashboard/page.tsx
Normal file
28
src/app/(dashboard)/teacher/dashboard/page.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import { TeacherStats } from "@/modules/dashboard/components/teacher-stats";
|
||||
import { TeacherSchedule } from "@/modules/dashboard/components/teacher-schedule";
|
||||
import { RecentSubmissions } from "@/modules/dashboard/components/recent-submissions";
|
||||
import { TeacherQuickActions } from "@/modules/dashboard/components/teacher-quick-actions";
|
||||
|
||||
export default function TeacherDashboardPage() {
|
||||
return (
|
||||
<div className="flex-1 space-y-4">
|
||||
<div className="flex items-center justify-between space-y-2">
|
||||
<h2 className="text-3xl font-bold tracking-tight">Teacher Dashboard</h2>
|
||||
<div className="flex items-center space-x-2">
|
||||
<TeacherQuickActions />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Overview Stats */}
|
||||
<TeacherStats />
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-7">
|
||||
{/* Left Column: Schedule (3/7 width) */}
|
||||
<TeacherSchedule />
|
||||
|
||||
{/* Right Column: Recent Activity (4/7 width) */}
|
||||
<RecentSubmissions />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
76
src/app/(dashboard)/teacher/exams/[id]/build/page.tsx
Normal file
76
src/app/(dashboard)/teacher/exams/[id]/build/page.tsx
Normal file
@@ -0,0 +1,76 @@
|
||||
import { notFound } from "next/navigation"
|
||||
import { ExamAssembly } from "@/modules/exams/components/exam-assembly"
|
||||
import { getExamById } from "@/modules/exams/data-access"
|
||||
import { getQuestions } from "@/modules/questions/data-access"
|
||||
import type { Question } from "@/modules/questions/types"
|
||||
import type { ExamNode } from "@/modules/exams/components/assembly/selected-question-list"
|
||||
import { createId } from "@paralleldrive/cuid2"
|
||||
|
||||
export default async function BuildExamPage({ params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await params
|
||||
|
||||
const exam = await getExamById(id)
|
||||
if (!exam) return notFound()
|
||||
|
||||
// Fetch all available questions (for selection pool)
|
||||
// In a real app, this might be paginated or filtered by exam subject/grade
|
||||
const { data: questionsData } = await getQuestions({ pageSize: 100 })
|
||||
|
||||
const questionOptions: Question[] = questionsData.map((q) => ({
|
||||
id: q.id,
|
||||
content: q.content as any,
|
||||
type: q.type as any,
|
||||
difficulty: q.difficulty ?? 1,
|
||||
createdAt: new Date(q.createdAt),
|
||||
updatedAt: new Date(q.updatedAt),
|
||||
author: q.author ? {
|
||||
id: q.author.id,
|
||||
name: q.author.name || "Unknown",
|
||||
image: q.author.image || null
|
||||
} : null,
|
||||
knowledgePoints: (q.questionsToKnowledgePoints || []).map((kp) => ({
|
||||
id: kp.knowledgePoint.id,
|
||||
name: kp.knowledgePoint.name
|
||||
}))
|
||||
}))
|
||||
|
||||
const initialSelected = (exam.questions || []).map(q => ({
|
||||
id: q.id,
|
||||
score: q.score || 0
|
||||
}))
|
||||
|
||||
// Prepare initialStructure on server side to avoid hydration mismatch with random IDs
|
||||
let initialStructure: ExamNode[] = exam.structure as ExamNode[] || []
|
||||
|
||||
if (initialStructure.length === 0 && initialSelected.length > 0) {
|
||||
initialStructure = initialSelected.map(s => ({
|
||||
id: createId(), // Generate stable ID on server
|
||||
type: 'question',
|
||||
questionId: s.id,
|
||||
score: s.score
|
||||
}))
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col space-y-8 p-8">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold tracking-tight">Build Exam</h2>
|
||||
<p className="text-muted-foreground">Add questions and adjust scores.</p>
|
||||
</div>
|
||||
</div>
|
||||
<ExamAssembly
|
||||
examId={exam.id}
|
||||
title={exam.title}
|
||||
subject={exam.subject}
|
||||
grade={exam.grade}
|
||||
difficulty={exam.difficulty}
|
||||
totalScore={exam.totalScore}
|
||||
durationMin={exam.durationMin}
|
||||
initialSelected={initialSelected}
|
||||
initialStructure={initialStructure}
|
||||
questionOptions={questionOptions}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
25
src/app/(dashboard)/teacher/exams/all/loading.tsx
Normal file
25
src/app/(dashboard)/teacher/exams/all/loading.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import { Skeleton } from "@/shared/components/ui/skeleton"
|
||||
|
||||
export default function Loading() {
|
||||
return (
|
||||
<div className="flex h-full flex-col space-y-8 p-8">
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-7 w-40" />
|
||||
<Skeleton className="h-4 w-72" />
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<Skeleton className="h-10 w-full" />
|
||||
<div className="rounded-md border p-4">
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-4 w-full" />
|
||||
<Skeleton className="h-4 w-[95%]" />
|
||||
<Skeleton className="h-4 w-[90%]" />
|
||||
<Skeleton className="h-4 w-[85%]" />
|
||||
<Skeleton className="h-4 w-[80%]" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
47
src/app/(dashboard)/teacher/exams/all/page.tsx
Normal file
47
src/app/(dashboard)/teacher/exams/all/page.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
import { Suspense } from "react"
|
||||
import Link from "next/link"
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import { ExamDataTable } from "@/modules/exams/components/exam-data-table"
|
||||
import { examColumns } from "@/modules/exams/components/exam-columns"
|
||||
import { ExamFilters } from "@/modules/exams/components/exam-filters"
|
||||
import { getExams } from "@/modules/exams/data-access"
|
||||
|
||||
export default async function AllExamsPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{ [key: string]: string | string[] | undefined }>
|
||||
}) {
|
||||
const params = await searchParams
|
||||
|
||||
const exams = await getExams({
|
||||
q: params.q as string,
|
||||
status: params.status as string,
|
||||
difficulty: params.difficulty as string,
|
||||
})
|
||||
|
||||
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">All Exams</h2>
|
||||
<p className="text-muted-foreground">View and manage all your exams.</p>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Button asChild>
|
||||
<Link href="/teacher/exams/create">Create Exam</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<Suspense fallback={<div className="h-10 w-full animate-pulse rounded-md bg-muted" />}>
|
||||
<ExamFilters />
|
||||
</Suspense>
|
||||
|
||||
<div className="rounded-md border bg-card">
|
||||
<ExamDataTable columns={examColumns} data={exams} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
17
src/app/(dashboard)/teacher/exams/create/loading.tsx
Normal file
17
src/app/(dashboard)/teacher/exams/create/loading.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import { Skeleton } from "@/shared/components/ui/skeleton"
|
||||
|
||||
export default function Loading() {
|
||||
return (
|
||||
<div className="flex h-full flex-col space-y-8 p-8">
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-7 w-40" />
|
||||
<Skeleton className="h-4 w-72" />
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<Skeleton className="h-10 w-full" />
|
||||
<Skeleton className="h-[240px] w-full" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
15
src/app/(dashboard)/teacher/exams/create/page.tsx
Normal file
15
src/app/(dashboard)/teacher/exams/create/page.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
import { ExamForm } from "@/modules/exams/components/exam-form"
|
||||
|
||||
export default function CreateExamPage() {
|
||||
return (
|
||||
<div className="flex h-full flex-col space-y-8 p-8">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold tracking-tight">Create Exam</h2>
|
||||
<p className="text-muted-foreground">Design a new exam for your students.</p>
|
||||
</div>
|
||||
</div>
|
||||
<ExamForm />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { notFound } from "next/navigation"
|
||||
import { GradingView } from "@/modules/exams/components/grading-view"
|
||||
import { getSubmissionDetails } from "@/modules/exams/data-access"
|
||||
import { formatDate } from "@/shared/lib/utils"
|
||||
|
||||
export default async function SubmissionGradingPage({ params }: { params: Promise<{ submissionId: string }> }) {
|
||||
const { submissionId } = await params
|
||||
const submission = await getSubmissionDetails(submissionId)
|
||||
|
||||
if (!submission) {
|
||||
return notFound()
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col space-y-4 p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold tracking-tight">{submission.examTitle}</h2>
|
||||
<div className="flex items-center gap-4 text-sm text-muted-foreground mt-1">
|
||||
<span>Student: <span className="font-medium text-foreground">{submission.studentName}</span></span>
|
||||
<span>•</span>
|
||||
<span>Submitted: {submission.submittedAt ? formatDate(submission.submittedAt) : "-"}</span>
|
||||
<span>•</span>
|
||||
<span className="capitalize">Status: {submission.status}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<GradingView
|
||||
submissionId={submission.id}
|
||||
studentName={submission.studentName}
|
||||
examTitle={submission.examTitle}
|
||||
submittedAt={submission.submittedAt}
|
||||
status={submission.status || "started"}
|
||||
totalScore={submission.totalScore}
|
||||
answers={submission.answers}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
21
src/app/(dashboard)/teacher/exams/grading/loading.tsx
Normal file
21
src/app/(dashboard)/teacher/exams/grading/loading.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import { Skeleton } from "@/shared/components/ui/skeleton"
|
||||
|
||||
export default function Loading() {
|
||||
return (
|
||||
<div className="flex h-full flex-col space-y-8 p-8">
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-7 w-40" />
|
||||
<Skeleton className="h-4 w-72" />
|
||||
</div>
|
||||
<div className="rounded-md border p-4">
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-4 w-full" />
|
||||
<Skeleton className="h-4 w-[95%]" />
|
||||
<Skeleton className="h-4 w-[90%]" />
|
||||
<Skeleton className="h-4 w-[85%]" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
22
src/app/(dashboard)/teacher/exams/grading/page.tsx
Normal file
22
src/app/(dashboard)/teacher/exams/grading/page.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import { SubmissionDataTable } from "@/modules/exams/components/submission-data-table"
|
||||
import { submissionColumns } from "@/modules/exams/components/submission-columns"
|
||||
import { getExamSubmissions } from "@/modules/exams/data-access"
|
||||
|
||||
export default async function ExamGradingPage() {
|
||||
const submissions = await getExamSubmissions()
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col space-y-8 p-8">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold tracking-tight">Grading</h2>
|
||||
<p className="text-muted-foreground">Grade student exam submissions.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-md border bg-card">
|
||||
<SubmissionDataTable columns={submissionColumns} data={submissions} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
5
src/app/(dashboard)/teacher/exams/page.tsx
Normal file
5
src/app/(dashboard)/teacher/exams/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { redirect } from "next/navigation"
|
||||
|
||||
export default function ExamsPage() {
|
||||
redirect("/teacher/exams/all")
|
||||
}
|
||||
26
src/app/(dashboard)/teacher/homework/assignments/page.tsx
Normal file
26
src/app/(dashboard)/teacher/homework/assignments/page.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||
import { PenTool } from "lucide-react"
|
||||
|
||||
export default function AssignmentsPage() {
|
||||
return (
|
||||
<div className="h-full flex-1 flex-col space-y-8 p-8 md:flex">
|
||||
<div className="flex items-center justify-between space-y-2">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold tracking-tight">Assignments</h2>
|
||||
<p className="text-muted-foreground">
|
||||
Manage homework assignments.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<EmptyState
|
||||
title="No assignments"
|
||||
description="You haven't created any assignments yet."
|
||||
icon={PenTool}
|
||||
action={{
|
||||
label: "Create Assignment",
|
||||
href: "#"
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
5
src/app/(dashboard)/teacher/homework/page.tsx
Normal file
5
src/app/(dashboard)/teacher/homework/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { redirect } from "next/navigation"
|
||||
|
||||
export default function HomeworkPage() {
|
||||
redirect("/teacher/homework/assignments")
|
||||
}
|
||||
22
src/app/(dashboard)/teacher/homework/submissions/page.tsx
Normal file
22
src/app/(dashboard)/teacher/homework/submissions/page.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||
import { Inbox } from "lucide-react"
|
||||
|
||||
export default function SubmissionsPage() {
|
||||
return (
|
||||
<div className="h-full flex-1 flex-col space-y-8 p-8 md:flex">
|
||||
<div className="flex items-center justify-between space-y-2">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold tracking-tight">Submissions</h2>
|
||||
<p className="text-muted-foreground">
|
||||
Review student homework submissions.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<EmptyState
|
||||
title="No submissions"
|
||||
description="There are no homework submissions to review."
|
||||
icon={Inbox}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
74
src/app/(dashboard)/teacher/questions/page.tsx
Normal file
74
src/app/(dashboard)/teacher/questions/page.tsx
Normal file
@@ -0,0 +1,74 @@
|
||||
import { Suspense } from "react"
|
||||
import { Plus } from "lucide-react"
|
||||
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import { QuestionDataTable } from "@/modules/questions/components/question-data-table"
|
||||
import { columns } from "@/modules/questions/components/question-columns"
|
||||
import { QuestionFilters } from "@/modules/questions/components/question-filters"
|
||||
import { CreateQuestionButton } from "@/modules/questions/components/create-question-button"
|
||||
import { MOCK_QUESTIONS } from "@/modules/questions/mock-data"
|
||||
import { Question } from "@/modules/questions/types"
|
||||
|
||||
// Simulate backend delay and filtering
|
||||
async function getQuestions(searchParams: { [key: string]: string | string[] | undefined }) {
|
||||
// In a real app, you would call your DB or API here
|
||||
// await new Promise((resolve) => setTimeout(resolve, 500)) // Simulate network latency
|
||||
|
||||
let filtered = [...MOCK_QUESTIONS]
|
||||
|
||||
const q = searchParams.q as string
|
||||
const type = searchParams.type as string
|
||||
const difficulty = searchParams.difficulty as string
|
||||
|
||||
if (q) {
|
||||
filtered = filtered.filter((item) =>
|
||||
(typeof item.content === 'string' && item.content.toLowerCase().includes(q.toLowerCase())) ||
|
||||
(typeof item.content === 'object' && JSON.stringify(item.content).toLowerCase().includes(q.toLowerCase()))
|
||||
)
|
||||
}
|
||||
|
||||
if (type && type !== "all") {
|
||||
filtered = filtered.filter((item) => item.type === type)
|
||||
}
|
||||
|
||||
if (difficulty && difficulty !== "all") {
|
||||
filtered = filtered.filter((item) => item.difficulty === parseInt(difficulty))
|
||||
}
|
||||
|
||||
return filtered
|
||||
}
|
||||
|
||||
export default async function QuestionBankPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{ [key: string]: string | string[] | undefined }>
|
||||
}) {
|
||||
const params = await searchParams
|
||||
const questions = await getQuestions(params)
|
||||
|
||||
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">Question Bank</h2>
|
||||
<p className="text-muted-foreground">
|
||||
Manage your question repository for exams and assignments.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<CreateQuestionButton />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<Suspense fallback={<div className="h-10 w-full animate-pulse rounded-md bg-muted" />}>
|
||||
<QuestionFilters />
|
||||
</Suspense>
|
||||
|
||||
<div className="rounded-md border bg-card">
|
||||
<QuestionDataTable columns={columns} data={questions} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
66
src/app/(dashboard)/teacher/textbooks/[id]/loading.tsx
Normal file
66
src/app/(dashboard)/teacher/textbooks/[id]/loading.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
import { Skeleton } from "@/shared/components/ui/skeleton";
|
||||
import { Separator } from "@/shared/components/ui/separator";
|
||||
|
||||
export default function Loading() {
|
||||
return (
|
||||
<div className="space-y-6 max-w-5xl mx-auto">
|
||||
{/* Header Skeleton */}
|
||||
<div className="flex items-center gap-4">
|
||||
<Skeleton className="h-10 w-10 rounded-md" /> {/* Back Button */}
|
||||
<div className="flex-1 space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Skeleton className="h-5 w-16" />
|
||||
<Skeleton className="h-4 w-24" />
|
||||
</div>
|
||||
<Skeleton className="h-8 w-64" />
|
||||
</div>
|
||||
<Skeleton className="h-10 w-32" /> {/* Edit Button */}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-8">
|
||||
{/* Main Content Skeleton */}
|
||||
<div className="md:col-span-2 space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<Skeleton className="h-6 w-40" />
|
||||
<Skeleton className="h-8 w-24" />
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border bg-card p-6 space-y-4">
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<div key={i} className="flex items-center gap-4">
|
||||
<Skeleton className="h-6 w-6 rounded-md" />
|
||||
<Skeleton className="h-6 w-full rounded-md" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sidebar Skeleton */}
|
||||
<div className="space-y-6">
|
||||
<div className="rounded-lg border bg-card p-6 space-y-4">
|
||||
<Skeleton className="h-6 w-32 mb-4" />
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-4 w-16" />
|
||||
<Skeleton className="h-5 w-32" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-4 w-20" />
|
||||
<Skeleton className="h-20 w-full" />
|
||||
</div>
|
||||
<Separator />
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-3 w-12" />
|
||||
<Skeleton className="h-4 w-24" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-3 w-12" />
|
||||
<Skeleton className="h-4 w-24" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
80
src/app/(dashboard)/teacher/textbooks/[id]/page.tsx
Normal file
80
src/app/(dashboard)/teacher/textbooks/[id]/page.tsx
Normal file
@@ -0,0 +1,80 @@
|
||||
import { notFound } from "next/navigation";
|
||||
import { ArrowLeft, Edit } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { Button } from "@/shared/components/ui/button";
|
||||
import { Badge } from "@/shared/components/ui/badge";
|
||||
import { getTextbookById, getChaptersByTextbookId, getKnowledgePointsByChapterId } from "@/modules/textbooks/data-access";
|
||||
import { TextbookContentLayout } from "@/modules/textbooks/components/textbook-content-layout";
|
||||
import { TextbookSettingsDialog } from "@/modules/textbooks/components/textbook-settings-dialog";
|
||||
|
||||
export default async function TextbookDetailPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ id: string }>;
|
||||
}) {
|
||||
const { id } = await params;
|
||||
|
||||
const [textbook, chapters] = await Promise.all([
|
||||
getTextbookById(id),
|
||||
getChaptersByTextbookId(id),
|
||||
]);
|
||||
|
||||
if (!textbook) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
// Fetch all KPs for these chapters. In a real app, this might be optimized to fetch only needed or use a different query strategy.
|
||||
// For now, we simulate fetching KPs for all chapters to pass down, or we could fetch on demand.
|
||||
// Given the layout loads everything client-side for interactivity, let's fetch all KPs associated with any chapter in this textbook.
|
||||
// We'll need to extend the data access for this specific query pattern or loop.
|
||||
// For simplicity in this mock, let's assume getKnowledgePointsByChapterId can handle fetching all KPs for a textbook if we had such a function,
|
||||
// or we iterate. Let's create a helper to get all KPs for the textbook's chapters.
|
||||
|
||||
// Actually, let's update data-access to support getting KPs by Textbook ID directly or just fetch all for mock.
|
||||
// Since we don't have getKnowledgePointsByTextbookId, we will map over chapters.
|
||||
|
||||
const allKnowledgePoints = (await Promise.all(
|
||||
chapters.map(c => getKnowledgePointsByChapterId(c.id))
|
||||
)).flat();
|
||||
|
||||
// Also need to get KPs for children chapters if any
|
||||
const childrenKPs = (await Promise.all(
|
||||
chapters.flatMap(c => c.children || []).map(child => getKnowledgePointsByChapterId(child.id))
|
||||
)).flat();
|
||||
|
||||
const knowledgePoints = [...allKnowledgePoints, ...childrenKPs];
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-[calc(100vh-4rem)] overflow-hidden">
|
||||
{/* Header / Nav (Fixed height) */}
|
||||
<div className="flex items-center gap-4 py-4 border-b shrink-0 bg-background z-10">
|
||||
<Button variant="ghost" size="icon" asChild>
|
||||
<Link href="/teacher/textbooks">
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<Badge variant="outline">{textbook.subject}</Badge>
|
||||
<span className="text-xs text-muted-foreground uppercase tracking-wider font-medium">
|
||||
{textbook.grade}
|
||||
</span>
|
||||
</div>
|
||||
<h1 className="text-xl font-bold tracking-tight line-clamp-1">{textbook.title}</h1>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<TextbookSettingsDialog textbook={textbook} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main Content Layout (Flex grow) */}
|
||||
<div className="flex-1 overflow-hidden pt-6">
|
||||
<TextbookContentLayout
|
||||
chapters={chapters}
|
||||
knowledgePoints={knowledgePoints}
|
||||
textbookId={id}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
48
src/app/(dashboard)/teacher/textbooks/loading.tsx
Normal file
48
src/app/(dashboard)/teacher/textbooks/loading.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
import { Skeleton } from "@/shared/components/ui/skeleton";
|
||||
import { Card, CardContent, CardFooter, CardHeader } from "@/shared/components/ui/card";
|
||||
|
||||
export default function Loading() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header Skeleton */}
|
||||
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-8 w-48" />
|
||||
<Skeleton className="h-4 w-96" />
|
||||
</div>
|
||||
<Skeleton className="h-10 w-32" />
|
||||
</div>
|
||||
|
||||
{/* Toolbar Skeleton */}
|
||||
<div className="flex flex-col gap-4 md:flex-row md:items-center justify-between bg-card p-4 rounded-lg border shadow-sm">
|
||||
<Skeleton className="h-10 w-full md:w-96" />
|
||||
<div className="flex gap-2 w-full md:w-auto">
|
||||
<Skeleton className="h-10 w-[140px]" />
|
||||
<Skeleton className="h-10 w-[140px]" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Grid Content Skeleton */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
|
||||
{Array.from({ length: 8 }).map((_, i) => (
|
||||
<Card key={i} className="h-full overflow-hidden">
|
||||
<div className="aspect-[4/3] w-full bg-muted/30 p-6 flex items-center justify-center">
|
||||
<Skeleton className="h-24 w-20 rounded-sm" />
|
||||
</div>
|
||||
<CardHeader className="p-4 pb-2 space-y-2">
|
||||
<Skeleton className="h-5 w-16" />
|
||||
<Skeleton className="h-6 w-full" />
|
||||
</CardHeader>
|
||||
<CardContent className="p-4 pt-0 space-y-2">
|
||||
<Skeleton className="h-4 w-24" />
|
||||
<Skeleton className="h-4 w-32" />
|
||||
</CardContent>
|
||||
<CardFooter className="p-4 pt-0 mt-auto">
|
||||
<Skeleton className="h-6 w-full rounded-md" />
|
||||
</CardFooter>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
78
src/app/(dashboard)/teacher/textbooks/page.tsx
Normal file
78
src/app/(dashboard)/teacher/textbooks/page.tsx
Normal file
@@ -0,0 +1,78 @@
|
||||
import { Search, Filter } from "lucide-react";
|
||||
import { Button } from "@/shared/components/ui/button";
|
||||
import { Input } from "@/shared/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/shared/components/ui/select";
|
||||
import { TextbookCard } from "@/modules/textbooks/components/textbook-card";
|
||||
import { TextbookFormDialog } from "@/modules/textbooks/components/textbook-form-dialog";
|
||||
import { getTextbooks } from "@/modules/textbooks/data-access";
|
||||
|
||||
export default async function TextbooksPage() {
|
||||
// In a real app, we would parse searchParams here
|
||||
const textbooks = await getTextbooks();
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Page Header */}
|
||||
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">Textbooks</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Manage your digital curriculum resources and chapters.
|
||||
</p>
|
||||
</div>
|
||||
<TextbookFormDialog />
|
||||
</div>
|
||||
|
||||
{/* Toolbar */}
|
||||
<div className="flex flex-col gap-4 md:flex-row md:items-center justify-between bg-card p-4 rounded-lg border shadow-sm">
|
||||
<div className="relative w-full md:w-96">
|
||||
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search textbooks..."
|
||||
className="pl-9 bg-background"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-2 w-full md:w-auto">
|
||||
<Select>
|
||||
<SelectTrigger className="w-[140px] bg-background">
|
||||
<Filter className="mr-2 h-4 w-4 text-muted-foreground" />
|
||||
<SelectValue placeholder="Subject" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All Subjects</SelectItem>
|
||||
<SelectItem value="math">Mathematics</SelectItem>
|
||||
<SelectItem value="physics">Physics</SelectItem>
|
||||
<SelectItem value="history">History</SelectItem>
|
||||
<SelectItem value="english">English</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Select>
|
||||
<SelectTrigger className="w-[140px] bg-background">
|
||||
<SelectValue placeholder="Grade" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All Grades</SelectItem>
|
||||
<SelectItem value="10">Grade 10</SelectItem>
|
||||
<SelectItem value="11">Grade 11</SelectItem>
|
||||
<SelectItem value="12">Grade 12</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Grid Content */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
|
||||
{textbooks.map((textbook) => (
|
||||
<TextbookCard key={textbook.id} textbook={textbook} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,26 +1,177 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
@plugin "tailwindcss-animate";
|
||||
|
||||
@custom-variant dark (&:where(.dark, .dark *));
|
||||
|
||||
:root {
|
||||
--background: #ffffff;
|
||||
--foreground: #171717;
|
||||
/* Neutral: Zinc - Clean, Professional, International Style */
|
||||
--background: 0 0% 100%;
|
||||
--foreground: 240 10% 3.9%;
|
||||
|
||||
--card: 0 0% 100%;
|
||||
--card-foreground: 240 10% 3.9%;
|
||||
|
||||
--popover: 0 0% 100%;
|
||||
--popover-foreground: 240 10% 3.9%;
|
||||
|
||||
/* Brand: Deep Indigo */
|
||||
--primary: 240 5.9% 10%;
|
||||
--primary-foreground: 0 0% 98%;
|
||||
|
||||
--secondary: 240 4.8% 95.9%;
|
||||
--secondary-foreground: 240 5.9% 10%;
|
||||
|
||||
--muted: 240 4.8% 95.9%;
|
||||
--muted-foreground: 240 3.8% 46.1%;
|
||||
|
||||
--accent: 240 4.8% 95.9%;
|
||||
--accent-foreground: 240 5.9% 10%;
|
||||
|
||||
/* Destructive: Subtle Red */
|
||||
--destructive: 0 84.2% 60.2%;
|
||||
--destructive-foreground: 0 0% 98%;
|
||||
|
||||
/* Borders & UI */
|
||||
--border: 240 5.9% 90%;
|
||||
--input: 240 5.9% 90%;
|
||||
--ring: 240 5.9% 10%;
|
||||
|
||||
--radius: 0.5rem;
|
||||
|
||||
/* Chart / Data Visualization Colors */
|
||||
--chart-1: 12 76% 61%;
|
||||
--chart-2: 173 58% 39%;
|
||||
--chart-3: 197 37% 24%;
|
||||
--chart-4: 43 74% 66%;
|
||||
--chart-5: 27 87% 67%;
|
||||
|
||||
/* Sidebar Specific */
|
||||
--sidebar-background: 0 0% 98%;
|
||||
--sidebar-foreground: 240 5.3% 26.1%;
|
||||
--sidebar-primary: 240 5.9% 10%;
|
||||
--sidebar-primary-foreground: 0 0% 98%;
|
||||
--sidebar-accent: 240 4.8% 95.9%;
|
||||
--sidebar-accent-foreground: 240 5.9% 10%;
|
||||
--sidebar-border: 220 13% 91%;
|
||||
--sidebar-ring: 217.2 91.2% 59.8%;
|
||||
}
|
||||
|
||||
.dark {
|
||||
/* Dark Mode: Deep Zinc Base */
|
||||
--background: 240 10% 3.9%;
|
||||
--foreground: 0 0% 98%;
|
||||
|
||||
--card: 240 10% 3.9%;
|
||||
--card-foreground: 0 0% 98%;
|
||||
|
||||
--popover: 240 10% 3.9%;
|
||||
--popover-foreground: 0 0% 98%;
|
||||
|
||||
/* Brand Dark: Adjusted for contrast */
|
||||
--primary: 0 0% 98%;
|
||||
--primary-foreground: 240 5.9% 10%;
|
||||
|
||||
--secondary: 240 3.7% 15.9%;
|
||||
--secondary-foreground: 0 0% 98%;
|
||||
|
||||
--muted: 240 3.7% 15.9%;
|
||||
--muted-foreground: 240 5% 64.9%;
|
||||
|
||||
--accent: 240 3.7% 15.9%;
|
||||
--accent-foreground: 0 0% 98%;
|
||||
|
||||
--destructive: 0 62.8% 30.6%;
|
||||
--destructive-foreground: 0 0% 98%;
|
||||
|
||||
--border: 240 3.7% 15.9%;
|
||||
--input: 240 3.7% 15.9%;
|
||||
--ring: 240 4.9% 83.9%;
|
||||
|
||||
--chart-1: 220 70% 50%;
|
||||
--chart-2: 160 60% 45%;
|
||||
--chart-3: 30 80% 55%;
|
||||
--chart-4: 280 65% 60%;
|
||||
--chart-5: 340 75% 55%;
|
||||
|
||||
--sidebar-background: 240 5.9% 10%;
|
||||
--sidebar-foreground: 240 4.8% 95.9%;
|
||||
--sidebar-primary: 224.3 76.3% 48%;
|
||||
--sidebar-primary-foreground: 0 0% 100%;
|
||||
--sidebar-accent: 240 3.7% 15.9%;
|
||||
--sidebar-accent-foreground: 240 4.8% 95.9%;
|
||||
--sidebar-border: 240 3.7% 15.9%;
|
||||
--sidebar-ring: 217.2 91.2% 59.8%;
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--font-sans: var(--font-geist-sans);
|
||||
--font-mono: var(--font-geist-mono);
|
||||
}
|
||||
--color-background: hsl(var(--background));
|
||||
--color-foreground: hsl(var(--foreground));
|
||||
|
||||
--color-card: hsl(var(--card));
|
||||
--color-card-foreground: hsl(var(--card-foreground));
|
||||
|
||||
--color-popover: hsl(var(--popover));
|
||||
--color-popover-foreground: hsl(var(--popover-foreground));
|
||||
|
||||
--color-primary: hsl(var(--primary));
|
||||
--color-primary-foreground: hsl(var(--primary-foreground));
|
||||
|
||||
--color-secondary: hsl(var(--secondary));
|
||||
--color-secondary-foreground: hsl(var(--secondary-foreground));
|
||||
|
||||
--color-muted: hsl(var(--muted));
|
||||
--color-muted-foreground: hsl(var(--muted-foreground));
|
||||
|
||||
--color-accent: hsl(var(--accent));
|
||||
--color-accent-foreground: hsl(var(--accent-foreground));
|
||||
|
||||
--color-destructive: hsl(var(--destructive));
|
||||
--color-destructive-foreground: hsl(var(--destructive-foreground));
|
||||
|
||||
--color-border: hsl(var(--border));
|
||||
--color-input: hsl(var(--input));
|
||||
--color-ring: hsl(var(--ring));
|
||||
|
||||
--color-chart-1: hsl(var(--chart-1));
|
||||
--color-chart-2: hsl(var(--chart-2));
|
||||
--color-chart-3: hsl(var(--chart-3));
|
||||
--color-chart-4: hsl(var(--chart-4));
|
||||
--color-chart-5: hsl(var(--chart-5));
|
||||
|
||||
--color-sidebar: hsl(var(--sidebar-background));
|
||||
--color-sidebar-foreground: hsl(var(--sidebar-foreground));
|
||||
--color-sidebar-primary: hsl(var(--sidebar-primary));
|
||||
--color-sidebar-primary-foreground: hsl(var(--sidebar-primary-foreground));
|
||||
--color-sidebar-accent: hsl(var(--sidebar-accent));
|
||||
--color-sidebar-accent-foreground: hsl(var(--sidebar-accent-foreground));
|
||||
--color-sidebar-border: hsl(var(--sidebar-border));
|
||||
--color-sidebar-ring: hsl(var(--sidebar-ring));
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--background: #0a0a0a;
|
||||
--foreground: #ededed;
|
||||
--radius-lg: var(--radius);
|
||||
--radius-md: calc(var(--radius) - 2px);
|
||||
--radius-sm: calc(var(--radius) - 4px);
|
||||
|
||||
--animate-accordion-down: accordion-down 0.2s ease-out;
|
||||
--animate-accordion-up: accordion-up 0.2s ease-out;
|
||||
|
||||
@keyframes accordion-down {
|
||||
from { height: 0; }
|
||||
to { height: var(--radix-accordion-content-height); }
|
||||
}
|
||||
@keyframes accordion-up {
|
||||
from { height: var(--radix-accordion-content-height); }
|
||||
to { height: 0; }
|
||||
}
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--background);
|
||||
color: var(--foreground);
|
||||
font-family: Arial, Helvetica, sans-serif;
|
||||
/* Base Styles */
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
font-feature-settings: "rlig" 1, "calt" 1;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,20 +1,12 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Geist, Geist_Mono } from "next/font/google";
|
||||
import { ThemeProvider } from "@/shared/components/theme-provider";
|
||||
import { Toaster } from "@/shared/components/ui/sonner";
|
||||
import { NuqsAdapter } from 'nuqs/adapters/next/app'
|
||||
import "./globals.css";
|
||||
|
||||
const geistSans = Geist({
|
||||
variable: "--font-geist-sans",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
const geistMono = Geist_Mono({
|
||||
variable: "--font-geist-mono",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Create Next App",
|
||||
description: "Generated by create next app",
|
||||
title: "Next_Edu - K12 智慧教务系统",
|
||||
description: "Enterprise Grade K12 Education Management System",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
@@ -23,11 +15,21 @@ export default function RootLayout({
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<html lang="en" suppressHydrationWarning>
|
||||
<body
|
||||
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
||||
className={`antialiased`}
|
||||
>
|
||||
{children}
|
||||
<ThemeProvider
|
||||
attribute="class"
|
||||
defaultTheme="system"
|
||||
enableSystem
|
||||
disableTransitionOnChange
|
||||
>
|
||||
<NuqsAdapter>
|
||||
{children}
|
||||
</NuqsAdapter>
|
||||
<Toaster />
|
||||
</ThemeProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
||||
@@ -1,66 +1,5 @@
|
||||
import Image from "next/image";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export default function Home() {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-zinc-50 font-sans dark:bg-black">
|
||||
<main className="flex min-h-screen w-full max-w-3xl flex-col items-center justify-between py-32 px-16 bg-white dark:bg-black sm:items-start">
|
||||
<Image
|
||||
className="dark:invert"
|
||||
src="/next.svg"
|
||||
alt="Next.js logo"
|
||||
width={100}
|
||||
height={20}
|
||||
priority
|
||||
/>
|
||||
<div className="flex flex-col items-center gap-6 text-center sm:items-start sm:text-left">
|
||||
<h1 className="max-w-xs text-3xl font-semibold leading-10 tracking-tight text-black dark:text-zinc-50">
|
||||
To get started, edit the page.tsx file.
|
||||
This is Update.
|
||||
</h1>
|
||||
<p className="max-w-md text-lg leading-8 text-zinc-600 dark:text-zinc-400">
|
||||
Looking for a starting point or more instructions? Head over to{" "}
|
||||
<a
|
||||
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
className="font-medium text-zinc-950 dark:text-zinc-50"
|
||||
>
|
||||
Templates
|
||||
</a>{" "}
|
||||
or the{" "}
|
||||
<a
|
||||
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
className="font-medium text-zinc-950 dark:text-zinc-50"
|
||||
>
|
||||
Learning
|
||||
</a>{" "}
|
||||
center.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-4 text-base font-medium sm:flex-row">
|
||||
<a
|
||||
className="flex h-12 w-full items-center justify-center gap-2 rounded-full bg-foreground px-5 text-background transition-colors hover:bg-[#383838] dark:hover:bg-[#ccc] md:w-[158px]"
|
||||
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Image
|
||||
className="dark:invert"
|
||||
src="/vercel.svg"
|
||||
alt="Vercel logomark"
|
||||
width={16}
|
||||
height={16}
|
||||
/>
|
||||
Deploy Now
|
||||
</a>
|
||||
<a
|
||||
className="flex h-12 w-full items-center justify-center rounded-full border border-solid border-black/[.08] px-5 transition-colors hover:border-transparent hover:bg-black/[.04] dark:border-white/[.145] dark:hover:bg-[#1a1a1a] md:w-[158px]"
|
||||
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Documentation
|
||||
</a>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
redirect("/dashboard");
|
||||
}
|
||||
|
||||
23
src/env.mjs
Normal file
23
src/env.mjs
Normal file
@@ -0,0 +1,23 @@
|
||||
import { createEnv } from "@t3-oss/env-nextjs";
|
||||
import { z } from "zod";
|
||||
|
||||
export const env = createEnv({
|
||||
server: {
|
||||
DATABASE_URL: z.string().url(),
|
||||
NODE_ENV: z.enum(["development", "test", "production"]).default("development"),
|
||||
NEXTAUTH_SECRET: z.string().min(1).optional(),
|
||||
NEXTAUTH_URL: z.string().url().optional(),
|
||||
},
|
||||
client: {
|
||||
NEXT_PUBLIC_APP_URL: z.string().url().optional(),
|
||||
},
|
||||
runtimeEnv: {
|
||||
DATABASE_URL: process.env.DATABASE_URL,
|
||||
NODE_ENV: process.env.NODE_ENV,
|
||||
NEXTAUTH_SECRET: process.env.NEXTAUTH_SECRET,
|
||||
NEXTAUTH_URL: process.env.NEXTAUTH_URL,
|
||||
NEXT_PUBLIC_APP_URL: process.env.NEXT_PUBLIC_APP_URL,
|
||||
},
|
||||
skipValidation: !!process.env.SKIP_ENV_VALIDATION,
|
||||
emptyStringAsUndefined: true,
|
||||
});
|
||||
33
src/modules/auth/components/auth-layout.tsx
Normal file
33
src/modules/auth/components/auth-layout.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import Link from "next/link"
|
||||
import { GraduationCap } from "lucide-react"
|
||||
|
||||
interface AuthLayoutProps {
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
export function AuthLayout({ children }: AuthLayoutProps) {
|
||||
return (
|
||||
<div className="container relative h-screen flex-col items-center justify-center grid lg:max-w-none lg:grid-cols-2 lg:px-0">
|
||||
<div className="relative hidden h-full flex-col bg-muted p-10 text-white dark:border-r lg:flex">
|
||||
<div className="absolute inset-0 bg-zinc-900" />
|
||||
<div className="relative z-20 flex items-center text-lg font-medium">
|
||||
<GraduationCap className="mr-2 h-6 w-6" />
|
||||
Next_Edu
|
||||
</div>
|
||||
<div className="relative z-20 mt-auto">
|
||||
<blockquote className="space-y-2">
|
||||
<p className="text-lg">
|
||||
“This platform has completely transformed how we deliver education to our students. The attention to detail and performance is unmatched.”
|
||||
</p>
|
||||
<footer className="text-sm">Sofia Davis</footer>
|
||||
</blockquote>
|
||||
</div>
|
||||
</div>
|
||||
<div className="lg:p-8">
|
||||
<div className="mx-auto flex w-full flex-col justify-center space-y-6 sm:w-[350px]">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
103
src/modules/auth/components/login-form.tsx
Normal file
103
src/modules/auth/components/login-form.tsx
Normal file
@@ -0,0 +1,103 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import Link from "next/link"
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import { Input } from "@/shared/components/ui/input"
|
||||
import { Label } from "@/shared/components/ui/label"
|
||||
import { cn } from "@/shared/lib/utils"
|
||||
import { Loader2, Github } from "lucide-react"
|
||||
|
||||
interface LoginFormProps extends React.HTMLAttributes<HTMLDivElement> {}
|
||||
|
||||
export function LoginForm({ className, ...props }: LoginFormProps) {
|
||||
const [isLoading, setIsLoading] = React.useState<boolean>(false)
|
||||
|
||||
async function onSubmit(event: React.SyntheticEvent) {
|
||||
event.preventDefault()
|
||||
setIsLoading(true)
|
||||
|
||||
setTimeout(() => {
|
||||
setIsLoading(false)
|
||||
}, 3000)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn("grid gap-6", className)} {...props}>
|
||||
<div className="flex flex-col space-y-2 text-center">
|
||||
<h1 className="text-2xl font-semibold tracking-tight">
|
||||
Welcome back
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Enter your email to sign in to your account
|
||||
</p>
|
||||
</div>
|
||||
<form onSubmit={onSubmit}>
|
||||
<div className="grid gap-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="email">Email</Label>
|
||||
<Input
|
||||
id="email"
|
||||
placeholder="name@example.com"
|
||||
type="email"
|
||||
autoCapitalize="none"
|
||||
autoComplete="email"
|
||||
autoCorrect="off"
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="password">Password</Label>
|
||||
<Link
|
||||
href="/forgot-password"
|
||||
className="text-sm font-medium text-muted-foreground hover:underline"
|
||||
>
|
||||
Forgot password?
|
||||
</Link>
|
||||
</div>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</div>
|
||||
<Button disabled={isLoading}>
|
||||
{isLoading && (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
)}
|
||||
Sign In with Email
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
<div className="relative">
|
||||
<div className="absolute inset-0 flex items-center">
|
||||
<span className="w-full border-t" />
|
||||
</div>
|
||||
<div className="relative flex justify-center text-xs uppercase">
|
||||
<span className="bg-background px-2 text-muted-foreground">
|
||||
Or continue with
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<Button variant="outline" type="button" disabled={isLoading}>
|
||||
{isLoading ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Github className="mr-2 h-4 w-4" />
|
||||
)}{" "}
|
||||
GitHub
|
||||
</Button>
|
||||
<p className="px-8 text-center text-sm text-muted-foreground">
|
||||
Don't have an account?{" "}
|
||||
<Link
|
||||
href="/register"
|
||||
className="underline underline-offset-4 hover:text-primary"
|
||||
>
|
||||
Sign up
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
107
src/modules/auth/components/register-form.tsx
Normal file
107
src/modules/auth/components/register-form.tsx
Normal file
@@ -0,0 +1,107 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import Link from "next/link"
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import { Input } from "@/shared/components/ui/input"
|
||||
import { Label } from "@/shared/components/ui/label"
|
||||
import { cn } from "@/shared/lib/utils"
|
||||
import { Loader2, Github } from "lucide-react"
|
||||
|
||||
interface RegisterFormProps extends React.HTMLAttributes<HTMLDivElement> {}
|
||||
|
||||
export function RegisterForm({ className, ...props }: RegisterFormProps) {
|
||||
const [isLoading, setIsLoading] = React.useState<boolean>(false)
|
||||
|
||||
async function onSubmit(event: React.SyntheticEvent) {
|
||||
event.preventDefault()
|
||||
setIsLoading(true)
|
||||
|
||||
setTimeout(() => {
|
||||
setIsLoading(false)
|
||||
}, 3000)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn("grid gap-6", className)} {...props}>
|
||||
<div className="flex flex-col space-y-2 text-center">
|
||||
<h1 className="text-2xl font-semibold tracking-tight">
|
||||
Create an account
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Enter your email below to create your account
|
||||
</p>
|
||||
</div>
|
||||
<form onSubmit={onSubmit}>
|
||||
<div className="grid gap-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="name">Full Name</Label>
|
||||
<Input
|
||||
id="name"
|
||||
placeholder="John Doe"
|
||||
type="text"
|
||||
autoCapitalize="words"
|
||||
autoComplete="name"
|
||||
autoCorrect="off"
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="email">Email</Label>
|
||||
<Input
|
||||
id="email"
|
||||
placeholder="name@example.com"
|
||||
type="email"
|
||||
autoCapitalize="none"
|
||||
autoComplete="email"
|
||||
autoCorrect="off"
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="password">Password</Label>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</div>
|
||||
<Button disabled={isLoading}>
|
||||
{isLoading && (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
)}
|
||||
Create Account
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
<div className="relative">
|
||||
<div className="absolute inset-0 flex items-center">
|
||||
<span className="w-full border-t" />
|
||||
</div>
|
||||
<div className="relative flex justify-center text-xs uppercase">
|
||||
<span className="bg-background px-2 text-muted-foreground">
|
||||
Or continue with
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<Button variant="outline" type="button" disabled={isLoading}>
|
||||
{isLoading ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Github className="mr-2 h-4 w-4" />
|
||||
)}{" "}
|
||||
GitHub
|
||||
</Button>
|
||||
<p className="px-8 text-center text-sm text-muted-foreground">
|
||||
Already have an account?{" "}
|
||||
<Link
|
||||
href="/login"
|
||||
className="underline underline-offset-4 hover:text-primary"
|
||||
>
|
||||
Sign in
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
25
src/modules/dashboard/components/admin-view.tsx
Normal file
25
src/modules/dashboard/components/admin-view.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
"use client"
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
||||
|
||||
export function AdminDashboard() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<h1 className="text-3xl font-bold tracking-tight">Admin Dashboard</h1>
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
<Card>
|
||||
<CardHeader><CardTitle>System Status</CardTitle></CardHeader>
|
||||
<CardContent className="text-green-600 font-bold">Operational</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader><CardTitle>Total Users</CardTitle></CardHeader>
|
||||
<CardContent>2,450</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader><CardTitle>Active Sessions</CardTitle></CardHeader>
|
||||
<CardContent>142</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
105
src/modules/dashboard/components/recent-submissions.tsx
Normal file
105
src/modules/dashboard/components/recent-submissions.tsx
Normal file
@@ -0,0 +1,105 @@
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/shared/components/ui/avatar";
|
||||
import { EmptyState } from "@/shared/components/ui/empty-state";
|
||||
import { Inbox } from "lucide-react";
|
||||
|
||||
interface SubmissionItem {
|
||||
id: string;
|
||||
studentName: string;
|
||||
studentAvatar?: string;
|
||||
assignment: string;
|
||||
submittedAt: string;
|
||||
status: "submitted" | "late";
|
||||
}
|
||||
|
||||
const MOCK_SUBMISSIONS: SubmissionItem[] = [
|
||||
{
|
||||
id: "1",
|
||||
studentName: "Alice Johnson",
|
||||
assignment: "React Component Composition",
|
||||
submittedAt: "10 minutes ago",
|
||||
status: "submitted",
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
studentName: "Bob Smith",
|
||||
assignment: "Design System Analysis",
|
||||
submittedAt: "1 hour ago",
|
||||
status: "submitted",
|
||||
},
|
||||
{
|
||||
id: "3",
|
||||
studentName: "Charlie Brown",
|
||||
assignment: "React Component Composition",
|
||||
submittedAt: "2 hours ago",
|
||||
status: "late",
|
||||
},
|
||||
{
|
||||
id: "4",
|
||||
studentName: "Diana Prince",
|
||||
assignment: "CSS Grid Layout",
|
||||
submittedAt: "Yesterday",
|
||||
status: "submitted",
|
||||
},
|
||||
{
|
||||
id: "5",
|
||||
studentName: "Evan Wright",
|
||||
assignment: "Design System Analysis",
|
||||
submittedAt: "Yesterday",
|
||||
status: "submitted",
|
||||
},
|
||||
];
|
||||
|
||||
export function RecentSubmissions() {
|
||||
const hasSubmissions = MOCK_SUBMISSIONS.length > 0;
|
||||
|
||||
return (
|
||||
<Card className="col-span-4 lg:col-span-4">
|
||||
<CardHeader>
|
||||
<CardTitle>Recent Submissions</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{!hasSubmissions ? (
|
||||
<EmptyState
|
||||
icon={Inbox}
|
||||
title="No New Submissions"
|
||||
description="All caught up! There are no new submissions to review."
|
||||
className="border-none h-[300px]"
|
||||
/>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
{MOCK_SUBMISSIONS.map((item) => (
|
||||
<div key={item.id} className="flex items-center justify-between group">
|
||||
<div className="flex items-center space-x-4">
|
||||
<Avatar className="h-9 w-9">
|
||||
<AvatarImage src={item.studentAvatar} alt={item.studentName} />
|
||||
<AvatarFallback>{item.studentName.charAt(0)}</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-medium leading-none">
|
||||
{item.studentName}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Submitted <span className="font-medium text-foreground">{item.assignment}</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{/* Using static date for demo to prevent hydration mismatch */}
|
||||
{item.submittedAt}
|
||||
</div>
|
||||
{item.status === "late" && (
|
||||
<span className="inline-flex items-center rounded-full border border-destructive px-2 py-0.5 text-xs font-semibold text-destructive transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2">
|
||||
Late
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
21
src/modules/dashboard/components/student-view.tsx
Normal file
21
src/modules/dashboard/components/student-view.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
"use client"
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
||||
|
||||
export function StudentDashboard() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<h1 className="text-3xl font-bold tracking-tight">Student Dashboard</h1>
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||
<Card>
|
||||
<CardHeader><CardTitle>My Courses</CardTitle></CardHeader>
|
||||
<CardContent>Enrolled in 5 courses</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader><CardTitle>Assignments</CardTitle></CardHeader>
|
||||
<CardContent>2 due this week</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
21
src/modules/dashboard/components/teacher-quick-actions.tsx
Normal file
21
src/modules/dashboard/components/teacher-quick-actions.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import { Button } from "@/shared/components/ui/button";
|
||||
import { PlusCircle, CheckSquare, MessageSquare } from "lucide-react";
|
||||
|
||||
export function TeacherQuickActions() {
|
||||
return (
|
||||
<div className="flex items-center space-x-2">
|
||||
<Button size="sm">
|
||||
<PlusCircle className="mr-2 h-4 w-4" />
|
||||
Create Assignment
|
||||
</Button>
|
||||
<Button variant="outline" size="sm">
|
||||
<CheckSquare className="mr-2 h-4 w-4" />
|
||||
Grade All
|
||||
</Button>
|
||||
<Button variant="outline" size="sm">
|
||||
<MessageSquare className="mr-2 h-4 w-4" />
|
||||
Message Class
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
81
src/modules/dashboard/components/teacher-schedule.tsx
Normal file
81
src/modules/dashboard/components/teacher-schedule.tsx
Normal file
@@ -0,0 +1,81 @@
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card";
|
||||
import { Badge } from "@/shared/components/ui/badge";
|
||||
import { Clock, MapPin, CalendarX } from "lucide-react";
|
||||
import { EmptyState } from "@/shared/components/ui/empty-state";
|
||||
|
||||
interface ScheduleItem {
|
||||
id: string;
|
||||
course: string;
|
||||
time: string;
|
||||
location: string;
|
||||
type: "Lecture" | "Workshop" | "Lab";
|
||||
}
|
||||
|
||||
// MOCK_SCHEDULE can be empty to test empty state
|
||||
const MOCK_SCHEDULE: ScheduleItem[] = [
|
||||
{
|
||||
id: "1",
|
||||
course: "Advanced Web Development",
|
||||
time: "09:00 AM - 10:30 AM",
|
||||
location: "Room 304",
|
||||
type: "Lecture",
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
course: "UI/UX Design Principles",
|
||||
time: "11:00 AM - 12:30 PM",
|
||||
location: "Design Studio A",
|
||||
type: "Workshop",
|
||||
},
|
||||
{
|
||||
id: "3",
|
||||
course: "Frontend Frameworks",
|
||||
time: "02:00 PM - 03:30 PM",
|
||||
location: "Online (Zoom)",
|
||||
type: "Lecture",
|
||||
},
|
||||
];
|
||||
|
||||
export function TeacherSchedule() {
|
||||
const hasSchedule = MOCK_SCHEDULE.length > 0;
|
||||
|
||||
return (
|
||||
<Card className="col-span-3">
|
||||
<CardHeader>
|
||||
<CardTitle>Today's Schedule</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{!hasSchedule ? (
|
||||
<EmptyState
|
||||
icon={CalendarX}
|
||||
title="No Classes Today"
|
||||
description="You have no classes scheduled for today. Enjoy your free time!"
|
||||
className="border-none h-[300px]"
|
||||
/>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{MOCK_SCHEDULE.map((item) => (
|
||||
<div
|
||||
key={item.id}
|
||||
className="flex items-center justify-between border-b pb-4 last:border-0 last:pb-0"
|
||||
>
|
||||
<div className="space-y-1">
|
||||
<p className="font-medium leading-none">{item.course}</p>
|
||||
<div className="flex items-center text-sm text-muted-foreground">
|
||||
<Clock className="mr-1 h-3 w-3" />
|
||||
<span className="mr-3">{item.time}</span>
|
||||
<MapPin className="mr-1 h-3 w-3" />
|
||||
<span>{item.location}</span>
|
||||
</div>
|
||||
</div>
|
||||
<Badge variant={item.type === "Lecture" ? "default" : "secondary"}>
|
||||
{item.type}
|
||||
</Badge>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
83
src/modules/dashboard/components/teacher-stats.tsx
Normal file
83
src/modules/dashboard/components/teacher-stats.tsx
Normal file
@@ -0,0 +1,83 @@
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card";
|
||||
import { Users, BookOpen, FileCheck, Calendar } from "lucide-react";
|
||||
import { Skeleton } from "@/shared/components/ui/skeleton";
|
||||
|
||||
interface StatItem {
|
||||
title: string;
|
||||
value: string;
|
||||
description: string;
|
||||
icon: React.ElementType;
|
||||
}
|
||||
|
||||
const MOCK_STATS: StatItem[] = [
|
||||
{
|
||||
title: "Total Students",
|
||||
value: "1,248",
|
||||
description: "+12% from last semester",
|
||||
icon: Users,
|
||||
},
|
||||
{
|
||||
title: "Active Courses",
|
||||
value: "4",
|
||||
description: "2 lectures, 2 workshops",
|
||||
icon: BookOpen,
|
||||
},
|
||||
{
|
||||
title: "To Grade",
|
||||
value: "28",
|
||||
description: "5 submissions pending review",
|
||||
icon: FileCheck,
|
||||
},
|
||||
{
|
||||
title: "Upcoming Classes",
|
||||
value: "3",
|
||||
description: "Today's schedule",
|
||||
icon: Calendar,
|
||||
},
|
||||
];
|
||||
|
||||
interface TeacherStatsProps {
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
export function TeacherStats({ isLoading = false }: TeacherStatsProps) {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<Card key={i}>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<Skeleton className="h-4 w-[100px]" />
|
||||
<Skeleton className="h-4 w-4 rounded-full" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Skeleton className="h-8 w-[60px] mb-2" />
|
||||
<Skeleton className="h-3 w-[140px]" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||
{MOCK_STATS.map((stat, i) => (
|
||||
<Card key={i}>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">
|
||||
{stat.title}
|
||||
</CardTitle>
|
||||
<stat.icon className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{stat.value}</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{stat.description}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
25
src/modules/dashboard/components/teacher-view.tsx
Normal file
25
src/modules/dashboard/components/teacher-view.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
"use client"
|
||||
|
||||
import { TeacherQuickActions } from "@/modules/dashboard/components/teacher-quick-actions";
|
||||
import { TeacherStats } from "@/modules/dashboard/components/teacher-stats";
|
||||
import { TeacherSchedule } from "@/modules/dashboard/components/teacher-schedule";
|
||||
import { RecentSubmissions } from "@/modules/dashboard/components/recent-submissions";
|
||||
|
||||
// This component is now exclusively for the Teacher Role View
|
||||
export function TeacherDashboard() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-3xl font-bold tracking-tight">Teacher Dashboard</h1>
|
||||
<TeacherQuickActions />
|
||||
</div>
|
||||
|
||||
<TeacherStats />
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-7">
|
||||
<TeacherSchedule />
|
||||
<RecentSubmissions />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
244
src/modules/exams/actions.ts
Normal file
244
src/modules/exams/actions.ts
Normal file
@@ -0,0 +1,244 @@
|
||||
"use server"
|
||||
|
||||
import { revalidatePath } from "next/cache"
|
||||
import { ActionState } from "@/shared/types/action-state"
|
||||
import { z } from "zod"
|
||||
import { createId } from "@paralleldrive/cuid2"
|
||||
import { db } from "@/shared/db"
|
||||
import { exams, examQuestions, submissionAnswers, examSubmissions } from "@/shared/db/schema"
|
||||
import { eq } from "drizzle-orm"
|
||||
|
||||
const ExamCreateSchema = z.object({
|
||||
title: z.string().min(1),
|
||||
subject: z.string().min(1),
|
||||
grade: z.string().min(1),
|
||||
difficulty: z.coerce.number().int().min(1).max(5),
|
||||
totalScore: z.coerce.number().int().min(1),
|
||||
durationMin: z.coerce.number().int().min(1),
|
||||
scheduledAt: z.string().optional().nullable(),
|
||||
questions: z
|
||||
.array(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
score: z.coerce.number().int().min(0),
|
||||
})
|
||||
)
|
||||
.optional(),
|
||||
})
|
||||
|
||||
export async function createExamAction(
|
||||
prevState: ActionState<string> | null,
|
||||
formData: FormData
|
||||
): Promise<ActionState<string>> {
|
||||
const rawQuestions = formData.get("questionsJson") as string | null
|
||||
|
||||
const parsed = ExamCreateSchema.safeParse({
|
||||
title: formData.get("title"),
|
||||
subject: formData.get("subject"),
|
||||
grade: formData.get("grade"),
|
||||
difficulty: formData.get("difficulty"),
|
||||
totalScore: formData.get("totalScore"),
|
||||
durationMin: formData.get("durationMin"),
|
||||
scheduledAt: formData.get("scheduledAt"),
|
||||
questions: rawQuestions ? JSON.parse(rawQuestions) : [],
|
||||
})
|
||||
|
||||
if (!parsed.success) {
|
||||
return {
|
||||
success: false,
|
||||
message: "Invalid form data",
|
||||
errors: parsed.error.flatten().fieldErrors,
|
||||
}
|
||||
}
|
||||
|
||||
const input = parsed.data
|
||||
|
||||
const examId = createId()
|
||||
const scheduled = input.scheduledAt || undefined
|
||||
|
||||
const meta = {
|
||||
subject: input.subject,
|
||||
grade: input.grade,
|
||||
difficulty: input.difficulty,
|
||||
totalScore: input.totalScore,
|
||||
durationMin: input.durationMin,
|
||||
scheduledAt: scheduled ?? undefined,
|
||||
}
|
||||
|
||||
try {
|
||||
const user = await getCurrentUser()
|
||||
await db.insert(exams).values({
|
||||
id: examId,
|
||||
title: input.title,
|
||||
description: JSON.stringify(meta),
|
||||
creatorId: user?.id ?? "user_teacher_123",
|
||||
startTime: scheduled ? new Date(scheduled) : null,
|
||||
status: "draft",
|
||||
})
|
||||
} catch (error) {
|
||||
console.error("Failed to create exam:", error)
|
||||
return {
|
||||
success: false,
|
||||
message: "Database error: Failed to create exam",
|
||||
}
|
||||
}
|
||||
|
||||
revalidatePath("/teacher/exams/all")
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: "Exam created successfully.",
|
||||
data: examId,
|
||||
}
|
||||
}
|
||||
|
||||
const ExamUpdateSchema = z.object({
|
||||
examId: z.string().min(1),
|
||||
questions: z
|
||||
.array(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
score: z.coerce.number().int().min(0),
|
||||
})
|
||||
)
|
||||
.default([]),
|
||||
structure: z.any().optional(), // Accept structure JSON
|
||||
status: z.enum(["draft", "published", "archived"]).optional(),
|
||||
})
|
||||
|
||||
export async function updateExamAction(
|
||||
prevState: ActionState<string> | null,
|
||||
formData: FormData
|
||||
): Promise<ActionState<string>> {
|
||||
const rawQuestions = formData.get("questionsJson") as string | null
|
||||
const rawStructure = formData.get("structureJson") as string | null
|
||||
|
||||
const parsed = ExamUpdateSchema.safeParse({
|
||||
examId: formData.get("examId"),
|
||||
questions: rawQuestions ? JSON.parse(rawQuestions) : [],
|
||||
structure: rawStructure ? JSON.parse(rawStructure) : undefined,
|
||||
status: formData.get("status") ?? undefined,
|
||||
})
|
||||
|
||||
if (!parsed.success) {
|
||||
return {
|
||||
success: false,
|
||||
message: "Invalid update data",
|
||||
errors: parsed.error.flatten().fieldErrors,
|
||||
}
|
||||
}
|
||||
|
||||
const { examId, questions, structure, status } = parsed.data
|
||||
|
||||
try {
|
||||
await db.delete(examQuestions).where(eq(examQuestions.examId, examId))
|
||||
if (questions.length > 0) {
|
||||
await db.insert(examQuestions).values(
|
||||
questions.map((q, idx) => ({
|
||||
examId,
|
||||
questionId: q.id,
|
||||
score: q.score ?? 0,
|
||||
order: idx,
|
||||
}))
|
||||
)
|
||||
}
|
||||
|
||||
// Prepare update object
|
||||
const updateData: any = {}
|
||||
if (status) updateData.status = status
|
||||
if (structure) updateData.structure = structure
|
||||
|
||||
if (Object.keys(updateData).length > 0) {
|
||||
await db.update(exams).set(updateData).where(eq(exams.id, examId))
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error("Failed to update exam:", error)
|
||||
return {
|
||||
success: false,
|
||||
message: "Database error: Failed to update exam",
|
||||
}
|
||||
}
|
||||
|
||||
revalidatePath("/teacher/exams/all")
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: "Exam updated",
|
||||
data: examId,
|
||||
}
|
||||
}
|
||||
|
||||
const GradingSchema = z.object({
|
||||
submissionId: z.string().min(1),
|
||||
answers: z.array(z.object({
|
||||
id: z.string(), // answer id
|
||||
score: z.coerce.number().min(0),
|
||||
feedback: z.string().optional()
|
||||
}))
|
||||
})
|
||||
|
||||
export async function gradeSubmissionAction(
|
||||
prevState: ActionState<string> | null,
|
||||
formData: FormData
|
||||
): Promise<ActionState<string>> {
|
||||
const rawAnswers = formData.get("answersJson") as string | null
|
||||
const parsed = GradingSchema.safeParse({
|
||||
submissionId: formData.get("submissionId"),
|
||||
answers: rawAnswers ? JSON.parse(rawAnswers) : []
|
||||
})
|
||||
|
||||
if (!parsed.success) {
|
||||
return {
|
||||
success: false,
|
||||
message: "Invalid grading data",
|
||||
errors: parsed.error.flatten().fieldErrors
|
||||
}
|
||||
}
|
||||
|
||||
const { submissionId, answers } = parsed.data
|
||||
|
||||
try {
|
||||
let totalScore = 0
|
||||
|
||||
// Update each answer
|
||||
for (const ans of answers) {
|
||||
await db.update(submissionAnswers)
|
||||
.set({
|
||||
score: ans.score,
|
||||
feedback: ans.feedback,
|
||||
updatedAt: new Date()
|
||||
})
|
||||
.where(eq(submissionAnswers.id, ans.id))
|
||||
|
||||
totalScore += ans.score
|
||||
}
|
||||
|
||||
// Update submission total score and status
|
||||
await db.update(examSubmissions)
|
||||
.set({
|
||||
score: totalScore,
|
||||
status: "graded",
|
||||
updatedAt: new Date()
|
||||
})
|
||||
.where(eq(examSubmissions.id, submissionId))
|
||||
|
||||
} catch (error) {
|
||||
console.error("Grading failed:", error)
|
||||
return {
|
||||
success: false,
|
||||
message: "Database error during grading"
|
||||
}
|
||||
}
|
||||
|
||||
revalidatePath(`/teacher/exams/grading`)
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: "Grading saved successfully"
|
||||
}
|
||||
}
|
||||
|
||||
async function getCurrentUser() {
|
||||
return { id: "user_teacher_123", role: "teacher" }
|
||||
}
|
||||
65
src/modules/exams/components/assembly/question-bank-list.tsx
Normal file
65
src/modules/exams/components/assembly/question-bank-list.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
"use client"
|
||||
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import { Badge } from "@/shared/components/ui/badge"
|
||||
import { Card } from "@/shared/components/ui/card"
|
||||
import { Plus } from "lucide-react"
|
||||
import type { Question } from "@/modules/questions/types"
|
||||
|
||||
type QuestionBankListProps = {
|
||||
questions: Question[]
|
||||
onAdd: (question: Question) => void
|
||||
isAdded: (id: string) => boolean
|
||||
}
|
||||
|
||||
export function QuestionBankList({ questions, onAdd, isAdded }: QuestionBankListProps) {
|
||||
if (questions.length === 0) {
|
||||
return (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
No questions found matching your filters.
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{questions.map((q) => {
|
||||
const added = isAdded(q.id)
|
||||
const content = q.content as { text?: string }
|
||||
return (
|
||||
<Card key={q.id} className="p-3 flex gap-3 hover:bg-muted/50 transition-colors">
|
||||
<div className="flex-1 space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="outline" className="text-[10px] uppercase">
|
||||
{q.type.replace("_", " ")}
|
||||
</Badge>
|
||||
<Badge variant="secondary" className="text-[10px]">
|
||||
Lvl {q.difficulty}
|
||||
</Badge>
|
||||
{q.knowledgePoints?.slice(0, 1).map((kp) => (
|
||||
<Badge key={kp.id} variant="outline" className="text-[10px] truncate max-w-[100px]">
|
||||
{kp.name}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-sm line-clamp-2 text-muted-foreground">
|
||||
{content.text || "No content preview"}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<Button
|
||||
size="sm"
|
||||
variant={added ? "secondary" : "default"}
|
||||
disabled={added}
|
||||
onClick={() => onAdd(q)}
|
||||
className="h-8 w-8 p-0"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
181
src/modules/exams/components/assembly/selected-question-list.tsx
Normal file
181
src/modules/exams/components/assembly/selected-question-list.tsx
Normal file
@@ -0,0 +1,181 @@
|
||||
"use client"
|
||||
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import { Input } from "@/shared/components/ui/input"
|
||||
import { Label } from "@/shared/components/ui/label"
|
||||
import { ArrowUp, ArrowDown, Trash2 } from "lucide-react"
|
||||
import type { Question } from "@/modules/questions/types"
|
||||
|
||||
export type ExamNode = {
|
||||
id: string
|
||||
type: 'group' | 'question'
|
||||
title?: string // For group
|
||||
questionId?: string // For question
|
||||
score?: number
|
||||
children?: ExamNode[] // For group
|
||||
question?: Question // Populated for rendering
|
||||
}
|
||||
|
||||
type SelectedQuestionListProps = {
|
||||
items: ExamNode[]
|
||||
onRemove: (id: string, parentId?: string) => void
|
||||
onMove: (id: string, direction: 'up' | 'down', parentId?: string) => void
|
||||
onScoreChange: (id: string, score: number) => void
|
||||
onGroupTitleChange: (id: string, title: string) => void
|
||||
onAddGroup: () => void
|
||||
}
|
||||
|
||||
export function SelectedQuestionList({
|
||||
items,
|
||||
onRemove,
|
||||
onMove,
|
||||
onScoreChange,
|
||||
onGroupTitleChange,
|
||||
onAddGroup
|
||||
}: SelectedQuestionListProps) {
|
||||
if (items.length === 0) {
|
||||
return (
|
||||
<div className="border border-dashed rounded-lg p-8 text-center text-muted-foreground text-sm flex flex-col gap-4">
|
||||
<p>No questions selected. Add questions from the bank or create a group.</p>
|
||||
<Button variant="outline" onClick={onAddGroup}>Create Section</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{items.map((node, idx) => {
|
||||
if (node.type === 'group') {
|
||||
return (
|
||||
<div key={node.id} className="rounded-lg border bg-muted/10 p-4 space-y-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Input
|
||||
value={node.title || "Untitled Section"}
|
||||
onChange={(e) => onGroupTitleChange(node.id, e.target.value)}
|
||||
className="font-semibold h-9 bg-transparent border-transparent hover:border-input focus:bg-background"
|
||||
/>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={() => onMove(node.id, 'up')} disabled={idx === 0}>
|
||||
<ArrowUp className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={() => onMove(node.id, 'down')} disabled={idx === items.length - 1}>
|
||||
<ArrowDown className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8 text-destructive" onClick={() => onRemove(node.id)}>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pl-4 border-l-2 border-muted space-y-3">
|
||||
{node.children?.length === 0 ? (
|
||||
<div className="text-xs text-muted-foreground italic py-2">Drag questions here or add from bank</div>
|
||||
) : (
|
||||
node.children?.map((child, cIdx) => (
|
||||
<QuestionItem
|
||||
key={child.id}
|
||||
item={child}
|
||||
index={cIdx}
|
||||
total={node.children?.length || 0}
|
||||
onRemove={() => onRemove(child.id, node.id)}
|
||||
onMove={(dir) => onMove(child.id, dir, node.id)}
|
||||
onScoreChange={(score) => onScoreChange(child.id, score)}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<QuestionItem
|
||||
key={node.id}
|
||||
item={node}
|
||||
index={idx}
|
||||
total={items.length}
|
||||
onRemove={() => onRemove(node.id)}
|
||||
onMove={(dir) => onMove(node.id, dir)}
|
||||
onScoreChange={(score) => onScoreChange(node.id, score)}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
|
||||
<div className="flex justify-center pt-2">
|
||||
<Button variant="outline" size="sm" onClick={onAddGroup} className="w-full border-dashed">
|
||||
+ Add Section
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function QuestionItem({ item, index, total, onRemove, onMove, onScoreChange }: {
|
||||
item: ExamNode
|
||||
index: number
|
||||
total: number
|
||||
onRemove: () => void
|
||||
onMove: (dir: 'up' | 'down') => void
|
||||
onScoreChange: (score: number) => void
|
||||
}) {
|
||||
const content = item.question?.content as { text?: string }
|
||||
return (
|
||||
<div className="group flex flex-col gap-3 rounded-md border p-3 bg-card hover:border-primary/50 transition-colors">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex gap-2">
|
||||
<span className="flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-muted text-xs font-medium">
|
||||
{index + 1}
|
||||
</span>
|
||||
<p className="text-sm line-clamp-2 pt-0.5">
|
||||
{content?.text || "Question content"}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 text-muted-foreground hover:text-destructive"
|
||||
onClick={onRemove}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between pl-8">
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6"
|
||||
disabled={index === 0}
|
||||
onClick={() => onMove('up')}
|
||||
>
|
||||
<ArrowUp className="h-3 w-3" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6"
|
||||
disabled={index === total - 1}
|
||||
onClick={() => onMove('down')}
|
||||
>
|
||||
<ArrowDown className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Label htmlFor={`score-${item.id}`} className="text-xs text-muted-foreground">
|
||||
Score
|
||||
</Label>
|
||||
<Input
|
||||
id={`score-${item.id}`}
|
||||
type="number"
|
||||
min={0}
|
||||
className="h-7 w-16 text-right"
|
||||
value={item.score}
|
||||
onChange={(e) => onScoreChange(parseInt(e.target.value) || 0)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
570
src/modules/exams/components/assembly/structure-editor.tsx
Normal file
570
src/modules/exams/components/assembly/structure-editor.tsx
Normal file
@@ -0,0 +1,570 @@
|
||||
"use client"
|
||||
|
||||
import React, { useMemo, useState } from "react"
|
||||
import {
|
||||
DndContext,
|
||||
pointerWithin,
|
||||
rectIntersection,
|
||||
getFirstCollision,
|
||||
CollisionDetection,
|
||||
KeyboardSensor,
|
||||
PointerSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
DragOverlay,
|
||||
defaultDropAnimationSideEffects,
|
||||
DragStartEvent,
|
||||
DragOverEvent,
|
||||
DragEndEvent,
|
||||
DropAnimation,
|
||||
MeasuringStrategy,
|
||||
} from "@dnd-kit/core"
|
||||
import {
|
||||
arrayMove,
|
||||
SortableContext,
|
||||
sortableKeyboardCoordinates,
|
||||
verticalListSortingStrategy,
|
||||
useSortable,
|
||||
} from "@dnd-kit/sortable"
|
||||
import { CSS } from "@dnd-kit/utilities"
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import { Input } from "@/shared/components/ui/input"
|
||||
import { Label } from "@/shared/components/ui/label"
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/shared/components/ui/collapsible"
|
||||
import { Trash2, GripVertical, ChevronDown, ChevronRight, Calculator } from "lucide-react"
|
||||
import { cn } from "@/shared/lib/utils"
|
||||
import type { ExamNode } from "./selected-question-list"
|
||||
import type { Question } from "@/modules/questions/types"
|
||||
|
||||
// --- Types ---
|
||||
|
||||
type StructureEditorProps = {
|
||||
items: ExamNode[]
|
||||
onChange: (items: ExamNode[]) => void
|
||||
onScoreChange: (id: string, score: number) => void
|
||||
onGroupTitleChange: (id: string, title: string) => void
|
||||
onRemove: (id: string) => void
|
||||
onAddGroup: () => void
|
||||
}
|
||||
|
||||
// --- Components ---
|
||||
|
||||
function SortableItem({
|
||||
id,
|
||||
item,
|
||||
onRemove,
|
||||
onScoreChange
|
||||
}: {
|
||||
id: string
|
||||
item: ExamNode
|
||||
onRemove: () => void
|
||||
onScoreChange: (val: number) => void
|
||||
}) {
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
setNodeRef,
|
||||
transform,
|
||||
transition,
|
||||
isDragging,
|
||||
} = useSortable({ id })
|
||||
|
||||
const style = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
opacity: isDragging ? 0.5 : 1,
|
||||
}
|
||||
|
||||
const content = item.question?.content as { text?: string }
|
||||
|
||||
return (
|
||||
<div ref={setNodeRef} style={style} className={cn("group flex flex-col gap-3 rounded-md border p-3 bg-card hover:border-primary/50 transition-colors", isDragging && "ring-2 ring-primary")}>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex gap-2 items-start flex-1">
|
||||
<button {...attributes} {...listeners} className="mt-1 cursor-grab active:cursor-grabbing text-muted-foreground hover:text-foreground">
|
||||
<GripVertical className="h-4 w-4" />
|
||||
</button>
|
||||
<p className="text-sm line-clamp-2 pt-0.5 select-none">
|
||||
{content?.text || "Question content"}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 text-muted-foreground hover:text-destructive shrink-0"
|
||||
onClick={onRemove}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-end pl-8">
|
||||
<div className="flex items-center gap-2">
|
||||
<Label htmlFor={`score-${item.id}`} className="text-xs text-muted-foreground">
|
||||
Score
|
||||
</Label>
|
||||
<Input
|
||||
id={`score-${item.id}`}
|
||||
type="number"
|
||||
min={0}
|
||||
className="h-7 w-16 text-right"
|
||||
value={item.score}
|
||||
onChange={(e) => onScoreChange(parseInt(e.target.value) || 0)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SortableGroup({
|
||||
id,
|
||||
item,
|
||||
children,
|
||||
onRemove,
|
||||
onTitleChange
|
||||
}: {
|
||||
id: string
|
||||
item: ExamNode
|
||||
children: React.ReactNode
|
||||
onRemove: () => void
|
||||
onTitleChange: (val: string) => void
|
||||
}) {
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
setNodeRef,
|
||||
transform,
|
||||
transition,
|
||||
isDragging,
|
||||
} = useSortable({ id })
|
||||
|
||||
const [isOpen, setIsOpen] = useState(true)
|
||||
|
||||
const style = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
opacity: isDragging ? 0.5 : 1,
|
||||
}
|
||||
|
||||
const totalScore = useMemo(() => {
|
||||
const calc = (nodes: ExamNode[]): number => {
|
||||
return nodes.reduce((acc, node) => {
|
||||
if (node.type === 'question') return acc + (node.score || 0)
|
||||
if (node.type === 'group') return acc + calc(node.children || [])
|
||||
return acc
|
||||
}, 0)
|
||||
}
|
||||
return calc(item.children || [])
|
||||
}, [item])
|
||||
|
||||
return (
|
||||
<Collapsible open={isOpen} onOpenChange={setIsOpen} ref={setNodeRef} style={style} className={cn("rounded-lg border bg-muted/10 p-3 space-y-2", isDragging && "ring-2 ring-primary")}>
|
||||
<div className="flex items-center gap-3">
|
||||
<button {...attributes} {...listeners} className="cursor-grab active:cursor-grabbing text-muted-foreground hover:text-foreground">
|
||||
<GripVertical className="h-5 w-5" />
|
||||
</button>
|
||||
|
||||
<CollapsibleTrigger asChild>
|
||||
<Button variant="ghost" size="sm" className="p-0 h-6 w-6 hover:bg-transparent">
|
||||
{isOpen ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
|
||||
<Input
|
||||
value={item.title || ""}
|
||||
onChange={(e) => onTitleChange(e.target.value)}
|
||||
placeholder="Section Title"
|
||||
className="font-semibold h-9 bg-transparent border-transparent hover:border-input focus:bg-background flex-1"
|
||||
/>
|
||||
|
||||
<div className="flex items-center gap-1 text-muted-foreground text-xs bg-background/50 px-2 py-1 rounded">
|
||||
<Calculator className="h-3 w-3" />
|
||||
<span>{totalScore} pts</span>
|
||||
</div>
|
||||
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8 text-destructive" onClick={onRemove}>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<CollapsibleContent className="pl-4 border-l-2 border-muted space-y-3 min-h-[50px] animate-in slide-in-from-top-2 fade-in duration-200">
|
||||
{children}
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
)
|
||||
}
|
||||
|
||||
function StructureRenderer({ nodes, ...props }: {
|
||||
nodes: ExamNode[]
|
||||
onRemove: (id: string) => void
|
||||
onScoreChange: (id: string, score: number) => void
|
||||
onGroupTitleChange: (id: string, title: string) => void
|
||||
}) {
|
||||
return (
|
||||
<SortableContext items={nodes.map(n => n.id)} strategy={verticalListSortingStrategy}>
|
||||
{nodes.map(node => (
|
||||
<React.Fragment key={node.id}>
|
||||
{node.type === 'group' ? (
|
||||
<SortableGroup
|
||||
id={node.id}
|
||||
item={node}
|
||||
onRemove={() => props.onRemove(node.id)}
|
||||
onTitleChange={(val) => props.onGroupTitleChange(node.id, val)}
|
||||
>
|
||||
<StructureRenderer
|
||||
nodes={node.children || []}
|
||||
onRemove={props.onRemove}
|
||||
onScoreChange={props.onScoreChange}
|
||||
onGroupTitleChange={props.onGroupTitleChange}
|
||||
/>
|
||||
{(!node.children || node.children.length === 0) && (
|
||||
<div className="text-xs text-muted-foreground italic py-2 text-center border-2 border-dashed border-muted/50 rounded">
|
||||
Drag items here
|
||||
</div>
|
||||
)}
|
||||
</SortableGroup>
|
||||
) : (
|
||||
<SortableItem
|
||||
id={node.id}
|
||||
item={node}
|
||||
onRemove={() => props.onRemove(node.id)}
|
||||
onScoreChange={(val) => props.onScoreChange(node.id, val)}
|
||||
/>
|
||||
)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</SortableContext>
|
||||
)
|
||||
}
|
||||
|
||||
// --- Main Component ---
|
||||
|
||||
const dropAnimation: DropAnimation = {
|
||||
sideEffects: defaultDropAnimationSideEffects({
|
||||
styles: {
|
||||
active: {
|
||||
opacity: '0.5',
|
||||
},
|
||||
},
|
||||
}),
|
||||
}
|
||||
|
||||
export function StructureEditor({ items, onChange, onScoreChange, onGroupTitleChange, onRemove, onAddGroup }: StructureEditorProps) {
|
||||
const [activeId, setActiveId] = useState<string | null>(null)
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor),
|
||||
useSensor(KeyboardSensor, {
|
||||
coordinateGetter: sortableKeyboardCoordinates,
|
||||
})
|
||||
)
|
||||
|
||||
// Recursively find item
|
||||
const findItem = (id: string, nodes: ExamNode[] = items): ExamNode | null => {
|
||||
for (const node of nodes) {
|
||||
if (node.id === id) return node
|
||||
if (node.children) {
|
||||
const found = findItem(id, node.children)
|
||||
if (found) return found
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const activeItem = activeId ? findItem(activeId) : null
|
||||
|
||||
// DND Handlers
|
||||
|
||||
function handleDragStart(event: DragStartEvent) {
|
||||
setActiveId(event.active.id as string)
|
||||
}
|
||||
|
||||
// Custom collision detection for nested sortables
|
||||
const customCollisionDetection: CollisionDetection = (args) => {
|
||||
// 1. First check pointer within for precise container detection
|
||||
const pointerCollisions = pointerWithin(args)
|
||||
|
||||
// If we have pointer collisions, prioritize the most specific one (usually the smallest/innermost container)
|
||||
if (pointerCollisions.length > 0) {
|
||||
return pointerCollisions
|
||||
}
|
||||
|
||||
// 2. Fallback to rect intersection for smoother sortable reordering when not directly over a container
|
||||
return rectIntersection(args)
|
||||
}
|
||||
|
||||
function handleDragOver(event: DragOverEvent) {
|
||||
const { active, over } = event
|
||||
if (!over) return
|
||||
|
||||
const activeId = active.id as string
|
||||
const overId = over.id as string
|
||||
|
||||
if (activeId === overId) return
|
||||
|
||||
// Find if we are moving over a Group container
|
||||
// "overId" could be a SortableItem (Question) OR a SortableGroup (Group)
|
||||
|
||||
const activeNode = findItem(activeId)
|
||||
const overNode = findItem(overId)
|
||||
|
||||
if (!activeNode || !overNode) return
|
||||
|
||||
// CRITICAL FIX: Prevent dragging a node onto its own descendant
|
||||
// This happens when dragging a group and hovering over its own children.
|
||||
// If we proceed, we would remove the group (and its children) and then fail to find the child to insert next to.
|
||||
const isDescendantOfActive = (childId: string): boolean => {
|
||||
const check = (node: ExamNode): boolean => {
|
||||
if (!node.children) return false
|
||||
return node.children.some(c => c.id === childId || check(c))
|
||||
}
|
||||
return check(activeNode)
|
||||
}
|
||||
|
||||
if (isDescendantOfActive(overId)) return
|
||||
|
||||
// Find which list the `over` item belongs to
|
||||
const findContainerId = (id: string, list: ExamNode[], parentId: string = 'root'): string | undefined => {
|
||||
if (list.some(i => i.id === id)) return parentId
|
||||
for (const node of list) {
|
||||
if (node.children) {
|
||||
const res = findContainerId(id, node.children, node.id)
|
||||
if (res) return res
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
const activeContainerId = findContainerId(activeId, items)
|
||||
const overContainerId = findContainerId(overId, items)
|
||||
|
||||
// Scenario 1: Moving item into a Group by hovering over the Group itself
|
||||
// If overNode is a Group, we might want to move INTO it
|
||||
if (overNode.type === 'group') {
|
||||
// Logic: If active item is NOT in this group already
|
||||
// AND we are not trying to move a group into its own descendant (circular check)
|
||||
|
||||
const isDescendant = (parent: ExamNode, childId: string): boolean => {
|
||||
if (!parent.children) return false
|
||||
for (const c of parent.children) {
|
||||
if (c.id === childId) return true
|
||||
if (isDescendant(c, childId)) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// If moving a group, check if overNode is a descendant of activeNode
|
||||
if (activeNode.type === 'group' && isDescendant(activeNode, overNode.id)) {
|
||||
return
|
||||
}
|
||||
|
||||
if (activeContainerId !== overNode.id) {
|
||||
// ... implementation continues ...
|
||||
|
||||
const newItems = JSON.parse(JSON.stringify(items)) as ExamNode[]
|
||||
|
||||
// Remove active from old location
|
||||
const removeRecursive = (list: ExamNode[]): ExamNode | null => {
|
||||
const idx = list.findIndex(i => i.id === activeId)
|
||||
if (idx !== -1) return list.splice(idx, 1)[0]
|
||||
for (const node of list) {
|
||||
if (node.children) {
|
||||
const res = removeRecursive(node.children)
|
||||
if (res) return res
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const movedItem = removeRecursive(newItems)
|
||||
if (!movedItem) return
|
||||
|
||||
// Insert into new Group (overNode)
|
||||
// We need to find the overNode in the NEW structure (since we cloned it)
|
||||
const findGroupAndInsert = (list: ExamNode[]) => {
|
||||
for (const node of list) {
|
||||
if (node.id === overId) {
|
||||
if (!node.children) node.children = []
|
||||
node.children.push(movedItem)
|
||||
return true
|
||||
}
|
||||
if (node.children) {
|
||||
if (findGroupAndInsert(node.children)) return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
findGroupAndInsert(newItems)
|
||||
onChange(newItems)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Scenario 2: Moving between different lists (e.g. from Root to Group A, or Group A to Group B)
|
||||
if (activeContainerId !== overContainerId) {
|
||||
// Standard Sortable Move
|
||||
const newItems = JSON.parse(JSON.stringify(items)) as ExamNode[]
|
||||
|
||||
const removeRecursive = (list: ExamNode[]): ExamNode | null => {
|
||||
const idx = list.findIndex(i => i.id === activeId)
|
||||
if (idx !== -1) return list.splice(idx, 1)[0]
|
||||
for (const node of list) {
|
||||
if (node.children) {
|
||||
const res = removeRecursive(node.children)
|
||||
if (res) return res
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const movedItem = removeRecursive(newItems)
|
||||
if (!movedItem) return
|
||||
|
||||
// Insert into destination list at specific index
|
||||
// We need to find the destination list array and the index of `overId`
|
||||
const insertRecursive = (list: ExamNode[]): boolean => {
|
||||
const idx = list.findIndex(i => i.id === overId)
|
||||
if (idx !== -1) {
|
||||
// Insert before or after based on direction?
|
||||
// Usually dnd-kit handles order if we are in same container, but cross-container we need to pick a spot.
|
||||
// We'll insert at the index of `overId`.
|
||||
|
||||
// However, if we insert AT the index, dnd-kit might get confused if we are dragging DOWN vs UP.
|
||||
// But since we are changing containers, just inserting at the target index is usually fine.
|
||||
// The issue "swapping positions is not smooth" might be because we insert *at* index, displacing the target.
|
||||
// Let's try to determine if we are "below" or "above" the target?
|
||||
// For cross-container, simpler is better. Inserting at index is standard.
|
||||
|
||||
list.splice(idx, 0, movedItem)
|
||||
return true
|
||||
}
|
||||
for (const node of list) {
|
||||
if (node.children) {
|
||||
if (insertRecursive(node.children)) return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
insertRecursive(newItems)
|
||||
onChange(newItems)
|
||||
}
|
||||
}
|
||||
|
||||
function handleDragEnd(event: DragEndEvent) {
|
||||
const { active, over } = event
|
||||
setActiveId(null)
|
||||
|
||||
if (!over) return
|
||||
|
||||
const activeId = active.id as string
|
||||
const overId = over.id as string
|
||||
|
||||
if (activeId === overId) return
|
||||
|
||||
// Re-find positions in the potentially updated state
|
||||
// Note: Since we mutate in DragOver, the item might already be in the new container.
|
||||
// So activeContainerId might equal overContainerId now!
|
||||
|
||||
const findContainerId = (id: string, list: ExamNode[], parentId: string = 'root'): string | undefined => {
|
||||
if (list.some(i => i.id === id)) return parentId
|
||||
for (const node of list) {
|
||||
if (node.children) {
|
||||
const res = findContainerId(id, node.children, node.id)
|
||||
if (res) return res
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
const activeContainerId = findContainerId(activeId, items)
|
||||
const overContainerId = findContainerId(overId, items)
|
||||
|
||||
if (activeContainerId === overContainerId) {
|
||||
// Same container reorder
|
||||
const newItems = JSON.parse(JSON.stringify(items)) as ExamNode[]
|
||||
|
||||
const getMutableList = (groupId?: string): ExamNode[] => {
|
||||
if (groupId === 'root') return newItems
|
||||
// Need recursive find
|
||||
const findGroup = (list: ExamNode[]): ExamNode | null => {
|
||||
for (const node of list) {
|
||||
if (node.id === groupId) return node
|
||||
if (node.children) {
|
||||
const res = findGroup(node.children)
|
||||
if (res) return res
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
return findGroup(newItems)?.children || []
|
||||
}
|
||||
|
||||
const list = getMutableList(activeContainerId)
|
||||
const oldIndex = list.findIndex(i => i.id === activeId)
|
||||
const newIndex = list.findIndex(i => i.id === overId)
|
||||
|
||||
if (oldIndex !== -1 && newIndex !== -1 && oldIndex !== newIndex) {
|
||||
const moved = arrayMove(list, oldIndex, newIndex)
|
||||
|
||||
// Update the list reference in parent
|
||||
if (activeContainerId === 'root') {
|
||||
onChange(moved)
|
||||
} else {
|
||||
// list is already a reference to children array if we did it right?
|
||||
// getMutableList returned `group.children`. Modifying `list` directly via arrayMove returns NEW array.
|
||||
// So we need to re-assign.
|
||||
const group = findItem(activeContainerId!, newItems)
|
||||
if (group) group.children = moved
|
||||
onChange(newItems)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={customCollisionDetection}
|
||||
onDragStart={handleDragStart}
|
||||
onDragOver={handleDragOver}
|
||||
onDragEnd={handleDragEnd}
|
||||
measuring={{ droppable: { strategy: MeasuringStrategy.Always } }}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<StructureRenderer
|
||||
nodes={items}
|
||||
onRemove={onRemove}
|
||||
onScoreChange={onScoreChange}
|
||||
onGroupTitleChange={onGroupTitleChange}
|
||||
/>
|
||||
|
||||
<div className="flex justify-center pt-2">
|
||||
<Button variant="outline" size="sm" onClick={onAddGroup} className="w-full border-dashed">
|
||||
+ Add Section
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DragOverlay dropAnimation={dropAnimation}>
|
||||
{activeItem ? (
|
||||
activeItem.type === 'group' ? (
|
||||
<div className="rounded-lg border bg-background p-4 shadow-lg opacity-80 w-[300px]">
|
||||
<div className="flex items-center gap-3">
|
||||
<GripVertical className="h-5 w-5" />
|
||||
<span className="font-semibold">{activeItem.title || "Section"}</span>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-md border bg-background p-3 shadow-lg opacity-80 w-[300px] flex items-center gap-3">
|
||||
<GripVertical className="h-4 w-4" />
|
||||
<p className="text-sm line-clamp-1">{(activeItem.question?.content as any)?.text || "Question"}</p>
|
||||
</div>
|
||||
)
|
||||
) : null}
|
||||
</DragOverlay>
|
||||
</DndContext>
|
||||
)
|
||||
}
|
||||
170
src/modules/exams/components/exam-actions.tsx
Normal file
170
src/modules/exams/components/exam-actions.tsx
Normal file
@@ -0,0 +1,170 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { MoreHorizontal, Eye, Pencil, Trash, Archive, UploadCloud, Undo2, Copy } from "lucide-react"
|
||||
import { toast } from "sonner"
|
||||
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
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,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/shared/components/ui/dialog"
|
||||
|
||||
import { Exam } from "../types"
|
||||
|
||||
interface ExamActionsProps {
|
||||
exam: Exam
|
||||
}
|
||||
|
||||
export function ExamActions({ exam }: ExamActionsProps) {
|
||||
const router = useRouter()
|
||||
const [showViewDialog, setShowViewDialog] = useState(false)
|
||||
const [showDeleteDialog, setShowDeleteDialog] = useState(false)
|
||||
|
||||
const copyId = () => {
|
||||
navigator.clipboard.writeText(exam.id)
|
||||
toast.success("Exam ID copied to clipboard")
|
||||
}
|
||||
|
||||
const publishExam = async () => {
|
||||
toast.success("Exam published")
|
||||
}
|
||||
|
||||
const unpublishExam = async () => {
|
||||
toast.success("Exam moved to draft")
|
||||
}
|
||||
|
||||
const archiveExam = async () => {
|
||||
toast.success("Exam archived")
|
||||
}
|
||||
|
||||
const handleDelete = async () => {
|
||||
try {
|
||||
await new Promise((r) => setTimeout(r, 800))
|
||||
toast.success("Exam deleted successfully")
|
||||
setShowDeleteDialog(false)
|
||||
} catch (e) {
|
||||
toast.error("Failed to delete exam")
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" className="h-8 w-8 p-0">
|
||||
<span className="sr-only">Open menu</span>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuLabel>Actions</DropdownMenuLabel>
|
||||
<DropdownMenuItem onClick={copyId}>
|
||||
<Copy className="mr-2 h-4 w-4" /> Copy ID
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={() => setShowViewDialog(true)}>
|
||||
<Eye className="mr-2 h-4 w-4" /> View
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem>
|
||||
<Pencil className="mr-2 h-4 w-4" /> Edit
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => router.push(`/teacher/exams/${exam.id}/build`)}>
|
||||
<MoreHorizontal className="mr-2 h-4 w-4" /> Build
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={publishExam}>
|
||||
<UploadCloud className="mr-2 h-4 w-4" /> Publish
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={unpublishExam}>
|
||||
<Undo2 className="mr-2 h-4 w-4" /> Move to Draft
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={archiveExam}>
|
||||
<Archive className="mr-2 h-4 w-4" /> Archive
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
className="text-destructive focus:text-destructive"
|
||||
onClick={() => setShowDeleteDialog(true)}
|
||||
>
|
||||
<Trash className="mr-2 h-4 w-4" /> Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<Dialog open={showViewDialog} onOpenChange={setShowViewDialog}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Exam Details</DialogTitle>
|
||||
<DialogDescription>ID: {exam.id}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<span className="font-medium">Title:</span>
|
||||
<span className="col-span-3">{exam.title}</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<span className="font-medium">Subject:</span>
|
||||
<span className="col-span-3">{exam.subject}</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<span className="font-medium">Grade:</span>
|
||||
<span className="col-span-3">{exam.grade}</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<span className="font-medium">Total Score:</span>
|
||||
<span className="col-span-3">{exam.totalScore}</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<span className="font-medium">Duration:</span>
|
||||
<span className="col-span-3">{exam.durationMin} min</span>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<AlertDialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete exam?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This action cannot be undone. This will permanently delete the exam.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
handleDelete()
|
||||
}}
|
||||
>
|
||||
Delete
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</>
|
||||
)
|
||||
}
|
||||
343
src/modules/exams/components/exam-assembly.tsx
Normal file
343
src/modules/exams/components/exam-assembly.tsx
Normal file
@@ -0,0 +1,343 @@
|
||||
"use client"
|
||||
|
||||
import { useMemo, useState } from "react"
|
||||
import { useFormStatus } from "react-dom"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { toast } from "sonner"
|
||||
import { Search } from "lucide-react"
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
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 { ScrollArea } from "@/shared/components/ui/scroll-area"
|
||||
import { Separator } from "@/shared/components/ui/separator"
|
||||
import { Badge } from "@/shared/components/ui/badge"
|
||||
import type { Question } from "@/modules/questions/types"
|
||||
import { updateExamAction } from "@/modules/exams/actions"
|
||||
import { StructureEditor } from "./assembly/structure-editor"
|
||||
import { QuestionBankList } from "./assembly/question-bank-list"
|
||||
import type { ExamNode } from "./assembly/selected-question-list"
|
||||
import { createId } from "@paralleldrive/cuid2"
|
||||
|
||||
type ExamAssemblyProps = {
|
||||
examId: string
|
||||
title: string
|
||||
subject: string
|
||||
grade: string
|
||||
difficulty: number
|
||||
totalScore: number
|
||||
durationMin: number
|
||||
initialSelected?: Array<{ id: string; score: number }>
|
||||
initialStructure?: ExamNode[] // New prop
|
||||
questionOptions: Question[]
|
||||
}
|
||||
|
||||
function SubmitButton({ label }: { label: string }) {
|
||||
const { pending } = useFormStatus()
|
||||
return (
|
||||
<Button type="submit" disabled={pending} className="w-full">
|
||||
{pending ? "Saving..." : label}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
export function ExamAssembly(props: ExamAssemblyProps) {
|
||||
const router = useRouter()
|
||||
const [search, setSearch] = useState("")
|
||||
const [typeFilter, setTypeFilter] = useState<string>("all")
|
||||
const [difficultyFilter, setDifficultyFilter] = useState<string>("all")
|
||||
|
||||
// Initialize structure state
|
||||
const [structure, setStructure] = useState<ExamNode[]>(() => {
|
||||
// Hydrate structure with full question objects
|
||||
const hydrate = (nodes: ExamNode[]): ExamNode[] => {
|
||||
return nodes.map(node => {
|
||||
if (node.type === 'question') {
|
||||
const q = props.questionOptions.find(opt => opt.id === node.questionId)
|
||||
return { ...node, question: q }
|
||||
}
|
||||
if (node.type === 'group') {
|
||||
return { ...node, children: hydrate(node.children || []) }
|
||||
}
|
||||
return node
|
||||
})
|
||||
}
|
||||
|
||||
// Use initialStructure if provided (Server generated or DB stored)
|
||||
if (props.initialStructure && props.initialStructure.length > 0) {
|
||||
return hydrate(props.initialStructure)
|
||||
}
|
||||
|
||||
// Fallback logic removed as Server Component handles initial migration
|
||||
return []
|
||||
})
|
||||
|
||||
const filteredQuestions = useMemo(() => {
|
||||
let list: Question[] = [...props.questionOptions]
|
||||
|
||||
if (search) {
|
||||
const lower = search.toLowerCase()
|
||||
list = list.filter(q => {
|
||||
const content = q.content as { text?: string }
|
||||
return content.text?.toLowerCase().includes(lower)
|
||||
})
|
||||
}
|
||||
|
||||
if (typeFilter !== "all") {
|
||||
list = list.filter((q) => q.type === (typeFilter as Question["type"]))
|
||||
}
|
||||
if (difficultyFilter !== "all") {
|
||||
const d = parseInt(difficultyFilter)
|
||||
list = list.filter((q) => q.difficulty === d)
|
||||
}
|
||||
return list
|
||||
}, [search, typeFilter, difficultyFilter, props.questionOptions])
|
||||
|
||||
// Recursively calculate total score
|
||||
const assignedTotal = useMemo(() => {
|
||||
const calc = (nodes: ExamNode[]): number => {
|
||||
return nodes.reduce((sum, node) => {
|
||||
if (node.type === 'question') return sum + (node.score || 0)
|
||||
if (node.type === 'group') return sum + calc(node.children || [])
|
||||
return sum
|
||||
}, 0)
|
||||
}
|
||||
return calc(structure)
|
||||
}, [structure])
|
||||
|
||||
const progress = Math.min(100, Math.max(0, (assignedTotal / props.totalScore) * 100))
|
||||
|
||||
const handleAdd = (question: Question) => {
|
||||
setStructure(prev => [
|
||||
...prev,
|
||||
{
|
||||
id: createId(),
|
||||
type: 'question',
|
||||
questionId: question.id,
|
||||
score: 10,
|
||||
question
|
||||
}
|
||||
])
|
||||
}
|
||||
|
||||
const handleAddGroup = () => {
|
||||
setStructure(prev => [
|
||||
...prev,
|
||||
{
|
||||
id: createId(),
|
||||
type: 'group',
|
||||
title: 'New Section',
|
||||
children: []
|
||||
}
|
||||
])
|
||||
}
|
||||
|
||||
const handleRemove = (id: string) => {
|
||||
const removeRecursive = (nodes: ExamNode[]): ExamNode[] => {
|
||||
return nodes.filter(n => n.id !== id).map(n => {
|
||||
if (n.type === 'group') {
|
||||
return { ...n, children: removeRecursive(n.children || []) }
|
||||
}
|
||||
return n
|
||||
})
|
||||
}
|
||||
setStructure(prev => removeRecursive(prev))
|
||||
}
|
||||
|
||||
const handleScoreChange = (id: string, score: number) => {
|
||||
const updateRecursive = (nodes: ExamNode[]): ExamNode[] => {
|
||||
return nodes.map(n => {
|
||||
if (n.id === id) return { ...n, score }
|
||||
if (n.type === 'group') return { ...n, children: updateRecursive(n.children || []) }
|
||||
return n
|
||||
})
|
||||
}
|
||||
setStructure(prev => updateRecursive(prev))
|
||||
}
|
||||
|
||||
const handleGroupTitleChange = (id: string, title: string) => {
|
||||
const updateRecursive = (nodes: ExamNode[]): ExamNode[] => {
|
||||
return nodes.map(n => {
|
||||
if (n.id === id) return { ...n, title }
|
||||
if (n.type === 'group') return { ...n, children: updateRecursive(n.children || []) }
|
||||
return n
|
||||
})
|
||||
}
|
||||
setStructure(prev => updateRecursive(prev))
|
||||
}
|
||||
|
||||
// Helper to extract flat list for DB examQuestions table
|
||||
const getFlatQuestions = () => {
|
||||
const list: Array<{ id: string; score: number }> = []
|
||||
const traverse = (nodes: ExamNode[]) => {
|
||||
nodes.forEach(n => {
|
||||
if (n.type === 'question' && n.questionId) {
|
||||
list.push({ id: n.questionId, score: n.score || 0 })
|
||||
}
|
||||
if (n.type === 'group') {
|
||||
traverse(n.children || [])
|
||||
}
|
||||
})
|
||||
}
|
||||
traverse(structure)
|
||||
return list
|
||||
}
|
||||
|
||||
// Helper to strip runtime question objects for DB structure storage
|
||||
const getCleanStructure = () => {
|
||||
const clean = (nodes: ExamNode[]): any[] => {
|
||||
return nodes.map(n => {
|
||||
const { question, ...rest } = n
|
||||
if (n.type === 'group') {
|
||||
return { ...rest, children: clean(n.children || []) }
|
||||
}
|
||||
return rest
|
||||
})
|
||||
}
|
||||
return clean(structure)
|
||||
}
|
||||
|
||||
const handleSave = async (formData: FormData) => {
|
||||
formData.set("examId", props.examId)
|
||||
formData.set("questionsJson", JSON.stringify(getFlatQuestions()))
|
||||
formData.set("structureJson", JSON.stringify(getCleanStructure()))
|
||||
|
||||
const result = await updateExamAction(null, formData)
|
||||
if (result.success) {
|
||||
toast.success("Saved draft")
|
||||
} else {
|
||||
toast.error(result.message || "Save failed")
|
||||
}
|
||||
}
|
||||
|
||||
const handlePublish = async (formData: FormData) => {
|
||||
formData.set("examId", props.examId)
|
||||
formData.set("questionsJson", JSON.stringify(getFlatQuestions()))
|
||||
formData.set("structureJson", JSON.stringify(getCleanStructure()))
|
||||
formData.set("status", "published")
|
||||
|
||||
const result = await updateExamAction(null, formData)
|
||||
if (result.success) {
|
||||
toast.success("Published exam")
|
||||
router.push("/teacher/exams/all")
|
||||
} else {
|
||||
toast.error(result.message || "Publish failed")
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid h-[calc(100vh-12rem)] gap-6 lg:grid-cols-5">
|
||||
{/* Left: Preview (3 cols) */}
|
||||
<Card className="lg:col-span-3 flex flex-col overflow-hidden border-2 border-primary/10">
|
||||
<CardHeader className="bg-muted/30 pb-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle>Exam Structure</CardTitle>
|
||||
<div className="flex items-center gap-4 text-sm">
|
||||
<div className="flex flex-col items-end">
|
||||
<span className="font-medium">{assignedTotal} / {props.totalScore}</span>
|
||||
<span className="text-xs text-muted-foreground">Total Score</span>
|
||||
</div>
|
||||
<div className="h-2 w-24 rounded-full bg-secondary">
|
||||
<div
|
||||
className={`h-full rounded-full transition-all ${
|
||||
assignedTotal > props.totalScore ? "bg-destructive" : "bg-primary"
|
||||
}`}
|
||||
style={{ width: `${progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<ScrollArea className="flex-1 p-4">
|
||||
<div className="space-y-6">
|
||||
<div className="grid grid-cols-3 gap-4 text-sm text-muted-foreground bg-muted/20 p-3 rounded-md">
|
||||
<div><span className="font-medium text-foreground">{props.subject}</span></div>
|
||||
<div><span className="font-medium text-foreground">{props.grade}</span></div>
|
||||
<div>Duration: <span className="font-medium text-foreground">{props.durationMin} min</span></div>
|
||||
</div>
|
||||
|
||||
<StructureEditor
|
||||
items={structure}
|
||||
onChange={setStructure}
|
||||
onScoreChange={handleScoreChange}
|
||||
onGroupTitleChange={handleGroupTitleChange}
|
||||
onRemove={handleRemove}
|
||||
onAddGroup={handleAddGroup}
|
||||
/>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
<div className="border-t p-4 bg-muted/30 flex gap-3 justify-end">
|
||||
<form action={handleSave} className="flex-1">
|
||||
<SubmitButton label="Save Draft" />
|
||||
</form>
|
||||
<form action={handlePublish} className="flex-1">
|
||||
<SubmitButton label="Publish Exam" />
|
||||
</form>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Right: Question Bank (2 cols) */}
|
||||
<Card className="lg:col-span-2 flex flex-col overflow-hidden">
|
||||
<CardHeader className="pb-3 space-y-3">
|
||||
<CardTitle className="text-base">Question Bank</CardTitle>
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search questions..."
|
||||
className="pl-8"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Select value={typeFilter} onValueChange={setTypeFilter}>
|
||||
<SelectTrigger className="flex-1 h-8 text-xs"><SelectValue placeholder="Type" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All Types</SelectItem>
|
||||
<SelectItem value="single_choice">Single Choice</SelectItem>
|
||||
<SelectItem value="multiple_choice">Multiple Choice</SelectItem>
|
||||
<SelectItem value="judgment">True/False</SelectItem>
|
||||
<SelectItem value="text">Short Answer</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select value={difficultyFilter} onValueChange={setDifficultyFilter}>
|
||||
<SelectTrigger className="w-[80px] h-8 text-xs"><SelectValue placeholder="Diff" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All</SelectItem>
|
||||
<SelectItem value="1">Lvl 1</SelectItem>
|
||||
<SelectItem value="2">Lvl 2</SelectItem>
|
||||
<SelectItem value="3">Lvl 3</SelectItem>
|
||||
<SelectItem value="4">Lvl 4</SelectItem>
|
||||
<SelectItem value="5">Lvl 5</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<Separator />
|
||||
|
||||
<ScrollArea className="flex-1 p-4 bg-muted/10">
|
||||
<QuestionBankList
|
||||
questions={filteredQuestions}
|
||||
onAdd={handleAdd}
|
||||
isAdded={(id) => {
|
||||
// Check if question is added anywhere in the structure
|
||||
const isAddedRecursive = (nodes: ExamNode[]): boolean => {
|
||||
return nodes.some(n => {
|
||||
if (n.type === 'question' && n.questionId === id) return true
|
||||
if (n.type === 'group' && n.children) return isAddedRecursive(n.children)
|
||||
return false
|
||||
})
|
||||
}
|
||||
return isAddedRecursive(structure)
|
||||
}}
|
||||
/>
|
||||
</ScrollArea>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
137
src/modules/exams/components/exam-columns.tsx
Normal file
137
src/modules/exams/components/exam-columns.tsx
Normal file
@@ -0,0 +1,137 @@
|
||||
"use client"
|
||||
|
||||
import { ColumnDef } from "@tanstack/react-table"
|
||||
import { Checkbox } from "@/shared/components/ui/checkbox"
|
||||
import { Badge } from "@/shared/components/ui/badge"
|
||||
import { cn, formatDate } from "@/shared/lib/utils"
|
||||
import { Exam } from "../types"
|
||||
import { ExamActions } from "./exam-actions"
|
||||
|
||||
export const examColumns: ColumnDef<Exam>[] = [
|
||||
{
|
||||
id: "select",
|
||||
header: ({ table }) => (
|
||||
<Checkbox
|
||||
checked={table.getIsAllPageRowsSelected()}
|
||||
onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
|
||||
aria-label="Select all"
|
||||
/>
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<Checkbox
|
||||
checked={row.getIsSelected()}
|
||||
onCheckedChange={(value) => row.toggleSelected(!!value)}
|
||||
aria-label="Select row"
|
||||
/>
|
||||
),
|
||||
enableSorting: false,
|
||||
enableHiding: false,
|
||||
size: 36,
|
||||
},
|
||||
{
|
||||
accessorKey: "title",
|
||||
header: "Title",
|
||||
cell: ({ row }) => (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium">{row.original.title}</span>
|
||||
{row.original.tags && row.original.tags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{row.original.tags.slice(0, 2).map((t) => (
|
||||
<Badge key={t} variant="outline" className="text-xs">
|
||||
{t}
|
||||
</Badge>
|
||||
))}
|
||||
{row.original.tags.length > 2 && (
|
||||
<Badge variant="outline" className="text-xs">+{row.original.tags.length - 2}</Badge>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "subject",
|
||||
header: "Subject",
|
||||
},
|
||||
{
|
||||
accessorKey: "grade",
|
||||
header: "Grade",
|
||||
cell: ({ row }) => (
|
||||
<span className="text-muted-foreground text-xs">{row.original.grade}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "status",
|
||||
header: "Status",
|
||||
cell: ({ row }) => {
|
||||
const status = row.original.status
|
||||
const variant = status === "published" ? "secondary" : status === "archived" ? "destructive" : "outline"
|
||||
return (
|
||||
<Badge variant={variant as any} className="capitalize">
|
||||
{status}
|
||||
</Badge>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "difficulty",
|
||||
header: "Difficulty",
|
||||
cell: ({ row }) => {
|
||||
const diff = row.original.difficulty
|
||||
return (
|
||||
<div className="flex items-center">
|
||||
<span
|
||||
className={cn(
|
||||
"font-medium",
|
||||
diff <= 2 ? "text-green-600" : diff === 3 ? "text-yellow-600" : "text-red-600"
|
||||
)}
|
||||
>
|
||||
{diff === 1
|
||||
? "Easy"
|
||||
: diff === 2
|
||||
? "Easy-Med"
|
||||
: diff === 3
|
||||
? "Medium"
|
||||
: diff === 4
|
||||
? "Med-Hard"
|
||||
: "Hard"}
|
||||
</span>
|
||||
<span className="ml-1 text-xs text-muted-foreground">({diff})</span>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "durationMin",
|
||||
header: "Duration",
|
||||
cell: ({ row }) => <span className="text-muted-foreground text-xs">{row.original.durationMin} min</span>,
|
||||
},
|
||||
{
|
||||
accessorKey: "totalScore",
|
||||
header: "Total",
|
||||
cell: ({ row }) => <span className="text-muted-foreground text-xs">{row.original.totalScore}</span>,
|
||||
},
|
||||
{
|
||||
accessorKey: "scheduledAt",
|
||||
header: "Scheduled",
|
||||
cell: ({ row }) => (
|
||||
<span className="text-muted-foreground text-xs whitespace-nowrap">
|
||||
{row.original.scheduledAt ? formatDate(row.original.scheduledAt) : "-"}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "createdAt",
|
||||
header: "Created",
|
||||
cell: ({ row }) => (
|
||||
<span className="text-muted-foreground text-xs whitespace-nowrap">
|
||||
{formatDate(row.original.createdAt)}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
cell: ({ row }) => <ExamActions exam={row.original} />,
|
||||
},
|
||||
]
|
||||
|
||||
110
src/modules/exams/components/exam-data-table.tsx
Normal file
110
src/modules/exams/components/exam-data-table.tsx
Normal file
@@ -0,0 +1,110 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import {
|
||||
ColumnDef,
|
||||
flexRender,
|
||||
getCoreRowModel,
|
||||
useReactTable,
|
||||
getPaginationRowModel,
|
||||
SortingState,
|
||||
getSortedRowModel,
|
||||
getFilteredRowModel,
|
||||
RowSelectionState,
|
||||
} from "@tanstack/react-table"
|
||||
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/shared/components/ui/table"
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import { ChevronLeft, ChevronRight } from "lucide-react"
|
||||
|
||||
interface DataTableProps<TData, TValue> {
|
||||
columns: ColumnDef<TData, TValue>[]
|
||||
data: TData[]
|
||||
}
|
||||
|
||||
export function ExamDataTable<TData, TValue>({ columns, data }: DataTableProps<TData, TValue>) {
|
||||
const [sorting, setSorting] = React.useState<SortingState>([])
|
||||
const [rowSelection, setRowSelection] = React.useState<RowSelectionState>({})
|
||||
|
||||
const table = useReactTable({
|
||||
data,
|
||||
columns,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getPaginationRowModel: getPaginationRowModel(),
|
||||
onSortingChange: setSorting,
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
onRowSelectionChange: setRowSelection,
|
||||
getFilteredRowModel: getFilteredRowModel(),
|
||||
state: {
|
||||
sorting,
|
||||
rowSelection,
|
||||
},
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<TableRow key={headerGroup.id}>
|
||||
{headerGroup.headers.map((header) => {
|
||||
return (
|
||||
<TableHead key={header.id}>
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(header.column.columnDef.header, header.getContext())}
|
||||
</TableHead>
|
||||
)
|
||||
})}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{table.getRowModel().rows?.length ? (
|
||||
table.getRowModel().rows.map((row) => (
|
||||
<TableRow key={row.id} data-state={row.getIsSelected() && "selected"}>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell key={cell.id}>
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell colSpan={columns.length} className="h-24 text-center">
|
||||
No results.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
<div className="flex items-center justify-end space-x-2 py-4">
|
||||
<div className="flex-1 text-sm text-muted-foreground">
|
||||
{table.getFilteredSelectedRowModel().rows.length} of {table.getFilteredRowModel().rows.length} row(s)
|
||||
selected.
|
||||
</div>
|
||||
<div className="space-x-2">
|
||||
<Button variant="outline" size="sm" onClick={() => table.previousPage()} disabled={!table.getCanPreviousPage()}>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
Previous
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={() => table.nextPage()} disabled={!table.getCanNextPage()}>
|
||||
Next
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
76
src/modules/exams/components/exam-filters.tsx
Normal file
76
src/modules/exams/components/exam-filters.tsx
Normal file
@@ -0,0 +1,76 @@
|
||||
"use client"
|
||||
|
||||
import { useQueryState, parseAsString } from "nuqs"
|
||||
import { Search, X } from "lucide-react"
|
||||
|
||||
import { Input } from "@/shared/components/ui/input"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/shared/components/ui/select"
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
|
||||
export function ExamFilters() {
|
||||
const [search, setSearch] = useQueryState("q", parseAsString.withOptions({ shallow: false }))
|
||||
const [status, setStatus] = useQueryState("status", parseAsString.withOptions({ shallow: false }))
|
||||
const [difficulty, setDifficulty] = useQueryState("difficulty", parseAsString.withOptions({ shallow: false }))
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="relative w-full md:w-[260px]">
|
||||
<Search className="absolute left-2 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search exams..."
|
||||
className="pl-7"
|
||||
value={search || ""}
|
||||
onChange={(e) => setSearch(e.target.value || null)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Select value={status || "all"} onValueChange={(val) => setStatus(val === "all" ? null : val)}>
|
||||
<SelectTrigger className="w-[160px]">
|
||||
<SelectValue placeholder="Status" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">Any Status</SelectItem>
|
||||
<SelectItem value="draft">Draft</SelectItem>
|
||||
<SelectItem value="published">Published</SelectItem>
|
||||
<SelectItem value="archived">Archived</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Select value={difficulty || "all"} onValueChange={(val) => setDifficulty(val === "all" ? null : val)}>
|
||||
<SelectTrigger className="w-[160px]">
|
||||
<SelectValue placeholder="Difficulty" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">Any Difficulty</SelectItem>
|
||||
<SelectItem value="1">Easy (1)</SelectItem>
|
||||
<SelectItem value="2">Easy-Med (2)</SelectItem>
|
||||
<SelectItem value="3">Medium (3)</SelectItem>
|
||||
<SelectItem value="4">Med-Hard (4)</SelectItem>
|
||||
<SelectItem value="5">Hard (5)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{(search || (status && status !== "all") || (difficulty && difficulty !== "all")) && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
setSearch(null)
|
||||
setStatus(null)
|
||||
setDifficulty(null)
|
||||
}}
|
||||
className="h-8 px-2 lg:px-3"
|
||||
>
|
||||
Reset
|
||||
<X className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
99
src/modules/exams/components/exam-form.tsx
Normal file
99
src/modules/exams/components/exam-form.tsx
Normal file
@@ -0,0 +1,99 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { useFormStatus } from "react-dom"
|
||||
import { toast } from "sonner"
|
||||
import { useRouter } from "next/navigation"
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardFooter } from "@/shared/components/ui/card"
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
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 { createExamAction } from "../actions"
|
||||
|
||||
function SubmitButton() {
|
||||
const { pending } = useFormStatus()
|
||||
return (
|
||||
<Button type="submit" disabled={pending}>
|
||||
{pending ? "Creating..." : "Create Exam"}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
export function ExamForm() {
|
||||
const router = useRouter()
|
||||
const [difficulty, setDifficulty] = useState<string>("3")
|
||||
|
||||
const handleSubmit = async (formData: FormData) => {
|
||||
const result = await createExamAction(null, formData)
|
||||
if (result.success) {
|
||||
toast.success(result.message)
|
||||
if (result.data) {
|
||||
router.push(`/teacher/exams/${result.data}/build`)
|
||||
}
|
||||
} else {
|
||||
toast.error(result.message)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Exam Creator</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form action={handleSubmit} className="space-y-6">
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="title">Title</Label>
|
||||
<Input id="title" name="title" placeholder="e.g. Algebra Midterm" required />
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="subject">Subject</Label>
|
||||
<Input id="subject" name="subject" placeholder="e.g. Mathematics" required />
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="grade">Grade</Label>
|
||||
<Input id="grade" name="grade" placeholder="e.g. Grade 10" required />
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label>Difficulty</Label>
|
||||
<Select value={difficulty} onValueChange={(val) => setDifficulty(val)} name="difficulty">
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select difficulty" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="1">Easy (1)</SelectItem>
|
||||
<SelectItem value="2">Easy-Med (2)</SelectItem>
|
||||
<SelectItem value="3">Medium (3)</SelectItem>
|
||||
<SelectItem value="4">Med-Hard (4)</SelectItem>
|
||||
<SelectItem value="5">Hard (5)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<input type="hidden" name="difficulty" value={difficulty} />
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="totalScore">Total Score</Label>
|
||||
<Input id="totalScore" name="totalScore" type="number" min={1} placeholder="e.g. 100" required />
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="durationMin">Duration (min)</Label>
|
||||
<Input id="durationMin" name="durationMin" type="number" min={10} placeholder="e.g. 90" required />
|
||||
</div>
|
||||
<div className="grid gap-2 md:col-span-2">
|
||||
<Label htmlFor="scheduledAt">Scheduled At (optional)</Label>
|
||||
<Input id="scheduledAt" name="scheduledAt" type="datetime-local" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
<CardFooter className="justify-end">
|
||||
<SubmitButton />
|
||||
</CardFooter>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
177
src/modules/exams/components/grading-view.tsx
Normal file
177
src/modules/exams/components/grading-view.tsx
Normal file
@@ -0,0 +1,177 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { toast } from "sonner"
|
||||
import { Badge } from "@/shared/components/ui/badge"
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
||||
import { Input } from "@/shared/components/ui/input"
|
||||
import { Label } from "@/shared/components/ui/label"
|
||||
import { Textarea } from "@/shared/components/ui/textarea"
|
||||
import { ScrollArea } from "@/shared/components/ui/scroll-area"
|
||||
import { Separator } from "@/shared/components/ui/separator"
|
||||
import { gradeSubmissionAction } from "../actions"
|
||||
|
||||
type Answer = {
|
||||
id: string
|
||||
questionId: string
|
||||
questionContent: any
|
||||
questionType: string
|
||||
maxScore: number
|
||||
studentAnswer: any
|
||||
score: number | null
|
||||
feedback: string | null
|
||||
order: number
|
||||
}
|
||||
|
||||
type GradingViewProps = {
|
||||
submissionId: string
|
||||
studentName: string
|
||||
examTitle: string
|
||||
submittedAt: string | null
|
||||
status: string
|
||||
totalScore: number | null
|
||||
answers: Answer[]
|
||||
}
|
||||
|
||||
export function GradingView({
|
||||
submissionId,
|
||||
studentName,
|
||||
examTitle,
|
||||
submittedAt,
|
||||
status,
|
||||
totalScore,
|
||||
answers: initialAnswers
|
||||
}: GradingViewProps) {
|
||||
const router = useRouter()
|
||||
const [answers, setAnswers] = useState(initialAnswers)
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
|
||||
const handleScoreChange = (id: string, val: string) => {
|
||||
const score = val === "" ? 0 : parseInt(val)
|
||||
setAnswers(prev => prev.map(a => a.id === id ? { ...a, score } : a))
|
||||
}
|
||||
|
||||
const handleFeedbackChange = (id: string, val: string) => {
|
||||
setAnswers(prev => prev.map(a => a.id === id ? { ...a, feedback: val } : a))
|
||||
}
|
||||
|
||||
const currentTotal = answers.reduce((sum, a) => sum + (a.score || 0), 0)
|
||||
|
||||
const handleSubmit = async () => {
|
||||
setIsSubmitting(true)
|
||||
const payload = answers.map(a => ({
|
||||
id: a.id,
|
||||
score: a.score || 0,
|
||||
feedback: a.feedback
|
||||
}))
|
||||
|
||||
const formData = new FormData()
|
||||
formData.set("submissionId", submissionId)
|
||||
formData.set("answersJson", JSON.stringify(payload))
|
||||
|
||||
const result = await gradeSubmissionAction(null, formData)
|
||||
|
||||
if (result.success) {
|
||||
toast.success("Grading saved")
|
||||
router.push("/teacher/exams/grading")
|
||||
} else {
|
||||
toast.error(result.message || "Failed to save")
|
||||
}
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid h-[calc(100vh-8rem)] grid-cols-1 gap-6 lg:grid-cols-3">
|
||||
{/* Left: Questions & Answers */}
|
||||
<div className="lg:col-span-2 flex flex-col h-full overflow-hidden rounded-md border bg-card">
|
||||
<div className="border-b p-4">
|
||||
<h3 className="font-semibold">Student Response</h3>
|
||||
</div>
|
||||
<ScrollArea className="flex-1 p-4">
|
||||
<div className="space-y-8">
|
||||
{answers.map((ans, index) => (
|
||||
<div key={ans.id} className="space-y-4">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="space-y-1">
|
||||
<span className="text-sm font-medium text-muted-foreground">Question {index + 1}</span>
|
||||
<div className="text-sm">{ans.questionContent?.text}</div>
|
||||
{/* Render options if multiple choice, etc. - Simplified for now */}
|
||||
</div>
|
||||
<Badge variant="outline">Max: {ans.maxScore}</Badge>
|
||||
</div>
|
||||
|
||||
<div className="rounded-md bg-muted/50 p-4">
|
||||
<Label className="mb-2 block text-xs text-muted-foreground">Student Answer</Label>
|
||||
<p className="text-sm font-medium">
|
||||
{typeof ans.studentAnswer?.answer === 'string'
|
||||
? ans.studentAnswer.answer
|
||||
: JSON.stringify(ans.studentAnswer)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
|
||||
{/* Right: Grading Panel */}
|
||||
<div className="flex flex-col h-full overflow-hidden rounded-md border bg-card">
|
||||
<div className="border-b p-4">
|
||||
<h3 className="font-semibold">Grading</h3>
|
||||
<div className="mt-2 flex items-center justify-between text-sm">
|
||||
<span className="text-muted-foreground">Total Score</span>
|
||||
<span className="font-bold text-lg text-primary">{currentTotal}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ScrollArea className="flex-1 p-4">
|
||||
<div className="space-y-6">
|
||||
{answers.map((ans, index) => (
|
||||
<Card key={ans.id} className="border-l-4 border-l-primary/20">
|
||||
<CardHeader className="py-3 px-4">
|
||||
<CardTitle className="text-sm font-medium flex justify-between">
|
||||
Q{index + 1}
|
||||
<span className="text-xs text-muted-foreground">Max: {ans.maxScore}</span>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="py-3 px-4 space-y-3">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor={`score-${ans.id}`}>Score</Label>
|
||||
<Input
|
||||
id={`score-${ans.id}`}
|
||||
type="number"
|
||||
min={0}
|
||||
max={ans.maxScore}
|
||||
value={ans.score ?? ""}
|
||||
onChange={(e) => handleScoreChange(ans.id, e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor={`fb-${ans.id}`}>Feedback</Label>
|
||||
<Textarea
|
||||
id={`fb-${ans.id}`}
|
||||
placeholder="Optional feedback..."
|
||||
className="min-h-[60px] resize-none"
|
||||
value={ans.feedback ?? ""}
|
||||
onChange={(e) => handleFeedbackChange(ans.id, e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
<div className="border-t p-4 bg-muted/20">
|
||||
<Button className="w-full" onClick={handleSubmit} disabled={isSubmitting}>
|
||||
{isSubmitting ? "Saving..." : "Submit Grades"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
63
src/modules/exams/components/submission-columns.tsx
Normal file
63
src/modules/exams/components/submission-columns.tsx
Normal file
@@ -0,0 +1,63 @@
|
||||
"use client"
|
||||
|
||||
import { ColumnDef } from "@tanstack/react-table"
|
||||
import { Badge } from "@/shared/components/ui/badge"
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import { Eye, CheckSquare } from "lucide-react"
|
||||
import { ExamSubmission } from "../types"
|
||||
import Link from "next/link"
|
||||
import { formatDate } from "@/shared/lib/utils"
|
||||
|
||||
export const submissionColumns: ColumnDef<ExamSubmission>[] = [
|
||||
{
|
||||
accessorKey: "studentName",
|
||||
header: "Student",
|
||||
},
|
||||
{
|
||||
accessorKey: "examTitle",
|
||||
header: "Exam",
|
||||
},
|
||||
{
|
||||
accessorKey: "submittedAt",
|
||||
header: "Submitted",
|
||||
cell: ({ row }) => (
|
||||
<span className="text-muted-foreground text-xs whitespace-nowrap">
|
||||
{formatDate(row.original.submittedAt)}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "status",
|
||||
header: "Status",
|
||||
cell: ({ row }) => {
|
||||
const status = row.original.status
|
||||
const variant = status === "graded" ? "secondary" : "outline"
|
||||
return <Badge variant={variant as any} className="capitalize">{status}</Badge>
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "score",
|
||||
header: "Score",
|
||||
cell: ({ row }) => (
|
||||
<span className="text-muted-foreground text-xs">{row.original.score ?? "-"}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
cell: ({ row }) => (
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="ghost" size="sm" asChild>
|
||||
<Link href={`/teacher/exams/grading/${row.original.id}`}>
|
||||
<Eye className="h-4 w-4 mr-1" /> View
|
||||
</Link>
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" asChild>
|
||||
<Link href={`/teacher/exams/grading/${row.original.id}`}>
|
||||
<CheckSquare className="h-4 w-4 mr-1" /> Grade
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
94
src/modules/exams/components/submission-data-table.tsx
Normal file
94
src/modules/exams/components/submission-data-table.tsx
Normal file
@@ -0,0 +1,94 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import {
|
||||
ColumnDef,
|
||||
flexRender,
|
||||
getCoreRowModel,
|
||||
useReactTable,
|
||||
getPaginationRowModel,
|
||||
SortingState,
|
||||
getSortedRowModel,
|
||||
} from "@tanstack/react-table"
|
||||
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/shared/components/ui/table"
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import { ChevronLeft, ChevronRight } from "lucide-react"
|
||||
|
||||
interface DataTableProps<TData, TValue> {
|
||||
columns: ColumnDef<TData, TValue>[]
|
||||
data: TData[]
|
||||
}
|
||||
|
||||
export function SubmissionDataTable<TData, TValue>({ columns, data }: DataTableProps<TData, TValue>) {
|
||||
const [sorting, setSorting] = React.useState<SortingState>([])
|
||||
|
||||
const table = useReactTable({
|
||||
data,
|
||||
columns,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getPaginationRowModel: getPaginationRowModel(),
|
||||
onSortingChange: setSorting,
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
state: {
|
||||
sorting,
|
||||
},
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<TableRow key={headerGroup.id}>
|
||||
{headerGroup.headers.map((header) => (
|
||||
<TableHead key={header.id}>
|
||||
{header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())}
|
||||
</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{table.getRowModel().rows?.length ? (
|
||||
table.getRowModel().rows.map((row) => (
|
||||
<TableRow key={row.id} className="group">
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell key={cell.id}>{flexRender(cell.column.columnDef.cell, cell.getContext())}</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell colSpan={columns.length} className="h-24 text-center">
|
||||
No submissions.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
<div className="flex items-center justify-end space-x-2 py-4">
|
||||
<div className="space-x-2">
|
||||
<Button variant="outline" size="sm" onClick={() => table.previousPage()} disabled={!table.getCanPreviousPage()}>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
Previous
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={() => table.nextPage()} disabled={!table.getCanNextPage()}>
|
||||
Next
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
182
src/modules/exams/data-access.ts
Normal file
182
src/modules/exams/data-access.ts
Normal file
@@ -0,0 +1,182 @@
|
||||
import { db } from "@/shared/db"
|
||||
import { exams, examQuestions, examSubmissions, submissionAnswers, users } from "@/shared/db/schema"
|
||||
import { eq, desc, like, and, or } from "drizzle-orm"
|
||||
import { cache } from "react"
|
||||
|
||||
import type { ExamStatus } from "./types"
|
||||
|
||||
export type GetExamsParams = {
|
||||
q?: string
|
||||
status?: string
|
||||
difficulty?: string
|
||||
page?: number
|
||||
pageSize?: number
|
||||
}
|
||||
|
||||
|
||||
export const getExams = cache(async (params: GetExamsParams) => {
|
||||
const conditions = []
|
||||
|
||||
if (params.q) {
|
||||
const search = `%${params.q}%`
|
||||
conditions.push(or(like(exams.title, search), like(exams.description, search)))
|
||||
}
|
||||
|
||||
if (params.status && params.status !== "all") {
|
||||
conditions.push(eq(exams.status, params.status as any))
|
||||
}
|
||||
|
||||
// Note: Difficulty is stored in JSON description field in current schema,
|
||||
// so we might need to filter in memory or adjust schema.
|
||||
// For now, let's fetch and filter in memory if difficulty is needed,
|
||||
// or just ignore strict DB filtering for JSON fields to keep it simple.
|
||||
|
||||
const data = await db.query.exams.findMany({
|
||||
where: conditions.length ? and(...conditions) : undefined,
|
||||
orderBy: [desc(exams.createdAt)],
|
||||
})
|
||||
|
||||
// Transform and Filter (especially for JSON fields)
|
||||
let result = data.map((exam) => {
|
||||
let meta: any = {}
|
||||
try {
|
||||
meta = JSON.parse(exam.description || "{}")
|
||||
} catch { }
|
||||
|
||||
return {
|
||||
id: exam.id,
|
||||
title: exam.title,
|
||||
status: (exam.status as ExamStatus) || "draft",
|
||||
subject: meta.subject || "General",
|
||||
grade: meta.grade || "General",
|
||||
difficulty: meta.difficulty || 1,
|
||||
totalScore: meta.totalScore || 100,
|
||||
durationMin: meta.durationMin || 60,
|
||||
questionCount: meta.questionCount || 0,
|
||||
scheduledAt: exam.startTime?.toISOString(),
|
||||
createdAt: exam.createdAt.toISOString(),
|
||||
tags: meta.tags || [],
|
||||
}
|
||||
})
|
||||
|
||||
if (params.difficulty && params.difficulty !== "all") {
|
||||
const d = parseInt(params.difficulty)
|
||||
result = result.filter((e) => e.difficulty === d)
|
||||
}
|
||||
|
||||
return result
|
||||
})
|
||||
|
||||
export const getExamById = cache(async (id: string) => {
|
||||
const exam = await db.query.exams.findFirst({
|
||||
where: eq(exams.id, id),
|
||||
with: {
|
||||
questions: {
|
||||
orderBy: (examQuestions, { asc }) => [asc(examQuestions.order)],
|
||||
with: {
|
||||
question: true
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
if (!exam) return null
|
||||
|
||||
let meta: any = {}
|
||||
try {
|
||||
meta = JSON.parse(exam.description || "{}")
|
||||
} catch { }
|
||||
|
||||
return {
|
||||
id: exam.id,
|
||||
title: exam.title,
|
||||
status: (exam.status as ExamStatus) || "draft",
|
||||
subject: meta.subject || "General",
|
||||
grade: meta.grade || "General",
|
||||
difficulty: meta.difficulty || 1,
|
||||
totalScore: meta.totalScore || 100,
|
||||
durationMin: meta.durationMin || 60,
|
||||
scheduledAt: exam.startTime?.toISOString(),
|
||||
createdAt: exam.createdAt.toISOString(),
|
||||
tags: meta.tags || [],
|
||||
structure: exam.structure as any, // Return structure
|
||||
questions: exam.questions.map(eq => ({
|
||||
id: eq.questionId,
|
||||
score: eq.score,
|
||||
order: eq.order,
|
||||
// ... include question details if needed
|
||||
}))
|
||||
}
|
||||
})
|
||||
|
||||
export const getExamSubmissions = cache(async () => {
|
||||
const data = await db.query.examSubmissions.findMany({
|
||||
orderBy: [desc(examSubmissions.submittedAt)],
|
||||
with: {
|
||||
exam: true,
|
||||
student: true
|
||||
}
|
||||
})
|
||||
|
||||
return data.map(sub => ({
|
||||
id: sub.id,
|
||||
examId: sub.examId,
|
||||
examTitle: sub.exam.title,
|
||||
studentName: sub.student.name || "Unknown",
|
||||
submittedAt: sub.submittedAt ? sub.submittedAt.toISOString() : new Date().toISOString(),
|
||||
score: sub.score || undefined,
|
||||
status: sub.status as "pending" | "graded",
|
||||
}))
|
||||
})
|
||||
|
||||
export const getSubmissionDetails = cache(async (submissionId: string) => {
|
||||
const submission = await db.query.examSubmissions.findFirst({
|
||||
where: eq(examSubmissions.id, submissionId),
|
||||
with: {
|
||||
student: true,
|
||||
exam: true,
|
||||
}
|
||||
})
|
||||
|
||||
if (!submission) return null
|
||||
|
||||
// Fetch answers
|
||||
const answers = await db.query.submissionAnswers.findMany({
|
||||
where: eq(submissionAnswers.submissionId, submissionId),
|
||||
with: {
|
||||
question: true
|
||||
}
|
||||
})
|
||||
|
||||
// Fetch exam questions structure (to know max score and order)
|
||||
const examQ = await db.query.examQuestions.findMany({
|
||||
where: eq(examQuestions.examId, submission.examId),
|
||||
orderBy: [desc(examQuestions.order)],
|
||||
})
|
||||
|
||||
// Map answers with question details
|
||||
const answersWithDetails = answers.map(ans => {
|
||||
const eqRel = examQ.find(q => q.questionId === ans.questionId)
|
||||
return {
|
||||
id: ans.id,
|
||||
questionId: ans.questionId,
|
||||
questionContent: ans.question.content,
|
||||
questionType: ans.question.type,
|
||||
maxScore: eqRel?.score || 0,
|
||||
studentAnswer: ans.answerContent,
|
||||
score: ans.score,
|
||||
feedback: ans.feedback,
|
||||
order: eqRel?.order || 0
|
||||
}
|
||||
}).sort((a, b) => a.order - b.order)
|
||||
|
||||
return {
|
||||
id: submission.id,
|
||||
studentName: submission.student.name || "Unknown",
|
||||
examTitle: submission.exam.title,
|
||||
submittedAt: submission.submittedAt ? submission.submittedAt.toISOString() : null,
|
||||
status: submission.status,
|
||||
totalScore: submission.score,
|
||||
answers: answersWithDetails
|
||||
}
|
||||
})
|
||||
102
src/modules/exams/mock-data.ts
Normal file
102
src/modules/exams/mock-data.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
import { Exam, ExamSubmission } from "./types"
|
||||
|
||||
export let MOCK_EXAMS: Exam[] = [
|
||||
{
|
||||
id: "exam_001",
|
||||
title: "Algebra Midterm",
|
||||
subject: "Mathematics",
|
||||
grade: "Grade 10",
|
||||
status: "draft",
|
||||
difficulty: 3,
|
||||
totalScore: 100,
|
||||
durationMin: 90,
|
||||
questionCount: 25,
|
||||
scheduledAt: undefined,
|
||||
createdAt: new Date().toISOString(),
|
||||
tags: ["Algebra", "Functions"],
|
||||
},
|
||||
{
|
||||
id: "exam_002",
|
||||
title: "Physics Mechanics Quiz",
|
||||
subject: "Physics",
|
||||
grade: "Grade 11",
|
||||
status: "published",
|
||||
difficulty: 4,
|
||||
totalScore: 50,
|
||||
durationMin: 45,
|
||||
questionCount: 15,
|
||||
scheduledAt: new Date(Date.now() + 86400000).toISOString(),
|
||||
createdAt: new Date().toISOString(),
|
||||
tags: ["Mechanics", "Kinematics"],
|
||||
},
|
||||
{
|
||||
id: "exam_003",
|
||||
title: "English Reading Comprehension",
|
||||
subject: "English",
|
||||
grade: "Grade 12",
|
||||
status: "published",
|
||||
difficulty: 2,
|
||||
totalScore: 80,
|
||||
durationMin: 60,
|
||||
questionCount: 20,
|
||||
scheduledAt: new Date(Date.now() + 2 * 86400000).toISOString(),
|
||||
createdAt: new Date().toISOString(),
|
||||
tags: ["Reading", "Vocabulary"],
|
||||
},
|
||||
{
|
||||
id: "exam_004",
|
||||
title: "Chemistry Final",
|
||||
subject: "Chemistry",
|
||||
grade: "Grade 12",
|
||||
status: "archived",
|
||||
difficulty: 5,
|
||||
totalScore: 120,
|
||||
durationMin: 120,
|
||||
questionCount: 40,
|
||||
scheduledAt: new Date(Date.now() - 30 * 86400000).toISOString(),
|
||||
createdAt: new Date().toISOString(),
|
||||
tags: ["Organic", "Inorganic"],
|
||||
},
|
||||
{
|
||||
id: "exam_005",
|
||||
title: "Geometry Chapter Test",
|
||||
subject: "Mathematics",
|
||||
grade: "Grade 9",
|
||||
status: "published",
|
||||
difficulty: 3,
|
||||
totalScore: 60,
|
||||
durationMin: 50,
|
||||
questionCount: 18,
|
||||
scheduledAt: new Date(Date.now() + 3 * 86400000).toISOString(),
|
||||
createdAt: new Date().toISOString(),
|
||||
tags: ["Geometry", "Triangles"],
|
||||
},
|
||||
]
|
||||
|
||||
export const MOCK_SUBMISSIONS: ExamSubmission[] = [
|
||||
{
|
||||
id: "sub_001",
|
||||
examId: "exam_002",
|
||||
examTitle: "Physics Mechanics Quiz",
|
||||
studentName: "Alice Zhang",
|
||||
submittedAt: new Date().toISOString(),
|
||||
status: "pending",
|
||||
},
|
||||
{
|
||||
id: "sub_002",
|
||||
examId: "exam_003",
|
||||
examTitle: "English Reading Comprehension",
|
||||
studentName: "Bob Li",
|
||||
submittedAt: new Date().toISOString(),
|
||||
score: 72,
|
||||
status: "graded",
|
||||
},
|
||||
]
|
||||
|
||||
export function addMockExam(exam: Exam) {
|
||||
MOCK_EXAMS = [exam, ...MOCK_EXAMS]
|
||||
}
|
||||
|
||||
export function updateMockExam(id: string, updates: Partial<Exam>) {
|
||||
MOCK_EXAMS = MOCK_EXAMS.map((e) => (e.id === id ? { ...e, ...updates } : e))
|
||||
}
|
||||
32
src/modules/exams/types.ts
Normal file
32
src/modules/exams/types.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
export type ExamStatus = "draft" | "published" | "archived"
|
||||
|
||||
export type ExamDifficulty = 1 | 2 | 3 | 4 | 5
|
||||
|
||||
export interface Exam {
|
||||
id: string
|
||||
title: string
|
||||
subject: string
|
||||
grade: string
|
||||
status: ExamStatus
|
||||
difficulty: ExamDifficulty
|
||||
totalScore: number
|
||||
durationMin: number
|
||||
questionCount: number
|
||||
scheduledAt?: string
|
||||
createdAt: string
|
||||
updatedAt?: string
|
||||
tags?: string[]
|
||||
}
|
||||
|
||||
export type SubmissionStatus = "pending" | "graded"
|
||||
|
||||
export interface ExamSubmission {
|
||||
id: string
|
||||
examId: string
|
||||
examTitle: string
|
||||
studentName: string
|
||||
submittedAt: string
|
||||
score?: number
|
||||
status: SubmissionStatus
|
||||
}
|
||||
|
||||
185
src/modules/layout/components/app-sidebar.tsx
Normal file
185
src/modules/layout/components/app-sidebar.tsx
Normal file
@@ -0,0 +1,185 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import Link from "next/link"
|
||||
import { usePathname } from "next/navigation"
|
||||
import { ChevronRight } from "lucide-react"
|
||||
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "@/shared/components/ui/collapsible"
|
||||
import { ScrollArea } from "@/shared/components/ui/scroll-area"
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/shared/components/ui/tooltip"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/shared/components/ui/select"
|
||||
import { cn } from "@/shared/lib/utils"
|
||||
import { useSidebar } from "./sidebar-provider"
|
||||
import { NAV_CONFIG, Role } from "../config/navigation"
|
||||
|
||||
interface AppSidebarProps {
|
||||
mode?: "mobile" | "desktop"
|
||||
}
|
||||
|
||||
export function AppSidebar({ mode }: AppSidebarProps) {
|
||||
const { expanded, toggleSidebar, isMobile } = useSidebar()
|
||||
const pathname = usePathname()
|
||||
|
||||
// MOCK ROLE: In real app, get this from auth context / session
|
||||
const [currentRole, setCurrentRole] = React.useState<Role>("admin")
|
||||
|
||||
const navItems = NAV_CONFIG[currentRole]
|
||||
|
||||
// Ensure consistent state for hydration
|
||||
if (!expanded && mode === 'mobile') return null
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col gap-4">
|
||||
{/* Sidebar Header */}
|
||||
<div className={cn("flex h-16 items-center border-b px-4 transition-all duration-300", !expanded && !isMobile ? "justify-center px-2" : "justify-between")}>
|
||||
{expanded || isMobile ? (
|
||||
<Link href="/" className="flex items-center gap-2 font-bold">
|
||||
<div className="bg-primary text-primary-foreground flex size-8 items-center justify-center rounded-lg">
|
||||
NE
|
||||
</div>
|
||||
<span className="truncate text-lg">Next_Edu</span>
|
||||
</Link>
|
||||
) : (
|
||||
<div className="bg-primary text-primary-foreground flex size-8 items-center justify-center rounded-lg">
|
||||
NE
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Role Switcher (Dev Only - for Demo) */}
|
||||
{(expanded || isMobile) && (
|
||||
<div className="px-4">
|
||||
<label className="text-muted-foreground mb-2 block text-xs font-medium uppercase">
|
||||
View As (Dev Mode)
|
||||
</label>
|
||||
<Select value={currentRole} onValueChange={(v) => setCurrentRole(v as Role)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select Role" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="admin">Admin</SelectItem>
|
||||
<SelectItem value="teacher">Teacher</SelectItem>
|
||||
<SelectItem value="student">Student</SelectItem>
|
||||
<SelectItem value="parent">Parent</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Navigation */}
|
||||
<ScrollArea className="flex-1 px-3">
|
||||
<nav className="flex flex-col gap-2 py-4">
|
||||
<TooltipProvider delayDuration={0}>
|
||||
{navItems.map((item, index) => {
|
||||
const isActive = pathname.startsWith(item.href)
|
||||
const hasChildren = item.items && item.items.length > 0
|
||||
|
||||
if (!expanded && !isMobile) {
|
||||
// Collapsed Mode (Icon Only + Tooltip)
|
||||
return (
|
||||
<Tooltip key={index}>
|
||||
<TooltipTrigger asChild>
|
||||
<Link
|
||||
href={item.href}
|
||||
className={cn(
|
||||
"hover:bg-sidebar-accent hover:text-sidebar-accent-foreground flex size-10 items-center justify-center rounded-md transition-colors",
|
||||
isActive && "bg-sidebar-accent text-sidebar-accent-foreground"
|
||||
)}
|
||||
>
|
||||
<item.icon className="size-5" />
|
||||
<span className="sr-only">{item.title}</span>
|
||||
</Link>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right">{item.title}</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
// Expanded Mode
|
||||
if (hasChildren) {
|
||||
return (
|
||||
<Collapsible key={index} defaultOpen={isActive} className="group/collapsible">
|
||||
<CollapsibleTrigger asChild>
|
||||
<button
|
||||
className={cn(
|
||||
"hover:bg-sidebar-accent hover:text-sidebar-accent-foreground flex w-full items-center justify-between rounded-md p-2 text-sm font-medium transition-colors",
|
||||
isActive && "text-sidebar-accent-foreground"
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<item.icon className="size-4" />
|
||||
<span>{item.title}</span>
|
||||
</div>
|
||||
<ChevronRight className="text-muted-foreground size-4 transition-transform duration-200 group-data-[state=open]/collapsible:rotate-90" />
|
||||
</button>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="data-[state=open]:animate-accordion-down data-[state=closed]:animate-accordion-up overflow-hidden">
|
||||
<div className="ml-6 mt-1 flex flex-col gap-1 border-l pl-2">
|
||||
{item.items?.map((subItem, subIndex) => (
|
||||
<Link
|
||||
key={subIndex}
|
||||
href={subItem.href}
|
||||
className={cn(
|
||||
"text-muted-foreground hover:text-foreground block rounded-md px-2 py-1 text-sm transition-colors",
|
||||
pathname === subItem.href && "text-foreground font-medium"
|
||||
)}
|
||||
>
|
||||
{subItem.title}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={index}
|
||||
href={item.href}
|
||||
className={cn(
|
||||
"hover:bg-sidebar-accent hover:text-sidebar-accent-foreground flex items-center gap-2 rounded-md p-2 text-sm font-medium transition-colors",
|
||||
isActive && "bg-sidebar-accent text-sidebar-accent-foreground"
|
||||
)}
|
||||
>
|
||||
<item.icon className="size-4" />
|
||||
<span>{item.title}</span>
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
</TooltipProvider>
|
||||
</nav>
|
||||
</ScrollArea>
|
||||
|
||||
{/* Sidebar Footer */}
|
||||
<div className="p-4">
|
||||
{!isMobile && (
|
||||
<button
|
||||
onClick={toggleSidebar}
|
||||
className="hover:bg-sidebar-accent text-sidebar-foreground flex w-full items-center justify-center rounded-md border p-2 text-sm transition-colors"
|
||||
>
|
||||
{expanded ? "Collapse" : <ChevronRight className="size-4" />}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
AppSidebar.displayName = "AppSidebar"
|
||||
102
src/modules/layout/components/sidebar-provider.tsx
Normal file
102
src/modules/layout/components/sidebar-provider.tsx
Normal file
@@ -0,0 +1,102 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Menu } from "lucide-react"
|
||||
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from "@/shared/components/ui/sheet"
|
||||
import { cn } from "@/shared/lib/utils"
|
||||
|
||||
type SidebarContextType = {
|
||||
expanded: boolean
|
||||
setExpanded: (expanded: boolean) => void
|
||||
isMobile: boolean
|
||||
toggleSidebar: () => void
|
||||
}
|
||||
|
||||
const SidebarContext = React.createContext<SidebarContextType | undefined>(
|
||||
undefined
|
||||
)
|
||||
|
||||
export function useSidebar() {
|
||||
const context = React.useContext(SidebarContext)
|
||||
if (!context) {
|
||||
throw new Error("useSidebar must be used within a SidebarProvider")
|
||||
}
|
||||
return context
|
||||
}
|
||||
|
||||
interface SidebarProviderProps {
|
||||
children: React.ReactNode
|
||||
sidebar: React.ReactNode
|
||||
}
|
||||
|
||||
export function SidebarProvider({ children, sidebar }: SidebarProviderProps) {
|
||||
const [expanded, setExpanded] = React.useState(true)
|
||||
const [isMobile, setIsMobile] = React.useState(false)
|
||||
const [openMobile, setOpenMobile] = React.useState(false)
|
||||
|
||||
React.useEffect(() => {
|
||||
const checkMobile = () => {
|
||||
const mobile = window.innerWidth < 768
|
||||
setIsMobile(mobile)
|
||||
if (mobile) {
|
||||
setExpanded(true)
|
||||
}
|
||||
}
|
||||
checkMobile()
|
||||
window.addEventListener("resize", checkMobile)
|
||||
return () => window.removeEventListener("resize", checkMobile)
|
||||
}, [])
|
||||
|
||||
const toggleSidebar = () => {
|
||||
if (isMobile) {
|
||||
setOpenMobile(!openMobile)
|
||||
} else {
|
||||
setExpanded(!expanded)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<SidebarContext.Provider
|
||||
value={{ expanded, setExpanded, isMobile, toggleSidebar }}
|
||||
>
|
||||
<div className="flex min-h-screen flex-col md:flex-row bg-background">
|
||||
{/* Mobile Trigger & Sheet */}
|
||||
{isMobile && (
|
||||
<Sheet open={openMobile} onOpenChange={setOpenMobile}>
|
||||
<SheetContent side="left" className="w-[80%] p-0 sm:w-[300px]">
|
||||
<SheetHeader className="sr-only">
|
||||
<SheetTitle>Navigation Menu</SheetTitle>
|
||||
</SheetHeader>
|
||||
<div className="h-full py-4">
|
||||
{sidebar}
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
)}
|
||||
|
||||
{/* Desktop Sidebar Wrapper */}
|
||||
{!isMobile && (
|
||||
<aside
|
||||
className={cn(
|
||||
"bg-sidebar border-sidebar-border text-sidebar-foreground sticky top-0 hidden h-screen flex-col border-r transition-[width] duration-300 ease-in-out md:flex",
|
||||
expanded ? "w-64" : "w-16"
|
||||
)}
|
||||
>
|
||||
{sidebar}
|
||||
</aside>
|
||||
)}
|
||||
|
||||
{/* Main Content Wrapper - Right Side */}
|
||||
<div className="flex-1 flex flex-col min-w-0 transition-[margin] duration-300 ease-in-out h-screen overflow-hidden">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</SidebarContext.Provider>
|
||||
)
|
||||
}
|
||||
105
src/modules/layout/components/site-header.tsx
Normal file
105
src/modules/layout/components/site-header.tsx
Normal file
@@ -0,0 +1,105 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Bell, Menu, Search } from "lucide-react"
|
||||
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import { Input } from "@/shared/components/ui/input"
|
||||
import { Separator } from "@/shared/components/ui/separator"
|
||||
import {
|
||||
Breadcrumb,
|
||||
BreadcrumbItem,
|
||||
BreadcrumbLink,
|
||||
BreadcrumbList,
|
||||
BreadcrumbPage,
|
||||
BreadcrumbSeparator,
|
||||
} from "@/shared/components/ui/breadcrumb"
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/shared/components/ui/avatar"
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/shared/components/ui/dropdown-menu"
|
||||
|
||||
import { useSidebar } from "./sidebar-provider"
|
||||
|
||||
export function SiteHeader() {
|
||||
const { toggleSidebar, isMobile } = useSidebar()
|
||||
|
||||
return (
|
||||
<header className="bg-background/95 supports-[backdrop-filter]:bg-background/60 sticky top-0 z-50 flex h-16 items-center border-b px-4 backdrop-blur-sm">
|
||||
<div className="flex flex-1 items-center gap-4">
|
||||
{/* Mobile Toggle */}
|
||||
{isMobile && (
|
||||
<Button variant="ghost" size="icon" onClick={toggleSidebar} className="mr-2">
|
||||
<Menu className="size-5" />
|
||||
<span className="sr-only">Toggle Sidebar</span>
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Separator orientation="vertical" className="mr-2 hidden h-6 md:block" />
|
||||
|
||||
{/* Breadcrumbs */}
|
||||
<Breadcrumb className="hidden md:flex">
|
||||
<BreadcrumbList>
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbLink href="/dashboard">Dashboard</BreadcrumbLink>
|
||||
</BreadcrumbItem>
|
||||
<BreadcrumbSeparator />
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbPage>Overview</BreadcrumbPage>
|
||||
</BreadcrumbItem>
|
||||
</BreadcrumbList>
|
||||
</Breadcrumb>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
{/* Global Search */}
|
||||
<div className="relative hidden md:block">
|
||||
<Search className="text-muted-foreground absolute top-2.5 left-2.5 size-4" />
|
||||
<Input
|
||||
type="search"
|
||||
placeholder="Search... (Cmd+K)"
|
||||
className="w-[200px] pl-9 lg:w-[300px]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Notifications */}
|
||||
<Button variant="ghost" size="icon" className="text-muted-foreground">
|
||||
<Bell className="size-5" />
|
||||
<span className="sr-only">Notifications</span>
|
||||
</Button>
|
||||
|
||||
{/* User Nav */}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" className="relative size-8 rounded-full">
|
||||
<Avatar className="size-8">
|
||||
<AvatarImage src="/avatars/01.png" alt="@user" />
|
||||
<AvatarFallback>AD</AvatarFallback>
|
||||
</Avatar>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="w-56" align="end" forceMount>
|
||||
<DropdownMenuLabel className="font-normal">
|
||||
<div className="flex flex-col space-y-1">
|
||||
<p className="text-sm font-medium leading-none">Admin User</p>
|
||||
<p className="text-muted-foreground text-xs leading-none">admin@nextedu.com</p>
|
||||
</div>
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem>Profile</DropdownMenuItem>
|
||||
<DropdownMenuItem>Settings</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem className="text-destructive focus:bg-destructive/10">
|
||||
Log out
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</header>
|
||||
)
|
||||
}
|
||||
176
src/modules/layout/config/navigation.ts
Normal file
176
src/modules/layout/config/navigation.ts
Normal file
@@ -0,0 +1,176 @@
|
||||
import {
|
||||
BarChart,
|
||||
BookOpen,
|
||||
Calendar,
|
||||
GraduationCap,
|
||||
LayoutDashboard,
|
||||
Settings,
|
||||
Users,
|
||||
FileText,
|
||||
MessageSquare,
|
||||
Shield,
|
||||
CreditCard,
|
||||
FileQuestion,
|
||||
ClipboardList,
|
||||
Library,
|
||||
PenTool
|
||||
} from "lucide-react"
|
||||
|
||||
export type NavItem = {
|
||||
title: string
|
||||
icon: any
|
||||
href: string
|
||||
items?: { title: string; href: string }[]
|
||||
}
|
||||
|
||||
export type Role = "admin" | "teacher" | "student" | "parent"
|
||||
|
||||
export const NAV_CONFIG: Record<Role, NavItem[]> = {
|
||||
admin: [
|
||||
{
|
||||
title: "Dashboard",
|
||||
icon: LayoutDashboard,
|
||||
href: "/admin/dashboard",
|
||||
},
|
||||
{
|
||||
title: "School Management",
|
||||
icon: Shield,
|
||||
href: "/admin/school",
|
||||
items: [
|
||||
{ title: "Departments", href: "/admin/school/departments" },
|
||||
{ title: "Classrooms", href: "/admin/school/classrooms" },
|
||||
{ title: "Academic Year", href: "/admin/school/academic-year" },
|
||||
]
|
||||
},
|
||||
{
|
||||
title: "Users",
|
||||
icon: Users,
|
||||
href: "/admin/users",
|
||||
items: [
|
||||
{ title: "Teachers", href: "/admin/users/teachers" },
|
||||
{ title: "Students", href: "/admin/users/students" },
|
||||
{ title: "Parents", href: "/admin/users/parents" },
|
||||
{ title: "Staff", href: "/admin/users/staff" },
|
||||
]
|
||||
},
|
||||
{
|
||||
title: "Courses",
|
||||
icon: BookOpen,
|
||||
href: "/courses",
|
||||
items: [
|
||||
{ title: "Course Catalog", href: "/courses/catalog" },
|
||||
{ title: "Schedules", href: "/courses/schedules" },
|
||||
]
|
||||
},
|
||||
{
|
||||
title: "Reports",
|
||||
icon: BarChart,
|
||||
href: "/reports",
|
||||
},
|
||||
{
|
||||
title: "Finance",
|
||||
icon: CreditCard,
|
||||
href: "/finance",
|
||||
},
|
||||
{
|
||||
title: "Settings",
|
||||
icon: Settings,
|
||||
href: "/settings",
|
||||
},
|
||||
],
|
||||
teacher: [
|
||||
{
|
||||
title: "Dashboard",
|
||||
icon: LayoutDashboard,
|
||||
href: "/dashboard",
|
||||
},
|
||||
{
|
||||
title: "Textbooks",
|
||||
icon: Library,
|
||||
href: "/teacher/textbooks",
|
||||
},
|
||||
{
|
||||
title: "Exams",
|
||||
icon: FileQuestion,
|
||||
href: "/teacher/exams",
|
||||
items: [
|
||||
{ title: "All Exams", href: "/teacher/exams/all" },
|
||||
{ title: "Create Exam", href: "/teacher/exams/create" },
|
||||
{ title: "Grading", href: "/teacher/exams/grading" },
|
||||
]
|
||||
},
|
||||
{
|
||||
title: "Homework",
|
||||
icon: PenTool,
|
||||
href: "/teacher/homework",
|
||||
items: [
|
||||
{ title: "Assignments", href: "/teacher/homework/assignments" },
|
||||
{ title: "Submissions", href: "/teacher/homework/submissions" },
|
||||
]
|
||||
},
|
||||
{
|
||||
title: "Question Bank",
|
||||
icon: ClipboardList,
|
||||
href: "/teacher/questions",
|
||||
},
|
||||
{
|
||||
title: "Class Management",
|
||||
icon: Users,
|
||||
href: "/teacher/classes",
|
||||
items: [
|
||||
{ title: "My Classes", href: "/teacher/classes/my" },
|
||||
{ title: "Students", href: "/teacher/classes/students" },
|
||||
{ title: "Schedule", href: "/teacher/classes/schedule" },
|
||||
]
|
||||
},
|
||||
],
|
||||
student: [
|
||||
{
|
||||
title: "Dashboard",
|
||||
icon: LayoutDashboard,
|
||||
href: "/dashboard",
|
||||
},
|
||||
{
|
||||
title: "My Learning",
|
||||
icon: BookOpen,
|
||||
href: "/student/learning",
|
||||
items: [
|
||||
{ title: "Courses", href: "/student/learning/courses" },
|
||||
{ title: "Assignments", href: "/student/learning/assignments" },
|
||||
{ title: "Grades", href: "/student/learning/grades" },
|
||||
]
|
||||
},
|
||||
{
|
||||
title: "Schedule",
|
||||
icon: Calendar,
|
||||
href: "/student/schedule",
|
||||
},
|
||||
{
|
||||
title: "Resources",
|
||||
icon: FileText,
|
||||
href: "/student/resources",
|
||||
},
|
||||
],
|
||||
parent: [
|
||||
{
|
||||
title: "Dashboard",
|
||||
icon: LayoutDashboard,
|
||||
href: "/parent/dashboard",
|
||||
},
|
||||
{
|
||||
title: "Children",
|
||||
icon: Users,
|
||||
href: "/parent/children",
|
||||
},
|
||||
{
|
||||
title: "Tuition",
|
||||
icon: CreditCard,
|
||||
href: "/parent/tuition",
|
||||
},
|
||||
{
|
||||
title: "Messages",
|
||||
icon: MessageSquare,
|
||||
href: "/messages",
|
||||
},
|
||||
]
|
||||
}
|
||||
140
src/modules/questions/actions.ts
Normal file
140
src/modules/questions/actions.ts
Normal file
@@ -0,0 +1,140 @@
|
||||
"use server";
|
||||
|
||||
import { db } from "@/shared/db";
|
||||
import { questions, questionsToKnowledgePoints } from "@/shared/db/schema";
|
||||
import { CreateQuestionInput, CreateQuestionSchema } from "./schema";
|
||||
import { ActionState } from "@/shared/types/action-state";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { ZodError } from "zod";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { createId } from "@paralleldrive/cuid2";
|
||||
|
||||
// --- Mock Auth Helper (Replace with actual Auth.js call) ---
|
||||
async function getCurrentUser() {
|
||||
// In production: const session = await auth(); return session?.user;
|
||||
// Mocking a teacher user for this demonstration
|
||||
return {
|
||||
id: "user_teacher_123",
|
||||
role: "teacher", // or "admin"
|
||||
};
|
||||
}
|
||||
|
||||
async function ensureTeacher() {
|
||||
const user = await getCurrentUser();
|
||||
if (!user || (user.role !== "teacher" && user.role !== "admin")) {
|
||||
throw new Error("Unauthorized: Only teachers can perform this action.");
|
||||
}
|
||||
return user;
|
||||
}
|
||||
|
||||
// --- Recursive Insert Helper ---
|
||||
// We pass 'tx' to ensure all operations run within the same transaction
|
||||
async function insertQuestionWithRelations(
|
||||
tx: any, // using any or strict Drizzle Transaction type if imported
|
||||
input: CreateQuestionInput,
|
||||
authorId: string,
|
||||
parentId: string | null = null
|
||||
) {
|
||||
// We generate ID explicitly here.
|
||||
const newQuestionId = createId();
|
||||
|
||||
await tx.insert(questions).values({
|
||||
id: newQuestionId,
|
||||
content: input.content,
|
||||
type: input.type,
|
||||
difficulty: input.difficulty,
|
||||
authorId: authorId,
|
||||
parentId: parentId,
|
||||
});
|
||||
|
||||
// 2. Link Knowledge Points
|
||||
if (input.knowledgePointIds && input.knowledgePointIds.length > 0) {
|
||||
await tx.insert(questionsToKnowledgePoints).values(
|
||||
input.knowledgePointIds.map((kpId) => ({
|
||||
questionId: newQuestionId,
|
||||
knowledgePointId: kpId,
|
||||
}))
|
||||
);
|
||||
}
|
||||
|
||||
// 3. Handle Sub-Questions (Recursion)
|
||||
if (input.subQuestions && input.subQuestions.length > 0) {
|
||||
for (const subQ of input.subQuestions) {
|
||||
await insertQuestionWithRelations(tx, subQ, authorId, newQuestionId);
|
||||
}
|
||||
}
|
||||
|
||||
return newQuestionId;
|
||||
}
|
||||
|
||||
// --- Main Server Action ---
|
||||
|
||||
export async function createNestedQuestion(
|
||||
prevState: ActionState<string> | undefined,
|
||||
formData: FormData | CreateQuestionInput // Support both FormData and JSON input
|
||||
): Promise<ActionState<string>> {
|
||||
try {
|
||||
// 1. Auth Check
|
||||
const user = await ensureTeacher();
|
||||
|
||||
// 2. Parse Input
|
||||
// If formData is actual FormData, we need to convert it.
|
||||
// For complex nested structures, frontend usually sends JSON string or pure JSON object if using `useServerAction` with arguments.
|
||||
// Here we assume the client might send a raw object (if using direct function call) or we parse FormData.
|
||||
let rawInput: any = formData;
|
||||
|
||||
if (formData instanceof FormData) {
|
||||
// Parsing complex nested JSON from FormData is messy.
|
||||
// We assume one field 'data' contains the JSON, or we expect direct object usage (common in modern Next.js RPC).
|
||||
const jsonString = formData.get("json");
|
||||
if (typeof jsonString === "string") {
|
||||
rawInput = JSON.parse(jsonString);
|
||||
} else {
|
||||
return { success: false, message: "Invalid submission format. Expected JSON." };
|
||||
}
|
||||
}
|
||||
|
||||
const validatedFields = CreateQuestionSchema.safeParse(rawInput);
|
||||
|
||||
if (!validatedFields.success) {
|
||||
return {
|
||||
success: false,
|
||||
message: "Validation failed",
|
||||
errors: validatedFields.error.flatten().fieldErrors,
|
||||
};
|
||||
}
|
||||
|
||||
const input = validatedFields.data;
|
||||
|
||||
// 3. Database Transaction
|
||||
await db.transaction(async (tx) => {
|
||||
await insertQuestionWithRelations(tx, input, user.id, null);
|
||||
});
|
||||
|
||||
// 4. Revalidate Cache
|
||||
revalidatePath("/questions");
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: "Question created successfully",
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
console.error("Failed to create question:", error);
|
||||
|
||||
// Drizzle/DB Error Handling (Generic)
|
||||
if (error instanceof Error) {
|
||||
// Check for specific DB errors (constraints, etc.)
|
||||
// e.g., if (error.message.includes("Duplicate entry")) ...
|
||||
return {
|
||||
success: false,
|
||||
message: error.message || "Database error occurred",
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
message: "An unexpected error occurred",
|
||||
};
|
||||
}
|
||||
}
|
||||
20
src/modules/questions/components/create-question-button.tsx
Normal file
20
src/modules/questions/components/create-question-button.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { Plus } from "lucide-react"
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import { CreateQuestionDialog } from "./create-question-dialog"
|
||||
|
||||
export function CreateQuestionButton() {
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button onClick={() => setOpen(true)}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Add Question
|
||||
</Button>
|
||||
<CreateQuestionDialog open={open} onOpenChange={setOpen} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
286
src/modules/questions/components/create-question-dialog.tsx
Normal file
286
src/modules/questions/components/create-question-dialog.tsx
Normal file
@@ -0,0 +1,286 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect } from "react"
|
||||
import { useForm, type SubmitHandler } from "react-hook-form"
|
||||
import { zodResolver } from "@hookform/resolvers/zod"
|
||||
import { z } from "zod"
|
||||
import { Plus, Trash2, GripVertical } from "lucide-react"
|
||||
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/shared/components/ui/dialog"
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/shared/components/ui/form"
|
||||
import { Input } from "@/shared/components/ui/input"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/shared/components/ui/select"
|
||||
import { Textarea } from "@/shared/components/ui/textarea"
|
||||
import { BaseQuestionSchema } from "../schema"
|
||||
import { createNestedQuestion } from "../actions"
|
||||
import { toast } from "sonner"
|
||||
import { Question } from "../types"
|
||||
|
||||
// Extend schema for form usage (e.g. handling options for choice questions)
|
||||
const QuestionFormSchema = BaseQuestionSchema.extend({
|
||||
difficulty: z.number().min(1).max(5),
|
||||
content: z.string().min(1, "Question content is required"),
|
||||
options: z.array(z.object({
|
||||
label: z.string(),
|
||||
value: z.string(),
|
||||
isCorrect: z.boolean().default(false)
|
||||
})).optional(),
|
||||
})
|
||||
|
||||
type QuestionFormValues = z.input<typeof QuestionFormSchema>
|
||||
|
||||
interface CreateQuestionDialogProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
initialData?: Question | null
|
||||
}
|
||||
|
||||
export function CreateQuestionDialog({ open, onOpenChange, initialData }: CreateQuestionDialogProps) {
|
||||
const [isPending, setIsPending] = useState(false)
|
||||
const isEdit = !!initialData
|
||||
|
||||
const form = useForm<QuestionFormValues>({
|
||||
resolver: zodResolver(QuestionFormSchema),
|
||||
defaultValues: {
|
||||
type: initialData?.type || "single_choice",
|
||||
difficulty: initialData?.difficulty || 1,
|
||||
content: (typeof initialData?.content === 'string' ? initialData.content : "") || "",
|
||||
options: [
|
||||
{ label: "Option A", value: "A", isCorrect: true },
|
||||
{ label: "Option B", value: "B", isCorrect: false },
|
||||
],
|
||||
},
|
||||
})
|
||||
|
||||
// Reset form when initialData changes
|
||||
useEffect(() => {
|
||||
if (initialData) {
|
||||
form.reset({
|
||||
type: initialData.type,
|
||||
difficulty: initialData.difficulty,
|
||||
content: typeof initialData.content === 'string' ? initialData.content : JSON.stringify(initialData.content),
|
||||
options: [
|
||||
{ label: "Option A", value: "A", isCorrect: true },
|
||||
{ label: "Option B", value: "B", isCorrect: false },
|
||||
]
|
||||
})
|
||||
} else {
|
||||
form.reset({
|
||||
type: "single_choice",
|
||||
difficulty: 1,
|
||||
content: "",
|
||||
options: [
|
||||
{ label: "Option A", value: "A", isCorrect: true },
|
||||
{ label: "Option B", value: "B", isCorrect: false },
|
||||
]
|
||||
})
|
||||
}
|
||||
}, [initialData, form])
|
||||
|
||||
const questionType = form.watch("type")
|
||||
|
||||
const onSubmit: SubmitHandler<QuestionFormValues> = async (data) => {
|
||||
setIsPending(true)
|
||||
try {
|
||||
const payload = {
|
||||
type: data.type,
|
||||
difficulty: data.difficulty,
|
||||
content: data.content,
|
||||
knowledgePointIds: [],
|
||||
}
|
||||
const fd = new FormData()
|
||||
fd.set("json", JSON.stringify(payload))
|
||||
const res = await createNestedQuestion(undefined, fd)
|
||||
if (res.success) {
|
||||
toast.success(isEdit ? "Updated question" : "Created question")
|
||||
onOpenChange(false)
|
||||
if (!isEdit) {
|
||||
form.reset()
|
||||
}
|
||||
} else {
|
||||
toast.error(res.message || "Operation failed")
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error("Unexpected error")
|
||||
} finally {
|
||||
setIsPending(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-[600px] max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{isEdit ? "Edit Question" : "Create New Question"}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{isEdit ? "Update question details." : "Add a new question to the bank. Fill in the details below."}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="type"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Question Type</FormLabel>
|
||||
<Select onValueChange={field.onChange} defaultValue={field.value}>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select type" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value="single_choice">Single Choice</SelectItem>
|
||||
<SelectItem value="multiple_choice">Multiple Choice</SelectItem>
|
||||
<SelectItem value="judgment">True/False</SelectItem>
|
||||
<SelectItem value="text">Short Answer</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="difficulty"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Difficulty (1-5)</FormLabel>
|
||||
<Select
|
||||
onValueChange={(val) => field.onChange(parseInt(val))}
|
||||
defaultValue={String(field.value)}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select difficulty" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
{[1, 2, 3, 4, 5].map((level) => (
|
||||
<SelectItem key={level} value={String(level)}>
|
||||
{level} - {level === 1 ? "Easy" : level === 5 ? "Hard" : "Medium"}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="content"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Question Content</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
placeholder="Enter the question text here..."
|
||||
className="min-h-[100px]"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Supports basic text. Rich text editor coming soon.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{(questionType === "single_choice" || questionType === "multiple_choice") && (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<FormLabel>Options</FormLabel>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
const currentOptions = form.getValues("options") || [];
|
||||
form.setValue("options", [
|
||||
...currentOptions,
|
||||
{ label: `Option ${String.fromCharCode(65 + currentOptions.length)}`, value: String.fromCharCode(65 + currentOptions.length), isCorrect: false }
|
||||
]);
|
||||
}}
|
||||
>
|
||||
<Plus className="mr-2 h-3 w-3" /> Add Option
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
{form.watch("options")?.map((option, index) => (
|
||||
<div key={index} className="flex items-center gap-2">
|
||||
<div className="flex h-8 w-8 items-center justify-center text-muted-foreground">
|
||||
<GripVertical className="h-4 w-4" />
|
||||
</div>
|
||||
<Input
|
||||
value={option.label}
|
||||
onChange={(e) => {
|
||||
const newOptions = [...(form.getValues("options") || [])];
|
||||
newOptions[index].label = e.target.value;
|
||||
form.setValue("options", newOptions);
|
||||
}}
|
||||
placeholder={`Option ${index + 1}`}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="text-destructive hover:text-destructive/90"
|
||||
onClick={() => {
|
||||
const newOptions = [...(form.getValues("options") || [])];
|
||||
newOptions.splice(index, 1);
|
||||
form.setValue("options", newOptions);
|
||||
}}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={isPending}>
|
||||
{isPending ? "Creating..." : "Create Question"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
178
src/modules/questions/components/question-actions.tsx
Normal file
178
src/modules/questions/components/question-actions.tsx
Normal file
@@ -0,0 +1,178 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { MoreHorizontal, Pencil, Trash, Eye, Copy } from "lucide-react"
|
||||
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
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,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/shared/components/ui/dialog"
|
||||
|
||||
import { Question } from "../types"
|
||||
import { CreateQuestionDialog } from "./create-question-dialog"
|
||||
import { toast } from "sonner"
|
||||
|
||||
interface QuestionActionsProps {
|
||||
question: Question
|
||||
}
|
||||
|
||||
export function QuestionActions({ question }: QuestionActionsProps) {
|
||||
const [showEditDialog, setShowEditDialog] = useState(false)
|
||||
const [showDeleteDialog, setShowDeleteDialog] = useState(false)
|
||||
const [showViewDialog, setShowViewDialog] = useState(false)
|
||||
const [isDeleting, setIsDeleting] = useState(false)
|
||||
|
||||
const copyId = () => {
|
||||
navigator.clipboard.writeText(question.id)
|
||||
toast.success("Question ID copied to clipboard")
|
||||
}
|
||||
|
||||
const handleDelete = async () => {
|
||||
setIsDeleting(true)
|
||||
try {
|
||||
// Simulate API call
|
||||
console.log("Deleting question:", question.id)
|
||||
await new Promise(resolve => setTimeout(resolve, 1000))
|
||||
toast.success("Question deleted successfully")
|
||||
setShowDeleteDialog(false)
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
toast.error("Failed to delete question")
|
||||
} finally {
|
||||
setIsDeleting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" className="h-8 w-8 p-0">
|
||||
<span className="sr-only">Open menu</span>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuLabel>Actions</DropdownMenuLabel>
|
||||
<DropdownMenuItem onClick={copyId}>
|
||||
<Copy className="mr-2 h-4 w-4" /> Copy ID
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={() => setShowViewDialog(true)}>
|
||||
<Eye className="mr-2 h-4 w-4" /> View Details
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setShowEditDialog(true)}>
|
||||
<Pencil className="mr-2 h-4 w-4" /> Edit
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
className="text-destructive focus:text-destructive"
|
||||
onClick={() => setShowDeleteDialog(true)}
|
||||
>
|
||||
<Trash className="mr-2 h-4 w-4" /> Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
{/* Edit Dialog */}
|
||||
<CreateQuestionDialog
|
||||
open={showEditDialog}
|
||||
onOpenChange={setShowEditDialog}
|
||||
initialData={question}
|
||||
/>
|
||||
|
||||
{/* Delete Confirmation Dialog */}
|
||||
<AlertDialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Are you absolutely sure?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This action cannot be undone. This will permanently delete the question
|
||||
and remove it from our servers.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
handleDelete()
|
||||
}}
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
disabled={isDeleting}
|
||||
>
|
||||
{isDeleting ? "Deleting..." : "Delete"}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
{/* View Details Dialog (Simple Read-only View) */}
|
||||
<Dialog open={showViewDialog} onOpenChange={setShowViewDialog}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Question Details</DialogTitle>
|
||||
<DialogDescription>ID: {question.id}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<span className="font-medium">Type:</span>
|
||||
<span className="col-span-3 capitalize">{question.type.replace('_', ' ')}</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<span className="font-medium">Difficulty:</span>
|
||||
<span className="col-span-3">{question.difficulty}</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-4 items-start gap-4">
|
||||
<span className="font-medium pt-1">Content:</span>
|
||||
<div className="col-span-3 rounded-md bg-muted p-2 text-sm">
|
||||
{typeof question.content === 'string' ? question.content : JSON.stringify(question.content, null, 2)}
|
||||
</div>
|
||||
</div>
|
||||
{/* Show Author if exists */}
|
||||
{question.author && (
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<span className="font-medium">Author:</span>
|
||||
<span className="col-span-3">{question.author.name || "Unknown"}</span>
|
||||
</div>
|
||||
)}
|
||||
{/* Show Knowledge Points */}
|
||||
{question.knowledgePoints && question.knowledgePoints.length > 0 && (
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<span className="font-medium">Tags:</span>
|
||||
<div className="col-span-3 flex flex-wrap gap-1">
|
||||
{question.knowledgePoints.map(kp => (
|
||||
<span key={kp.id} className="rounded-full bg-secondary px-2 py-0.5 text-xs text-secondary-foreground">
|
||||
{kp.name}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
)
|
||||
}
|
||||
144
src/modules/questions/components/question-columns.tsx
Normal file
144
src/modules/questions/components/question-columns.tsx
Normal file
@@ -0,0 +1,144 @@
|
||||
"use client"
|
||||
|
||||
import { ColumnDef } from "@tanstack/react-table"
|
||||
|
||||
import { Badge } from "@/shared/components/ui/badge"
|
||||
import { Checkbox } from "@/shared/components/ui/checkbox"
|
||||
import { Question, QuestionType } from "../types"
|
||||
import { cn } from "@/shared/lib/utils"
|
||||
import { QuestionActions } from "./question-actions"
|
||||
|
||||
// Helper for Type Colors
|
||||
const getTypeColor = (type: QuestionType) => {
|
||||
switch (type) {
|
||||
case "single_choice":
|
||||
return "default"; // Primary
|
||||
case "multiple_choice":
|
||||
return "secondary";
|
||||
case "judgment":
|
||||
return "outline";
|
||||
case "text":
|
||||
return "secondary"; // Changed from 'accent' which might not be a valid badge variant key in standard shadcn, usually it's default, secondary, destructive, outline.
|
||||
default:
|
||||
return "secondary";
|
||||
}
|
||||
}
|
||||
|
||||
const getTypeLabel = (type: QuestionType) => {
|
||||
switch (type) {
|
||||
case "single_choice": return "Single Choice";
|
||||
case "multiple_choice": return "Multiple Choice";
|
||||
case "judgment": return "True/False";
|
||||
case "text": return "Short Answer";
|
||||
case "composite": return "Composite";
|
||||
default: return type;
|
||||
}
|
||||
}
|
||||
|
||||
export const columns: ColumnDef<Question>[] = [
|
||||
{
|
||||
id: "select",
|
||||
header: ({ table }) => (
|
||||
<Checkbox
|
||||
checked={table.getIsAllPageRowsSelected()}
|
||||
onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
|
||||
aria-label="Select all"
|
||||
/>
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<Checkbox
|
||||
checked={row.getIsSelected()}
|
||||
onCheckedChange={(value) => row.toggleSelected(!!value)}
|
||||
aria-label="Select row"
|
||||
/>
|
||||
),
|
||||
enableSorting: false,
|
||||
enableHiding: false,
|
||||
},
|
||||
{
|
||||
accessorKey: "type",
|
||||
header: "Type",
|
||||
cell: ({ row }) => {
|
||||
const type = row.getValue("type") as QuestionType
|
||||
return (
|
||||
<Badge variant={getTypeColor(type)} className="whitespace-nowrap">
|
||||
{getTypeLabel(type)}
|
||||
</Badge>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "content",
|
||||
header: "Content",
|
||||
cell: ({ row }) => {
|
||||
const content = row.getValue("content");
|
||||
let preview = "";
|
||||
if (typeof content === 'string') {
|
||||
preview = content;
|
||||
} else if (content && typeof content === 'object') {
|
||||
preview = JSON.stringify(content).slice(0, 50);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-[400px] truncate font-medium" title={preview}>
|
||||
{preview}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "difficulty",
|
||||
header: "Difficulty",
|
||||
cell: ({ row }) => {
|
||||
const diff = row.getValue("difficulty") as number;
|
||||
// 1-5 scale
|
||||
return (
|
||||
<div className="flex items-center">
|
||||
<span className={cn("font-medium",
|
||||
diff <= 2 ? "text-green-600" :
|
||||
diff === 3 ? "text-yellow-600" : "text-red-600"
|
||||
)}>
|
||||
{diff === 1 ? "Easy" : diff === 2 ? "Easy-Med" : diff === 3 ? "Medium" : diff === 4 ? "Med-Hard" : "Hard"}
|
||||
</span>
|
||||
<span className="ml-1 text-xs text-muted-foreground">({diff})</span>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "knowledgePoints",
|
||||
header: "Knowledge Points",
|
||||
cell: ({ row }) => {
|
||||
const kps = row.original.knowledgePoints;
|
||||
if (!kps || kps.length === 0) return <span className="text-muted-foreground">-</span>;
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{kps.slice(0, 2).map(kp => (
|
||||
<Badge key={kp.id} variant="outline" className="text-xs">
|
||||
{kp.name}
|
||||
</Badge>
|
||||
))}
|
||||
{kps.length > 2 && (
|
||||
<Badge variant="outline" className="text-xs">+{kps.length - 2}</Badge>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: "createdAt",
|
||||
header: "Created",
|
||||
cell: ({ row }) => {
|
||||
return (
|
||||
<span className="text-muted-foreground text-xs whitespace-nowrap">
|
||||
{new Date(row.getValue("createdAt")).toLocaleDateString()}
|
||||
</span>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
cell: ({ row }) => <QuestionActions question={row.original} />,
|
||||
},
|
||||
]
|
||||
134
src/modules/questions/components/question-data-table.tsx
Normal file
134
src/modules/questions/components/question-data-table.tsx
Normal file
@@ -0,0 +1,134 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import {
|
||||
ColumnDef,
|
||||
flexRender,
|
||||
getCoreRowModel,
|
||||
useReactTable,
|
||||
getPaginationRowModel,
|
||||
SortingState,
|
||||
getSortedRowModel,
|
||||
getFilteredRowModel,
|
||||
RowSelectionState,
|
||||
} from "@tanstack/react-table"
|
||||
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/shared/components/ui/table"
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import { ChevronLeft, ChevronRight } from "lucide-react"
|
||||
|
||||
interface DataTableProps<TData, TValue> {
|
||||
columns: ColumnDef<TData, TValue>[]
|
||||
data: TData[]
|
||||
}
|
||||
|
||||
export function QuestionDataTable<TData, TValue>({
|
||||
columns,
|
||||
data,
|
||||
}: DataTableProps<TData, TValue>) {
|
||||
const [sorting, setSorting] = React.useState<SortingState>([])
|
||||
const [rowSelection, setRowSelection] = React.useState<RowSelectionState>({})
|
||||
|
||||
const table = useReactTable({
|
||||
data,
|
||||
columns,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getPaginationRowModel: getPaginationRowModel(),
|
||||
onSortingChange: setSorting,
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
onRowSelectionChange: setRowSelection,
|
||||
getFilteredRowModel: getFilteredRowModel(),
|
||||
state: {
|
||||
sorting,
|
||||
rowSelection,
|
||||
},
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<TableRow key={headerGroup.id}>
|
||||
{headerGroup.headers.map((header) => {
|
||||
return (
|
||||
<TableHead key={header.id}>
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(
|
||||
header.column.columnDef.header,
|
||||
header.getContext()
|
||||
)}
|
||||
</TableHead>
|
||||
)
|
||||
})}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{table.getRowModel().rows?.length ? (
|
||||
table.getRowModel().rows.map((row) => (
|
||||
<TableRow
|
||||
key={row.id}
|
||||
data-state={row.getIsSelected() && "selected"}
|
||||
>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell key={cell.id}>
|
||||
{flexRender(
|
||||
cell.column.columnDef.cell,
|
||||
cell.getContext()
|
||||
)}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={columns.length}
|
||||
className="h-24 text-center"
|
||||
>
|
||||
No results.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
<div className="flex items-center justify-end space-x-2 py-4">
|
||||
<div className="flex-1 text-sm text-muted-foreground">
|
||||
{table.getFilteredSelectedRowModel().rows.length} of{" "}
|
||||
{table.getFilteredRowModel().rows.length} row(s) selected.
|
||||
</div>
|
||||
<div className="space-x-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => table.previousPage()}
|
||||
disabled={!table.getCanPreviousPage()}
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
Previous
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => table.nextPage()}
|
||||
disabled={!table.getCanNextPage()}
|
||||
>
|
||||
Next
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
80
src/modules/questions/components/question-filters.tsx
Normal file
80
src/modules/questions/components/question-filters.tsx
Normal file
@@ -0,0 +1,80 @@
|
||||
"use client"
|
||||
|
||||
import { useQueryState, parseAsString } from "nuqs"
|
||||
import { Search, X } from "lucide-react"
|
||||
|
||||
import { Input } from "@/shared/components/ui/input"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/shared/components/ui/select"
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
|
||||
export function QuestionFilters() {
|
||||
const [search, setSearch] = useQueryState("q", parseAsString.withDefault(""))
|
||||
const [type, setType] = useQueryState("type", parseAsString.withDefault("all"))
|
||||
const [difficulty, setDifficulty] = useQueryState("difficulty", parseAsString.withDefault("all"))
|
||||
|
||||
// Debounce could be added here for search, but for simplicity we rely on 'Enter' or blur or just let it update (nuqs handles some updates well, but for text input usually we want debounce or on change).
|
||||
// Actually nuqs with shallow: false (default) triggers server re-render.
|
||||
// For text input, it's better to use local state and update URL on debounce or enter.
|
||||
|
||||
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="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search questions..."
|
||||
className="pl-8"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value || null)}
|
||||
/>
|
||||
</div>
|
||||
<Select value={type} onValueChange={(val) => setType(val === "all" ? null : val)}>
|
||||
<SelectTrigger className="w-[150px]">
|
||||
<SelectValue placeholder="Type" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All Types</SelectItem>
|
||||
<SelectItem value="single_choice">Single Choice</SelectItem>
|
||||
<SelectItem value="multiple_choice">Multiple Choice</SelectItem>
|
||||
<SelectItem value="judgment">True/False</SelectItem>
|
||||
<SelectItem value="text">Short Answer</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select value={difficulty} onValueChange={(val) => setDifficulty(val === "all" ? null : val)}>
|
||||
<SelectTrigger className="w-[150px]">
|
||||
<SelectValue placeholder="Difficulty" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">Any Difficulty</SelectItem>
|
||||
<SelectItem value="1">Easy (1)</SelectItem>
|
||||
<SelectItem value="2">Easy-Med (2)</SelectItem>
|
||||
<SelectItem value="3">Medium (3)</SelectItem>
|
||||
<SelectItem value="4">Med-Hard (4)</SelectItem>
|
||||
<SelectItem value="5">Hard (5)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{(search || type !== "all" || difficulty !== "all") && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
setSearch(null)
|
||||
setType(null)
|
||||
setDifficulty(null)
|
||||
}}
|
||||
className="h-8 px-2 lg:px-3"
|
||||
>
|
||||
Reset
|
||||
<X className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user