- Fix ELAI video generation (correct render endpoint, script splitting for 60s limit) - Fix speaking script generation error handling and empty response display - Add custom exam list API (GET /api/exam/custom/list) - Add assignments REST API (list, create, get) - Add rubrics REST API (list, create) - Enhance Generation page: dynamic exam structures, auto-module selection, preview dialog, audio player - Improve submit feedback with exam ID and status in toast notifications - Fix ExamsListPage to show both custom exams and exam sessions - Connect RubricsPage to backend API with fallback data - Add Dockerfile, docker-compose.yml, requirements.txt for deployment - Fix placement, grading, scoring, and auth controllers - Add ErrorBoundary component for frontend resilience - Add QA report and credentials documentation Made-with: Cursor
452 lines
17 KiB
TypeScript
452 lines
17 KiB
TypeScript
import { useCallback, useEffect, useRef, useState } from "react";
|
|
import { useNavigate, useSearchParams, useLocation } from "react-router-dom";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Checkbox } from "@/components/ui/checkbox";
|
|
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
|
import { Skeleton } from "@/components/ui/skeleton";
|
|
import { Progress } from "@/components/ui/progress";
|
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
|
import { Mic, Loader2, Square } from "lucide-react";
|
|
import { placementService } from "@/services/placement.service";
|
|
import { usePlacementAnswer, usePlacementAutoSave } from "@/hooks/queries/usePlacement";
|
|
import type { CATQuestion, CATSection } from "@/types";
|
|
import { cn } from "@/lib/utils";
|
|
|
|
type LocationState = { question?: CATQuestion; sessionId?: string };
|
|
|
|
function sectionTitle(s: CATSection): string {
|
|
return s.charAt(0).toUpperCase() + s.slice(1);
|
|
}
|
|
|
|
export default function PlacementTest() {
|
|
const [searchParams] = useSearchParams();
|
|
const sessionId = searchParams.get("session") || "";
|
|
const navigate = useNavigate();
|
|
const location = useLocation();
|
|
const locState = location.state as LocationState | undefined;
|
|
|
|
const answerMut = usePlacementAnswer();
|
|
const autoSave = usePlacementAutoSave();
|
|
|
|
const [question, setQuestion] = useState<CATQuestion | null>(null);
|
|
const [initializing, setInitializing] = useState(true);
|
|
const [betweenQuestions, setBetweenQuestions] = useState(false);
|
|
const [progressInfo, setProgressInfo] = useState<{ current: number; estimated_total: number } | null>(null);
|
|
const [sectionBridge, setSectionBridge] = useState<{
|
|
from: CATSection;
|
|
to: CATSection;
|
|
nextQuestion: CATQuestion;
|
|
} | null>(null);
|
|
|
|
const [singleAnswer, setSingleAnswer] = useState("");
|
|
const [multiAnswer, setMultiAnswer] = useState<string[]>([]);
|
|
const questionStartedAt = useRef<number>(Date.now());
|
|
const draftRef = useRef<{ question_id: number; answer: string | string[] } | null>(null);
|
|
|
|
useEffect(() => {
|
|
if (!sessionId) {
|
|
navigate("/student/placement", { replace: true });
|
|
return;
|
|
}
|
|
const fromState = locState?.question;
|
|
const fromStorage = sessionStorage.getItem(`placement_session_${sessionId}`);
|
|
let parsed: CATQuestion | null = null;
|
|
if (fromStorage) {
|
|
try {
|
|
const j = JSON.parse(fromStorage) as { question?: CATQuestion };
|
|
parsed = j.question ?? null;
|
|
} catch {
|
|
parsed = null;
|
|
}
|
|
}
|
|
const q = fromState ?? parsed;
|
|
if (q) {
|
|
setQuestion(q);
|
|
questionStartedAt.current = Date.now();
|
|
}
|
|
setInitializing(false);
|
|
}, [sessionId, navigate, locState]);
|
|
|
|
useEffect(() => {
|
|
if (!question) return;
|
|
setSingleAnswer("");
|
|
setMultiAnswer([]);
|
|
questionStartedAt.current = Date.now();
|
|
}, [question?.id]);
|
|
|
|
useEffect(() => {
|
|
if (!question) return;
|
|
const id = question.id;
|
|
if (question.type === "mcq_multi") {
|
|
draftRef.current = { question_id: id, answer: multiAnswer };
|
|
} else {
|
|
draftRef.current = { question_id: id, answer: singleAnswer };
|
|
}
|
|
}, [question, singleAnswer, multiAnswer]);
|
|
|
|
useEffect(() => {
|
|
if (!sessionId || !draftRef.current) return;
|
|
const tick = window.setInterval(() => {
|
|
const d = draftRef.current;
|
|
if (!d) return;
|
|
void autoSave.mutate({ session_id: sessionId, current_answer: d });
|
|
}, 10000);
|
|
return () => window.clearInterval(tick);
|
|
}, [sessionId, autoSave]);
|
|
|
|
const elapsedRef = useRef(0);
|
|
const [, setTick] = useState(0);
|
|
useEffect(() => {
|
|
const t = window.setInterval(() => {
|
|
elapsedRef.current += 1;
|
|
setTick((x) => x + 1);
|
|
}, 1000);
|
|
return () => window.clearInterval(t);
|
|
}, []);
|
|
|
|
const formatElapsed = (sec: number) => {
|
|
const m = Math.floor(sec / 60);
|
|
const s = sec % 60;
|
|
return `${m}:${s.toString().padStart(2, "0")}`;
|
|
};
|
|
|
|
const buildAnswerPayload = (): string | string[] => {
|
|
if (!question) return "";
|
|
if (question.type === "mcq_multi") return multiAnswer;
|
|
if (question.type === "gap_fill") return singleAnswer.trim();
|
|
return singleAnswer;
|
|
};
|
|
|
|
const submit = async () => {
|
|
if (!question || !sessionId) return;
|
|
const timeSpent = Date.now() - questionStartedAt.current;
|
|
setBetweenQuestions(true);
|
|
try {
|
|
const res = await answerMut.mutateAsync({
|
|
session_id: sessionId,
|
|
question_id: question.id,
|
|
answer: buildAnswerPayload(),
|
|
time_spent_ms: timeSpent,
|
|
});
|
|
if (res.progress) setProgressInfo(res.progress);
|
|
if (res.test_complete) {
|
|
navigate(`/student/placement/results?session=${encodeURIComponent(sessionId)}`);
|
|
return;
|
|
}
|
|
if (res.section_complete && res.next_question) {
|
|
setSectionBridge({
|
|
from: question.section,
|
|
to: res.next_question.section,
|
|
nextQuestion: res.next_question,
|
|
});
|
|
return;
|
|
}
|
|
if (res.next_question) {
|
|
setQuestion(res.next_question);
|
|
sessionStorage.setItem(`placement_session_${sessionId}`, JSON.stringify({ question: res.next_question }));
|
|
}
|
|
} finally {
|
|
setBetweenQuestions(false);
|
|
}
|
|
};
|
|
|
|
const continueAfterSection = () => {
|
|
if (!sectionBridge || !sessionId) return;
|
|
setQuestion(sectionBridge.nextQuestion);
|
|
sessionStorage.setItem(
|
|
`placement_session_${sessionId}`,
|
|
JSON.stringify({ question: sectionBridge.nextQuestion }),
|
|
);
|
|
setSectionBridge(null);
|
|
};
|
|
|
|
const toggleMulti = (label: string) => {
|
|
setMultiAnswer((prev) => (prev.includes(label) ? prev.filter((x) => x !== label) : [...prev, label]));
|
|
};
|
|
|
|
const displayProgress = progressInfo ?? {
|
|
current: question?.estimated_total ? 1 : 0,
|
|
estimated_total: question?.estimated_total ?? 0,
|
|
};
|
|
|
|
const approxFinal =
|
|
displayProgress.estimated_total > 0 && displayProgress.current >= displayProgress.estimated_total;
|
|
|
|
if (!sessionId) return null;
|
|
|
|
if (!initializing && !question && !sectionBridge) {
|
|
return (
|
|
<div className="min-h-screen flex items-center justify-center p-6 bg-background">
|
|
<Card className="max-w-md w-full border-0 shadow-lg">
|
|
<CardHeader>
|
|
<CardTitle>Session unavailable</CardTitle>
|
|
<CardDescription>Return to the briefing and start the test again.</CardDescription>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<Button onClick={() => navigate("/student/placement")}>Back</Button>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (sectionBridge) {
|
|
return (
|
|
<div className="min-h-screen flex flex-col bg-background">
|
|
<header className="border-b px-6 py-4 flex items-center justify-between bg-card/80 backdrop-blur">
|
|
<span className="text-sm font-medium text-muted-foreground">Section complete</span>
|
|
<span className="text-sm tabular-nums">{formatElapsed(elapsedRef.current)}</span>
|
|
</header>
|
|
<main className="flex-1 flex items-center justify-center p-6">
|
|
<Card className="max-w-lg w-full border-0 shadow-xl text-center">
|
|
<CardHeader>
|
|
<CardTitle>
|
|
{sectionTitle(sectionBridge.from)} complete. Next: {sectionTitle(sectionBridge.to)}
|
|
</CardTitle>
|
|
<CardDescription>Take a short breath — continue when you're ready.</CardDescription>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<Button size="lg" className="w-full" onClick={continueAfterSection}>
|
|
Continue
|
|
</Button>
|
|
</CardContent>
|
|
</Card>
|
|
</main>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="min-h-screen flex flex-col bg-gradient-to-b from-muted/20 to-background">
|
|
<header className="sticky top-0 z-10 border-b px-4 md:px-8 py-3 flex flex-wrap items-center gap-4 justify-between bg-card/90 backdrop-blur supports-[backdrop-filter]:bg-card/80">
|
|
<div className="flex items-center gap-4 min-w-0">
|
|
<span className="text-sm font-semibold tabular-nums text-primary">{formatElapsed(elapsedRef.current)}</span>
|
|
<span className="text-sm font-medium truncate">{question ? sectionTitle(question.section) : "…"}</span>
|
|
</div>
|
|
<div className="flex items-center gap-3 flex-1 max-w-md min-w-[200px]">
|
|
<Progress
|
|
value={
|
|
displayProgress.estimated_total
|
|
? Math.min(100, (displayProgress.current / displayProgress.estimated_total) * 100)
|
|
: 0
|
|
}
|
|
className="h-2 flex-1"
|
|
/>
|
|
<span className="text-xs text-muted-foreground whitespace-nowrap tabular-nums">
|
|
Question {displayProgress.current} of ~{displayProgress.estimated_total || "?"}
|
|
</span>
|
|
</div>
|
|
</header>
|
|
|
|
<main className="flex-1 flex items-stretch justify-center p-4 md:p-8">
|
|
<div className="w-full max-w-3xl">
|
|
{initializing || betweenQuestions || !question ? (
|
|
<div className="space-y-4">
|
|
<Skeleton className="h-8 w-3/4" />
|
|
<Skeleton className="h-24 w-full" />
|
|
<Skeleton className="h-10 w-full" />
|
|
</div>
|
|
) : (
|
|
<Card className="border-0 shadow-lg">
|
|
<CardContent className="pt-8 space-y-8">
|
|
{question.passage_text && (
|
|
<div className="rounded-lg bg-muted/50 p-4 text-sm leading-relaxed whitespace-pre-wrap">
|
|
{question.passage_text}
|
|
</div>
|
|
)}
|
|
<p className="text-lg font-medium leading-snug">{question.stem}</p>
|
|
|
|
{(question.type === "mcq" || question.type === "definition_match") && question.options && (
|
|
<RadioGroup value={singleAnswer} onValueChange={setSingleAnswer} className="space-y-3">
|
|
{question.options.map((o) => (
|
|
<label
|
|
key={o.label}
|
|
className={cn(
|
|
"flex cursor-pointer items-center gap-3 rounded-lg border p-4 transition-colors",
|
|
singleAnswer === o.label && "border-primary bg-primary/5",
|
|
)}
|
|
>
|
|
<RadioGroupItem value={o.label} id={`opt-${o.label}`} />
|
|
<span className="text-sm leading-snug">{o.text}</span>
|
|
</label>
|
|
))}
|
|
</RadioGroup>
|
|
)}
|
|
|
|
{question.type === "mcq_multi" && question.options && (
|
|
<div className="space-y-3">
|
|
{question.options.map((o) => (
|
|
<label
|
|
key={o.label}
|
|
className="flex cursor-pointer items-center gap-3 rounded-lg border p-4"
|
|
>
|
|
<Checkbox
|
|
checked={multiAnswer.includes(o.label)}
|
|
onCheckedChange={() => toggleMulti(o.label)}
|
|
/>
|
|
<span className="text-sm">{o.text}</span>
|
|
</label>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{question.type === "gap_fill" && (
|
|
<Input
|
|
value={singleAnswer}
|
|
onChange={(e) => setSingleAnswer(e.target.value)}
|
|
placeholder="Your answer"
|
|
className="text-base"
|
|
/>
|
|
)}
|
|
|
|
{question.type === "tfng" && (
|
|
<RadioGroup value={singleAnswer} onValueChange={setSingleAnswer} className="space-y-3">
|
|
{(question.options?.length
|
|
? question.options
|
|
: [
|
|
{ label: "T", text: "True" },
|
|
{ label: "F", text: "False" },
|
|
{ label: "NG", text: "Not Given" },
|
|
]
|
|
).map((o) => (
|
|
<label
|
|
key={o.label}
|
|
className={cn(
|
|
"flex cursor-pointer items-center gap-3 rounded-lg border p-4",
|
|
singleAnswer === o.label && "border-primary bg-primary/5",
|
|
)}
|
|
>
|
|
<RadioGroupItem value={o.label} id={`tfng-${o.label}`} />
|
|
<span className="text-sm">{o.text}</span>
|
|
</label>
|
|
))}
|
|
</RadioGroup>
|
|
)}
|
|
|
|
{question.type === "audio_recording" && (
|
|
<PlacementSpeakingRecorder
|
|
sessionId={sessionId}
|
|
promptId={question.id}
|
|
onUploaded={() => setSingleAnswer("audio_submitted")}
|
|
/>
|
|
)}
|
|
|
|
<div className="flex justify-end pt-4">
|
|
<Button
|
|
size="lg"
|
|
className="min-w-[160px]"
|
|
disabled={answerMut.isPending || betweenQuestions}
|
|
onClick={() => void submit()}
|
|
>
|
|
{answerMut.isPending || betweenQuestions ? (
|
|
<>
|
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
|
Saving
|
|
</>
|
|
) : approxFinal ? (
|
|
"Finish Test"
|
|
) : (
|
|
"Submit answer"
|
|
)}
|
|
</Button>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
</div>
|
|
</main>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function PlacementSpeakingRecorder({ sessionId, promptId, onUploaded }: {
|
|
sessionId: string; promptId: number; onUploaded: () => void;
|
|
}) {
|
|
const [recording, setRecording] = useState(false);
|
|
const [uploading, setUploading] = useState(false);
|
|
const [audioUrl, setAudioUrl] = useState<string | null>(null);
|
|
const [elapsed, setElapsed] = useState(0);
|
|
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
|
|
const chunksRef = useRef<Blob[]>([]);
|
|
const timerRef = useRef<number>(0);
|
|
|
|
const startRecording = async () => {
|
|
try {
|
|
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
|
const mr = new MediaRecorder(stream, { mimeType: "audio/webm" });
|
|
chunksRef.current = [];
|
|
mr.ondataavailable = (e) => { if (e.data.size > 0) chunksRef.current.push(e.data); };
|
|
mr.onstop = () => {
|
|
const blob = new Blob(chunksRef.current, { type: "audio/webm" });
|
|
setAudioUrl(URL.createObjectURL(blob));
|
|
stream.getTracks().forEach((t) => t.stop());
|
|
uploadAudio(blob);
|
|
};
|
|
mediaRecorderRef.current = mr;
|
|
mr.start();
|
|
setRecording(true);
|
|
setElapsed(0);
|
|
timerRef.current = window.setInterval(() => setElapsed((t) => t + 1), 1000);
|
|
} catch {
|
|
// microphone permission denied
|
|
}
|
|
};
|
|
|
|
const stopRecording = () => {
|
|
mediaRecorderRef.current?.stop();
|
|
setRecording(false);
|
|
window.clearInterval(timerRef.current);
|
|
};
|
|
|
|
const uploadAudio = async (blob: Blob) => {
|
|
setUploading(true);
|
|
try {
|
|
const file = new File([blob], "speaking.webm", { type: "audio/webm" });
|
|
await placementService.uploadSpeaking(sessionId, promptId, file);
|
|
onUploaded();
|
|
} catch {
|
|
// upload error handled silently
|
|
} finally {
|
|
setUploading(false);
|
|
}
|
|
};
|
|
|
|
const mm = Math.floor(elapsed / 60);
|
|
const ss = elapsed % 60;
|
|
|
|
return (
|
|
<div className="flex flex-col items-center gap-4 py-8 rounded-xl border border-dashed bg-muted/30">
|
|
{!recording && !audioUrl && (
|
|
<>
|
|
<Mic className="h-12 w-12 text-muted-foreground" />
|
|
<p className="text-sm text-muted-foreground text-center max-w-sm">
|
|
Click the button below to start recording your speaking response.
|
|
</p>
|
|
<Button type="button" variant="secondary" size="lg" onClick={startRecording} className="gap-2">
|
|
<Mic className="h-5 w-5" /> Start Recording
|
|
</Button>
|
|
</>
|
|
)}
|
|
{recording && (
|
|
<div className="flex flex-col items-center gap-3">
|
|
<div className="flex items-center gap-3">
|
|
<div className="h-3 w-3 rounded-full bg-red-500 animate-pulse" />
|
|
<span className="font-mono text-lg tabular-nums">{String(mm).padStart(2, "0")}:{String(ss).padStart(2, "0")}</span>
|
|
</div>
|
|
<Button type="button" variant="destructive" size="lg" onClick={stopRecording} className="gap-2">
|
|
<Square className="h-4 w-4" /> Stop Recording
|
|
</Button>
|
|
</div>
|
|
)}
|
|
{audioUrl && (
|
|
<div className="space-y-3 w-full max-w-md">
|
|
<audio controls src={audioUrl} className="w-full" />
|
|
{uploading && <p className="text-sm text-center text-muted-foreground flex items-center justify-center gap-2"><Loader2 className="h-4 w-4 animate-spin" /> Uploading...</p>}
|
|
{!uploading && <p className="text-sm text-center text-green-600">Recording uploaded successfully</p>}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|