@@ -53,24 +136,26 @@ export default function ExamResults() {
{practice ? Practice mode : null}
-
-
- Skill profile
- Per-skill performance vs maximum scale
-
-
-
-
-
+ {RADAR_DATA.length > 0 && (
+
+
+ Skill profile
+ Per-skill performance vs maximum scale
+
+
+
+
+
+ )}
@@ -100,19 +185,24 @@ export default function ExamResults() {
-
-
Section feedback
-
- {["Listening", "Reading", "Writing", "Speaking"].map((name) => (
-
- {name}
-
- Detailed feedback for {name} will appear here once released by your instructor.
-
-
- ))}
-
-
+ {results.feedback.length > 0 && (
+
+
Feedback
+
+ {results.feedback.map((fb, i) => (
+
+
+ {fb.source === "ai" ? "AI Feedback" : fb.source === "teacher" ? "Teacher Feedback" : "Feedback"}{" "}
+ {fb.question_id ? `(Q${fb.question_id})` : ""}
+
+
+ {fb.feedback_text || "No detailed feedback available."}
+
+
+ ))}
+
+
+ )}
@@ -121,16 +211,21 @@ export default function ExamResults() {
- - Strengthen task response structure in Writing Task 2.
- - Extend range of cohesive devices in argumentative essays.
- - Maintain fluency while reducing hesitation in Speaking Part 2.
+ {SKILLS.filter((s) => s.gap > 0)
+ .sort((a, b) => b.gap - a.gap)
+ .map((s) => (
+ -
+ Focus on {s.skill} — current band {s.band}, target {s.target} (gap: {s.gap}).
+
+ ))}
+ {SKILLS.every((s) => s.gap === 0) && - Excellent performance across all skills!
}
-
);
}
+
+function ListeningPlayer({ audioUrl, audioBase64 }: { audioUrl?: string; audioBase64?: string }) {
+ const audioRef = useRef
(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 (
+
+ {src ? (
+ <>
+
+ );
+}
+
+function SpeakingRecorder({ questionId, onRecorded }: { questionId: number; onRecorded: (blob: Blob) => void }) {
+ const [recording, setRecording] = useState(false);
+ const [audioUrl, setAudioUrl] = useState(null);
+ const [elapsed, setElapsed] = useState(0);
+ const mediaRecorderRef = useRef(null);
+ const chunksRef = useRef([]);
+ const timerRef = useRef(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 (
+
+
+ {!recording && !audioUrl && (
+
+ Start Recording
+
+ )}
+ {recording && (
+
+
+
{String(mm).padStart(2, "0")}:{String(ss).padStart(2, "0")}
+
+ Stop
+
+
+ )}
+
+ {audioUrl && (
+
+
+
+ { setAudioUrl(null); setElapsed(0); }}>
+ Re-record
+
+
+
+ )}
+
+ );
+}
diff --git a/src/pages/student/PlacementTest.tsx b/src/pages/student/PlacementTest.tsx
index 99635f04d..9cd94b6d1 100644
--- a/src/pages/student/PlacementTest.tsx
+++ b/src/pages/student/PlacementTest.tsx
@@ -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" && (
-
-
-
- Recording will be available in a future update. Use the button below to proceed for now.
-
-
- Record (placeholder)
-
-
+ setSingleAnswer("audio_submitted")}
+ />
)}
@@ -362,3 +359,93 @@ export default function PlacementTest() {
);
}
+
+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(null);
+ const [elapsed, setElapsed] = useState(0);
+ const mediaRecorderRef = useRef(null);
+ const chunksRef = useRef([]);
+ const timerRef = useRef(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 (
+
+ {!recording && !audioUrl && (
+ <>
+
+
+ Click the button below to start recording your speaking response.
+
+
+ Start Recording
+
+ >
+ )}
+ {recording && (
+
+
+
+
{String(mm).padStart(2, "0")}:{String(ss).padStart(2, "0")}
+
+
+ Stop Recording
+
+
+ )}
+ {audioUrl && (
+
+
+ {uploading &&
Uploading...
}
+ {!uploading &&
Recording uploaded successfully
}
+
+ )}
+
+ );
+}
diff --git a/src/pages/teacher/AiWorkbench.tsx b/src/pages/teacher/AiWorkbench.tsx
index c3b2deadd..7e88e94c9 100644
--- a/src/pages/teacher/AiWorkbench.tsx
+++ b/src/pages/teacher/AiWorkbench.tsx
@@ -1,4 +1,4 @@
-import { useState } from "react";
+import { useState, useMemo } from "react";
import { useParams } from "react-router-dom";
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
@@ -12,6 +12,23 @@ import { useGenerateOutline, useGenerateChapterContent, usePublishWorkbench } fr
import { useToast } from "@/hooks/use-toast";
import type { WorkbenchGeneratedOutline, WorkbenchGeneratedChapter } from "@/types/courseware";
+function sanitizeHtml(dirty: string): string {
+ const div = document.createElement("div");
+ div.textContent = "";
+ const parser = new DOMParser();
+ const doc = parser.parseFromString(dirty, "text/html");
+ const scripts = doc.querySelectorAll("script, iframe, object, embed, link[rel=import]");
+ scripts.forEach((el) => el.remove());
+ doc.querySelectorAll("*").forEach((el) => {
+ for (const attr of Array.from(el.attributes)) {
+ if (attr.name.startsWith("on") || attr.value.trim().toLowerCase().startsWith("javascript:")) {
+ el.removeAttribute(attr.name);
+ }
+ }
+ });
+ return doc.body.innerHTML;
+}
+
type Complexity = "beginner" | "intermediate" | "advanced";
export default function AiWorkbench() {
@@ -160,7 +177,7 @@ export default function AiWorkbench() {
Review the detailed content before publishing.
-
+
{generatedContent.exercises.length > 0 && (
Exercises ({generatedContent.exercises.length})
diff --git a/src/services/adaptive-engine.service.ts b/src/services/adaptive-engine.service.ts
index 386351efc..c09e7d7ce 100644
--- a/src/services/adaptive-engine.service.ts
+++ b/src/services/adaptive-engine.service.ts
@@ -9,22 +9,22 @@ import type {
import type { ApiSuccessResponse } from "@/types";
export const adaptiveEngineService = {
- getDashboard: () => api.get
("/adaptive-engine/dashboard"),
+ getDashboard: () => api.get("/adaptive/dashboard"),
getStudents: (params?: { page?: number; limit?: number }) =>
api.get<{ data: AdaptiveEngineStudentRow[]; pagination?: { total: number; page: number } }>(
- "/adaptive-engine/students",
+ "/adaptive/students",
params as Record,
),
getStudentSignals: (studentId: number) =>
- api.get(`/adaptive-engine/students/${studentId}/signals`),
+ api.get(`/adaptive/student/${studentId}/signals`),
getStudentAbility: (studentId: number) =>
- api.get(`/adaptive-engine/students/${studentId}/ability`),
+ api.get(`/adaptive/student/${studentId}/ability`),
- getSettings: () => api.get("/adaptive-engine/settings"),
+ getSettings: () => api.get("/adaptive/settings"),
updateSettings: (data: AdaptiveThresholdSettings) =>
- api.put("/adaptive-engine/settings", data),
+ api.put("/adaptive/settings", data),
};
diff --git a/src/services/entity-onboarding.service.ts b/src/services/entity-onboarding.service.ts
index a812892f3..b6e93387b 100644
--- a/src/services/entity-onboarding.service.ts
+++ b/src/services/entity-onboarding.service.ts
@@ -11,14 +11,14 @@ export const entityOnboardingService = {
validateCsv: (file: File) => {
const fd = new FormData();
fd.append("file", file);
- return api.upload("/entity/students/csv/validate", fd);
+ return api.upload("/entity/students/validate-csv", fd);
},
bulkCreate: (payload: { validate_session_id?: string }) =>
api.post("/entity/students/bulk-create", payload),
sendCredentials: (studentIds: number[]) =>
- api.post("/entity/students/send-credentials", { student_ids: studentIds }),
+ api.post("/entity/students/send-credentials", { user_ids: studentIds }),
getCredentialStatuses: (
filters?: CredentialFilters & { page?: number; limit?: number },
@@ -29,8 +29,8 @@ export const entityOnboardingService = {
),
resendCredential: (studentId: number) =>
- api.post(`/entity/students/${studentId}/resend-credential`),
+ api.post(`/entity/students/${studentId}/resend-credentials`),
resendAllPending: () =>
- api.post("/entity/students/resend-credentials/pending"),
+ api.post("/entity/students/resend-all-pending"),
};
diff --git a/src/services/exam-session.service.ts b/src/services/exam-session.service.ts
index 9d1578394..4d87f163e 100644
--- a/src/services/exam-session.service.ts
+++ b/src/services/exam-session.service.ts
@@ -14,4 +14,7 @@ export const examSessionService = {
getStatus: (examId: number) =>
api.get<{ status: string; scores_available: boolean }>(`/exam/${examId}/status`),
+
+ getResults: (examId: number) =>
+ api.get(`/exam/${examId}/results`),
};
diff --git a/src/services/report.service.ts b/src/services/report.service.ts
index 42030d8ad..7c32b4a06 100644
--- a/src/services/report.service.ts
+++ b/src/services/report.service.ts
@@ -1,11 +1,15 @@
-import { api } from "@/lib/api-client";
+const API_BASE_URL = (import.meta.env.VITE_API_BASE_URL?.trim() || "/api").replace(/\/$/, "");
export const reportService = {
downloadPdf: async (attemptId: number): Promise => {
- const response = await fetch(`/api/reports/exam/${attemptId}/pdf`, {
- headers: {
- Authorization: `Bearer ${localStorage.getItem("encoach_token")}`,
- },
+ const token = localStorage.getItem("encoach_token");
+ const headers: Record = {};
+ if (token) {
+ headers["Authorization"] = `Bearer ${token}`;
+ }
+
+ const response = await fetch(`${API_BASE_URL}/reports/exam/${attemptId}/pdf`, {
+ headers,
});
if (!response.ok) throw new Error("Failed to generate report");
const blob = await response.blob();
@@ -13,7 +17,9 @@ export const reportService = {
const link = document.createElement("a");
link.href = url;
link.download = `exam-report-${attemptId}.pdf`;
+ document.body.appendChild(link);
link.click();
+ document.body.removeChild(link);
window.URL.revokeObjectURL(url);
},
};