Module Update
Some checks failed
CI / build-and-test (push) Failing after 1m31s
CI / deploy (push) Has been skipped

This commit is contained in:
SpecialX
2025-12-30 14:42:30 +08:00
parent f1797265b2
commit e7c902e8e1
148 changed files with 19317 additions and 113 deletions

View File

@@ -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
View 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
```

View 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 ๅ…ณ็ณปๅฎšไน‰
```

View 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`

View 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` ็ป“ๆž„ๆธ…ๆ™ฐใ€‚

View 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
View 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**.

View 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`).

View 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" (ๆŸฅ็œ‹ๅ…จ้ƒจ) ่ทณ่ฝฌๅฏผ่ˆชใ€‚

View 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` ๆ•ฐๆฎๅบ“่ฐƒ็”จใ€‚

View 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.

View 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๏ผŒไปฅ็กฎไฟ็ฑปๅž‹ๅฎ‰ๅ…จๅนถ่‡ชๅŠจ้‡ๆ–ฐ้ชŒ่ฏ็ผ“ๅญ˜ใ€‚

View 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)

View 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
View 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
View 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
View 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!,
},
});

View 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`);

View 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;

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View 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

File diff suppressed because it is too large Load Diff

View File

@@ -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
View 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
View 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>
)
}

View 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>
}

View 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 />
}

View 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>
)
}

View 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 />
}

View File

@@ -0,0 +1,5 @@
import { AdminDashboard } from "@/modules/dashboard/components/admin-view"
export default function AdminDashboardPage() {
return <AdminDashboard />
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View File

@@ -0,0 +1,5 @@
import { StudentDashboard } from "@/modules/dashboard/components/student-view"
export default function StudentDashboardPage() {
return <StudentDashboard />
}

View 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>
)
}

View File

@@ -0,0 +1,5 @@
import { redirect } from "next/navigation"
export default function ClassesPage() {
redirect("/teacher/classes/my")
}

View 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>
)
}

View 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>
)
}

View 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>
);
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View File

@@ -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>
)
}

View 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>
)
}

View 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>
)
}

View File

@@ -0,0 +1,5 @@
import { redirect } from "next/navigation"
export default function ExamsPage() {
redirect("/teacher/exams/all")
}

View 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>
)
}

View File

@@ -0,0 +1,5 @@
import { redirect } from "next/navigation"
export default function HomeworkPage() {
redirect("/teacher/homework/assignments")
}

View 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>
)
}

View 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>
)
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View File

@@ -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;
}
}

View File

@@ -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>
);

View File

@@ -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
View 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,
});

View 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">
&ldquo;This platform has completely transformed how we deliver education to our students. The attention to detail and performance is unmatched.&rdquo;
</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>
)
}

View 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&apos;t have an account?{" "}
<Link
href="/register"
className="underline underline-offset-4 hover:text-primary"
>
Sign up
</Link>
</p>
</div>
)
}

View 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>
)
}

View 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>
)
}

View 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>
);
}

View 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>
)
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
)
}

View 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" }
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
</>
)
}

View 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>
)
}

View 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} />,
},
]

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
),
},
]

View 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>
)
}

View 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
}
})

View 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))
}

View 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