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
654 lines
23 KiB
TypeScript
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>
|
|
);
|
|
}
|