Files
full_encoach_platform/frontend/src/pages/student/ExamSession.tsx
Yamen Ahmad 35ccc0dfb2 feat(platform): ship AI fallback stack and entity-scoped course planning
Unifies the new LangGraph-driven course-plan/media flow with robust provider fallbacks, admin AI provider settings, editable book-style materials, and strict entity isolation across LMS/course-plan APIs. Adds admin-only entity membership management in the Entities UI so users can switch linked entities directly from the platform.

Made-with: Cursor
2026-04-26 02:34:52 +04:00

654 lines
23 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, ExamOptionItem, 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, Mic, Square } from "lucide-react";
import { cn } from "@/lib/utils";
import { mediaService } from "@/services/media.service";
function normalizeType(t: string | null | undefined) {
return (t ?? "").toLowerCase().replace(/\s+/g, "_");
}
function normalizeOption(o: ExamOptionItem): { label: string; text: string } {
if (typeof o === "string") return { label: o, text: o };
return { label: o.label ?? o.text ?? "", text: o.text ?? o.label ?? "" };
}
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) {
// The backend `/api/exam/<id>/session` endpoint returns a
// `saved_answer` per question whenever a previous attempt is
// resumed (browser refresh, network drop, accidental tab-close).
// Seed the local map from that value so the student picks up
// exactly where they left off — without this, autosaved answers
// were silently dropped on every reload.
const saved = (q as unknown as { saved_answer?: unknown }).saved_answer;
const flagged = Boolean(
(q as unknown as { flagged?: boolean }).flagged,
);
map.set(q.id, {
question_id: q.id,
answer:
saved === undefined || saved === null
? null
: (saved as ExamAnswer["answer"]),
flagged,
time_spent_ms: 0,
});
}
}
return map;
}
export default function ExamSession() {
const { examId: examIdParam } = useParams();
const examId = Number(examIdParam);
const navigate = useNavigate();
const { data: rawSession, isLoading, isError } = useExamSession(examId);
const autoSave = useExamAutoSave();
const submitMut = useExamSubmit();
const session = useMemo(() => {
if (!rawSession) return rawSession;
const raw = rawSession as any;
return {
...raw,
title: raw.title || raw.exam_title || "",
sections: (raw.sections || []).map((s: any) => ({
...s,
questions: (s.questions || []).map((q: any) => ({
...q,
type: q.type || q.question_type || q.skill || "",
})),
})),
} as typeof rawSession;
}, [rawSession]);
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 attemptId = (session as any)?.attempt_id;
const id = window.setInterval(() => {
autoSave.mutate({
examId,
payload: { attempt_id: attemptId, 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);
const opts = (q.options ?? []).map(normalizeOption);
if (nt.includes("listen") || q.audio_url) {
return (
<div className="space-y-4">
<ListeningPlayer audioUrl={q.audio_url} audioBase64={q.audio_base64} />
{opts.length ? (
<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>
) : null}
</div>
);
}
if (nt.includes("multi") || nt === "mcq_multi") {
const selected = Array.isArray(a.answer) ? a.answer : [];
return (
<div className="space-y-3">
{opts.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") || nt.includes("fill") || nt.includes("short_answer") || nt.includes("summary")) {
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 tfOpts = opts.length
? opts
: [
{ label: "TRUE", text: "TRUE" },
{ label: "FALSE", text: "FALSE" },
{ label: "NOT GIVEN", text: "NOT GIVEN" },
];
return (
<RadioGroup
value={typeof a.answer === "string" ? a.answer : ""}
onValueChange={(v) => updateAnswer(q.id, { answer: v })}
className="space-y-2"
>
{tfOpts.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 (
<SpeakingRecorder
questionId={q.id}
onRecorded={(blob) => {
const url = URL.createObjectURL(blob);
updateAnswer(q.id, { answer: url });
}}
/>
);
}
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 (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">
{section?.passage_text ? (
<ScrollArea className="w-full md:w-1/2 border-r p-6">
<div className="prose prose-sm dark:prose-invert max-w-none">
<h3 className="text-base font-semibold mb-2">{section.title}</h3>
{section.difficulty && (
<span className="inline-block text-xs font-medium bg-primary/10 text-primary px-2 py-0.5 rounded mb-3">
Level: {section.difficulty}
</span>
)}
<div className="rounded-lg bg-muted/50 p-4 text-sm leading-relaxed whitespace-pre-line">
{section.passage_text}
</div>
</div>
</ScrollArea>
) : null}
<ScrollArea className={cn("flex-1 p-6", section?.passage_text && "md:w-1/2")}>
{question ? (
<Card className="border-none shadow-none">
<CardContent className="space-y-6 p-0">
{section?.instructions_text ? (
<div className="rounded-lg bg-blue-50 dark:bg-blue-900/20 p-4 text-sm leading-relaxed border border-blue-200 dark:border-blue-800">
<p className="font-medium text-blue-800 dark:text-blue-200 mb-1">Instructions</p>
{section.instructions_text}
</div>
) : null}
<div className="flex items-center gap-3">
<span className="inline-flex items-center justify-center h-7 w-7 rounded-full bg-primary text-primary-foreground text-xs font-bold">
{questionIdx + 1}
</span>
<span className="text-xs text-muted-foreground">
of {section?.questions.length ?? 0} · {question.marks} mark{question.marks !== 1 ? "s" : ""}
</span>
</div>
<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={() => {
const attemptId = (session as any)?.attempt_id;
const allAnswers = Array.from(answers.entries()).map(([qId, a]) => ({
question_id: qId,
answer: a.answer ?? "",
}));
submitMut.mutate(
{ examId, attempt_id: attemptId, answers: allAnswers },
{
onSuccess: () => {
setReviewOpen(false);
navigate(`/student/exam/${examId}/status`);
},
},
);
}}
disabled={submitMut.isPending}
>
Submit exam
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
function ListeningPlayer({ audioUrl, audioBase64 }: { audioUrl?: string; audioBase64?: string }) {
const audioRef = useRef<HTMLAudioElement>(null);
const [isPlaying, setIsPlaying] = useState(false);
const [currentTime, setCurrentTime] = useState(0);
const [duration, setDuration] = useState(0);
const src = audioUrl || (audioBase64 ? `data:audio/mpeg;base64,${audioBase64}` : "");
const formatTime = (sec: number) => {
const m = Math.floor(sec / 60);
const s = Math.floor(sec % 60);
return `${m}:${s.toString().padStart(2, "0")}`;
};
return (
<div className="space-y-2">
{src ? (
<>
<audio
ref={audioRef}
src={src}
onTimeUpdate={() => setCurrentTime(audioRef.current?.currentTime ?? 0)}
onLoadedMetadata={() => setDuration(audioRef.current?.duration ?? 0)}
onEnded={() => setIsPlaying(false)}
/>
<div className="flex items-center gap-3 rounded-lg border bg-muted/40 p-4">
<Button
type="button" variant="outline" size="icon"
onClick={() => {
if (!audioRef.current) return;
if (isPlaying) { audioRef.current.pause(); setIsPlaying(false); }
else { audioRef.current.play(); setIsPlaying(true); }
}}
>
{isPlaying ? <Pause className="h-5 w-5" /> : <Play className="h-5 w-5" />}
</Button>
<div className="h-2 flex-1 rounded-full bg-muted cursor-pointer" onClick={(e) => {
if (!audioRef.current || !duration) return;
const rect = e.currentTarget.getBoundingClientRect();
const pct = (e.clientX - rect.left) / rect.width;
audioRef.current.currentTime = pct * duration;
}}>
<div className="h-2 rounded-full bg-primary transition-all" style={{ width: duration ? `${(currentTime / duration) * 100}%` : "0%" }} />
</div>
<span className="text-sm text-muted-foreground tabular-nums">{formatTime(currentTime)} / {formatTime(duration)}</span>
</div>
</>
) : (
<div className="flex items-center gap-3 rounded-lg border bg-muted/40 p-4 text-muted-foreground text-sm">
Audio will be played by the examiner or is not yet available.
</div>
)}
</div>
);
}
function SpeakingRecorder({ questionId, onRecorded }: { questionId: number; onRecorded: (blob: Blob) => void }) {
const [recording, setRecording] = 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" });
const url = URL.createObjectURL(blob);
setAudioUrl(url);
onRecorded(blob);
stream.getTracks().forEach((t) => t.stop());
};
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 mm = Math.floor(elapsed / 60);
const ss = elapsed % 60;
return (
<div className="rounded-lg border p-6 space-y-4">
<div className="flex items-center justify-center gap-4">
{!recording && !audioUrl && (
<Button type="button" size="lg" onClick={startRecording} className="gap-2">
<Mic className="h-5 w-5" /> Start Recording
</Button>
)}
{recording && (
<div className="flex items-center gap-4">
<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>
<Button type="button" variant="destructive" size="lg" onClick={stopRecording} className="gap-2">
<Square className="h-4 w-4" /> Stop
</Button>
</div>
)}
</div>
{audioUrl && (
<div className="space-y-2">
<audio controls src={audioUrl} className="w-full" />
<div className="flex gap-2 justify-center">
<Button type="button" variant="outline" size="sm" onClick={() => { setAudioUrl(null); setElapsed(0); }}>
Re-record
</Button>
</div>
</div>
)}
</div>
);
}