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,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