feat: QA fixes, new APIs (assignments, rubrics, custom exams), Generation page enhancements
- 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
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import { Link, useParams, useSearchParams } from "react-router-dom";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion";
|
||||
@@ -19,25 +20,107 @@ import {
|
||||
PolarRadiusAxis,
|
||||
ResponsiveContainer,
|
||||
} from "recharts";
|
||||
import { Award, BookOpen, Download, RefreshCw } from "lucide-react";
|
||||
import { Award, BookOpen, Download, RefreshCw, Loader2 } from "lucide-react";
|
||||
import { examSessionService } from "@/services/exam-session.service";
|
||||
import { reportService } from "@/services/report.service";
|
||||
import { useState } from "react";
|
||||
|
||||
const SKILLS = [
|
||||
{ skill: "Listening", band: 7.5, cefr: "C1", gap: 0.5, target: 8 },
|
||||
{ skill: "Reading", band: 8, cefr: "C1", gap: 0, target: 8 },
|
||||
{ skill: "Writing", band: 6.5, cefr: "B2", gap: 1.5, target: 8 },
|
||||
{ skill: "Speaking", band: 7, cefr: "C1", gap: 1, target: 8 },
|
||||
];
|
||||
interface ScoreEntry {
|
||||
skill: string;
|
||||
band_score: number;
|
||||
raw_score: number;
|
||||
max_score: number;
|
||||
cefr_level: string;
|
||||
}
|
||||
|
||||
const RADAR_DATA = SKILLS.map((s) => ({ skill: s.skill, band: s.band }));
|
||||
interface FeedbackEntry {
|
||||
question_id: number | null;
|
||||
feedback_text: string;
|
||||
source: string;
|
||||
}
|
||||
|
||||
interface ExamResultsData {
|
||||
attempt_id: number;
|
||||
exam_id: number | null;
|
||||
status: string;
|
||||
completed_at: string;
|
||||
released_at: string;
|
||||
listening_band: number;
|
||||
reading_band: number;
|
||||
writing_band: number;
|
||||
speaking_band: number;
|
||||
overall_band: number;
|
||||
cefr_level: string;
|
||||
scores: ScoreEntry[];
|
||||
feedback: FeedbackEntry[];
|
||||
}
|
||||
|
||||
export default function ExamResults() {
|
||||
const { examId } = useParams();
|
||||
const [searchParams] = useSearchParams();
|
||||
const practice = searchParams.get("mode") === "practice";
|
||||
const overall = 7.5;
|
||||
const cefr = "C1";
|
||||
const [downloading, setDownloading] = useState(false);
|
||||
|
||||
const { data: results, isLoading, isError } = useQuery<ExamResultsData>({
|
||||
queryKey: ["exam-results", examId],
|
||||
queryFn: () => examSessionService.getResults(Number(examId)),
|
||||
enabled: !!examId,
|
||||
});
|
||||
|
||||
const handleDownloadPdf = async () => {
|
||||
if (!results?.attempt_id) return;
|
||||
setDownloading(true);
|
||||
try {
|
||||
await reportService.downloadPdf(results.attempt_id);
|
||||
} catch {
|
||||
// error handled silently
|
||||
} finally {
|
||||
setDownloading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isError || !results) {
|
||||
return (
|
||||
<div className="mx-auto max-w-5xl p-6 text-center">
|
||||
<p className="text-lg font-medium text-destructive">Results not yet available</p>
|
||||
<p className="text-muted-foreground mt-2">Your results may still be pending approval or grading.</p>
|
||||
<Button variant="outline" className="mt-4" asChild>
|
||||
<Link to={`/student/exam/${examId}/status`}>Check Status</Link>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const overall = results.overall_band;
|
||||
const cefr = results.cefr_level?.toUpperCase() || "N/A";
|
||||
const passed = overall >= 7;
|
||||
|
||||
const skillScores = results.scores.filter((s) => s.skill !== "overall");
|
||||
const SKILLS = skillScores.map((s) => ({
|
||||
skill: s.skill.charAt(0).toUpperCase() + s.skill.slice(1),
|
||||
band: s.band_score,
|
||||
cefr: s.cefr_level?.toUpperCase() || "N/A",
|
||||
gap: Math.max(0, 8 - s.band_score),
|
||||
target: 8,
|
||||
}));
|
||||
|
||||
const RADAR_DATA = SKILLS.map((s) => ({ skill: s.skill, band: s.band }));
|
||||
|
||||
const feedbackBySkill: Record<string, FeedbackEntry[]> = {};
|
||||
for (const fb of results.feedback) {
|
||||
const key = "General";
|
||||
if (!feedbackBySkill[key]) feedbackBySkill[key] = [];
|
||||
feedbackBySkill[key].push(fb);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-5xl space-y-8 p-6">
|
||||
<div className="text-center">
|
||||
@@ -53,24 +136,26 @@ export default function ExamResults() {
|
||||
{practice ? <Badge className="mt-2">Practice mode</Badge> : null}
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Skill profile</CardTitle>
|
||||
<CardDescription>Per-skill performance vs maximum scale</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="h-72 w-full">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<RadarChart data={RADAR_DATA} cx="50%" cy="50%" outerRadius="80%">
|
||||
<PolarGrid />
|
||||
<PolarAngleAxis dataKey="skill" />
|
||||
<PolarRadiusAxis angle={30} domain={[0, 9]} tickCount={6} />
|
||||
<Radar name="Band" dataKey="band" stroke="hsl(var(--primary))" fill="hsl(var(--primary))" fillOpacity={0.35} />
|
||||
</RadarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
{RADAR_DATA.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Skill profile</CardTitle>
|
||||
<CardDescription>Per-skill performance vs maximum scale</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="h-72 w-full">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<RadarChart data={RADAR_DATA} cx="50%" cy="50%" outerRadius="80%">
|
||||
<PolarGrid />
|
||||
<PolarAngleAxis dataKey="skill" />
|
||||
<PolarRadiusAxis angle={30} domain={[0, 9]} tickCount={6} />
|
||||
<Radar name="Band" dataKey="band" stroke="hsl(var(--primary))" fill="hsl(var(--primary))" fillOpacity={0.35} />
|
||||
</RadarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
@@ -100,19 +185,24 @@ export default function ExamResults() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div>
|
||||
<h2 className="mb-3 text-lg font-semibold">Section feedback</h2>
|
||||
<Accordion type="multiple" className="w-full">
|
||||
{["Listening", "Reading", "Writing", "Speaking"].map((name) => (
|
||||
<AccordionItem key={name} value={name}>
|
||||
<AccordionTrigger>{name}</AccordionTrigger>
|
||||
<AccordionContent className="text-muted-foreground">
|
||||
Detailed feedback for {name} will appear here once released by your instructor.
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
))}
|
||||
</Accordion>
|
||||
</div>
|
||||
{results.feedback.length > 0 && (
|
||||
<div>
|
||||
<h2 className="mb-3 text-lg font-semibold">Feedback</h2>
|
||||
<Accordion type="multiple" className="w-full">
|
||||
{results.feedback.map((fb, i) => (
|
||||
<AccordionItem key={i} value={`fb-${i}`}>
|
||||
<AccordionTrigger>
|
||||
{fb.source === "ai" ? "AI Feedback" : fb.source === "teacher" ? "Teacher Feedback" : "Feedback"}{" "}
|
||||
{fb.question_id ? `(Q${fb.question_id})` : ""}
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="text-muted-foreground">
|
||||
{fb.feedback_text || "No detailed feedback available."}
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
))}
|
||||
</Accordion>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
@@ -121,16 +211,21 @@ export default function ExamResults() {
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ul className="list-inside list-disc space-y-2 text-sm">
|
||||
<li>Strengthen task response structure in Writing Task 2.</li>
|
||||
<li>Extend range of cohesive devices in argumentative essays.</li>
|
||||
<li>Maintain fluency while reducing hesitation in Speaking Part 2.</li>
|
||||
{SKILLS.filter((s) => s.gap > 0)
|
||||
.sort((a, b) => b.gap - a.gap)
|
||||
.map((s) => (
|
||||
<li key={s.skill}>
|
||||
Focus on <strong>{s.skill}</strong> — current band {s.band}, target {s.target} (gap: {s.gap}).
|
||||
</li>
|
||||
))}
|
||||
{SKILLS.every((s) => s.gap === 0) && <li>Excellent performance across all skills!</li>}
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<Button type="button" variant="outline">
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
<Button type="button" variant="outline" onClick={handleDownloadPdf} disabled={downloading}>
|
||||
{downloading ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <Download className="mr-2 h-4 w-4" />}
|
||||
Download PDF Report
|
||||
</Button>
|
||||
<Button type="button" asChild>
|
||||
|
||||
@@ -19,8 +19,9 @@ import {
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Flag, ChevronLeft, ChevronRight, Pause, Play } from "lucide-react";
|
||||
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, "_");
|
||||
@@ -225,15 +226,7 @@ export default function ExamSession() {
|
||||
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>
|
||||
<ListeningPlayer audioUrl={q.audio_url} audioBase64={q.audio_base64} />
|
||||
{q.options?.length ? (
|
||||
<RadioGroup
|
||||
value={typeof a.answer === "string" ? a.answer : ""}
|
||||
@@ -329,9 +322,13 @@ export default function ExamSession() {
|
||||
|
||||
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>
|
||||
<SpeakingRecorder
|
||||
questionId={q.id}
|
||||
onRecorded={(blob) => {
|
||||
const url = URL.createObjectURL(blob);
|
||||
updateAnswer(q.id, { answer: url });
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -481,3 +478,131 @@ export default function ExamSession() {
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
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";
|
||||
@@ -7,7 +7,8 @@ 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 } from "lucide-react";
|
||||
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";
|
||||
@@ -324,15 +325,11 @@ export default function PlacementTest() {
|
||||
)}
|
||||
|
||||
{question.type === "audio_recording" && (
|
||||
<div className="flex flex-col items-center gap-4 py-8 rounded-xl border border-dashed bg-muted/30">
|
||||
<Mic className="h-12 w-12 text-muted-foreground" />
|
||||
<p className="text-sm text-muted-foreground text-center max-w-sm">
|
||||
Recording will be available in a future update. Use the button below to proceed for now.
|
||||
</p>
|
||||
<Button type="button" variant="secondary" size="lg" disabled>
|
||||
Record (placeholder)
|
||||
</Button>
|
||||
</div>
|
||||
<PlacementSpeakingRecorder
|
||||
sessionId={sessionId}
|
||||
promptId={question.id}
|
||||
onUploaded={() => setSingleAnswer("audio_submitted")}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end pt-4">
|
||||
@@ -362,3 +359,93 @@ export default function PlacementTest() {
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user