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:
Yamen Ahmad
2026-04-12 14:26:39 +04:00
parent 571a08d0f7
commit 82ec3debcc
38 changed files with 2324 additions and 243 deletions

View File

@@ -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>
);
}