- Restructure: move backend from new_project/ to backend/ - Add full React/TypeScript frontend (37 pages, 17 services, 16 type defs, 11 query hooks) - Add docs/ with SRS specs, user stories, and workflow documentation - Update .gitignore for new directory layout Workflows implemented: WF1 User Signup, WF2 Placement Test, WF3 Exam Configuration, WF4 General English Exam, WF5 Course Generation, WF6 Entity Student Onboarding, AI Course Generation, Adaptive Learning Engine UI, White-Label Branding, Score Release Made-with: Cursor
459 lines
15 KiB
TypeScript
459 lines
15 KiB
TypeScript
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
import { useNavigate, useParams } from "react-router-dom";
|
|
import { useExamSession, useExamAutoSave, useExamSubmit } from "@/hooks/queries/useExamSession";
|
|
import type { ExamAnswer, ExamQuestion, ExamSessionSection } from "@/types";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Card, CardContent } from "@/components/ui/card";
|
|
import { Progress } from "@/components/ui/progress";
|
|
import { Checkbox } from "@/components/ui/checkbox";
|
|
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
|
import { Label } from "@/components/ui/label";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Textarea } from "@/components/ui/textarea";
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogDescription,
|
|
DialogFooter,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
} from "@/components/ui/dialog";
|
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
|
import { Flag, ChevronLeft, ChevronRight, Pause, Play } from "lucide-react";
|
|
import { cn } from "@/lib/utils";
|
|
|
|
function normalizeType(t: string) {
|
|
return t.toLowerCase().replace(/\s+/g, "_");
|
|
}
|
|
|
|
function countWords(s: string) {
|
|
return s.trim() ? s.trim().split(/\s+/).length : 0;
|
|
}
|
|
|
|
function buildAnswerMap(sections: ExamSessionSection[]) {
|
|
const map = new Map<number, ExamAnswer>();
|
|
for (const sec of sections) {
|
|
for (const q of sec.questions) {
|
|
map.set(q.id, {
|
|
question_id: q.id,
|
|
answer: null,
|
|
flagged: false,
|
|
time_spent_ms: 0,
|
|
});
|
|
}
|
|
}
|
|
return map;
|
|
}
|
|
|
|
export default function ExamSession() {
|
|
const { examId: examIdParam } = useParams();
|
|
const examId = Number(examIdParam);
|
|
const navigate = useNavigate();
|
|
const { data: session, isLoading, isError } = useExamSession(examId);
|
|
const autoSave = useExamAutoSave();
|
|
const submitMut = useExamSubmit();
|
|
|
|
const [sectionIdx, setSectionIdx] = useState(0);
|
|
const [questionIdx, setQuestionIdx] = useState(0);
|
|
const [answers, setAnswers] = useState<Map<number, ExamAnswer>>(new Map());
|
|
const [sectionGate, setSectionGate] = useState<{ open: boolean; sec: number; remaining: number } | null>(null);
|
|
const [reviewOpen, setReviewOpen] = useState(false);
|
|
const [playing, setPlaying] = useState(false);
|
|
|
|
const totalSecRef = useRef(0);
|
|
const qStartRef = useRef(Date.now());
|
|
|
|
useEffect(() => {
|
|
if (!session) return;
|
|
setAnswers(buildAnswerMap(session.sections));
|
|
totalSecRef.current = session.total_time_min * 60;
|
|
setTick(0);
|
|
}, [session]);
|
|
|
|
const section = session?.sections[sectionIdx];
|
|
const question = section?.questions[questionIdx];
|
|
const totalQuestions = useMemo(
|
|
() => session?.sections.reduce((n, s) => n + s.questions.length, 0) ?? 0,
|
|
[session],
|
|
);
|
|
|
|
const [tick, setTick] = useState(0);
|
|
useEffect(() => {
|
|
const id = window.setInterval(() => setTick((t) => t + 1), 1000);
|
|
return () => window.clearInterval(id);
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
qStartRef.current = Date.now();
|
|
}, [question?.id]);
|
|
|
|
const answeredCount = useMemo(() => {
|
|
let n = 0;
|
|
answers.forEach((a) => {
|
|
if (a.answer === null || a.answer === "") return;
|
|
if (Array.isArray(a.answer) && a.answer.length === 0) return;
|
|
n += 1;
|
|
});
|
|
return n;
|
|
}, [answers]);
|
|
|
|
const progressPct = totalQuestions ? (answeredCount / totalQuestions) * 100 : 0;
|
|
|
|
const updateAnswer = useCallback(
|
|
(questionId: number, patch: Partial<ExamAnswer>) => {
|
|
setAnswers((prev) => {
|
|
const next = new Map(prev);
|
|
const cur = next.get(questionId);
|
|
if (!cur) return prev;
|
|
const spent = Date.now() - qStartRef.current;
|
|
next.set(questionId, { ...cur, ...patch, time_spent_ms: cur.time_spent_ms + spent });
|
|
qStartRef.current = Date.now();
|
|
return next;
|
|
});
|
|
},
|
|
[],
|
|
);
|
|
|
|
const currentSectionAnswers = useCallback((): ExamAnswer[] => {
|
|
if (!section) return [];
|
|
return section.questions.map((q) => answers.get(q.id)!).filter(Boolean);
|
|
}, [section, answers]);
|
|
|
|
useEffect(() => {
|
|
if (!session || !section) return;
|
|
const id = window.setInterval(() => {
|
|
autoSave.mutate({
|
|
examId,
|
|
payload: { section_id: section.id, answers: currentSectionAnswers() },
|
|
});
|
|
}, 10000);
|
|
return () => window.clearInterval(id);
|
|
}, [session, section, examId, autoSave, currentSectionAnswers]);
|
|
|
|
const handleFlag = () => {
|
|
if (!question) return;
|
|
const cur = answers.get(question.id);
|
|
if (!cur) return;
|
|
updateAnswer(question.id, { flagged: !cur.flagged });
|
|
};
|
|
|
|
const goNext = () => {
|
|
if (!session || !section) return;
|
|
if (questionIdx < section.questions.length - 1) {
|
|
setQuestionIdx((i) => i + 1);
|
|
return;
|
|
}
|
|
if (sectionIdx < session.sections.length - 1) {
|
|
setSectionGate({ open: true, sec: sectionIdx + 1, remaining: 5 });
|
|
return;
|
|
}
|
|
setReviewOpen(true);
|
|
};
|
|
|
|
const goPrev = () => {
|
|
if (questionIdx > 0) {
|
|
setQuestionIdx((i) => i - 1);
|
|
return;
|
|
}
|
|
if (sectionIdx > 0) {
|
|
const prevSec = session!.sections[sectionIdx - 1];
|
|
setSectionIdx((s) => s - 1);
|
|
setQuestionIdx(prevSec.questions.length - 1);
|
|
}
|
|
};
|
|
|
|
useEffect(() => {
|
|
if (!sectionGate?.open) return;
|
|
if (sectionGate.remaining <= 0) {
|
|
setSectionIdx(sectionGate.sec);
|
|
setQuestionIdx(0);
|
|
setSectionGate(null);
|
|
return;
|
|
}
|
|
const t = window.setTimeout(
|
|
() => setSectionGate((g) => (g ? { ...g, remaining: g.remaining - 1 } : null)),
|
|
1000,
|
|
);
|
|
return () => window.clearTimeout(t);
|
|
}, [sectionGate]);
|
|
|
|
const summary = useMemo(() => {
|
|
let answered = 0;
|
|
let unanswered = 0;
|
|
let flagged = 0;
|
|
session?.sections.forEach((sec) => {
|
|
sec.questions.forEach((q) => {
|
|
const a = answers.get(q.id);
|
|
if (!a) {
|
|
unanswered += 1;
|
|
return;
|
|
}
|
|
if (a.flagged) flagged += 1;
|
|
const empty =
|
|
a.answer === null ||
|
|
a.answer === "" ||
|
|
(Array.isArray(a.answer) && a.answer.length === 0);
|
|
if (empty) unanswered += 1;
|
|
else answered += 1;
|
|
});
|
|
});
|
|
return { answered, unanswered, flagged, total: totalQuestions };
|
|
}, [session, answers, totalQuestions]);
|
|
|
|
const renderQuestion = (q: ExamQuestion) => {
|
|
const a = answers.get(q.id);
|
|
if (!a) return null;
|
|
const nt = normalizeType(q.type);
|
|
|
|
if (nt.includes("listen") || q.audio_url) {
|
|
return (
|
|
<div className="space-y-4">
|
|
<div className="flex items-center gap-3 rounded-lg border bg-muted/40 p-4">
|
|
<Button type="button" variant="outline" size="icon" onClick={() => setPlaying((p) => !p)}>
|
|
{playing ? <Pause className="h-5 w-5" /> : <Play className="h-5 w-5" />}
|
|
</Button>
|
|
<div className="h-2 flex-1 rounded-full bg-muted">
|
|
<div className="h-2 w-1/3 rounded-full bg-primary" />
|
|
</div>
|
|
<span className="text-sm text-muted-foreground">0:00 / 3:42</span>
|
|
</div>
|
|
{q.options?.length ? (
|
|
<RadioGroup
|
|
value={typeof a.answer === "string" ? a.answer : ""}
|
|
onValueChange={(v) => updateAnswer(q.id, { answer: v })}
|
|
className="space-y-2"
|
|
>
|
|
{q.options.map((o) => (
|
|
<div key={o.label} className="flex items-center space-x-2">
|
|
<RadioGroupItem value={o.label} id={`${q.id}-${o.label}`} />
|
|
<Label htmlFor={`${q.id}-${o.label}`}>{o.text}</Label>
|
|
</div>
|
|
))}
|
|
</RadioGroup>
|
|
) : null}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (nt.includes("multi") || nt === "mcq_multi") {
|
|
const selected = Array.isArray(a.answer) ? a.answer : [];
|
|
return (
|
|
<div className="space-y-3">
|
|
{q.options?.map((o) => (
|
|
<div key={o.label} className="flex items-center space-x-2">
|
|
<Checkbox
|
|
id={`${q.id}-${o.label}`}
|
|
checked={selected.includes(o.label)}
|
|
onCheckedChange={(ck) => {
|
|
const on = ck === true;
|
|
const next = on ? [...selected, o.label] : selected.filter((x) => x !== o.label);
|
|
updateAnswer(q.id, { answer: next });
|
|
}}
|
|
/>
|
|
<Label htmlFor={`${q.id}-${o.label}`}>{o.text}</Label>
|
|
</div>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (nt.includes("gap")) {
|
|
return (
|
|
<Input
|
|
value={typeof a.answer === "string" ? a.answer : ""}
|
|
onChange={(e) => updateAnswer(q.id, { answer: e.target.value })}
|
|
placeholder="Your answer"
|
|
className="max-w-md"
|
|
/>
|
|
);
|
|
}
|
|
|
|
if (nt.includes("true") || nt.includes("tfng") || nt === "yes_no_not_given") {
|
|
const opts = q.options?.length
|
|
? q.options
|
|
: [
|
|
{ label: "T", text: "True" },
|
|
{ label: "F", text: "False" },
|
|
{ label: "NG", text: "Not Given" },
|
|
];
|
|
return (
|
|
<RadioGroup
|
|
value={typeof a.answer === "string" ? a.answer : ""}
|
|
onValueChange={(v) => updateAnswer(q.id, { answer: v })}
|
|
className="space-y-2"
|
|
>
|
|
{opts.map((o) => (
|
|
<div key={o.label} className="flex items-center space-x-2">
|
|
<RadioGroupItem value={o.label} id={`${q.id}-${o.label}`} />
|
|
<Label htmlFor={`${q.id}-${o.label}`}>{o.text}</Label>
|
|
</div>
|
|
))}
|
|
</RadioGroup>
|
|
);
|
|
}
|
|
|
|
if (nt.includes("writing") || nt.includes("essay")) {
|
|
const text = typeof a.answer === "string" ? a.answer : "";
|
|
const wc = countWords(text);
|
|
const min = q.min_words ?? 150;
|
|
return (
|
|
<div className="space-y-2">
|
|
<Textarea
|
|
value={text}
|
|
onChange={(e) => updateAnswer(q.id, { answer: e.target.value })}
|
|
className="min-h-[200px]"
|
|
/>
|
|
<p className={cn("text-sm", wc < min ? "text-amber-600" : "text-muted-foreground")}>
|
|
{wc} words · minimum {min}
|
|
</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (nt.includes("speak") || nt.includes("record") || nt.includes("audio")) {
|
|
return (
|
|
<div className="rounded-lg border border-dashed p-8 text-center text-muted-foreground">
|
|
Recording interface will appear here.
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<RadioGroup
|
|
value={typeof a.answer === "string" ? a.answer : ""}
|
|
onValueChange={(v) => updateAnswer(q.id, { answer: v })}
|
|
className="space-y-2"
|
|
>
|
|
{q.options?.map((o) => (
|
|
<div key={o.label} className="flex items-center space-x-2">
|
|
<RadioGroupItem value={o.label} id={`${q.id}-${o.label}`} />
|
|
<Label htmlFor={`${q.id}-${o.label}`}>{o.text}</Label>
|
|
</div>
|
|
))}
|
|
</RadioGroup>
|
|
);
|
|
};
|
|
|
|
if (isLoading) {
|
|
return (
|
|
<div className="fixed inset-0 z-[100] flex items-center justify-center bg-background">
|
|
<div className="h-10 w-10 animate-spin rounded-full border-2 border-primary border-t-transparent" />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (isError || !session) {
|
|
return (
|
|
<div className="fixed inset-0 z-[100] flex flex-col items-center justify-center gap-4 bg-background p-6">
|
|
<p className="text-destructive text-lg font-medium">Unable to load this exam session.</p>
|
|
<p className="text-muted-foreground text-sm">The exam may not exist or the server is unreachable.</p>
|
|
<Button variant="outline" onClick={() => navigate(-1)}>Go Back</Button>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const remainOverall = Math.max(0, totalSecRef.current - tick);
|
|
const mm = Math.floor(remainOverall / 60);
|
|
const ss = remainOverall % 60;
|
|
|
|
return (
|
|
<div className="fixed inset-0 z-[100] flex flex-col bg-background">
|
|
<header className="flex flex-wrap items-center justify-between gap-4 border-b px-6 py-3">
|
|
<div>
|
|
<h1 className="text-lg font-semibold leading-tight">{session.title}</h1>
|
|
<p className="text-sm text-muted-foreground">{section?.title}</p>
|
|
</div>
|
|
<div className="flex flex-wrap items-center gap-6">
|
|
<span className="font-mono text-lg tabular-nums">
|
|
{String(mm).padStart(2, "0")}:{String(ss).padStart(2, "0")}
|
|
</span>
|
|
<div className="w-48 space-y-1">
|
|
<Progress value={progressPct} className="h-2" />
|
|
<p className="text-xs text-muted-foreground">Overall progress</p>
|
|
</div>
|
|
</div>
|
|
</header>
|
|
|
|
<div className="flex flex-1 min-h-0 flex-col md:flex-row">
|
|
<ScrollArea className="flex-1 p-6">
|
|
{question ? (
|
|
<Card className="border-none shadow-none">
|
|
<CardContent className="space-y-6 p-0">
|
|
{question.passage_text ? (
|
|
<div className="rounded-lg bg-muted/50 p-4 text-sm leading-relaxed">{question.passage_text}</div>
|
|
) : null}
|
|
<div className="prose prose-sm dark:prose-invert max-w-none">
|
|
<p className="font-medium">{question.stem}</p>
|
|
</div>
|
|
{renderQuestion(question)}
|
|
</CardContent>
|
|
</Card>
|
|
) : null}
|
|
</ScrollArea>
|
|
</div>
|
|
|
|
<footer className="flex flex-wrap items-center justify-between gap-3 border-t px-6 py-4">
|
|
<div className="flex gap-2">
|
|
<Button type="button" variant="outline" onClick={goPrev} disabled={sectionIdx === 0 && questionIdx === 0}>
|
|
<ChevronLeft className="mr-1 h-4 w-4" />
|
|
Previous
|
|
</Button>
|
|
<Button type="button" variant="outline" onClick={handleFlag}>
|
|
<Flag className="mr-1 h-4 w-4" />
|
|
Flag for Review
|
|
</Button>
|
|
</div>
|
|
<div className="flex gap-2">
|
|
<Button type="button" onClick={goNext}>
|
|
Next
|
|
<ChevronRight className="ml-1 h-4 w-4" />
|
|
</Button>
|
|
</div>
|
|
</footer>
|
|
|
|
<Dialog open={!!sectionGate?.open} onOpenChange={() => setSectionGate(null)}>
|
|
<DialogContent className="sm:max-w-sm">
|
|
<DialogHeader>
|
|
<DialogTitle>Next section</DialogTitle>
|
|
<DialogDescription>
|
|
The next section begins in{" "}
|
|
<span className="font-mono font-semibold">{sectionGate?.remaining ?? 0}</span>s.
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
</DialogContent>
|
|
</Dialog>
|
|
|
|
<Dialog open={reviewOpen} onOpenChange={setReviewOpen}>
|
|
<DialogContent className="max-w-lg">
|
|
<DialogHeader>
|
|
<DialogTitle>Review and submit</DialogTitle>
|
|
<DialogDescription>
|
|
{summary.answered} answered · {summary.unanswered} unanswered · {summary.flagged} flagged ·{" "}
|
|
{summary.total} total
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
<DialogFooter className="gap-2 sm:justify-end">
|
|
<Button type="button" variant="outline" onClick={() => setReviewOpen(false)}>
|
|
Back
|
|
</Button>
|
|
<Button
|
|
type="button"
|
|
onClick={() => {
|
|
submitMut.mutate(examId, {
|
|
onSuccess: () => {
|
|
setReviewOpen(false);
|
|
navigate(`/student/exam/${examId}/status`);
|
|
},
|
|
});
|
|
}}
|
|
disabled={submitMut.isPending}
|
|
>
|
|
Submit exam
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
</div>
|
|
);
|
|
}
|