feat: exam actions and data safety fixes

This commit is contained in:
SpecialX
2025-12-30 17:48:22 +08:00
parent e7c902e8e1
commit f7ff018490
27 changed files with 896 additions and 194 deletions

View File

@@ -8,7 +8,7 @@ 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> {}
type LoginFormProps = React.HTMLAttributes<HTMLDivElement>
export function LoginForm({ className, ...props }: LoginFormProps) {
const [isLoading, setIsLoading] = React.useState<boolean>(false)

View File

@@ -8,7 +8,7 @@ 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> {}
type RegisterFormProps = React.HTMLAttributes<HTMLDivElement>
export function RegisterForm({ className, ...props }: RegisterFormProps) {
const [isLoading, setIsLoading] = React.useState<boolean>(false)

View File

@@ -40,9 +40,9 @@ export function TeacherSchedule() {
const hasSchedule = MOCK_SCHEDULE.length > 0;
return (
<Card className="col-span-3">
<Card className="col-span-3">
<CardHeader>
<CardTitle>Today's Schedule</CardTitle>
<CardTitle>Today&apos;s Schedule</CardTitle>
</CardHeader>
<CardContent>
{!hasSchedule ? (

View File

@@ -101,8 +101,8 @@ const ExamUpdateSchema = z.object({
score: z.coerce.number().int().min(0),
})
)
.default([]),
structure: z.any().optional(), // Accept structure JSON
.optional(),
structure: z.unknown().optional(),
status: z.enum(["draft", "published", "archived"]).optional(),
})
@@ -110,13 +110,15 @@ 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 rawQuestions = formData.get("questionsJson")
const rawStructure = formData.get("structureJson")
const hasQuestions = typeof rawQuestions === "string"
const hasStructure = typeof rawStructure === "string"
const parsed = ExamUpdateSchema.safeParse({
examId: formData.get("examId"),
questions: rawQuestions ? JSON.parse(rawQuestions) : [],
structure: rawStructure ? JSON.parse(rawStructure) : undefined,
questions: hasQuestions ? JSON.parse(rawQuestions) : undefined,
structure: hasStructure ? JSON.parse(rawStructure) : undefined,
status: formData.get("status") ?? undefined,
})
@@ -131,22 +133,24 @@ export async function updateExamAction(
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,
}))
)
if (questions) {
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 = {}
const updateData: Partial<typeof exams.$inferInsert> = {}
if (status) updateData.status = status
if (structure) updateData.structure = structure
if (structure !== undefined) updateData.structure = structure
if (Object.keys(updateData).length > 0) {
await db.update(exams).set(updateData).where(eq(exams.id, examId))
@@ -169,6 +173,143 @@ export async function updateExamAction(
}
}
const ExamDeleteSchema = z.object({
examId: z.string().min(1),
})
export async function deleteExamAction(
prevState: ActionState<string> | null,
formData: FormData
): Promise<ActionState<string>> {
const parsed = ExamDeleteSchema.safeParse({
examId: formData.get("examId"),
})
if (!parsed.success) {
return {
success: false,
message: "Invalid delete data",
errors: parsed.error.flatten().fieldErrors,
}
}
const { examId } = parsed.data
try {
await db.delete(exams).where(eq(exams.id, examId))
} catch (error) {
console.error("Failed to delete exam:", error)
return {
success: false,
message: "Database error: Failed to delete exam",
}
}
revalidatePath("/teacher/exams/all")
revalidatePath("/teacher/exams/grading")
return {
success: true,
message: "Exam deleted",
data: examId,
}
}
const ExamDuplicateSchema = z.object({
examId: z.string().min(1),
})
const omitScheduledAtFromDescription = (description: string | null) => {
if (!description) return null
try {
const parsed: unknown = JSON.parse(description)
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return description
const meta = parsed as Record<string, unknown>
if ("scheduledAt" in meta) delete meta.scheduledAt
return JSON.stringify(meta)
} catch {
return description
}
}
export async function duplicateExamAction(
prevState: ActionState<string> | null,
formData: FormData
): Promise<ActionState<string>> {
const parsed = ExamDuplicateSchema.safeParse({
examId: formData.get("examId"),
})
if (!parsed.success) {
return {
success: false,
message: "Invalid duplicate data",
errors: parsed.error.flatten().fieldErrors,
}
}
const { examId } = parsed.data
const source = await db.query.exams.findFirst({
where: eq(exams.id, examId),
with: {
questions: {
orderBy: (examQuestions, { asc }) => [asc(examQuestions.order)],
},
},
})
if (!source) {
return {
success: false,
message: "Exam not found",
}
}
const newExamId = createId()
const user = await getCurrentUser()
try {
await db.transaction(async (tx) => {
await tx.insert(exams).values({
id: newExamId,
title: `${source.title} (Copy)`,
description: omitScheduledAtFromDescription(source.description),
creatorId: user?.id ?? "user_teacher_123",
startTime: null,
endTime: null,
status: "draft",
structure: source.structure,
})
if (source.questions.length > 0) {
await tx.insert(examQuestions).values(
source.questions.map((q) => ({
examId: newExamId,
questionId: q.questionId,
score: q.score ?? 0,
order: q.order ?? 0,
}))
)
}
})
} catch (error) {
console.error("Failed to duplicate exam:", error)
return {
success: false,
message: "Database error: Failed to duplicate exam",
}
}
revalidatePath("/teacher/exams/all")
return {
success: true,
message: "Exam duplicated",
data: newExamId,
}
}
const GradingSchema = z.object({
submissionId: z.string().min(1),
answers: z.array(z.object({

View File

@@ -0,0 +1,141 @@
"use client"
import React from "react"
import { ScrollArea } from "@/shared/components/ui/scroll-area"
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/shared/components/ui/dialog"
import { Button } from "@/shared/components/ui/button"
import { Eye, Printer } from "lucide-react"
import type { ExamNode } from "./selected-question-list"
type ChoiceOption = {
id: string
text: string
}
type QuestionContent = {
text?: string
options?: ChoiceOption[]
}
type ExamPaperPreviewProps = {
title: string
subject: string
grade: string
durationMin: number
totalScore: number
nodes: ExamNode[]
}
export function ExamPaperPreview({ title, subject, grade, durationMin, totalScore, nodes }: ExamPaperPreviewProps) {
// Helper to flatten questions for continuous numbering
let questionCounter = 0
const renderNode = (node: ExamNode, depth: number = 0) => {
if (node.type === 'group') {
return (
<div key={node.id} className="space-y-4 mb-6">
<div className="flex items-center gap-2">
<h3 className={`font-bold ${depth === 0 ? 'text-lg' : 'text-md'} text-foreground/90`}>
{node.title || "Section"}
</h3>
{/* Optional: Show section score if needed */}
</div>
<div className="pl-0">
{node.children?.map(child => renderNode(child, depth + 1))}
</div>
</div>
)
}
if (node.type === 'question' && node.question) {
questionCounter++
const q = node.question
const content = q.content as QuestionContent
return (
<div key={node.id} className="mb-6 break-inside-avoid">
<div className="flex gap-2">
<span className="font-semibold text-foreground min-w-[24px]">{questionCounter}.</span>
<div className="flex-1 space-y-2">
<div className="text-foreground/90 leading-relaxed whitespace-pre-wrap">
{content.text ?? ""}
<span className="text-muted-foreground text-sm ml-2">({node.score})</span>
</div>
{/* Options for Choice Questions */}
{(q.type === 'single_choice' || q.type === 'multiple_choice') && content.options && (
<div className="grid grid-cols-1 md:grid-cols-2 gap-y-2 gap-x-4 mt-2 pl-2">
{content.options.map((opt) => (
<div key={opt.id} className="flex gap-2 text-sm text-muted-foreground hover:text-foreground transition-colors">
<span className="font-medium">{opt.id}.</span>
<span>{opt.text}</span>
</div>
))}
</div>
)}
{/* Space for written answers */}
{q.type === 'text' && (
<div className="mt-4 h-24 border-b border-dashed border-muted-foreground/30 w-full"></div>
)}
</div>
</div>
</div>
)
}
return null
}
return (
<Dialog>
<DialogTrigger asChild>
<Button variant="secondary" size="sm" className="gap-2">
<Eye className="h-4 w-4" />
Preview Exam
</Button>
</DialogTrigger>
<DialogContent className="max-w-4xl h-[90vh] flex flex-col p-0 gap-0">
<DialogHeader className="p-6 pb-2 border-b shrink-0">
<div className="flex items-center justify-between">
<DialogTitle>Exam Preview</DialogTitle>
<Button variant="outline" size="sm" onClick={() => window.print()} className="hidden">
<Printer className="h-4 w-4 mr-2" />
Print
</Button>
</div>
</DialogHeader>
<ScrollArea className="flex-1 p-8 bg-white/50 dark:bg-zinc-950/50">
<div className="max-w-3xl mx-auto bg-card shadow-sm border p-12 min-h-[1000px] print:shadow-none print:border-none">
{/* Header */}
<div className="text-center space-y-4 mb-12 border-b-2 border-primary/20 pb-8">
<h1 className="text-3xl font-black tracking-tight text-foreground">{title}</h1>
<div className="flex justify-center gap-8 text-sm text-muted-foreground font-medium uppercase tracking-wide">
<span>Subject: {subject}</span>
<span>Grade: {grade}</span>
<span>Time: {durationMin} mins</span>
<span>Total: {totalScore} pts</span>
</div>
<div className="flex justify-center gap-12 text-sm pt-4">
<div className="w-32 border-b border-foreground/20 text-left px-1">Class:</div>
<div className="w-32 border-b border-foreground/20 text-left px-1">Name:</div>
<div className="w-32 border-b border-foreground/20 text-left px-1">No.:</div>
</div>
</div>
{/* Content */}
<div className="space-y-2">
{nodes.length === 0 ? (
<div className="text-center py-20 text-muted-foreground">
Empty Exam Paper
</div>
) : (
nodes.map(node => renderNode(node))
)}
</div>
</div>
</ScrollArea>
</DialogContent>
</Dialog>
)
}

View File

@@ -5,7 +5,6 @@ import {
DndContext,
pointerWithin,
rectIntersection,
getFirstCollision,
CollisionDetection,
KeyboardSensor,
PointerSensor,
@@ -34,7 +33,6 @@ import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/shared/co
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 ---
@@ -47,6 +45,15 @@ type StructureEditorProps = {
onAddGroup: () => void
}
function cloneExamNodes(nodes: ExamNode[]): ExamNode[] {
return nodes.map((node) => {
if (node.type === "group") {
return { ...node, children: cloneExamNodes(node.children || []) }
}
return { ...node }
})
}
// --- Components ---
function SortableItem({
@@ -201,10 +208,20 @@ function StructureRenderer({ nodes, ...props }: {
onScoreChange: (id: string, score: number) => void
onGroupTitleChange: (id: string, title: string) => void
}) {
// Deduplicate nodes to prevent React key errors
const uniqueNodes = useMemo(() => {
const seen = new Set()
return nodes.filter(n => {
if (seen.has(n.id)) return false
seen.add(n.id)
return true
})
}, [nodes])
return (
<SortableContext items={nodes.map(n => n.id)} strategy={verticalListSortingStrategy}>
{nodes.map(node => (
<React.Fragment key={node.id}>
<SortableContext items={uniqueNodes.map(n => n.id)} strategy={verticalListSortingStrategy}>
{uniqueNodes.map(node => (
<div key={node.id}>
{node.type === 'group' ? (
<SortableGroup
id={node.id}
@@ -232,7 +249,7 @@ function StructureRenderer({ nodes, ...props }: {
onScoreChange={(val) => props.onScoreChange(node.id, val)}
/>
)}
</React.Fragment>
</div>
))}
</SortableContext>
)
@@ -362,7 +379,7 @@ export function StructureEditor({ items, onChange, onScoreChange, onGroupTitleCh
if (activeContainerId !== overNode.id) {
// ... implementation continues ...
const newItems = JSON.parse(JSON.stringify(items)) as ExamNode[]
const newItems = cloneExamNodes(items)
// Remove active from old location
const removeRecursive = (list: ExamNode[]): ExamNode | null => {
@@ -386,7 +403,10 @@ export function StructureEditor({ items, onChange, onScoreChange, onGroupTitleCh
for (const node of list) {
if (node.id === overId) {
if (!node.children) node.children = []
node.children.push(movedItem)
// Extra safety: Check if movedItem.id is already in children
if (!node.children.some(c => c.id === movedItem.id)) {
node.children.push(movedItem)
}
return true
}
if (node.children) {
@@ -404,8 +424,12 @@ export function StructureEditor({ items, onChange, onScoreChange, onGroupTitleCh
// Scenario 2: Moving between different lists (e.g. from Root to Group A, or Group A to Group B)
if (activeContainerId !== overContainerId) {
// FIX: If we are already inside the group we are hovering (i.e. activeContainerId IS overId),
// do not try to move "next to" the group (which would move us out).
if (activeContainerId === overId) return
// Standard Sortable Move
const newItems = JSON.parse(JSON.stringify(items)) as ExamNode[]
const newItems = cloneExamNodes(items)
const removeRecursive = (list: ExamNode[]): ExamNode | null => {
const idx = list.findIndex(i => i.id === activeId)
@@ -484,7 +508,7 @@ export function StructureEditor({ items, onChange, onScoreChange, onGroupTitleCh
if (activeContainerId === overContainerId) {
// Same container reorder
const newItems = JSON.parse(JSON.stringify(items)) as ExamNode[]
const newItems = cloneExamNodes(items)
const getMutableList = (groupId?: string): ExamNode[] => {
if (groupId === 'root') return newItems
@@ -560,7 +584,9 @@ export function StructureEditor({ items, onChange, onScoreChange, onGroupTitleCh
) : (
<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>
<p className="text-sm line-clamp-1">
{(activeItem.question?.content as { text?: string } | undefined)?.text || "Question"}
</p>
</div>
)
) : null}

View File

@@ -32,6 +32,7 @@ import {
DialogTitle,
} from "@/shared/components/ui/dialog"
import { deleteExamAction, duplicateExamAction, updateExamAction } from "../actions"
import { Exam } from "../types"
interface ExamActionsProps {
@@ -42,31 +43,70 @@ export function ExamActions({ exam }: ExamActionsProps) {
const router = useRouter()
const [showViewDialog, setShowViewDialog] = useState(false)
const [showDeleteDialog, setShowDeleteDialog] = useState(false)
const [isWorking, setIsWorking] = useState(false)
const copyId = () => {
navigator.clipboard.writeText(exam.id)
toast.success("Exam ID copied to clipboard")
}
const publishExam = async () => {
toast.success("Exam published")
const setStatus = async (status: Exam["status"]) => {
setIsWorking(true)
try {
const formData = new FormData()
formData.set("examId", exam.id)
formData.set("status", status)
const result = await updateExamAction(null, formData)
if (result.success) {
toast.success(status === "published" ? "Exam published" : status === "archived" ? "Exam archived" : "Exam moved to draft")
router.refresh()
} else {
toast.error(result.message || "Failed to update exam")
}
} catch {
toast.error("Failed to update exam")
} finally {
setIsWorking(false)
}
}
const unpublishExam = async () => {
toast.success("Exam moved to draft")
}
const archiveExam = async () => {
toast.success("Exam archived")
const duplicateExam = async () => {
setIsWorking(true)
try {
const formData = new FormData()
formData.set("examId", exam.id)
const result = await duplicateExamAction(null, formData)
if (result.success && result.data) {
toast.success("Exam duplicated")
router.push(`/teacher/exams/${result.data}/build`)
router.refresh()
} else {
toast.error(result.message || "Failed to duplicate exam")
}
} catch {
toast.error("Failed to duplicate exam")
} finally {
setIsWorking(false)
}
}
const handleDelete = async () => {
setIsWorking(true)
try {
await new Promise((r) => setTimeout(r, 800))
toast.success("Exam deleted successfully")
setShowDeleteDialog(false)
} catch (e) {
const formData = new FormData()
formData.set("examId", exam.id)
const result = await deleteExamAction(null, formData)
if (result.success) {
toast.success("Exam deleted successfully")
setShowDeleteDialog(false)
router.refresh()
} else {
toast.error(result.message || "Failed to delete exam")
}
} catch {
toast.error("Failed to delete exam")
} finally {
setIsWorking(false)
}
}
@@ -88,25 +128,39 @@ export function ExamActions({ exam }: ExamActionsProps) {
<DropdownMenuItem onClick={() => setShowViewDialog(true)}>
<Eye className="mr-2 h-4 w-4" /> View
</DropdownMenuItem>
<DropdownMenuItem>
<DropdownMenuItem onClick={() => router.push(`/teacher/exams/${exam.id}/build`)}>
<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}>
<DropdownMenuItem onClick={duplicateExam} disabled={isWorking}>
<Copy className="mr-2 h-4 w-4" /> Duplicate
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => setStatus("published")}
disabled={isWorking || exam.status === "published"}
>
<UploadCloud className="mr-2 h-4 w-4" /> Publish
</DropdownMenuItem>
<DropdownMenuItem onClick={unpublishExam}>
<DropdownMenuItem
onClick={() => setStatus("draft")}
disabled={isWorking || exam.status === "draft"}
>
<Undo2 className="mr-2 h-4 w-4" /> Move to Draft
</DropdownMenuItem>
<DropdownMenuItem onClick={archiveExam}>
<DropdownMenuItem
onClick={() => setStatus("archived")}
disabled={isWorking || exam.status === "archived"}
>
<Archive className="mr-2 h-4 w-4" /> Archive
</DropdownMenuItem>
<DropdownMenuItem
className="text-destructive focus:text-destructive"
onClick={() => setShowDeleteDialog(true)}
disabled={isWorking}
>
<Trash className="mr-2 h-4 w-4" /> Delete
</DropdownMenuItem>
@@ -159,6 +213,7 @@ export function ExamActions({ exam }: ExamActionsProps) {
e.preventDefault()
handleDelete()
}}
disabled={isWorking}
>
Delete
</AlertDialogAction>

View File

@@ -1,6 +1,6 @@
"use client"
import { useMemo, useState } from "react"
import { useDeferredValue, useMemo, useState } from "react"
import { useFormStatus } from "react-dom"
import { useRouter } from "next/navigation"
import { toast } from "sonner"
@@ -19,6 +19,7 @@ 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 { ExamPaperPreview } from "./assembly/exam-paper-preview"
import { createId } from "@paralleldrive/cuid2"
type ExamAssemblyProps = {
@@ -48,17 +49,20 @@ export function ExamAssembly(props: ExamAssemblyProps) {
const [search, setSearch] = useState("")
const [typeFilter, setTypeFilter] = useState<string>("all")
const [difficultyFilter, setDifficultyFilter] = useState<string>("all")
const deferredSearch = useDeferredValue(search)
// Initialize structure state
const [structure, setStructure] = useState<ExamNode[]>(() => {
// Hydrate structure with full question objects
const questionById = new Map<string, Question>()
for (const q of props.questionOptions) questionById.set(q.id, q)
const hydrate = (nodes: ExamNode[]): ExamNode[] => {
return nodes.map(node => {
if (node.type === 'question') {
const q = props.questionOptions.find(opt => opt.id === node.questionId)
return nodes.map((node) => {
if (node.type === "question") {
const q = node.questionId ? questionById.get(node.questionId) : undefined
return { ...node, question: q }
}
if (node.type === 'group') {
if (node.type === "group") {
return { ...node, children: hydrate(node.children || []) }
}
return node
@@ -77,8 +81,8 @@ export function ExamAssembly(props: ExamAssemblyProps) {
const filteredQuestions = useMemo(() => {
let list: Question[] = [...props.questionOptions]
if (search) {
const lower = search.toLowerCase()
if (deferredSearch) {
const lower = deferredSearch.toLowerCase()
list = list.filter(q => {
const content = q.content as { text?: string }
return content.text?.toLowerCase().includes(lower)
@@ -93,7 +97,7 @@ export function ExamAssembly(props: ExamAssemblyProps) {
list = list.filter((q) => q.difficulty === d)
}
return list
}, [search, typeFilter, difficultyFilter, props.questionOptions])
}, [deferredSearch, typeFilter, difficultyFilter, props.questionOptions])
// Recursively calculate total score
const assignedTotal = useMemo(() => {
@@ -109,17 +113,40 @@ export function ExamAssembly(props: ExamAssemblyProps) {
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 addedQuestionIds = useMemo(() => {
const ids = new Set<string>()
const walk = (nodes: ExamNode[]) => {
for (const n of nodes) {
if (n.type === "question" && n.questionId) ids.add(n.questionId)
if (n.type === "group" && n.children) walk(n.children)
}
])
}
walk(structure)
return ids
}, [structure])
const handleAdd = (question: Question) => {
setStructure((prev) => {
const has = (nodes: ExamNode[]): boolean => {
return nodes.some((n) => {
if (n.type === "question") return n.questionId === question.id
if (n.type === "group" && n.children) return has(n.children)
return false
})
}
if (has(prev)) return prev
return [
...prev,
{
id: createId(),
type: "question",
questionId: question.id,
score: 10,
question,
},
]
})
}
const handleAddGroup = () => {
@@ -171,10 +198,14 @@ export function ExamAssembly(props: ExamAssemblyProps) {
// Helper to extract flat list for DB examQuestions table
const getFlatQuestions = () => {
const list: Array<{ id: string; score: number }> = []
const seen = new Set<string>()
const traverse = (nodes: ExamNode[]) => {
nodes.forEach(n => {
if (n.type === 'question' && n.questionId) {
list.push({ id: n.questionId, score: n.score || 0 })
if (!seen.has(n.questionId)) {
seen.add(n.questionId)
list.push({ id: n.questionId, score: n.score || 0 })
}
}
if (n.type === 'group') {
traverse(n.children || [])
@@ -187,9 +218,12 @@ export function ExamAssembly(props: ExamAssemblyProps) {
// Helper to strip runtime question objects for DB structure storage
const getCleanStructure = () => {
const clean = (nodes: ExamNode[]): any[] => {
type CleanExamNode = Omit<ExamNode, "question"> & { children?: CleanExamNode[] }
const clean = (nodes: ExamNode[]): CleanExamNode[] => {
return nodes.map(n => {
const { question, ...rest } = n
void question
if (n.type === 'group') {
return { ...rest, children: clean(n.children || []) }
}
@@ -233,7 +267,17 @@ export function ExamAssembly(props: ExamAssemblyProps) {
<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-3">
<CardTitle>Exam Structure</CardTitle>
<ExamPaperPreview
title={props.title}
subject={props.subject}
grade={props.grade}
durationMin={props.durationMin}
totalScore={props.totalScore}
nodes={structure}
/>
</div>
<div className="flex items-center gap-4 text-sm">
<div className="flex flex-col items-end">
<span className="font-medium">{assignedTotal} / {props.totalScore}</span>
@@ -324,17 +368,7 @@ export function ExamAssembly(props: ExamAssemblyProps) {
<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)
}}
isAdded={(id) => addedQuestionIds.has(id)}
/>
</ScrollArea>
</Card>

View File

@@ -2,7 +2,7 @@
import { ColumnDef } from "@tanstack/react-table"
import { Checkbox } from "@/shared/components/ui/checkbox"
import { Badge } from "@/shared/components/ui/badge"
import { Badge, type BadgeProps } from "@/shared/components/ui/badge"
import { cn, formatDate } from "@/shared/lib/utils"
import { Exam } from "../types"
import { ExamActions } from "./exam-actions"
@@ -36,8 +36,8 @@ export const examColumns: ColumnDef<Exam>[] = [
<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">
{row.original.tags.slice(0, 2).map((t, idx) => (
<Badge key={`${t}-${idx}`} variant="outline" className="text-xs">
{t}
</Badge>
))}
@@ -65,9 +65,14 @@ export const examColumns: ColumnDef<Exam>[] = [
header: "Status",
cell: ({ row }) => {
const status = row.original.status
const variant = status === "published" ? "secondary" : status === "archived" ? "destructive" : "outline"
const variant: BadgeProps["variant"] =
status === "published"
? "secondary"
: status === "archived"
? "destructive"
: "outline"
return (
<Badge variant={variant as any} className="capitalize">
<Badge variant={variant} className="capitalize">
{status}
</Badge>
)
@@ -134,4 +139,3 @@ export const examColumns: ColumnDef<Exam>[] = [
cell: ({ row }) => <ExamActions exam={row.original} />,
},
]

View File

@@ -13,13 +13,17 @@ import { ScrollArea } from "@/shared/components/ui/scroll-area"
import { Separator } from "@/shared/components/ui/separator"
import { gradeSubmissionAction } from "../actions"
const isRecord = (v: unknown): v is Record<string, unknown> => typeof v === "object" && v !== null
type QuestionContent = { text?: string } & Record<string, unknown>
type Answer = {
id: string
questionId: string
questionContent: any
questionContent: QuestionContent | null
questionType: string
maxScore: number
studentAnswer: any
studentAnswer: unknown
score: number | null
feedback: string | null
order: number
@@ -105,8 +109,8 @@ export function GradingView({
<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
{isRecord(ans.studentAnswer) && typeof ans.studentAnswer.answer === "string"
? ans.studentAnswer.answer
: JSON.stringify(ans.studentAnswer)}
</p>
</div>

View File

@@ -32,7 +32,7 @@ export const submissionColumns: ColumnDef<ExamSubmission>[] = [
cell: ({ row }) => {
const status = row.original.status
const variant = status === "graded" ? "secondary" : "outline"
return <Badge variant={variant as any} className="capitalize">{status}</Badge>
return <Badge variant={variant} className="capitalize">{status}</Badge>
},
},
{
@@ -60,4 +60,3 @@ export const submissionColumns: ColumnDef<ExamSubmission>[] = [
),
},
]

View File

@@ -1,9 +1,9 @@
import { db } from "@/shared/db"
import { exams, examQuestions, examSubmissions, submissionAnswers, users } from "@/shared/db/schema"
import { exams, examQuestions, examSubmissions, submissionAnswers } from "@/shared/db/schema"
import { eq, desc, like, and, or } from "drizzle-orm"
import { cache } from "react"
import type { ExamStatus } from "./types"
import type { Exam, ExamDifficulty, ExamStatus } from "./types"
export type GetExamsParams = {
q?: string
@@ -13,6 +13,40 @@ export type GetExamsParams = {
pageSize?: number
}
const isRecord = (v: unknown): v is Record<string, unknown> => typeof v === "object" && v !== null
const parseExamMeta = (description: string | null): Record<string, unknown> => {
if (!description) return {}
try {
const parsed: unknown = JSON.parse(description)
return isRecord(parsed) ? parsed : {}
} catch {
return {}
}
}
const getString = (obj: Record<string, unknown>, key: string): string | undefined => {
const v = obj[key]
return typeof v === "string" ? v : undefined
}
const getNumber = (obj: Record<string, unknown>, key: string): number | undefined => {
const v = obj[key]
return typeof v === "number" ? v : undefined
}
const getStringArray = (obj: Record<string, unknown>, key: string): string[] | undefined => {
const v = obj[key]
if (!Array.isArray(v)) return undefined
const items = v.filter((x): x is string => typeof x === "string")
return items.length === v.length ? items : undefined
}
const toExamDifficulty = (n: number | undefined): ExamDifficulty => {
if (n === 1 || n === 2 || n === 3 || n === 4 || n === 5) return n
return 1
}
export const getExams = cache(async (params: GetExamsParams) => {
const conditions = []
@@ -23,7 +57,7 @@ export const getExams = cache(async (params: GetExamsParams) => {
}
if (params.status && params.status !== "all") {
conditions.push(eq(exams.status, params.status as any))
conditions.push(eq(exams.status, params.status))
}
// Note: Difficulty is stored in JSON description field in current schema,
@@ -37,25 +71,23 @@ export const getExams = cache(async (params: GetExamsParams) => {
})
// Transform and Filter (especially for JSON fields)
let result = data.map((exam) => {
let meta: any = {}
try {
meta = JSON.parse(exam.description || "{}")
} catch { }
let result: Exam[] = data.map((exam) => {
const meta = parseExamMeta(exam.description || null)
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,
subject: getString(meta, "subject") || "General",
grade: getString(meta, "grade") || "General",
difficulty: toExamDifficulty(getNumber(meta, "difficulty")),
totalScore: getNumber(meta, "totalScore") || 100,
durationMin: getNumber(meta, "durationMin") || 60,
questionCount: getNumber(meta, "questionCount") || 0,
scheduledAt: exam.startTime?.toISOString(),
createdAt: exam.createdAt.toISOString(),
tags: meta.tags || [],
updatedAt: exam.updatedAt?.toISOString(),
tags: getStringArray(meta, "tags") || [],
}
})
@@ -82,30 +114,27 @@ export const getExamById = cache(async (id: string) => {
if (!exam) return null
let meta: any = {}
try {
meta = JSON.parse(exam.description || "{}")
} catch { }
const meta = parseExamMeta(exam.description || null)
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,
subject: getString(meta, "subject") || "General",
grade: getString(meta, "grade") || "General",
difficulty: toExamDifficulty(getNumber(meta, "difficulty")),
totalScore: getNumber(meta, "totalScore") || 100,
durationMin: getNumber(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
}))
updatedAt: exam.updatedAt?.toISOString(),
tags: getStringArray(meta, "tags") || [],
structure: exam.structure as unknown,
questions: exam.questions.map((eqRel) => ({
id: eqRel.questionId,
score: eqRel.score ?? 0,
order: eqRel.order ?? 0,
})),
}
})
@@ -154,13 +183,20 @@ export const getSubmissionDetails = cache(async (submissionId: string) => {
orderBy: [desc(examQuestions.order)],
})
type QuestionContent = { text?: string } & Record<string, unknown>
const toQuestionContent = (v: unknown): QuestionContent | null => {
if (!isRecord(v)) return null
return v as QuestionContent
}
// 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,
questionContent: toQuestionContent(ans.question.content),
questionType: ans.question.type,
maxScore: eqRel?.score || 0,
studentAnswer: ans.answerContent,

View File

@@ -2,7 +2,6 @@ import {
BarChart,
BookOpen,
Calendar,
GraduationCap,
LayoutDashboard,
Settings,
Users,
@@ -15,10 +14,11 @@ import {
Library,
PenTool
} from "lucide-react"
import type { LucideIcon } from "lucide-react"
export type NavItem = {
title: string
icon: any
icon: LucideIcon
href: string
items?: { title: string; href: string }[]
}

View File

@@ -5,8 +5,6 @@ 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) ---
@@ -29,8 +27,10 @@ async function ensureTeacher() {
// --- Recursive Insert Helper ---
// We pass 'tx' to ensure all operations run within the same transaction
type Tx = Parameters<Parameters<typeof db.transaction>[0]>[0]
async function insertQuestionWithRelations(
tx: any, // using any or strict Drizzle Transaction type if imported
tx: Tx,
input: CreateQuestionInput,
authorId: string,
parentId: string | null = null
@@ -81,14 +81,14 @@ export async function createNestedQuestion(
// 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;
let rawInput: unknown = 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);
rawInput = JSON.parse(jsonString) as unknown;
} else {
return { success: false, message: "Invalid submission format. Expected JSON." };
}

View File

@@ -9,6 +9,7 @@ import { cache } from "react";
export type GetQuestionsParams = {
page?: number;
pageSize?: number;
ids?: string[];
knowledgePointId?: string;
difficulty?: number;
};
@@ -18,6 +19,7 @@ export type GetQuestionsParams = {
export const getQuestions = cache(async ({
page = 1,
pageSize = 10,
ids,
knowledgePointId,
difficulty,
}: GetQuestionsParams = {}) => {
@@ -26,6 +28,10 @@ export const getQuestions = cache(async ({
// Build Where Conditions
const conditions = [];
if (ids && ids.length > 0) {
conditions.push(inArray(questions.id, ids));
}
if (difficulty) {
conditions.push(eq(questions.difficulty, difficulty));
}
@@ -40,9 +46,9 @@ export const getQuestions = cache(async ({
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`);
if (!ids || ids.length === 0) {
conditions.push(sql`${questions.parentId} IS NULL`)
}
const whereClause = conditions.length > 0 ? and(...conditions) : undefined;

View File

@@ -40,8 +40,7 @@ export function TextbookContentLayout({ chapters, knowledgePoints, textbookId }:
if (result.success) {
toast.success(result.message)
setIsEditing(false)
// Update local state to reflect change immediately (optimistic-like)
selectedChapter.content = editContent
setSelectedChapter((prev) => (prev ? { ...prev, content: editContent } : prev))
} else {
toast.error(result.message)
}

View File

@@ -61,7 +61,7 @@ export function TextbookFormDialog() {
<DialogHeader>
<DialogTitle>Add New Textbook</DialogTitle>
<DialogDescription>
Create a new digital textbook. Click save when you're done.
Create a new digital textbook. Click save when you&apos;re done.
</DialogDescription>
</DialogHeader>
<form action={handleSubmit}>

View File

@@ -65,7 +65,7 @@ let MOCK_KNOWLEDGE_POINTS: KnowledgePoint[] = [
export async function getTextbooks(query?: string, subject?: string, grade?: string): Promise<Textbook[]> {
await new Promise((resolve) => setTimeout(resolve, 500));
let results = [...MOCK_TEXTBOOKS];
const results = [...MOCK_TEXTBOOKS];
// ... (filtering logic)
return results;
}