feat(v3): restructure project + add complete frontend
- 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
This commit is contained in:
458
src/pages/student/ExamSession.tsx
Normal file
458
src/pages/student/ExamSession.tsx
Normal file
@@ -0,0 +1,458 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user