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

@@ -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
subject: string
grade: string
status: ExamStatus
difficulty: ExamDifficulty
totalScore: number
durationMin: number
questionCount: number
scheduledAt?: string
createdAt: string
updatedAt?: string
tags?: string[]
}
export type SubmissionStatus = "pending" | "graded"
export interface ExamSubmission {
id: string
examId: string
examTitle: string
studentName: string
submittedAt: string
score?: number
status: SubmissionStatus
}

View File

@@ -0,0 +1,185 @@
"use client"
import * as React from "react"
import Link from "next/link"
import { usePathname } from "next/navigation"
import { ChevronRight } from "lucide-react"
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/shared/components/ui/collapsible"
import { ScrollArea } from "@/shared/components/ui/scroll-area"
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/shared/components/ui/tooltip"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/shared/components/ui/select"
import { cn } from "@/shared/lib/utils"
import { useSidebar } from "./sidebar-provider"
import { NAV_CONFIG, Role } from "../config/navigation"
interface AppSidebarProps {
mode?: "mobile" | "desktop"
}
export function AppSidebar({ mode }: AppSidebarProps) {
const { expanded, toggleSidebar, isMobile } = useSidebar()
const pathname = usePathname()
// MOCK ROLE: In real app, get this from auth context / session
const [currentRole, setCurrentRole] = React.useState<Role>("admin")
const navItems = NAV_CONFIG[currentRole]
// Ensure consistent state for hydration
if (!expanded && mode === 'mobile') return null
return (
<div className="flex h-full flex-col gap-4">
{/* Sidebar Header */}
<div className={cn("flex h-16 items-center border-b px-4 transition-all duration-300", !expanded && !isMobile ? "justify-center px-2" : "justify-between")}>
{expanded || isMobile ? (
<Link href="/" className="flex items-center gap-2 font-bold">
<div className="bg-primary text-primary-foreground flex size-8 items-center justify-center rounded-lg">
NE
</div>
<span className="truncate text-lg">Next_Edu</span>
</Link>
) : (
<div className="bg-primary text-primary-foreground flex size-8 items-center justify-center rounded-lg">
NE
</div>
)}
</div>
{/* Role Switcher (Dev Only - for Demo) */}
{(expanded || isMobile) && (
<div className="px-4">
<label className="text-muted-foreground mb-2 block text-xs font-medium uppercase">
View As (Dev Mode)
</label>
<Select value={currentRole} onValueChange={(v) => setCurrentRole(v as Role)}>
<SelectTrigger>
<SelectValue placeholder="Select Role" />
</SelectTrigger>
<SelectContent>
<SelectItem value="admin">Admin</SelectItem>
<SelectItem value="teacher">Teacher</SelectItem>
<SelectItem value="student">Student</SelectItem>
<SelectItem value="parent">Parent</SelectItem>
</SelectContent>
</Select>
</div>
)}
{/* Navigation */}
<ScrollArea className="flex-1 px-3">
<nav className="flex flex-col gap-2 py-4">
<TooltipProvider delayDuration={0}>
{navItems.map((item, index) => {
const isActive = pathname.startsWith(item.href)
const hasChildren = item.items && item.items.length > 0
if (!expanded && !isMobile) {
// Collapsed Mode (Icon Only + Tooltip)
return (
<Tooltip key={index}>
<TooltipTrigger asChild>
<Link
href={item.href}
className={cn(
"hover:bg-sidebar-accent hover:text-sidebar-accent-foreground flex size-10 items-center justify-center rounded-md transition-colors",
isActive && "bg-sidebar-accent text-sidebar-accent-foreground"
)}
>
<item.icon className="size-5" />
<span className="sr-only">{item.title}</span>
</Link>
</TooltipTrigger>
<TooltipContent side="right">{item.title}</TooltipContent>
</Tooltip>
)
}
// Expanded Mode
if (hasChildren) {
return (
<Collapsible key={index} defaultOpen={isActive} className="group/collapsible">
<CollapsibleTrigger asChild>
<button
className={cn(
"hover:bg-sidebar-accent hover:text-sidebar-accent-foreground flex w-full items-center justify-between rounded-md p-2 text-sm font-medium transition-colors",
isActive && "text-sidebar-accent-foreground"
)}
>
<div className="flex items-center gap-2">
<item.icon className="size-4" />
<span>{item.title}</span>
</div>
<ChevronRight className="text-muted-foreground size-4 transition-transform duration-200 group-data-[state=open]/collapsible:rotate-90" />
</button>
</CollapsibleTrigger>
<CollapsibleContent className="data-[state=open]:animate-accordion-down data-[state=closed]:animate-accordion-up overflow-hidden">
<div className="ml-6 mt-1 flex flex-col gap-1 border-l pl-2">
{item.items?.map((subItem, subIndex) => (
<Link
key={subIndex}
href={subItem.href}
className={cn(
"text-muted-foreground hover:text-foreground block rounded-md px-2 py-1 text-sm transition-colors",
pathname === subItem.href && "text-foreground font-medium"
)}
>
{subItem.title}
</Link>
))}
</div>
</CollapsibleContent>
</Collapsible>
)
}
return (
<Link
key={index}
href={item.href}
className={cn(
"hover:bg-sidebar-accent hover:text-sidebar-accent-foreground flex items-center gap-2 rounded-md p-2 text-sm font-medium transition-colors",
isActive && "bg-sidebar-accent text-sidebar-accent-foreground"
)}
>
<item.icon className="size-4" />
<span>{item.title}</span>
</Link>
)
})}
</TooltipProvider>
</nav>
</ScrollArea>
{/* Sidebar Footer */}
<div className="p-4">
{!isMobile && (
<button
onClick={toggleSidebar}
className="hover:bg-sidebar-accent text-sidebar-foreground flex w-full items-center justify-center rounded-md border p-2 text-sm transition-colors"
>
{expanded ? "Collapse" : <ChevronRight className="size-4" />}
</button>
)}
</div>
</div>
)
}
AppSidebar.displayName = "AppSidebar"

View File

@@ -0,0 +1,102 @@
"use client"
import * as React from "react"
import { Menu } from "lucide-react"
import {
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
} from "@/shared/components/ui/sheet"
import { cn } from "@/shared/lib/utils"
type SidebarContextType = {
expanded: boolean
setExpanded: (expanded: boolean) => void
isMobile: boolean
toggleSidebar: () => void
}
const SidebarContext = React.createContext<SidebarContextType | undefined>(
undefined
)
export function useSidebar() {
const context = React.useContext(SidebarContext)
if (!context) {
throw new Error("useSidebar must be used within a SidebarProvider")
}
return context
}
interface SidebarProviderProps {
children: React.ReactNode
sidebar: React.ReactNode
}
export function SidebarProvider({ children, sidebar }: SidebarProviderProps) {
const [expanded, setExpanded] = React.useState(true)
const [isMobile, setIsMobile] = React.useState(false)
const [openMobile, setOpenMobile] = React.useState(false)
React.useEffect(() => {
const checkMobile = () => {
const mobile = window.innerWidth < 768
setIsMobile(mobile)
if (mobile) {
setExpanded(true)
}
}
checkMobile()
window.addEventListener("resize", checkMobile)
return () => window.removeEventListener("resize", checkMobile)
}, [])
const toggleSidebar = () => {
if (isMobile) {
setOpenMobile(!openMobile)
} else {
setExpanded(!expanded)
}
}
return (
<SidebarContext.Provider
value={{ expanded, setExpanded, isMobile, toggleSidebar }}
>
<div className="flex min-h-screen flex-col md:flex-row bg-background">
{/* Mobile Trigger & Sheet */}
{isMobile && (
<Sheet open={openMobile} onOpenChange={setOpenMobile}>
<SheetContent side="left" className="w-[80%] p-0 sm:w-[300px]">
<SheetHeader className="sr-only">
<SheetTitle>Navigation Menu</SheetTitle>
</SheetHeader>
<div className="h-full py-4">
{sidebar}
</div>
</SheetContent>
</Sheet>
)}
{/* Desktop Sidebar Wrapper */}
{!isMobile && (
<aside
className={cn(
"bg-sidebar border-sidebar-border text-sidebar-foreground sticky top-0 hidden h-screen flex-col border-r transition-[width] duration-300 ease-in-out md:flex",
expanded ? "w-64" : "w-16"
)}
>
{sidebar}
</aside>
)}
{/* Main Content Wrapper - Right Side */}
<div className="flex-1 flex flex-col min-w-0 transition-[margin] duration-300 ease-in-out h-screen overflow-hidden">
{children}
</div>
</div>
</SidebarContext.Provider>
)
}

View File

@@ -0,0 +1,105 @@
"use client"
import * as React from "react"
import { Bell, Menu, Search } from "lucide-react"
import { Button } from "@/shared/components/ui/button"
import { Input } from "@/shared/components/ui/input"
import { Separator } from "@/shared/components/ui/separator"
import {
Breadcrumb,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbList,
BreadcrumbPage,
BreadcrumbSeparator,
} from "@/shared/components/ui/breadcrumb"
import { Avatar, AvatarFallback, AvatarImage } from "@/shared/components/ui/avatar"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/shared/components/ui/dropdown-menu"
import { useSidebar } from "./sidebar-provider"
export function SiteHeader() {
const { toggleSidebar, isMobile } = useSidebar()
return (
<header className="bg-background/95 supports-[backdrop-filter]:bg-background/60 sticky top-0 z-50 flex h-16 items-center border-b px-4 backdrop-blur-sm">
<div className="flex flex-1 items-center gap-4">
{/* Mobile Toggle */}
{isMobile && (
<Button variant="ghost" size="icon" onClick={toggleSidebar} className="mr-2">
<Menu className="size-5" />
<span className="sr-only">Toggle Sidebar</span>
</Button>
)}
<Separator orientation="vertical" className="mr-2 hidden h-6 md:block" />
{/* Breadcrumbs */}
<Breadcrumb className="hidden md:flex">
<BreadcrumbList>
<BreadcrumbItem>
<BreadcrumbLink href="/dashboard">Dashboard</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbSeparator />
<BreadcrumbItem>
<BreadcrumbPage>Overview</BreadcrumbPage>
</BreadcrumbItem>
</BreadcrumbList>
</Breadcrumb>
</div>
<div className="flex items-center gap-4">
{/* Global Search */}
<div className="relative hidden md:block">
<Search className="text-muted-foreground absolute top-2.5 left-2.5 size-4" />
<Input
type="search"
placeholder="Search... (Cmd+K)"
className="w-[200px] pl-9 lg:w-[300px]"
/>
</div>
{/* Notifications */}
<Button variant="ghost" size="icon" className="text-muted-foreground">
<Bell className="size-5" />
<span className="sr-only">Notifications</span>
</Button>
{/* User Nav */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="relative size-8 rounded-full">
<Avatar className="size-8">
<AvatarImage src="/avatars/01.png" alt="@user" />
<AvatarFallback>AD</AvatarFallback>
</Avatar>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-56" align="end" forceMount>
<DropdownMenuLabel className="font-normal">
<div className="flex flex-col space-y-1">
<p className="text-sm font-medium leading-none">Admin User</p>
<p className="text-muted-foreground text-xs leading-none">admin@nextedu.com</p>
</div>
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem>Profile</DropdownMenuItem>
<DropdownMenuItem>Settings</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem className="text-destructive focus:bg-destructive/10">
Log out
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</header>
)
}

View File

@@ -0,0 +1,176 @@
import {
BarChart,
BookOpen,
Calendar,
GraduationCap,
LayoutDashboard,
Settings,
Users,
FileText,
MessageSquare,
Shield,
CreditCard,
FileQuestion,
ClipboardList,
Library,
PenTool
} from "lucide-react"
export type NavItem = {
title: string
icon: any
href: string
items?: { title: string; href: string }[]
}
export type Role = "admin" | "teacher" | "student" | "parent"
export const NAV_CONFIG: Record<Role, NavItem[]> = {
admin: [
{
title: "Dashboard",
icon: LayoutDashboard,
href: "/admin/dashboard",
},
{
title: "School Management",
icon: Shield,
href: "/admin/school",
items: [
{ title: "Departments", href: "/admin/school/departments" },
{ title: "Classrooms", href: "/admin/school/classrooms" },
{ title: "Academic Year", href: "/admin/school/academic-year" },
]
},
{
title: "Users",
icon: Users,
href: "/admin/users",
items: [
{ title: "Teachers", href: "/admin/users/teachers" },
{ title: "Students", href: "/admin/users/students" },
{ title: "Parents", href: "/admin/users/parents" },
{ title: "Staff", href: "/admin/users/staff" },
]
},
{
title: "Courses",
icon: BookOpen,
href: "/courses",
items: [
{ title: "Course Catalog", href: "/courses/catalog" },
{ title: "Schedules", href: "/courses/schedules" },
]
},
{
title: "Reports",
icon: BarChart,
href: "/reports",
},
{
title: "Finance",
icon: CreditCard,
href: "/finance",
},
{
title: "Settings",
icon: Settings,
href: "/settings",
},
],
teacher: [
{
title: "Dashboard",
icon: LayoutDashboard,
href: "/dashboard",
},
{
title: "Textbooks",
icon: Library,
href: "/teacher/textbooks",
},
{
title: "Exams",
icon: FileQuestion,
href: "/teacher/exams",
items: [
{ title: "All Exams", href: "/teacher/exams/all" },
{ title: "Create Exam", href: "/teacher/exams/create" },
{ title: "Grading", href: "/teacher/exams/grading" },
]
},
{
title: "Homework",
icon: PenTool,
href: "/teacher/homework",
items: [
{ title: "Assignments", href: "/teacher/homework/assignments" },
{ title: "Submissions", href: "/teacher/homework/submissions" },
]
},
{
title: "Question Bank",
icon: ClipboardList,
href: "/teacher/questions",
},
{
title: "Class Management",
icon: Users,
href: "/teacher/classes",
items: [
{ title: "My Classes", href: "/teacher/classes/my" },
{ title: "Students", href: "/teacher/classes/students" },
{ title: "Schedule", href: "/teacher/classes/schedule" },
]
},
],
student: [
{
title: "Dashboard",
icon: LayoutDashboard,
href: "/dashboard",
},
{
title: "My Learning",
icon: BookOpen,
href: "/student/learning",
items: [
{ title: "Courses", href: "/student/learning/courses" },
{ title: "Assignments", href: "/student/learning/assignments" },
{ title: "Grades", href: "/student/learning/grades" },
]
},
{
title: "Schedule",
icon: Calendar,
href: "/student/schedule",
},
{
title: "Resources",
icon: FileText,
href: "/student/resources",
},
],
parent: [
{
title: "Dashboard",
icon: LayoutDashboard,
href: "/parent/dashboard",
},
{
title: "Children",
icon: Users,
href: "/parent/children",
},
{
title: "Tuition",
icon: CreditCard,
href: "/parent/tuition",
},
{
title: "Messages",
icon: MessageSquare,
href: "/messages",
},
]
}

View File

@@ -0,0 +1,140 @@
"use server";
import { db } from "@/shared/db";
import { questions, questionsToKnowledgePoints } from "@/shared/db/schema";
import { CreateQuestionInput, CreateQuestionSchema } from "./schema";
import { ActionState } from "@/shared/types/action-state";
import { revalidatePath } from "next/cache";
import { ZodError } from "zod";
import { eq } from "drizzle-orm";
import { createId } from "@paralleldrive/cuid2";
// --- Mock Auth Helper (Replace with actual Auth.js call) ---
async function getCurrentUser() {
// In production: const session = await auth(); return session?.user;
// Mocking a teacher user for this demonstration
return {
id: "user_teacher_123",
role: "teacher", // or "admin"
};
}
async function ensureTeacher() {
const user = await getCurrentUser();
if (!user || (user.role !== "teacher" && user.role !== "admin")) {
throw new Error("Unauthorized: Only teachers can perform this action.");
}
return user;
}
// --- Recursive Insert Helper ---
// We pass 'tx' to ensure all operations run within the same transaction
async function insertQuestionWithRelations(
tx: any, // using any or strict Drizzle Transaction type if imported
input: CreateQuestionInput,
authorId: string,
parentId: string | null = null
) {
// We generate ID explicitly here.
const newQuestionId = createId();
await tx.insert(questions).values({
id: newQuestionId,
content: input.content,
type: input.type,
difficulty: input.difficulty,
authorId: authorId,
parentId: parentId,
});
// 2. Link Knowledge Points
if (input.knowledgePointIds && input.knowledgePointIds.length > 0) {
await tx.insert(questionsToKnowledgePoints).values(
input.knowledgePointIds.map((kpId) => ({
questionId: newQuestionId,
knowledgePointId: kpId,
}))
);
}
// 3. Handle Sub-Questions (Recursion)
if (input.subQuestions && input.subQuestions.length > 0) {
for (const subQ of input.subQuestions) {
await insertQuestionWithRelations(tx, subQ, authorId, newQuestionId);
}
}
return newQuestionId;
}
// --- Main Server Action ---
export async function createNestedQuestion(
prevState: ActionState<string> | undefined,
formData: FormData | CreateQuestionInput // Support both FormData and JSON input
): Promise<ActionState<string>> {
try {
// 1. Auth Check
const user = await ensureTeacher();
// 2. Parse Input
// If formData is actual FormData, we need to convert it.
// For complex nested structures, frontend usually sends JSON string or pure JSON object if using `useServerAction` with arguments.
// Here we assume the client might send a raw object (if using direct function call) or we parse FormData.
let rawInput: any = formData;
if (formData instanceof FormData) {
// Parsing complex nested JSON from FormData is messy.
// We assume one field 'data' contains the JSON, or we expect direct object usage (common in modern Next.js RPC).
const jsonString = formData.get("json");
if (typeof jsonString === "string") {
rawInput = JSON.parse(jsonString);
} else {
return { success: false, message: "Invalid submission format. Expected JSON." };
}
}
const validatedFields = CreateQuestionSchema.safeParse(rawInput);
if (!validatedFields.success) {
return {
success: false,
message: "Validation failed",
errors: validatedFields.error.flatten().fieldErrors,
};
}
const input = validatedFields.data;
// 3. Database Transaction
await db.transaction(async (tx) => {
await insertQuestionWithRelations(tx, input, user.id, null);
});
// 4. Revalidate Cache
revalidatePath("/questions");
return {
success: true,
message: "Question created successfully",
};
} catch (error) {
console.error("Failed to create question:", error);
// Drizzle/DB Error Handling (Generic)
if (error instanceof Error) {
// Check for specific DB errors (constraints, etc.)
// e.g., if (error.message.includes("Duplicate entry")) ...
return {
success: false,
message: error.message || "Database error occurred",
};
}
return {
success: false,
message: "An unexpected error occurred",
};
}
}

View File

@@ -0,0 +1,20 @@
"use client"
import { useState } from "react"
import { Plus } from "lucide-react"
import { Button } from "@/shared/components/ui/button"
import { CreateQuestionDialog } from "./create-question-dialog"
export function CreateQuestionButton() {
const [open, setOpen] = useState(false)
return (
<>
<Button onClick={() => setOpen(true)}>
<Plus className="mr-2 h-4 w-4" />
Add Question
</Button>
<CreateQuestionDialog open={open} onOpenChange={setOpen} />
</>
)
}

View File

@@ -0,0 +1,286 @@
"use client"
import { useState, useEffect } from "react"
import { useForm, type SubmitHandler } from "react-hook-form"
import { zodResolver } from "@hookform/resolvers/zod"
import { z } from "zod"
import { Plus, Trash2, GripVertical } from "lucide-react"
import { Button } from "@/shared/components/ui/button"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/shared/components/ui/dialog"
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/shared/components/ui/form"
import { Input } from "@/shared/components/ui/input"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/shared/components/ui/select"
import { Textarea } from "@/shared/components/ui/textarea"
import { BaseQuestionSchema } from "../schema"
import { createNestedQuestion } from "../actions"
import { toast } from "sonner"
import { Question } from "../types"
// Extend schema for form usage (e.g. handling options for choice questions)
const QuestionFormSchema = BaseQuestionSchema.extend({
difficulty: z.number().min(1).max(5),
content: z.string().min(1, "Question content is required"),
options: z.array(z.object({
label: z.string(),
value: z.string(),
isCorrect: z.boolean().default(false)
})).optional(),
})
type QuestionFormValues = z.input<typeof QuestionFormSchema>
interface CreateQuestionDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
initialData?: Question | null
}
export function CreateQuestionDialog({ open, onOpenChange, initialData }: CreateQuestionDialogProps) {
const [isPending, setIsPending] = useState(false)
const isEdit = !!initialData
const form = useForm<QuestionFormValues>({
resolver: zodResolver(QuestionFormSchema),
defaultValues: {
type: initialData?.type || "single_choice",
difficulty: initialData?.difficulty || 1,
content: (typeof initialData?.content === 'string' ? initialData.content : "") || "",
options: [
{ label: "Option A", value: "A", isCorrect: true },
{ label: "Option B", value: "B", isCorrect: false },
],
},
})
// Reset form when initialData changes
useEffect(() => {
if (initialData) {
form.reset({
type: initialData.type,
difficulty: initialData.difficulty,
content: typeof initialData.content === 'string' ? initialData.content : JSON.stringify(initialData.content),
options: [
{ label: "Option A", value: "A", isCorrect: true },
{ label: "Option B", value: "B", isCorrect: false },
]
})
} else {
form.reset({
type: "single_choice",
difficulty: 1,
content: "",
options: [
{ label: "Option A", value: "A", isCorrect: true },
{ label: "Option B", value: "B", isCorrect: false },
]
})
}
}, [initialData, form])
const questionType = form.watch("type")
const onSubmit: SubmitHandler<QuestionFormValues> = async (data) => {
setIsPending(true)
try {
const payload = {
type: data.type,
difficulty: data.difficulty,
content: data.content,
knowledgePointIds: [],
}
const fd = new FormData()
fd.set("json", JSON.stringify(payload))
const res = await createNestedQuestion(undefined, fd)
if (res.success) {
toast.success(isEdit ? "Updated question" : "Created question")
onOpenChange(false)
if (!isEdit) {
form.reset()
}
} else {
toast.error(res.message || "Operation failed")
}
} catch (error) {
toast.error("Unexpected error")
} finally {
setIsPending(false)
}
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[600px] max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>{isEdit ? "Edit Question" : "Create New Question"}</DialogTitle>
<DialogDescription>
{isEdit ? "Update question details." : "Add a new question to the bank. Fill in the details below."}
</DialogDescription>
</DialogHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
<div className="grid grid-cols-2 gap-4">
<FormField
control={form.control}
name="type"
render={({ field }) => (
<FormItem>
<FormLabel>Question Type</FormLabel>
<Select onValueChange={field.onChange} defaultValue={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select type" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="single_choice">Single Choice</SelectItem>
<SelectItem value="multiple_choice">Multiple Choice</SelectItem>
<SelectItem value="judgment">True/False</SelectItem>
<SelectItem value="text">Short Answer</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="difficulty"
render={({ field }) => (
<FormItem>
<FormLabel>Difficulty (1-5)</FormLabel>
<Select
onValueChange={(val) => field.onChange(parseInt(val))}
defaultValue={String(field.value)}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select difficulty" />
</SelectTrigger>
</FormControl>
<SelectContent>
{[1, 2, 3, 4, 5].map((level) => (
<SelectItem key={level} value={String(level)}>
{level} - {level === 1 ? "Easy" : level === 5 ? "Hard" : "Medium"}
</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
</div>
<FormField
control={form.control}
name="content"
render={({ field }) => (
<FormItem>
<FormLabel>Question Content</FormLabel>
<FormControl>
<Textarea
placeholder="Enter the question text here..."
className="min-h-[100px]"
{...field}
/>
</FormControl>
<FormDescription>
Supports basic text. Rich text editor coming soon.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
{(questionType === "single_choice" || questionType === "multiple_choice") && (
<div className="space-y-4">
<div className="flex items-center justify-between">
<FormLabel>Options</FormLabel>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => {
const currentOptions = form.getValues("options") || [];
form.setValue("options", [
...currentOptions,
{ label: `Option ${String.fromCharCode(65 + currentOptions.length)}`, value: String.fromCharCode(65 + currentOptions.length), isCorrect: false }
]);
}}
>
<Plus className="mr-2 h-3 w-3" /> Add Option
</Button>
</div>
<div className="space-y-2">
{form.watch("options")?.map((option, index) => (
<div key={index} className="flex items-center gap-2">
<div className="flex h-8 w-8 items-center justify-center text-muted-foreground">
<GripVertical className="h-4 w-4" />
</div>
<Input
value={option.label}
onChange={(e) => {
const newOptions = [...(form.getValues("options") || [])];
newOptions[index].label = e.target.value;
form.setValue("options", newOptions);
}}
placeholder={`Option ${index + 1}`}
/>
<Button
type="button"
variant="ghost"
size="icon"
className="text-destructive hover:text-destructive/90"
onClick={() => {
const newOptions = [...(form.getValues("options") || [])];
newOptions.splice(index, 1);
form.setValue("options", newOptions);
}}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
))}
</div>
</div>
)}
<DialogFooter>
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button type="submit" disabled={isPending}>
{isPending ? "Creating..." : "Create Question"}
</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
</Dialog>
)
}

View File

@@ -0,0 +1,178 @@
"use client"
import { useState } from "react"
import { MoreHorizontal, Pencil, Trash, Eye, Copy } from "lucide-react"
import { Button } from "@/shared/components/ui/button"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/shared/components/ui/dropdown-menu"
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/shared/components/ui/alert-dialog"
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/shared/components/ui/dialog"
import { Question } from "../types"
import { CreateQuestionDialog } from "./create-question-dialog"
import { toast } from "sonner"
interface QuestionActionsProps {
question: Question
}
export function QuestionActions({ question }: QuestionActionsProps) {
const [showEditDialog, setShowEditDialog] = useState(false)
const [showDeleteDialog, setShowDeleteDialog] = useState(false)
const [showViewDialog, setShowViewDialog] = useState(false)
const [isDeleting, setIsDeleting] = useState(false)
const copyId = () => {
navigator.clipboard.writeText(question.id)
toast.success("Question ID copied to clipboard")
}
const handleDelete = async () => {
setIsDeleting(true)
try {
// Simulate API call
console.log("Deleting question:", question.id)
await new Promise(resolve => setTimeout(resolve, 1000))
toast.success("Question deleted successfully")
setShowDeleteDialog(false)
} catch (error) {
console.error(error)
toast.error("Failed to delete question")
} finally {
setIsDeleting(false)
}
}
return (
<>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="h-8 w-8 p-0">
<span className="sr-only">Open menu</span>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>Actions</DropdownMenuLabel>
<DropdownMenuItem onClick={copyId}>
<Copy className="mr-2 h-4 w-4" /> Copy ID
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => setShowViewDialog(true)}>
<Eye className="mr-2 h-4 w-4" /> View Details
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setShowEditDialog(true)}>
<Pencil className="mr-2 h-4 w-4" /> Edit
</DropdownMenuItem>
<DropdownMenuItem
className="text-destructive focus:text-destructive"
onClick={() => setShowDeleteDialog(true)}
>
<Trash className="mr-2 h-4 w-4" /> Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
{/* Edit Dialog */}
<CreateQuestionDialog
open={showEditDialog}
onOpenChange={setShowEditDialog}
initialData={question}
/>
{/* Delete Confirmation Dialog */}
<AlertDialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Are you absolutely sure?</AlertDialogTitle>
<AlertDialogDescription>
This action cannot be undone. This will permanently delete the question
and remove it from our servers.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={(e) => {
e.preventDefault()
handleDelete()
}}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
disabled={isDeleting}
>
{isDeleting ? "Deleting..." : "Delete"}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{/* View Details Dialog (Simple Read-only View) */}
<Dialog open={showViewDialog} onOpenChange={setShowViewDialog}>
<DialogContent>
<DialogHeader>
<DialogTitle>Question Details</DialogTitle>
<DialogDescription>ID: {question.id}</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="grid grid-cols-4 items-center gap-4">
<span className="font-medium">Type:</span>
<span className="col-span-3 capitalize">{question.type.replace('_', ' ')}</span>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<span className="font-medium">Difficulty:</span>
<span className="col-span-3">{question.difficulty}</span>
</div>
<div className="grid grid-cols-4 items-start gap-4">
<span className="font-medium pt-1">Content:</span>
<div className="col-span-3 rounded-md bg-muted p-2 text-sm">
{typeof question.content === 'string' ? question.content : JSON.stringify(question.content, null, 2)}
</div>
</div>
{/* Show Author if exists */}
{question.author && (
<div className="grid grid-cols-4 items-center gap-4">
<span className="font-medium">Author:</span>
<span className="col-span-3">{question.author.name || "Unknown"}</span>
</div>
)}
{/* Show Knowledge Points */}
{question.knowledgePoints && question.knowledgePoints.length > 0 && (
<div className="grid grid-cols-4 items-center gap-4">
<span className="font-medium">Tags:</span>
<div className="col-span-3 flex flex-wrap gap-1">
{question.knowledgePoints.map(kp => (
<span key={kp.id} className="rounded-full bg-secondary px-2 py-0.5 text-xs text-secondary-foreground">
{kp.name}
</span>
))}
</div>
</div>
)}
</div>
</DialogContent>
</Dialog>
</>
)
}

View File

@@ -0,0 +1,144 @@
"use client"
import { ColumnDef } from "@tanstack/react-table"
import { Badge } from "@/shared/components/ui/badge"
import { Checkbox } from "@/shared/components/ui/checkbox"
import { Question, QuestionType } from "../types"
import { cn } from "@/shared/lib/utils"
import { QuestionActions } from "./question-actions"
// Helper for Type Colors
const getTypeColor = (type: QuestionType) => {
switch (type) {
case "single_choice":
return "default"; // Primary
case "multiple_choice":
return "secondary";
case "judgment":
return "outline";
case "text":
return "secondary"; // Changed from 'accent' which might not be a valid badge variant key in standard shadcn, usually it's default, secondary, destructive, outline.
default:
return "secondary";
}
}
const getTypeLabel = (type: QuestionType) => {
switch (type) {
case "single_choice": return "Single Choice";
case "multiple_choice": return "Multiple Choice";
case "judgment": return "True/False";
case "text": return "Short Answer";
case "composite": return "Composite";
default: return type;
}
}
export const columns: ColumnDef<Question>[] = [
{
id: "select",
header: ({ table }) => (
<Checkbox
checked={table.getIsAllPageRowsSelected()}
onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
aria-label="Select all"
/>
),
cell: ({ row }) => (
<Checkbox
checked={row.getIsSelected()}
onCheckedChange={(value) => row.toggleSelected(!!value)}
aria-label="Select row"
/>
),
enableSorting: false,
enableHiding: false,
},
{
accessorKey: "type",
header: "Type",
cell: ({ row }) => {
const type = row.getValue("type") as QuestionType
return (
<Badge variant={getTypeColor(type)} className="whitespace-nowrap">
{getTypeLabel(type)}
</Badge>
)
},
},
{
accessorKey: "content",
header: "Content",
cell: ({ row }) => {
const content = row.getValue("content");
let preview = "";
if (typeof content === 'string') {
preview = content;
} else if (content && typeof content === 'object') {
preview = JSON.stringify(content).slice(0, 50);
}
return (
<div className="max-w-[400px] truncate font-medium" title={preview}>
{preview}
</div>
)
},
},
{
accessorKey: "difficulty",
header: "Difficulty",
cell: ({ row }) => {
const diff = row.getValue("difficulty") as number;
// 1-5 scale
return (
<div className="flex items-center">
<span className={cn("font-medium",
diff <= 2 ? "text-green-600" :
diff === 3 ? "text-yellow-600" : "text-red-600"
)}>
{diff === 1 ? "Easy" : diff === 2 ? "Easy-Med" : diff === 3 ? "Medium" : diff === 4 ? "Med-Hard" : "Hard"}
</span>
<span className="ml-1 text-xs text-muted-foreground">({diff})</span>
</div>
)
},
},
{
accessorKey: "knowledgePoints",
header: "Knowledge Points",
cell: ({ row }) => {
const kps = row.original.knowledgePoints;
if (!kps || kps.length === 0) return <span className="text-muted-foreground">-</span>;
return (
<div className="flex flex-wrap gap-1">
{kps.slice(0, 2).map(kp => (
<Badge key={kp.id} variant="outline" className="text-xs">
{kp.name}
</Badge>
))}
{kps.length > 2 && (
<Badge variant="outline" className="text-xs">+{kps.length - 2}</Badge>
)}
</div>
)
}
},
{
accessorKey: "createdAt",
header: "Created",
cell: ({ row }) => {
return (
<span className="text-muted-foreground text-xs whitespace-nowrap">
{new Date(row.getValue("createdAt")).toLocaleDateString()}
</span>
)
},
},
{
id: "actions",
cell: ({ row }) => <QuestionActions question={row.original} />,
},
]

View File

@@ -0,0 +1,134 @@
"use client"
import * as React from "react"
import {
ColumnDef,
flexRender,
getCoreRowModel,
useReactTable,
getPaginationRowModel,
SortingState,
getSortedRowModel,
getFilteredRowModel,
RowSelectionState,
} from "@tanstack/react-table"
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/shared/components/ui/table"
import { Button } from "@/shared/components/ui/button"
import { ChevronLeft, ChevronRight } from "lucide-react"
interface DataTableProps<TData, TValue> {
columns: ColumnDef<TData, TValue>[]
data: TData[]
}
export function QuestionDataTable<TData, TValue>({
columns,
data,
}: DataTableProps<TData, TValue>) {
const [sorting, setSorting] = React.useState<SortingState>([])
const [rowSelection, setRowSelection] = React.useState<RowSelectionState>({})
const table = useReactTable({
data,
columns,
getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: getPaginationRowModel(),
onSortingChange: setSorting,
getSortedRowModel: getSortedRowModel(),
onRowSelectionChange: setRowSelection,
getFilteredRowModel: getFilteredRowModel(),
state: {
sorting,
rowSelection,
},
})
return (
<div className="space-y-4">
<div className="rounded-md border">
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => {
return (
<TableHead key={header.id}>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext()
)}
</TableHead>
)
})}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows?.length ? (
table.getRowModel().rows.map((row) => (
<TableRow
key={row.id}
data-state={row.getIsSelected() && "selected"}
>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>
{flexRender(
cell.column.columnDef.cell,
cell.getContext()
)}
</TableCell>
))}
</TableRow>
))
) : (
<TableRow>
<TableCell
colSpan={columns.length}
className="h-24 text-center"
>
No results.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
<div className="flex items-center justify-end space-x-2 py-4">
<div className="flex-1 text-sm text-muted-foreground">
{table.getFilteredSelectedRowModel().rows.length} of{" "}
{table.getFilteredRowModel().rows.length} row(s) selected.
</div>
<div className="space-x-2">
<Button
variant="outline"
size="sm"
onClick={() => table.previousPage()}
disabled={!table.getCanPreviousPage()}
>
<ChevronLeft className="h-4 w-4" />
Previous
</Button>
<Button
variant="outline"
size="sm"
onClick={() => table.nextPage()}
disabled={!table.getCanNextPage()}
>
Next
<ChevronRight className="h-4 w-4" />
</Button>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,80 @@
"use client"
import { useQueryState, parseAsString } from "nuqs"
import { Search, X } from "lucide-react"
import { Input } from "@/shared/components/ui/input"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/shared/components/ui/select"
import { Button } from "@/shared/components/ui/button"
export function QuestionFilters() {
const [search, setSearch] = useQueryState("q", parseAsString.withDefault(""))
const [type, setType] = useQueryState("type", parseAsString.withDefault("all"))
const [difficulty, setDifficulty] = useQueryState("difficulty", parseAsString.withDefault("all"))
// Debounce could be added here for search, but for simplicity we rely on 'Enter' or blur or just let it update (nuqs handles some updates well, but for text input usually we want debounce or on change).
// Actually nuqs with shallow: false (default) triggers server re-render.
// For text input, it's better to use local state and update URL on debounce or enter.
return (
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
<div className="flex flex-1 items-center gap-2">
<div className="relative flex-1 md:max-w-sm">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search questions..."
className="pl-8"
value={search}
onChange={(e) => setSearch(e.target.value || null)}
/>
</div>
<Select value={type} onValueChange={(val) => setType(val === "all" ? null : val)}>
<SelectTrigger className="w-[150px]">
<SelectValue placeholder="Type" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Types</SelectItem>
<SelectItem value="single_choice">Single Choice</SelectItem>
<SelectItem value="multiple_choice">Multiple Choice</SelectItem>
<SelectItem value="judgment">True/False</SelectItem>
<SelectItem value="text">Short Answer</SelectItem>
</SelectContent>
</Select>
<Select value={difficulty} onValueChange={(val) => setDifficulty(val === "all" ? null : val)}>
<SelectTrigger className="w-[150px]">
<SelectValue placeholder="Difficulty" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Any Difficulty</SelectItem>
<SelectItem value="1">Easy (1)</SelectItem>
<SelectItem value="2">Easy-Med (2)</SelectItem>
<SelectItem value="3">Medium (3)</SelectItem>
<SelectItem value="4">Med-Hard (4)</SelectItem>
<SelectItem value="5">Hard (5)</SelectItem>
</SelectContent>
</Select>
{(search || type !== "all" || difficulty !== "all") && (
<Button
variant="ghost"
onClick={() => {
setSearch(null)
setType(null)
setDifficulty(null)
}}
className="h-8 px-2 lg:px-3"
>
Reset
<X className="ml-2 h-4 w-4" />
</Button>
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,93 @@
import 'server-only';
import { db } from "@/shared/db";
import { questions, questionsToKnowledgePoints } from "@/shared/db/schema";
import { and, eq, inArray, count, desc, sql } from "drizzle-orm";
import { cache } from "react";
// Types for filters
export type GetQuestionsParams = {
page?: number;
pageSize?: number;
knowledgePointId?: string;
difficulty?: number;
};
// Cached Data Access Function
// Using React's cache() to deduplicate requests if called multiple times in one render pass
export const getQuestions = cache(async ({
page = 1,
pageSize = 10,
knowledgePointId,
difficulty,
}: GetQuestionsParams = {}) => {
const offset = (page - 1) * pageSize;
// Build Where Conditions
const conditions = [];
if (difficulty) {
conditions.push(eq(questions.difficulty, difficulty));
}
// Filter by Knowledge Point (using subquery pattern for Many-to-Many)
if (knowledgePointId) {
const subQuery = db
.select({ questionId: questionsToKnowledgePoints.questionId })
.from(questionsToKnowledgePoints)
.where(eq(questionsToKnowledgePoints.knowledgePointId, knowledgePointId));
conditions.push(inArray(questions.id, subQuery));
}
// Only fetch top-level questions (parent questions)
// Assuming we only want to list "root" questions, not sub-questions
conditions.push(sql`${questions.parentId} IS NULL`);
const whereClause = conditions.length > 0 ? and(...conditions) : undefined;
// 1. Get Total Count (for Pagination)
// Optimization: separate count query is often faster than fetching all data
const [totalResult] = await db
.select({ count: count() })
.from(questions)
.where(whereClause);
const total = totalResult?.count ?? 0;
// 2. Get Data with Relations
const data = await db.query.questions.findMany({
where: whereClause,
limit: pageSize,
offset: offset,
orderBy: [desc(questions.createdAt)],
with: {
// Preload Knowledge Points
questionsToKnowledgePoints: {
with: {
knowledgePoint: true,
},
},
// Preload Author
author: {
columns: {
id: true,
name: true,
image: true,
},
},
// Preload Child Questions (first level)
children: true,
},
});
return {
data,
meta: {
page,
pageSize,
total,
totalPages: Math.ceil(total / pageSize),
},
};
});

View File

@@ -0,0 +1,124 @@
import { Question } from "./types";
export const MOCK_QUESTIONS: Question[] = [
{
id: "q-001",
content: "What is the capital of France?",
type: "single_choice",
difficulty: 1,
createdAt: new Date("2023-11-01"),
updatedAt: new Date("2023-11-01"),
author: { id: "u-1", name: "Alice Teacher", image: null },
knowledgePoints: [{ id: "kp-1", name: "Geography" }, { id: "kp-2", name: "Europe" }],
},
{
id: "q-002",
content: "Explain the theory of relativity in simple terms.",
type: "text",
difficulty: 5,
createdAt: new Date("2023-11-02"),
updatedAt: new Date("2023-11-02"),
author: { id: "u-2", name: "Bob Physicist", image: null },
knowledgePoints: [{ id: "kp-3", name: "Physics" }],
},
{
id: "q-003",
content: "True or False: The earth is flat.",
type: "judgment",
difficulty: 1,
createdAt: new Date("2023-11-03"),
updatedAt: new Date("2023-11-03"),
author: { id: "u-1", name: "Alice Teacher", image: null },
knowledgePoints: [{ id: "kp-1", name: "Geography" }],
},
{
id: "q-004",
content: "Select all prime numbers below 10.",
type: "multiple_choice",
difficulty: 2,
createdAt: new Date("2023-11-04"),
updatedAt: new Date("2023-11-04"),
author: { id: "u-3", name: "Charlie Math", image: null },
knowledgePoints: [{ id: "kp-4", name: "Math" }],
},
{
id: "q-005",
content: "Write a function to reverse a string in JavaScript.",
type: "text",
difficulty: 3,
createdAt: new Date("2023-11-05"),
updatedAt: new Date("2023-11-05"),
author: { id: "u-4", name: "Dave Coder", image: null },
knowledgePoints: [{ id: "kp-5", name: "Programming" }, { id: "kp-6", name: "JavaScript" }],
},
{
id: "q-006",
content: "Which of the following are fruits? (Apple, Carrot, Banana, Potato)",
type: "multiple_choice",
difficulty: 1,
createdAt: new Date("2023-11-06"),
updatedAt: new Date("2023-11-06"),
author: { id: "u-1", name: "Alice Teacher", image: null },
knowledgePoints: [{ id: "kp-7", name: "Biology" }],
},
{
id: "q-007",
content: "Water boils at 100 degrees Celsius at sea level.",
type: "judgment",
difficulty: 2,
createdAt: new Date("2023-11-07"),
updatedAt: new Date("2023-11-07"),
author: { id: "u-2", name: "Bob Physicist", image: null },
knowledgePoints: [{ id: "kp-3", name: "Physics" }],
},
{
id: "q-008",
content: "What is the powerhouse of the cell?",
type: "single_choice",
difficulty: 2,
createdAt: new Date("2023-11-08"),
updatedAt: new Date("2023-11-08"),
author: { id: "u-5", name: "Eve Biologist", image: null },
knowledgePoints: [{ id: "kp-7", name: "Biology" }],
},
{
id: "q-009",
content: "Solve for x: 2x + 5 = 15",
type: "single_choice",
difficulty: 2,
createdAt: new Date("2023-11-09"),
updatedAt: new Date("2023-11-09"),
author: { id: "u-3", name: "Charlie Math", image: null },
knowledgePoints: [{ id: "kp-4", name: "Math" }],
},
{
id: "q-010",
content: "Describe the impact of the Industrial Revolution.",
type: "text",
difficulty: 4,
createdAt: new Date("2023-11-10"),
updatedAt: new Date("2023-11-10"),
author: { id: "u-6", name: "Frank Historian", image: null },
knowledgePoints: [{ id: "kp-8", name: "History" }],
},
{
id: "q-011",
content: "Light travels faster than sound.",
type: "judgment",
difficulty: 1,
createdAt: new Date("2023-11-11"),
updatedAt: new Date("2023-11-11"),
author: { id: "u-2", name: "Bob Physicist", image: null },
knowledgePoints: [{ id: "kp-3", name: "Physics" }],
},
{
id: "q-012",
content: "Which element has the chemical symbol 'O'?",
type: "single_choice",
difficulty: 1,
createdAt: new Date("2023-11-12"),
updatedAt: new Date("2023-11-12"),
author: { id: "u-7", name: "Grace Chemist", image: null },
knowledgePoints: [{ id: "kp-9", name: "Chemistry" }],
},
];

View File

@@ -0,0 +1,21 @@
import { z } from "zod";
// Enum for Question Types matching DB
export const QuestionTypeEnum = z.enum(["single_choice", "multiple_choice", "text", "judgment", "composite"]);
// Base Question Schema
export const BaseQuestionSchema = z.object({
content: z.any().describe("JSON content for the question (e.g. Slate nodes)"), // Using any for JSON flexibility, strict schemas can be applied if structure is known
type: QuestionTypeEnum,
difficulty: z.number().min(1).max(5).default(1),
knowledgePointIds: z.array(z.string()).optional(),
});
// Recursive Schema for Nested Questions (e.g. Composite -> Sub Questions)
export type CreateQuestionInput = z.infer<typeof BaseQuestionSchema> & {
subQuestions?: CreateQuestionInput[];
};
export const CreateQuestionSchema: z.ZodType<CreateQuestionInput> = BaseQuestionSchema.extend({
subQuestions: z.lazy(() => CreateQuestionSchema.array().optional()),
});

View File

@@ -0,0 +1,27 @@
import { z } from "zod";
import { QuestionTypeEnum } from "./schema";
// Infer types from Zod Schema
export type QuestionType = z.infer<typeof QuestionTypeEnum>;
// UI Model for Question (matching the structure returned by data-access or mock)
export interface Question {
id: string;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
content: any; // Rich text content
type: QuestionType;
difficulty: number;
createdAt: Date;
updatedAt: Date;
author: {
id: string;
name: string | null;
image: string | null;
} | null;
knowledgePoints: {
id: string;
name: string;
}[];
// For UI display
childrenCount?: number;
}

View File

@@ -0,0 +1,196 @@
"use server";
import { revalidatePath } from "next/cache";
import {
createTextbook,
createChapter,
updateChapterContent,
deleteChapter,
createKnowledgePoint,
deleteKnowledgePoint,
updateTextbook,
deleteTextbook
} from "./data-access";
import { CreateTextbookInput, UpdateTextbookInput } from "./types";
export type ActionState = {
success: boolean;
message?: string;
errors?: Record<string, string[]>;
};
// ... existing createTextbookAction ...
export async function createTextbookAction(
prevState: ActionState | null,
formData: FormData
): Promise<ActionState> {
// ... implementation same as before
const rawData: CreateTextbookInput = {
title: formData.get("title") as string,
subject: formData.get("subject") as string,
grade: formData.get("grade") as string,
publisher: formData.get("publisher") as string,
};
if (!rawData.title || !rawData.subject || !rawData.grade) {
return {
success: false,
message: "Please fill in all required fields.",
};
}
try {
await createTextbook(rawData);
revalidatePath("/teacher/textbooks");
return {
success: true,
message: "Textbook created successfully.",
};
} catch (error) {
console.error("Failed to create textbook:", error);
return {
success: false,
message: "Failed to create textbook.",
};
}
}
export async function updateTextbookAction(
textbookId: string,
prevState: ActionState | null,
formData: FormData
): Promise<ActionState> {
const rawData: UpdateTextbookInput = {
id: textbookId,
title: formData.get("title") as string,
subject: formData.get("subject") as string,
grade: formData.get("grade") as string,
publisher: formData.get("publisher") as string,
};
if (!rawData.title || !rawData.subject || !rawData.grade) {
return {
success: false,
message: "Please fill in all required fields.",
};
}
try {
await updateTextbook(rawData);
revalidatePath(`/teacher/textbooks/${textbookId}`);
return {
success: true,
message: "Textbook updated successfully.",
};
} catch (error) {
console.error("Failed to update textbook:", error);
return {
success: false,
message: "Failed to update textbook.",
};
}
}
export async function deleteTextbookAction(
textbookId: string
): Promise<ActionState> {
try {
await deleteTextbook(textbookId);
revalidatePath("/teacher/textbooks");
return {
success: true,
message: "Textbook deleted successfully.",
};
} catch (error) {
console.error("Failed to delete textbook:", error);
return {
success: false,
message: "Failed to delete textbook.",
};
}
}
export async function createChapterAction(
textbookId: string,
parentId: string | undefined,
prevState: ActionState | null,
formData: FormData
): Promise<ActionState> {
const title = formData.get("title") as string;
if (!title) return { success: false, message: "Title is required" };
try {
await createChapter({
textbookId,
title,
parentId,
order: 0 // Default order
});
revalidatePath(`/teacher/textbooks/${textbookId}`);
return { success: true, message: "Chapter created successfully" };
} catch (error) {
return { success: false, message: "Failed to create chapter" };
}
}
export async function updateChapterContentAction(
chapterId: string,
content: string,
textbookId: string
): Promise<ActionState> {
try {
await updateChapterContent({ chapterId, content });
revalidatePath(`/teacher/textbooks/${textbookId}`);
return { success: true, message: "Content updated successfully" };
} catch (error) {
return { success: false, message: "Failed to update content" };
}
}
export async function deleteChapterAction(
chapterId: string,
textbookId: string
): Promise<ActionState> {
try {
await deleteChapter(chapterId);
revalidatePath(`/teacher/textbooks/${textbookId}`);
return { success: true, message: "Chapter deleted successfully" };
} catch (error) {
return { success: false, message: "Failed to delete chapter" };
}
}
export async function createKnowledgePointAction(
chapterId: string,
textbookId: string,
prevState: ActionState | null,
formData: FormData
): Promise<ActionState> {
const name = formData.get("name") as string;
const description = formData.get("description") as string;
if (!name) return { success: false, message: "Name is required" };
try {
await createKnowledgePoint({ name, description, chapterId });
revalidatePath(`/teacher/textbooks/${textbookId}`);
return { success: true, message: "Knowledge point created successfully" };
} catch (error) {
return { success: false, message: "Failed to create knowledge point" };
}
}
export async function deleteKnowledgePointAction(
kpId: string,
textbookId: string
): Promise<ActionState> {
try {
await deleteKnowledgePoint(kpId);
revalidatePath(`/teacher/textbooks/${textbookId}`);
return { success: true, message: "Knowledge point deleted successfully" };
} catch (error) {
return { success: false, message: "Failed to delete knowledge point" };
}
}

View File

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

View File

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

View File

@@ -0,0 +1,124 @@
"use client"
import { useState } from "react"
import { ChevronRight, FileText, Folder, MoreHorizontal } from "lucide-react"
import { Chapter } from "../types"
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/shared/components/ui/collapsible"
import { Button } from "@/shared/components/ui/button"
import { cn } from "@/shared/lib/utils"
interface ChapterItemProps {
chapter: Chapter
level?: number
selectedId?: string
onSelect: (chapter: Chapter) => void
}
function ChapterItem({ chapter, level = 0, selectedId, onSelect }: ChapterItemProps) {
const [isOpen, setIsOpen] = useState(false)
const hasChildren = chapter.children && chapter.children.length > 0
const isSelected = chapter.id === selectedId
return (
<div className={cn("w-full", level > 0 && "ml-4 border-l pl-2")}>
<Collapsible open={isOpen} onOpenChange={setIsOpen}>
<div className={cn(
"flex items-center group py-1 rounded-md transition-colors",
isSelected ? "bg-accent text-accent-foreground" : "hover:bg-muted/50"
)}>
{hasChildren ? (
<CollapsibleTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 shrink-0 p-0 hover:bg-muted"
onClick={(e) => e.stopPropagation()} // Prevent selecting parent when toggling
>
<ChevronRight
className={cn(
"h-4 w-4 transition-transform duration-200 text-muted-foreground",
isOpen && "rotate-90"
)}
/>
<span className="sr-only">Toggle</span>
</Button>
</CollapsibleTrigger>
) : (
<div className="w-6 shrink-0" />
)}
<div
className={cn(
"flex flex-1 items-center gap-2 px-2 py-1.5 text-sm cursor-pointer",
level === 0 ? "font-medium" : "text-muted-foreground",
isSelected && "text-accent-foreground font-medium"
)}
onClick={() => onSelect(chapter)}
>
{hasChildren ? (
<Folder className={cn("h-4 w-4", isOpen ? "text-primary" : "text-muted-foreground/70")} />
) : (
<FileText className="h-4 w-4 text-muted-foreground/50" />
)}
<span className="truncate">{chapter.title}</span>
<Button
variant="ghost"
size="icon"
className="ml-auto h-6 w-6 opacity-0 group-hover:opacity-100 transition-opacity"
onClick={(e) => {
e.stopPropagation();
// Dropdown menu logic here
}}
>
<MoreHorizontal className="h-4 w-4 text-muted-foreground" />
</Button>
</div>
</div>
{hasChildren && (
<CollapsibleContent className="overflow-hidden data-[state=closed]:animate-collapsible-up data-[state=open]:animate-collapsible-down">
<div className="pt-1">
{chapter.children!.map((child) => (
<ChapterItem
key={child.id}
chapter={child}
level={level + 1}
selectedId={selectedId}
onSelect={onSelect}
/>
))}
</div>
</CollapsibleContent>
)}
</Collapsible>
</div>
)
}
export function ChapterSidebarList({
chapters,
selectedChapterId,
onSelectChapter
}: {
chapters: Chapter[],
selectedChapterId?: string,
onSelectChapter: (chapter: Chapter) => void
}) {
return (
<div className="space-y-1">
{chapters.map((chapter) => (
<ChapterItem
key={chapter.id}
chapter={chapter}
selectedId={selectedChapterId}
onSelect={onSelectChapter}
/>
))}
</div>
)
}

View File

@@ -0,0 +1,87 @@
"use client"
import { useState } from "react"
import { Plus } from "lucide-react"
import { useFormStatus } from "react-dom"
import { Button } from "@/shared/components/ui/button"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/shared/components/ui/dialog"
import { Input } from "@/shared/components/ui/input"
import { Label } from "@/shared/components/ui/label"
import { createChapterAction } from "../actions"
import { toast } from "sonner"
function SubmitButton() {
const { pending } = useFormStatus()
return (
<Button type="submit" disabled={pending}>
{pending ? "Creating..." : "Create Chapter"}
</Button>
)
}
interface CreateChapterDialogProps {
textbookId: string
parentId?: string
trigger?: React.ReactNode
}
export function CreateChapterDialog({ textbookId, parentId, trigger }: CreateChapterDialogProps) {
const [open, setOpen] = useState(false)
const handleSubmit = async (formData: FormData) => {
const result = await createChapterAction(textbookId, parentId, null, formData)
if (result.success) {
toast.success(result.message)
setOpen(false)
} else {
toast.error(result.message)
}
}
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
{trigger || (
<Button variant="ghost" size="sm" className="h-8 w-8 p-0">
<Plus className="h-4 w-4" />
</Button>
)}
</DialogTrigger>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>Add New Chapter</DialogTitle>
<DialogDescription>
Create a new chapter or section.
</DialogDescription>
</DialogHeader>
<form action={handleSubmit}>
<div className="grid gap-4 py-4">
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="title" className="text-right">
Title
</Label>
<Input
id="title"
name="title"
placeholder="e.g. Chapter 1: Introduction"
className="col-span-3"
required
/>
</div>
</div>
<DialogFooter>
<SubmitButton />
</DialogFooter>
</form>
</DialogContent>
</Dialog>
)
}

View File

@@ -0,0 +1,95 @@
"use client"
import { useState } from "react"
import { Plus } from "lucide-react"
import { useFormStatus } from "react-dom"
import { Button } from "@/shared/components/ui/button"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/shared/components/ui/dialog"
import { Input } from "@/shared/components/ui/input"
import { Label } from "@/shared/components/ui/label"
import { Textarea } from "@/shared/components/ui/textarea"
import { createKnowledgePointAction } from "../actions"
import { toast } from "sonner"
function SubmitButton() {
const { pending } = useFormStatus()
return (
<Button type="submit" disabled={pending}>
{pending ? "Adding..." : "Add Point"}
</Button>
)
}
interface CreateKnowledgePointDialogProps {
chapterId: string
textbookId: string
}
export function CreateKnowledgePointDialog({ chapterId, textbookId }: CreateKnowledgePointDialogProps) {
const [open, setOpen] = useState(false)
const handleSubmit = async (formData: FormData) => {
const result = await createKnowledgePointAction(chapterId, textbookId, null, formData)
if (result.success) {
toast.success(result.message)
setOpen(false)
} else {
toast.error(result.message)
}
}
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button variant="ghost" size="sm" className="h-8 w-8 p-0">
<Plus className="h-4 w-4" />
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>Add Knowledge Point</DialogTitle>
<DialogDescription>
Link a key concept to this chapter.
</DialogDescription>
</DialogHeader>
<form action={handleSubmit}>
<div className="grid gap-4 py-4">
<div className="grid gap-2">
<Label htmlFor="name">
Name
</Label>
<Input
id="name"
name="name"
placeholder="e.g. Pythagorean Theorem"
required
/>
</div>
<div className="grid gap-2">
<Label htmlFor="description">
Description
</Label>
<Textarea
id="description"
name="description"
placeholder="Brief explanation..."
className="h-20"
/>
</div>
</div>
<DialogFooter>
<SubmitButton />
</DialogFooter>
</form>
</DialogContent>
</Dialog>
)
}

View File

@@ -0,0 +1,100 @@
"use client"
import { Card, CardContent } from "@/shared/components/ui/card"
import { Badge } from "@/shared/components/ui/badge"
import { Button } from "@/shared/components/ui/button"
import { Tag, Trash2 } from "lucide-react"
import { KnowledgePoint } from "../types"
import { CreateKnowledgePointDialog } from "./create-knowledge-point-dialog"
import { deleteKnowledgePointAction } from "../actions"
import { toast } from "sonner"
import { ScrollArea } from "@/shared/components/ui/scroll-area"
interface KnowledgePointPanelProps {
knowledgePoints: KnowledgePoint[]
selectedChapterId: string | null
textbookId: string
}
export function KnowledgePointPanel({
knowledgePoints,
selectedChapterId,
textbookId
}: KnowledgePointPanelProps) {
const handleDelete = async (id: string) => {
if (!confirm("Are you sure you want to delete this knowledge point?")) return
const result = await deleteKnowledgePointAction(id, textbookId)
if (result.success) {
toast.success(result.message)
} else {
toast.error(result.message)
}
}
// Filter KPs for the selected chapter
const chapterKPs = selectedChapterId
? knowledgePoints.filter(kp => kp.chapterId === selectedChapterId)
: []
return (
<div className="h-full flex flex-col space-y-4">
<div className="flex items-center justify-between px-2">
<h3 className="font-semibold flex items-center gap-2">
<Tag className="h-4 w-4" />
Knowledge Points
</h3>
{selectedChapterId && (
<CreateKnowledgePointDialog
chapterId={selectedChapterId}
textbookId={textbookId}
/>
)}
</div>
<ScrollArea className="flex-1 -mx-2 px-2">
{selectedChapterId ? (
chapterKPs.length > 0 ? (
<div className="space-y-3">
{chapterKPs.map((kp) => (
<Card key={kp.id} className="relative group">
<CardContent className="p-3">
<div className="flex justify-between items-start gap-2">
<div className="space-y-1">
<div className="font-medium text-sm leading-tight">
{kp.name}
</div>
{kp.description && (
<p className="text-xs text-muted-foreground line-clamp-2">
{kp.description}
</p>
)}
</div>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 opacity-0 group-hover:opacity-100 transition-opacity text-destructive hover:text-destructive hover:bg-destructive/10 -mt-1 -mr-1"
onClick={() => handleDelete(kp.id)}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
</CardContent>
</Card>
))}
</div>
) : (
<div className="text-sm text-muted-foreground text-center py-8 border rounded-md border-dashed bg-muted/30">
No knowledge points linked to this chapter yet.
</div>
)
) : (
<div className="text-sm text-muted-foreground text-center py-8">
Select a chapter to manage its knowledge points.
</div>
)}
</ScrollArea>
</div>
)
}

View File

@@ -0,0 +1,75 @@
import Link from "next/link";
import { GraduationCap, Building2, BookOpen } from "lucide-react";
import {
Card,
CardContent,
CardFooter,
CardHeader,
CardTitle,
} from "@/shared/components/ui/card";
import { Badge } from "@/shared/components/ui/badge";
import { cn } from "@/shared/lib/utils";
import { Textbook } from "../types";
interface TextbookCardProps {
textbook: Textbook;
}
export function TextbookCard({ textbook }: TextbookCardProps) {
return (
<Link href={`/teacher/textbooks/${textbook.id}`} className="block h-full">
<Card
className={cn(
"group h-full overflow-hidden transition-all duration-300 ease-out",
"hover:-translate-y-1 hover:shadow-md hover:border-primary/50"
)}
>
<div className="relative aspect-[4/3] w-full overflow-hidden bg-muted/30 p-6 flex items-center justify-center">
{/* Fallback Cover Visualization */}
<div className="relative z-10 flex h-24 w-20 flex-col items-center justify-center rounded-sm bg-background shadow-sm border transition-transform duration-300 group-hover:scale-110">
<div className="h-full w-full bg-gradient-to-br from-primary/10 to-primary/5 p-2">
<div className="h-1 w-full rounded-full bg-primary/20 mb-1" />
<div className="h-1 w-2/3 rounded-full bg-primary/20" />
</div>
</div>
{/* Decorative Background Pattern */}
<div className="absolute inset-0 bg-grid-black/[0.02] dark:bg-grid-white/[0.02]" />
</div>
<CardHeader className="p-4 pb-2">
<div className="flex items-start justify-between gap-2">
<div className="space-y-1">
<Badge variant="outline" className="w-fit text-[10px] h-5 px-1.5 font-normal border-primary/20 text-primary bg-primary/5">
{textbook.subject}
</Badge>
<CardTitle className="line-clamp-2 text-base leading-tight">
{textbook.title}
</CardTitle>
</div>
</div>
</CardHeader>
<CardContent className="p-4 pt-0 text-sm text-muted-foreground">
<div className="flex flex-col gap-1.5">
<div className="flex items-center gap-2 text-xs">
<GraduationCap className="h-3.5 w-3.5 text-muted-foreground/70" />
<span>{textbook.grade}</span>
</div>
<div className="flex items-center gap-2 text-xs">
<Building2 className="h-3.5 w-3.5 text-muted-foreground/70" />
<span className="line-clamp-1">{textbook.publisher || "Unknown Publisher"}</span>
</div>
</div>
</CardContent>
<CardFooter className="p-4 pt-0 mt-auto">
<div className="flex items-center gap-2 text-xs text-muted-foreground/80 bg-muted/30 px-2 py-1 rounded-md w-full">
<BookOpen className="h-3.5 w-3.5" />
<span>{textbook._count?.chapters || 0} Chapters</span>
</div>
</CardFooter>
</Card>
</Link>
);
}

View File

@@ -0,0 +1,133 @@
"use client"
import { useState } from "react"
import { Chapter, KnowledgePoint } from "../types"
import { ChapterSidebarList } from "./chapter-sidebar-list"
import { KnowledgePointPanel } from "./knowledge-point-panel"
import { ScrollArea } from "@/shared/components/ui/scroll-area"
import { Button } from "@/shared/components/ui/button"
import { Edit2, Save, Plus } from "lucide-react"
import { CreateChapterDialog } from "./create-chapter-dialog"
import { updateChapterContentAction } from "../actions"
import { toast } from "sonner"
import { Textarea } from "@/shared/components/ui/textarea"
interface TextbookContentLayoutProps {
chapters: Chapter[]
knowledgePoints: KnowledgePoint[]
textbookId: string
}
export function TextbookContentLayout({ chapters, knowledgePoints, textbookId }: TextbookContentLayoutProps) {
const [selectedChapter, setSelectedChapter] = useState<Chapter | null>(null)
const [isEditing, setIsEditing] = useState(false)
const [editContent, setEditContent] = useState("")
const [isSaving, setIsSaving] = useState(false)
// Sync edit content when selection changes
const handleSelectChapter = (chapter: Chapter) => {
setSelectedChapter(chapter)
setEditContent(chapter.content || "")
setIsEditing(false)
}
const handleSaveContent = async () => {
if (!selectedChapter) return
setIsSaving(true)
const result = await updateChapterContentAction(selectedChapter.id, editContent, textbookId)
setIsSaving(false)
if (result.success) {
toast.success(result.message)
setIsEditing(false)
// Update local state to reflect change immediately (optimistic-like)
selectedChapter.content = editContent
} else {
toast.error(result.message)
}
}
return (
<div className="grid grid-cols-12 gap-6 h-[calc(100vh-140px)]">
{/* Left Sidebar: TOC (3 cols) */}
<div className="col-span-3 border-r pr-6 flex flex-col h-full">
<div className="flex items-center justify-between mb-4 px-2">
<h3 className="font-semibold">Chapters</h3>
<CreateChapterDialog textbookId={textbookId} />
</div>
<ScrollArea className="flex-1 -mx-2 px-2">
<ChapterSidebarList
chapters={chapters}
selectedChapterId={selectedChapter?.id}
onSelectChapter={handleSelectChapter}
/>
</ScrollArea>
</div>
{/* Middle: Content Viewer/Editor (6 cols) */}
<div className="col-span-6 flex flex-col h-full px-2">
{selectedChapter ? (
<>
<div className="flex items-center justify-between mb-4 pb-2 border-b">
<h2 className="text-xl font-bold tracking-tight">{selectedChapter.title}</h2>
<div className="flex gap-2">
{isEditing ? (
<>
<Button size="sm" variant="ghost" onClick={() => setIsEditing(false)} disabled={isSaving}>
Cancel
</Button>
<Button size="sm" onClick={handleSaveContent} disabled={isSaving}>
<Save className="mr-2 h-4 w-4" />
{isSaving ? "Saving..." : "Save"}
</Button>
</>
) : (
<Button size="sm" variant="outline" onClick={() => setIsEditing(true)}>
<Edit2 className="mr-2 h-4 w-4" />
Edit Content
</Button>
)}
</div>
</div>
<ScrollArea className="flex-1">
<div className="p-4 h-full">
{isEditing ? (
<Textarea
className="min-h-[500px] font-mono text-sm"
value={editContent}
onChange={(e) => setEditContent(e.target.value)}
placeholder="# Write markdown content here..."
/>
) : (
<div className="prose prose-sm dark:prose-invert max-w-none">
{selectedChapter.content ? (
<div className="whitespace-pre-wrap">{selectedChapter.content}</div>
) : (
<div className="text-muted-foreground italic py-8 text-center">
No content available. Click edit to add content.
</div>
)}
</div>
)}
</div>
</ScrollArea>
</>
) : (
<div className="h-full flex items-center justify-center text-muted-foreground">
Select a chapter from the left sidebar to view its content.
</div>
)}
</div>
{/* Right Sidebar: Knowledge Points (3 cols) */}
<div className="col-span-3 border-l pl-6 flex flex-col h-full">
<KnowledgePointPanel
knowledgePoints={knowledgePoints}
selectedChapterId={selectedChapter?.id || null}
textbookId={textbookId}
/>
</div>
</div>
)
}

View File

@@ -0,0 +1,132 @@
"use client"
import { useState } from "react"
import { Plus } from "lucide-react"
import { useFormStatus } from "react-dom"
import { Button } from "@/shared/components/ui/button"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/shared/components/ui/dialog"
import { Input } from "@/shared/components/ui/input"
import { Label } from "@/shared/components/ui/label"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/shared/components/ui/select"
import { createTextbookAction } from "../actions"
import { toast } from "sonner"
function SubmitButton() {
const { pending } = useFormStatus()
return (
<Button type="submit" disabled={pending}>
{pending ? "Saving..." : "Save changes"}
</Button>
)
}
export function TextbookFormDialog() {
const [open, setOpen] = useState(false)
// Using simple form action without useActionState hook for simplicity in this demo environment
// In production with React 19/Next 15, we'd use useActionState
const handleSubmit = async (formData: FormData) => {
const result = await createTextbookAction(null, formData)
if (result.success) {
toast.success(result.message)
setOpen(false)
} else {
toast.error(result.message)
}
}
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button>
<Plus className="mr-2 h-4 w-4" />
Add Textbook
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>Add New Textbook</DialogTitle>
<DialogDescription>
Create a new digital textbook. Click save when you're done.
</DialogDescription>
</DialogHeader>
<form action={handleSubmit}>
<div className="grid gap-4 py-4">
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="title" className="text-right">
Title
</Label>
<Input
id="title"
name="title"
placeholder="e.g. Advanced Calculus"
className="col-span-3"
required
/>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="subject" className="text-right">
Subject
</Label>
<Select name="subject" required>
<SelectTrigger className="col-span-3">
<SelectValue placeholder="Select subject" />
</SelectTrigger>
<SelectContent>
<SelectItem value="Mathematics">Mathematics</SelectItem>
<SelectItem value="Physics">Physics</SelectItem>
<SelectItem value="History">History</SelectItem>
<SelectItem value="English">English</SelectItem>
<SelectItem value="Chemistry">Chemistry</SelectItem>
</SelectContent>
</Select>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="grade" className="text-right">
Grade
</Label>
<Select name="grade" required>
<SelectTrigger className="col-span-3">
<SelectValue placeholder="Select grade" />
</SelectTrigger>
<SelectContent>
<SelectItem value="Grade 10">Grade 10</SelectItem>
<SelectItem value="Grade 11">Grade 11</SelectItem>
<SelectItem value="Grade 12">Grade 12</SelectItem>
</SelectContent>
</Select>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="publisher" className="text-right">
Publisher
</Label>
<Input
id="publisher"
name="publisher"
placeholder="e.g. Next Education"
className="col-span-3"
/>
</div>
</div>
<DialogFooter>
<SubmitButton />
</DialogFooter>
</form>
</DialogContent>
</Dialog>
)
}

View File

@@ -0,0 +1,160 @@
"use client"
import { useState } from "react"
import { Edit, Trash2 } from "lucide-react"
import { useRouter } from "next/navigation"
import { Button } from "@/shared/components/ui/button"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/shared/components/ui/dialog"
import { Input } from "@/shared/components/ui/input"
import { Label } from "@/shared/components/ui/label"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/shared/components/ui/select"
import { updateTextbookAction, deleteTextbookAction } from "../actions"
import { toast } from "sonner"
import { Textbook } from "../types"
interface TextbookSettingsDialogProps {
textbook: Textbook
trigger?: React.ReactNode
}
export function TextbookSettingsDialog({ textbook, trigger }: TextbookSettingsDialogProps) {
const [open, setOpen] = useState(false)
const [loading, setLoading] = useState(false)
const router = useRouter()
const handleUpdate = async (formData: FormData) => {
setLoading(true)
const result = await updateTextbookAction(textbook.id, null, formData)
setLoading(false)
if (result.success) {
toast.success(result.message)
setOpen(false)
} else {
toast.error(result.message)
}
}
const handleDelete = async () => {
if (!confirm("Are you sure you want to delete this textbook? This action cannot be undone.")) return
setLoading(true)
const result = await deleteTextbookAction(textbook.id)
if (result.success) {
toast.success(result.message)
router.push("/teacher/textbooks") // Redirect after delete
} else {
setLoading(false)
toast.error(result.message)
}
}
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
{trigger || (
<Button variant="outline" size="sm">
<Edit className="mr-2 h-4 w-4" />
Settings
</Button>
)}
</DialogTrigger>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>Textbook Settings</DialogTitle>
<DialogDescription>
Update textbook details or delete this textbook.
</DialogDescription>
</DialogHeader>
<form action={handleUpdate}>
<div className="grid gap-4 py-4">
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="title" className="text-right">
Title
</Label>
<Input
id="title"
name="title"
defaultValue={textbook.title}
className="col-span-3"
required
/>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="subject" className="text-right">
Subject
</Label>
<Select name="subject" defaultValue={textbook.subject} required>
<SelectTrigger className="col-span-3">
<SelectValue placeholder="Select subject" />
</SelectTrigger>
<SelectContent>
<SelectItem value="Mathematics">Mathematics</SelectItem>
<SelectItem value="Physics">Physics</SelectItem>
<SelectItem value="History">History</SelectItem>
<SelectItem value="English">English</SelectItem>
<SelectItem value="Chemistry">Chemistry</SelectItem>
</SelectContent>
</Select>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="grade" className="text-right">
Grade
</Label>
<Select name="grade" defaultValue={textbook.grade || undefined} required>
<SelectTrigger className="col-span-3">
<SelectValue placeholder="Select grade" />
</SelectTrigger>
<SelectContent>
<SelectItem value="Grade 10">Grade 10</SelectItem>
<SelectItem value="Grade 11">Grade 11</SelectItem>
<SelectItem value="Grade 12">Grade 12</SelectItem>
</SelectContent>
</Select>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="publisher" className="text-right">
Publisher
</Label>
<Input
id="publisher"
name="publisher"
defaultValue={textbook.publisher || ""}
className="col-span-3"
/>
</div>
</div>
<DialogFooter className="flex justify-between sm:justify-between">
<Button
type="button"
variant="destructive"
onClick={handleDelete}
disabled={loading}
>
{loading ? "Processing..." : "Delete Textbook"}
</Button>
<Button type="submit" disabled={loading}>
{loading ? "Saving..." : "Save Changes"}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
)
}

View File

@@ -0,0 +1,226 @@
import { Textbook, Chapter, CreateTextbookInput, CreateChapterInput, UpdateChapterContentInput, KnowledgePoint, CreateKnowledgePointInput, UpdateTextbookInput } from "./types";
// Mock Data (Moved from data/mock-data.ts and enhanced)
let MOCK_TEXTBOOKS: Textbook[] = [
// ... (previous textbooks remain same, keeping for brevity)
{
id: "tb_01",
title: "Advanced Mathematics Grade 10",
subject: "Mathematics",
grade: "Grade 10",
publisher: "Next Education Press",
createdAt: new Date(),
updatedAt: new Date(),
_count: { chapters: 12 },
},
// ... (other textbooks)
];
let MOCK_CHAPTERS: Chapter[] = [
// ... (previous chapters)
{
id: "ch_01",
textbookId: "tb_01",
title: "Chapter 1: Real Numbers",
order: 1,
parentId: null,
content: "# Chapter 1: Real Numbers\n\nIn this chapter, we will explore the properties of real numbers...",
createdAt: new Date(),
updatedAt: new Date(),
children: [
{
id: "ch_01_01",
textbookId: "tb_01",
title: "1.1 Introduction to Real Numbers",
order: 1,
parentId: "ch_01",
content: "## 1.1 Introduction\n\nReal numbers include rational and irrational numbers.",
createdAt: new Date(),
updatedAt: new Date(),
},
],
},
];
let MOCK_KNOWLEDGE_POINTS: KnowledgePoint[] = [
{
id: "kp_01",
name: "Real Numbers",
description: "Definition and properties of real numbers",
level: 1,
order: 1,
chapterId: "ch_01",
},
{
id: "kp_02",
name: "Rational Numbers",
description: "Numbers that can be expressed as a fraction",
level: 2,
order: 1,
chapterId: "ch_01_01",
}
];
// ... (existing imports and mock data)
export async function getTextbooks(query?: string, subject?: string, grade?: string): Promise<Textbook[]> {
await new Promise((resolve) => setTimeout(resolve, 500));
let results = [...MOCK_TEXTBOOKS];
// ... (filtering logic)
return results;
}
export async function getTextbookById(id: string): Promise<Textbook | undefined> {
await new Promise((resolve) => setTimeout(resolve, 300));
return MOCK_TEXTBOOKS.find((t) => t.id === id);
}
export async function getChaptersByTextbookId(textbookId: string): Promise<Chapter[]> {
await new Promise((resolve) => setTimeout(resolve, 300));
return MOCK_CHAPTERS.filter((c) => c.textbookId === textbookId);
}
export async function createTextbook(data: CreateTextbookInput): Promise<Textbook> {
await new Promise((resolve) => setTimeout(resolve, 800));
const newTextbook: Textbook = {
id: `tb_${Math.random().toString(36).substr(2, 9)}`,
...data,
createdAt: new Date(),
updatedAt: new Date(),
_count: { chapters: 0 },
};
MOCK_TEXTBOOKS = [newTextbook, ...MOCK_TEXTBOOKS];
return newTextbook;
}
export async function updateTextbook(data: UpdateTextbookInput): Promise<Textbook> {
await new Promise((resolve) => setTimeout(resolve, 800));
const index = MOCK_TEXTBOOKS.findIndex((t) => t.id === data.id);
if (index === -1) throw new Error("Textbook not found");
const updatedTextbook = {
...MOCK_TEXTBOOKS[index],
...data,
updatedAt: new Date(),
};
MOCK_TEXTBOOKS[index] = updatedTextbook;
return updatedTextbook;
}
export async function deleteTextbook(id: string): Promise<void> {
await new Promise((resolve) => setTimeout(resolve, 800));
MOCK_TEXTBOOKS = MOCK_TEXTBOOKS.filter((t) => t.id !== id);
}
// ... (rest of the file)
export async function createChapter(data: CreateChapterInput): Promise<Chapter> {
await new Promise((resolve) => setTimeout(resolve, 500));
const newChapter: Chapter = {
id: `ch_${Math.random().toString(36).substr(2, 9)}`,
textbookId: data.textbookId,
title: data.title,
order: data.order || 0,
parentId: data.parentId || null,
content: "",
createdAt: new Date(),
updatedAt: new Date(),
children: []
};
// Logic to add to nested structure (simplified for mock: add to root or find parent)
// For deep nesting in mock, we'd need recursive search.
// Here we just push to root or try to find parent in top level for simplicity of demo.
if (data.parentId) {
const parent = MOCK_CHAPTERS.find(c => c.id === data.parentId);
if (parent) {
if (!parent.children) parent.children = [];
parent.children.push(newChapter);
} else {
// Try searching one level deep
for (const ch of MOCK_CHAPTERS) {
if (ch.children) {
const subParent = ch.children.find(c => c.id === data.parentId);
if (subParent) {
if (!subParent.children) subParent.children = [];
subParent.children.push(newChapter);
return newChapter;
}
}
}
}
} else {
MOCK_CHAPTERS.push(newChapter);
}
return newChapter;
}
export async function updateChapterContent(data: UpdateChapterContentInput): Promise<Chapter> {
await new Promise((resolve) => setTimeout(resolve, 500));
// Recursive find and update
const updateContentRecursive = (chapters: Chapter[]): Chapter | null => {
for (const ch of chapters) {
if (ch.id === data.chapterId) {
ch.content = data.content;
ch.updatedAt = new Date();
return ch;
}
if (ch.children) {
const found = updateContentRecursive(ch.children);
if (found) return found;
}
}
return null;
};
const updated = updateContentRecursive(MOCK_CHAPTERS);
if (!updated) throw new Error("Chapter not found");
return updated;
}
export async function deleteChapter(id: string): Promise<void> {
await new Promise((resolve) => setTimeout(resolve, 500));
// Recursive delete
MOCK_CHAPTERS = MOCK_CHAPTERS.filter(c => c.id !== id);
MOCK_CHAPTERS.forEach(c => {
if (c.children) {
c.children = c.children.filter(child => child.id !== id);
}
});
}
// Knowledge Points
export async function getKnowledgePointsByChapterId(chapterId: string): Promise<KnowledgePoint[]> {
await new Promise((resolve) => setTimeout(resolve, 300));
return MOCK_KNOWLEDGE_POINTS.filter(kp => kp.chapterId === chapterId);
}
export async function createKnowledgePoint(data: CreateKnowledgePointInput): Promise<KnowledgePoint> {
await new Promise((resolve) => setTimeout(resolve, 500));
const newKP: KnowledgePoint = {
id: `kp_${Math.random().toString(36).substr(2, 9)}`,
name: data.name,
description: data.description,
chapterId: data.chapterId,
level: 1, // simplified
order: 0
};
MOCK_KNOWLEDGE_POINTS.push(newKP);
return newKP;
}
export async function deleteKnowledgePoint(id: string): Promise<void> {
await new Promise((resolve) => setTimeout(resolve, 500));
MOCK_KNOWLEDGE_POINTS = MOCK_KNOWLEDGE_POINTS.filter(kp => kp.id !== id);
}

View File

@@ -0,0 +1,76 @@
import { type InferSelectModel } from "drizzle-orm";
import { textbooks, chapters } from "@/shared/db/schema";
// Define types based on Drizzle Schema
// In a real app, we would infer these from the schema, but since we might not have the full schema setup running locally with DB,
// we will define interfaces that match the schema description in ARCHITECTURE.md and schema.ts
export type Textbook = {
id: string;
title: string;
subject: string;
grade: string | null;
publisher: string | null;
createdAt: Date;
updatedAt: Date;
// Computed/Joined fields
_count?: {
chapters: number;
};
};
export type Chapter = {
id: string;
textbookId: string;
title: string;
order: number | null;
parentId: string | null;
content?: string | null; // Added for content viewing
createdAt: Date;
updatedAt: Date;
// Recursive structure for UI
children?: Chapter[];
};
export type CreateTextbookInput = {
title: string;
subject: string;
grade: string;
publisher: string;
};
export type UpdateTextbookInput = {
id: string;
title: string;
subject: string;
grade: string;
publisher: string;
};
export type KnowledgePoint = {
id: string;
name: string;
description?: string | null;
parentId?: string | null;
chapterId?: string; // Logic link for this module context
level: number;
order: number;
};
export type CreateChapterInput = {
textbookId: string;
title: string;
parentId?: string;
order?: number;
};
export type UpdateChapterContentInput = {
chapterId: string;
content: string;
};
export type CreateKnowledgePointInput = {
name: string;
description?: string;
chapterId: string;
};