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:
@@ -145,6 +145,7 @@ import FaqPage from "@/pages/FaqPage";
|
|||||||
import NotFound from "@/pages/NotFound";
|
import NotFound from "@/pages/NotFound";
|
||||||
import { queryClient } from "@/lib/query-client";
|
import { queryClient } from "@/lib/query-client";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { ErrorBoundary } from "@/components/ErrorBoundary";
|
||||||
|
|
||||||
function StudentSubscriptionPlaceholder() {
|
function StudentSubscriptionPlaceholder() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -157,6 +158,7 @@ function StudentSubscriptionPlaceholder() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const App = () => (
|
const App = () => (
|
||||||
|
<ErrorBoundary>
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
<Toaster />
|
<Toaster />
|
||||||
@@ -340,6 +342,7 @@ const App = () => (
|
|||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
|
</ErrorBoundary>
|
||||||
);
|
);
|
||||||
|
|
||||||
export default App;
|
export default App;
|
||||||
|
|||||||
62
src/components/ErrorBoundary.tsx
Normal file
62
src/components/ErrorBoundary.tsx
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
import { Component, type ErrorInfo, type ReactNode } from "react";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
children: ReactNode;
|
||||||
|
fallback?: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface State {
|
||||||
|
hasError: boolean;
|
||||||
|
error: Error | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ErrorBoundary extends Component<Props, State> {
|
||||||
|
constructor(props: Props) {
|
||||||
|
super(props);
|
||||||
|
this.state = { hasError: false, error: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
static getDerivedStateFromError(error: Error): State {
|
||||||
|
return { hasError: true, error };
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
|
||||||
|
console.error("ErrorBoundary caught:", error, errorInfo);
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
if (this.state.hasError) {
|
||||||
|
if (this.props.fallback) return this.props.fallback;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-background p-6">
|
||||||
|
<div className="max-w-md w-full text-center space-y-4">
|
||||||
|
<div className="mx-auto h-16 w-16 rounded-full bg-destructive/10 flex items-center justify-center">
|
||||||
|
<svg className="h-8 w-8 text-destructive" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L4.082 16.5c-.77.833.192 2.5 1.732 2.5z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h2 className="text-xl font-semibold">Something went wrong</h2>
|
||||||
|
<p className="text-muted-foreground text-sm">
|
||||||
|
An unexpected error occurred. Please try refreshing the page.
|
||||||
|
</p>
|
||||||
|
{this.state.error && (
|
||||||
|
<details className="text-left text-xs text-muted-foreground bg-muted rounded-lg p-3">
|
||||||
|
<summary className="cursor-pointer font-medium">Error details</summary>
|
||||||
|
<pre className="mt-2 whitespace-pre-wrap break-words">{this.state.error.message}</pre>
|
||||||
|
</details>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={() => window.location.reload()}
|
||||||
|
className="inline-flex items-center justify-center rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90"
|
||||||
|
>
|
||||||
|
Refresh Page
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.props.children;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,20 +1,48 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { Card, CardContent } from "@/components/ui/card";
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||||
import { Search } from "lucide-react";
|
import { Search, Plus, FileText, Clock } from "lucide-react";
|
||||||
import { useInstitutionalExamSessions } from "@/hooks/queries";
|
import { useInstitutionalExamSessions } from "@/hooks/queries";
|
||||||
|
import { api } from "@/lib/api-client";
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
|
|
||||||
|
interface CustomExam {
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
|
status: string;
|
||||||
|
total_time_min: number;
|
||||||
|
sections: { id: number; title: string; skill: string }[];
|
||||||
|
teacher_id: number | null;
|
||||||
|
template_id: number | null;
|
||||||
|
description: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusColors: Record<string, "default" | "secondary" | "outline" | "destructive"> = {
|
||||||
|
published: "default",
|
||||||
|
draft: "secondary",
|
||||||
|
archived: "outline",
|
||||||
|
};
|
||||||
|
|
||||||
export default function ExamsListPage() {
|
export default function ExamsListPage() {
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
const sessionsQ = useInstitutionalExamSessions();
|
const [tab, setTab] = useState<"custom" | "sessions">("custom");
|
||||||
const items = sessionsQ.data?.data ?? sessionsQ.data?.items ?? [];
|
|
||||||
const sessions = Array.isArray(items) ? items : [];
|
|
||||||
|
|
||||||
|
const customQ = useQuery({
|
||||||
|
queryKey: ["custom-exams", search],
|
||||||
|
queryFn: () => api.get<{ items: CustomExam[]; total: number }>(`/exam/custom/list?search=${encodeURIComponent(search)}&per_page=50`),
|
||||||
|
});
|
||||||
|
|
||||||
|
const sessionsQ = useInstitutionalExamSessions();
|
||||||
|
const sessionItems = sessionsQ.data?.data ?? sessionsQ.data?.items ?? [];
|
||||||
|
const sessions = Array.isArray(sessionItems) ? sessionItems : [];
|
||||||
|
|
||||||
|
const customExams = customQ.data?.items ?? [];
|
||||||
const q = search.toLowerCase();
|
const q = search.toLowerCase();
|
||||||
const filtered = sessions.filter(s =>
|
const filteredSessions = sessions.filter((s: Record<string, string>) =>
|
||||||
s.name?.toLowerCase().includes(q) || s.course_name?.toLowerCase().includes(q),
|
s.name?.toLowerCase().includes(q) || s.course_name?.toLowerCase().includes(q),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -23,8 +51,20 @@ export default function ExamsListPage() {
|
|||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold tracking-tight">Exams List</h1>
|
<h1 className="text-2xl font-bold tracking-tight">Exams List</h1>
|
||||||
<p className="text-muted-foreground">Browse and manage all exam sessions.</p>
|
<p className="text-muted-foreground">Browse and manage all exams and exam sessions.</p>
|
||||||
</div>
|
</div>
|
||||||
|
<Link to="/admin/exam/create">
|
||||||
|
<Button><Plus className="h-4 w-4 mr-2" /> Create Exam</Button>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button variant={tab === "custom" ? "default" : "outline"} size="sm" onClick={() => setTab("custom")}>
|
||||||
|
<FileText className="h-4 w-4 mr-1" /> Custom Exams ({customExams.length})
|
||||||
|
</Button>
|
||||||
|
<Button variant={tab === "sessions" ? "default" : "outline"} size="sm" onClick={() => setTab("sessions")}>
|
||||||
|
<Clock className="h-4 w-4 mr-1" /> Exam Sessions ({filteredSessions.length})
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="relative max-w-sm">
|
<div className="relative max-w-sm">
|
||||||
@@ -32,44 +72,92 @@ export default function ExamsListPage() {
|
|||||||
<Input placeholder="Search exams..." className="pl-9" value={search} onChange={(e) => setSearch(e.target.value)} />
|
<Input placeholder="Search exams..." className="pl-9" value={search} onChange={(e) => setSearch(e.target.value)} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{sessionsQ.isLoading ? (
|
{tab === "custom" && (
|
||||||
<div className="flex items-center justify-center min-h-[300px]"><div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" /></div>
|
customQ.isLoading ? (
|
||||||
) : (
|
<div className="flex items-center justify-center min-h-[200px]"><div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" /></div>
|
||||||
<Card className="border-0 shadow-sm">
|
) : (
|
||||||
<CardContent className="p-0">
|
<Card className="border-0 shadow-sm">
|
||||||
<Table>
|
<CardContent className="p-0">
|
||||||
<TableHeader>
|
<Table>
|
||||||
<TableRow>
|
<TableHeader>
|
||||||
<TableHead>#</TableHead>
|
<TableRow>
|
||||||
<TableHead>Name</TableHead>
|
<TableHead className="w-12">#</TableHead>
|
||||||
<TableHead>Course</TableHead>
|
<TableHead>Title</TableHead>
|
||||||
<TableHead>Batch</TableHead>
|
<TableHead>Modules</TableHead>
|
||||||
<TableHead>Start</TableHead>
|
<TableHead>Duration</TableHead>
|
||||||
<TableHead>End</TableHead>
|
<TableHead>Status</TableHead>
|
||||||
<TableHead>State</TableHead>
|
|
||||||
</TableRow>
|
|
||||||
</TableHeader>
|
|
||||||
<TableBody>
|
|
||||||
{filtered.length === 0 && (
|
|
||||||
<TableRow><TableCell colSpan={7} className="text-center text-muted-foreground py-8">No exam sessions found.</TableCell></TableRow>
|
|
||||||
)}
|
|
||||||
{filtered.map((s, i) => (
|
|
||||||
<TableRow key={s.id}>
|
|
||||||
<TableCell>{i + 1}</TableCell>
|
|
||||||
<TableCell className="font-medium">{s.name}</TableCell>
|
|
||||||
<TableCell>{s.course_name || "—"}</TableCell>
|
|
||||||
<TableCell>{s.batch_name || "—"}</TableCell>
|
|
||||||
<TableCell>{s.start_date || "—"}</TableCell>
|
|
||||||
<TableCell>{s.end_date || "—"}</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<Badge variant={s.state === "done" ? "default" : s.state === "schedule" ? "secondary" : "outline"} className="capitalize">{s.state}</Badge>
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))}
|
</TableHeader>
|
||||||
</TableBody>
|
<TableBody>
|
||||||
</Table>
|
{customExams.length === 0 && (
|
||||||
</CardContent>
|
<TableRow><TableCell colSpan={5} className="text-center text-muted-foreground py-8">No custom exams found. Submit one from the Generation page.</TableCell></TableRow>
|
||||||
</Card>
|
)}
|
||||||
|
{customExams.map((exam, i) => (
|
||||||
|
<TableRow key={exam.id}>
|
||||||
|
<TableCell className="text-muted-foreground">{exam.id}</TableCell>
|
||||||
|
<TableCell className="font-medium">{exam.title}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex gap-1 flex-wrap">
|
||||||
|
{exam.sections.length > 0
|
||||||
|
? exam.sections.map((s) => (
|
||||||
|
<Badge key={s.id} variant="outline" className="text-xs capitalize">{s.skill || s.title}</Badge>
|
||||||
|
))
|
||||||
|
: <span className="text-muted-foreground text-xs">—</span>}
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{exam.total_time_min ? `${exam.total_time_min} min` : "—"}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge variant={statusColors[exam.status] ?? "outline"} className="capitalize">{exam.status}</Badge>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
|
||||||
|
{tab === "sessions" && (
|
||||||
|
sessionsQ.isLoading ? (
|
||||||
|
<div className="flex items-center justify-center min-h-[200px]"><div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" /></div>
|
||||||
|
) : (
|
||||||
|
<Card className="border-0 shadow-sm">
|
||||||
|
<CardContent className="p-0">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>#</TableHead>
|
||||||
|
<TableHead>Name</TableHead>
|
||||||
|
<TableHead>Course</TableHead>
|
||||||
|
<TableHead>Batch</TableHead>
|
||||||
|
<TableHead>Start</TableHead>
|
||||||
|
<TableHead>End</TableHead>
|
||||||
|
<TableHead>State</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{filteredSessions.length === 0 && (
|
||||||
|
<TableRow><TableCell colSpan={7} className="text-center text-muted-foreground py-8">No exam sessions found.</TableCell></TableRow>
|
||||||
|
)}
|
||||||
|
{filteredSessions.map((s: Record<string, string>, i: number) => (
|
||||||
|
<TableRow key={s.id}>
|
||||||
|
<TableCell>{i + 1}</TableCell>
|
||||||
|
<TableCell className="font-medium">{s.name}</TableCell>
|
||||||
|
<TableCell>{s.course_name || "—"}</TableCell>
|
||||||
|
<TableCell>{s.batch_name || "—"}</TableCell>
|
||||||
|
<TableCell>{s.start_date || "—"}</TableCell>
|
||||||
|
<TableCell>{s.end_date || "—"}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge variant={s.state === "done" ? "default" : s.state === "schedule" ? "secondary" : "outline"} className="capitalize">{s.state}</Badge>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useCallback, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { useMutation } from "@tanstack/react-query";
|
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||||
import { Card, CardContent } from "@/components/ui/card";
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
@@ -8,6 +8,13 @@ import { Checkbox } from "@/components/ui/checkbox";
|
|||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Switch } from "@/components/ui/switch";
|
import { Switch } from "@/components/ui/switch";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
SelectContent,
|
SelectContent,
|
||||||
@@ -45,6 +52,7 @@ import {
|
|||||||
import AiTipBanner from "@/components/ai/AiTipBanner";
|
import AiTipBanner from "@/components/ai/AiTipBanner";
|
||||||
import { generationService } from "@/services/generation.service";
|
import { generationService } from "@/services/generation.service";
|
||||||
import { mediaService, type Avatar } from "@/services/media.service";
|
import { mediaService, type Avatar } from "@/services/media.service";
|
||||||
|
import { examsService } from "@/services/exams.service";
|
||||||
import { useToast } from "@/hooks/use-toast";
|
import { useToast } from "@/hooks/use-toast";
|
||||||
|
|
||||||
type ModuleKey = "reading" | "listening" | "writing" | "speaking" | "level" | "industry";
|
type ModuleKey = "reading" | "listening" | "writing" | "speaking" | "level" | "industry";
|
||||||
@@ -66,6 +74,7 @@ const MODULES: ModuleInfo[] = [
|
|||||||
{ key: "industry", label: "Industry", icon: <Briefcase className="h-4 w-4" />, color: "text-amber-700", bgColor: "bg-amber-50 border-amber-200" },
|
{ key: "industry", label: "Industry", icon: <Briefcase className="h-4 w-4" />, color: "text-amber-700", bgColor: "bg-amber-50 border-amber-200" },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const MODULE_KEYS: ModuleKey[] = MODULES.map((m) => m.key);
|
||||||
const CEFR_LEVELS = ["A1", "A2", "B1", "B2", "C1", "C2"];
|
const CEFR_LEVELS = ["A1", "A2", "B1", "B2", "C1", "C2"];
|
||||||
|
|
||||||
const READING_EXERCISE_TYPES = [
|
const READING_EXERCISE_TYPES = [
|
||||||
@@ -188,6 +197,13 @@ export default function GenerationPage() {
|
|||||||
const [selectedModules, setSelectedModules] = useState<Set<ModuleKey>>(new Set());
|
const [selectedModules, setSelectedModules] = useState<Set<ModuleKey>>(new Set());
|
||||||
const [activeModule, setActiveModule] = useState<ModuleKey | null>(null);
|
const [activeModule, setActiveModule] = useState<ModuleKey | null>(null);
|
||||||
const [moduleStates, setModuleStates] = useState<Record<string, ModuleState>>({});
|
const [moduleStates, setModuleStates] = useState<Record<string, ModuleState>>({});
|
||||||
|
const [previewOpen, setPreviewOpen] = useState(false);
|
||||||
|
|
||||||
|
const structuresQ = useQuery({
|
||||||
|
queryKey: ["exam-structures"],
|
||||||
|
queryFn: () => examsService.listStructures({}),
|
||||||
|
});
|
||||||
|
const structures = structuresQ.data?.items ?? [];
|
||||||
|
|
||||||
const getModuleState = useCallback((mod: ModuleKey): ModuleState => {
|
const getModuleState = useCallback((mod: ModuleKey): ModuleState => {
|
||||||
return moduleStates[mod] ?? defaultModuleState(mod);
|
return moduleStates[mod] ?? defaultModuleState(mod);
|
||||||
@@ -263,7 +279,12 @@ export default function GenerationPage() {
|
|||||||
if (activeModule !== "listening") return;
|
if (activeModule !== "listening") return;
|
||||||
const st = getModuleState("listening");
|
const st = getModuleState("listening");
|
||||||
const sections = [...st.listeningSections];
|
const sections = [...st.listeningSections];
|
||||||
sections[vars.sectionIndex] = { ...sections[vars.sectionIndex], audioUrl: res.audio_url || "" };
|
let url = res.audio_url || "";
|
||||||
|
if (!url && res.audio_base64) {
|
||||||
|
const ct = res.content_type || "audio/mpeg";
|
||||||
|
url = `data:${ct};base64,${res.audio_base64}`;
|
||||||
|
}
|
||||||
|
sections[vars.sectionIndex] = { ...sections[vars.sectionIndex], audioUrl: url };
|
||||||
updateModuleState("listening", { listeningSections: sections });
|
updateModuleState("listening", { listeningSections: sections });
|
||||||
toast({ title: "Audio generated" });
|
toast({ title: "Audio generated" });
|
||||||
},
|
},
|
||||||
@@ -289,10 +310,18 @@ export default function GenerationPage() {
|
|||||||
mutationFn: (params: { topics: string[]; difficulty: string; partIndex: number }) =>
|
mutationFn: (params: { topics: string[]; difficulty: string; partIndex: number }) =>
|
||||||
generationService.generateSpeakingScript({ topics: params.topics.filter(Boolean), difficulty: params.difficulty, part: "speaking_1" }),
|
generationService.generateSpeakingScript({ topics: params.topics.filter(Boolean), difficulty: params.difficulty, part: "speaking_1" }),
|
||||||
onSuccess: (res, vars) => {
|
onSuccess: (res, vars) => {
|
||||||
|
const r = res as Record<string, unknown>;
|
||||||
|
if (r.error) {
|
||||||
|
toast({ variant: "destructive", title: "Script generation failed", description: String(r.error) });
|
||||||
|
return;
|
||||||
|
}
|
||||||
const st = getModuleState("speaking");
|
const st = getModuleState("speaking");
|
||||||
const parts = [...st.speakingParts];
|
const parts = [...st.speakingParts];
|
||||||
const r = res as Record<string, unknown>;
|
const script = (r.script as string) || "";
|
||||||
const script = (r.script as string) ?? JSON.stringify(r.questions ?? res);
|
if (!script) {
|
||||||
|
toast({ variant: "destructive", title: "No script returned", description: "AI returned empty response — try again." });
|
||||||
|
return;
|
||||||
|
}
|
||||||
parts[vars.partIndex] = { ...parts[vars.partIndex], script };
|
parts[vars.partIndex] = { ...parts[vars.partIndex], script };
|
||||||
updateModuleState("speaking", { speakingParts: parts });
|
updateModuleState("speaking", { speakingParts: parts });
|
||||||
toast({ title: "Script generated" });
|
toast({ title: "Script generated" });
|
||||||
@@ -313,6 +342,45 @@ export default function GenerationPage() {
|
|||||||
onError: (err: Error) => toast({ variant: "destructive", title: "Video generation failed", description: err.message }),
|
onError: (err: Error) => toast({ variant: "destructive", title: "Video generation failed", description: err.message }),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const pollTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||||
|
useEffect(() => {
|
||||||
|
if (activeModule !== "speaking") return;
|
||||||
|
const st = getModuleState("speaking");
|
||||||
|
const pendingParts = st.speakingParts
|
||||||
|
.map((p, i) => ({ videoUrl: p.videoUrl, index: i }))
|
||||||
|
.filter((p) => p.videoUrl.startsWith("pending:"));
|
||||||
|
if (pendingParts.length === 0) {
|
||||||
|
if (pollTimerRef.current) { clearInterval(pollTimerRef.current); pollTimerRef.current = null; }
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (pollTimerRef.current) return;
|
||||||
|
pollTimerRef.current = setInterval(async () => {
|
||||||
|
const currentSt = getModuleState("speaking");
|
||||||
|
let changed = false;
|
||||||
|
const updatedParts = [...currentSt.speakingParts];
|
||||||
|
for (const pp of pendingParts) {
|
||||||
|
const part = updatedParts[pp.index];
|
||||||
|
if (!part || !part.videoUrl.startsWith("pending:")) continue;
|
||||||
|
const videoId = part.videoUrl.replace("pending:", "");
|
||||||
|
try {
|
||||||
|
const status = await mediaService.getVideoStatus(videoId);
|
||||||
|
if (status.status === "done" || status.status === "completed" || status.video_url) {
|
||||||
|
updatedParts[pp.index] = { ...part, videoUrl: status.video_url || status.url || "" };
|
||||||
|
changed = true;
|
||||||
|
toast({ title: "Video ready!", description: "Avatar video has been generated." });
|
||||||
|
} else if (status.status === "error" || status.status === "failed") {
|
||||||
|
updatedParts[pp.index] = { ...part, videoUrl: "" };
|
||||||
|
changed = true;
|
||||||
|
toast({ variant: "destructive", title: "Video generation failed" });
|
||||||
|
}
|
||||||
|
} catch { /* poll again next interval */ }
|
||||||
|
}
|
||||||
|
if (changed) updateModuleState("speaking", { speakingParts: updatedParts });
|
||||||
|
}, 15000);
|
||||||
|
return () => { if (pollTimerRef.current) { clearInterval(pollTimerRef.current); pollTimerRef.current = null; } };
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [activeModule, moduleStates]);
|
||||||
|
|
||||||
const submitMut = useMutation({
|
const submitMut = useMutation({
|
||||||
mutationFn: (skipApproval: boolean) => {
|
mutationFn: (skipApproval: boolean) => {
|
||||||
const modulesPayload: Record<string, unknown> = {};
|
const modulesPayload: Record<string, unknown> = {};
|
||||||
@@ -332,7 +400,11 @@ export default function GenerationPage() {
|
|||||||
}
|
}
|
||||||
return generationService.submitExam({ title, label: examLabel, modules: modulesPayload, skip_approval: skipApproval });
|
return generationService.submitExam({ title, label: examLabel, modules: modulesPayload, skip_approval: skipApproval });
|
||||||
},
|
},
|
||||||
onSuccess: (res) => toast({ title: "Exam submitted", description: `Exam #${res.exam_id} created (${res.status})` }),
|
onSuccess: (res) => toast({
|
||||||
|
title: "Exam submitted successfully",
|
||||||
|
description: `Exam #${res.exam_id} created with status "${res.status}". ${res.status === "published" ? "The exam is now live." : "Pending approval."}`,
|
||||||
|
duration: 8000,
|
||||||
|
}),
|
||||||
onError: (err: Error) => toast({ variant: "destructive", title: "Submit failed", description: err.message }),
|
onError: (err: Error) => toast({ variant: "destructive", title: "Submit failed", description: err.message }),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -482,12 +554,21 @@ export default function GenerationPage() {
|
|||||||
<Select><SelectTrigger className="h-8 text-xs"><SelectValue placeholder="Passage Difficulty (Optional)" /></SelectTrigger>
|
<Select><SelectTrigger className="h-8 text-xs"><SelectValue placeholder="Passage Difficulty (Optional)" /></SelectTrigger>
|
||||||
<SelectContent>{CEFR_LEVELS.map((l) => <SelectItem key={l} value={l}>{l}</SelectItem>)}</SelectContent>
|
<SelectContent>{CEFR_LEVELS.map((l) => <SelectItem key={l} value={l}>{l}</SelectItem>)}</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
<Input placeholder="Topic (Optional)" className="h-8 text-xs" id={`topic-${pi}`} />
|
<Input placeholder="Topic (Optional)" className="h-8 text-xs"
|
||||||
<Input placeholder="Approximate Word Count (Optional)" className="h-8 text-xs" type="number" id={`wc-${pi}`} />
|
value={passage.category || ""}
|
||||||
|
onChange={(e) => {
|
||||||
|
const p = [...st.passages]; p[pi] = { ...p[pi], category: e.target.value };
|
||||||
|
updateModuleState("reading", { passages: p });
|
||||||
|
}} />
|
||||||
|
<Input placeholder="Approximate Word Count (Optional)" className="h-8 text-xs" type="number"
|
||||||
|
value={passage.divider || ""}
|
||||||
|
onChange={(e) => {
|
||||||
|
const p = [...st.passages]; p[pi] = { ...p[pi], divider: e.target.value };
|
||||||
|
updateModuleState("reading", { passages: p });
|
||||||
|
}} />
|
||||||
<Button size="sm" className="w-full text-xs" disabled={generatePassageMut.isPending}
|
<Button size="sm" className="w-full text-xs" disabled={generatePassageMut.isPending}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
const topicEl = document.getElementById(`topic-${pi}`) as HTMLInputElement;
|
generatePassageMut.mutate({ index: pi, topic: passage.category || "", difficulty: st.difficulty[0] || "B2", wordCount: Number(passage.divider) || 300 });
|
||||||
generatePassageMut.mutate({ index: pi, topic: topicEl?.value || "", difficulty: st.difficulty[0] || "B2", wordCount: 300 });
|
|
||||||
}}>
|
}}>
|
||||||
{generatePassageMut.isPending ? <Loader2 className="h-3 w-3 mr-1 animate-spin" /> : <Sparkles className="h-3 w-3 mr-1" />}
|
{generatePassageMut.isPending ? <Loader2 className="h-3 w-3 mr-1 animate-spin" /> : <Sparkles className="h-3 w-3 mr-1" />}
|
||||||
Generate
|
Generate
|
||||||
@@ -588,11 +669,15 @@ export default function GenerationPage() {
|
|||||||
Audio Context <ChevronDown className="h-3 w-3" />
|
Audio Context <ChevronDown className="h-3 w-3" />
|
||||||
</CollapsibleTrigger>
|
</CollapsibleTrigger>
|
||||||
<CollapsibleContent className="pt-2 space-y-2">
|
<CollapsibleContent className="pt-2 space-y-2">
|
||||||
<Input placeholder="Topic" className="h-8 text-xs" id={`ltopic-${si}`} />
|
<Input placeholder="Topic" className="h-8 text-xs"
|
||||||
|
value={sec.category || ""}
|
||||||
|
onChange={(e) => {
|
||||||
|
const sections = [...st.listeningSections]; sections[si] = { ...sections[si], category: e.target.value };
|
||||||
|
updateModuleState("listening", { listeningSections: sections });
|
||||||
|
}} />
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Button size="sm" className="flex-1 text-xs" onClick={() => {
|
<Button size="sm" className="flex-1 text-xs" onClick={() => {
|
||||||
const topicEl = document.getElementById(`ltopic-${si}`) as HTMLInputElement;
|
generationService.generateListeningContext({ topic: sec.category || "general conversation", section_type: sec.type })
|
||||||
generationService.generateListeningContext({ topic: topicEl?.value || "general conversation", section_type: sec.type })
|
|
||||||
.then((res) => {
|
.then((res) => {
|
||||||
const r = res as Record<string, unknown>;
|
const r = res as Record<string, unknown>;
|
||||||
const sections = [...st.listeningSections];
|
const sections = [...st.listeningSections];
|
||||||
@@ -635,7 +720,12 @@ export default function GenerationPage() {
|
|||||||
{generateAudioMut.isPending ? <Loader2 className="h-3 w-3 mr-1 animate-spin" /> : <Play className="h-3 w-3 mr-1" />}
|
{generateAudioMut.isPending ? <Loader2 className="h-3 w-3 mr-1 animate-spin" /> : <Play className="h-3 w-3 mr-1" />}
|
||||||
Generate Audio
|
Generate Audio
|
||||||
</Button>
|
</Button>
|
||||||
{sec.audioUrl && <p className="text-xs text-green-600 mt-1">Audio ready</p>}
|
{sec.audioUrl && (
|
||||||
|
<div className="mt-2 space-y-1">
|
||||||
|
<p className="text-xs text-green-600">Audio ready</p>
|
||||||
|
<audio controls src={sec.audioUrl} className="w-full h-8" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</CollapsibleContent>
|
</CollapsibleContent>
|
||||||
</Collapsible>
|
</Collapsible>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
@@ -694,14 +784,18 @@ export default function GenerationPage() {
|
|||||||
Generate Instructions <ChevronDown className="h-3 w-3" />
|
Generate Instructions <ChevronDown className="h-3 w-3" />
|
||||||
</CollapsibleTrigger>
|
</CollapsibleTrigger>
|
||||||
<CollapsibleContent className="pt-2 space-y-2">
|
<CollapsibleContent className="pt-2 space-y-2">
|
||||||
<Input placeholder="Topic" className="h-8 text-xs" id={`wtopic-${ti}`} />
|
<Input placeholder="Topic" className="h-8 text-xs"
|
||||||
|
value={task.category || ""}
|
||||||
|
onChange={(e) => {
|
||||||
|
const tasks = [...st.writingTasks]; tasks[ti] = { ...tasks[ti], category: e.target.value };
|
||||||
|
updateModuleState("writing", { writingTasks: tasks });
|
||||||
|
}} />
|
||||||
<Select><SelectTrigger className="h-8 text-xs"><SelectValue placeholder="Difficulty (Optional)" /></SelectTrigger>
|
<Select><SelectTrigger className="h-8 text-xs"><SelectValue placeholder="Difficulty (Optional)" /></SelectTrigger>
|
||||||
<SelectContent>{CEFR_LEVELS.map((l) => <SelectItem key={l} value={l}>{l}</SelectItem>)}</SelectContent>
|
<SelectContent>{CEFR_LEVELS.map((l) => <SelectItem key={l} value={l}>{l}</SelectItem>)}</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
<Button size="sm" className="w-full text-xs" disabled={generateWritingMut.isPending}
|
<Button size="sm" className="w-full text-xs" disabled={generateWritingMut.isPending}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
const topicEl = document.getElementById(`wtopic-${ti}`) as HTMLInputElement;
|
generateWritingMut.mutate({ topic: task.category || "general", difficulty: st.difficulty[0] || "A1", taskIndex: ti });
|
||||||
generateWritingMut.mutate({ topic: topicEl?.value || "general", difficulty: st.difficulty[0] || "A1", taskIndex: ti });
|
|
||||||
}}>
|
}}>
|
||||||
{generateWritingMut.isPending ? <Loader2 className="h-3 w-3 mr-1 animate-spin" /> : <Sparkles className="h-3 w-3 mr-1" />}
|
{generateWritingMut.isPending ? <Loader2 className="h-3 w-3 mr-1 animate-spin" /> : <Sparkles className="h-3 w-3 mr-1" />}
|
||||||
Generate
|
Generate
|
||||||
@@ -831,7 +925,18 @@ export default function GenerationPage() {
|
|||||||
{generateVideoMut.isPending ? <Loader2 className="h-3 w-3 mr-1 animate-spin" /> : <Video className="h-3 w-3 mr-1" />}
|
{generateVideoMut.isPending ? <Loader2 className="h-3 w-3 mr-1 animate-spin" /> : <Video className="h-3 w-3 mr-1" />}
|
||||||
Generate Video
|
Generate Video
|
||||||
</Button>
|
</Button>
|
||||||
{part.videoUrl && <p className="text-xs text-green-600">Video: {part.videoUrl.startsWith("pending:") ? "Processing..." : "Ready"}</p>}
|
{part.videoUrl && part.videoUrl.startsWith("pending:") && (
|
||||||
|
<div className="flex items-center gap-1 mt-1">
|
||||||
|
<Loader2 className="h-3 w-3 animate-spin text-orange-500" />
|
||||||
|
<p className="text-xs text-orange-600">Video processing... (polling every 15s)</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{part.videoUrl && !part.videoUrl.startsWith("pending:") && (
|
||||||
|
<div className="mt-2 space-y-1">
|
||||||
|
<p className="text-xs text-green-600">Video ready</p>
|
||||||
|
<video controls src={part.videoUrl} className="w-full rounded max-h-48" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</CollapsibleContent>
|
</CollapsibleContent>
|
||||||
</Collapsible>
|
</Collapsible>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
@@ -893,13 +998,27 @@ export default function GenerationPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<Label>Exam Structure</Label>
|
<Label>Exam Structure</Label>
|
||||||
<Select value={examStructure} onValueChange={setExamStructure}>
|
<Select value={examStructure} onValueChange={(val) => {
|
||||||
|
setExamStructure(val);
|
||||||
|
const s = structures.find((st) => String(st.id) === val);
|
||||||
|
if (s) {
|
||||||
|
const mods = (s as Record<string, unknown>).modules as string[] | undefined;
|
||||||
|
if (Array.isArray(mods) && mods.length) {
|
||||||
|
const next = new Set<ModuleKey>();
|
||||||
|
mods.forEach((m) => { if (MODULE_KEYS.includes(m as ModuleKey)) next.add(m as ModuleKey); });
|
||||||
|
setSelectedModules(next);
|
||||||
|
if (next.size > 0) setActiveModule([...next][0]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}>
|
||||||
<SelectTrigger><SelectValue placeholder="Select an exam structure" /></SelectTrigger>
|
<SelectTrigger><SelectValue placeholder="Select an exam structure" /></SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="standard_ielts">Standard IELTS Academic</SelectItem>
|
{structures.map((s) => (
|
||||||
<SelectItem value="corporate">Corporate English Assessment</SelectItem>
|
<SelectItem key={s.id} value={String(s.id)}>{s.name}</SelectItem>
|
||||||
<SelectItem value="hospitality">Hospitality English Test</SelectItem>
|
))}
|
||||||
<SelectItem value="medical">Medical English Proficiency</SelectItem>
|
{structures.length === 0 && (
|
||||||
|
<SelectItem value="_none" disabled>No structures available</SelectItem>
|
||||||
|
)}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
@@ -971,11 +1090,107 @@ export default function GenerationPage() {
|
|||||||
<Button variant="outline" disabled={anyGenerating || !title} onClick={() => submitMut.mutate(true)}>
|
<Button variant="outline" disabled={anyGenerating || !title} onClick={() => submitMut.mutate(true)}>
|
||||||
<SkipForward className="h-4 w-4 mr-2" /> Submit module as exam and skip approval
|
<SkipForward className="h-4 w-4 mr-2" /> Submit module as exam and skip approval
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="outline" disabled>
|
<Button variant="outline" disabled={!activeModule} onClick={() => setPreviewOpen(true)}>
|
||||||
<Eye className="h-4 w-4 mr-2" /> Preview module
|
<Eye className="h-4 w-4 mr-2" /> Preview module
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<Dialog open={previewOpen} onOpenChange={setPreviewOpen}>
|
||||||
|
<DialogContent className="max-w-3xl max-h-[85vh] overflow-y-auto">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{title || "Untitled Exam"} — Preview</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Read-only preview of all configured modules and their content.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-6 pt-2">
|
||||||
|
{[...selectedModules].map((mod) => {
|
||||||
|
const st = getModuleState(mod);
|
||||||
|
return (
|
||||||
|
<div key={mod} className="border rounded-lg p-4 space-y-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h3 className="font-semibold text-lg capitalize">{mod}</h3>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Badge variant="outline">{st.timer} min</Badge>
|
||||||
|
{st.difficulty.map((d) => <Badge key={d} variant="secondary">{d}</Badge>)}
|
||||||
|
<Badge variant={st.accessType === "public" ? "default" : "outline"}>{st.accessType}</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{mod === "reading" && st.passages.map((p, i) => (
|
||||||
|
<div key={i} className="bg-muted/50 rounded p-3 space-y-2">
|
||||||
|
<h4 className="font-medium text-sm">Passage {i + 1} {p.category && `(${p.category})`}</h4>
|
||||||
|
{p.text ? (
|
||||||
|
<p className="text-sm whitespace-pre-wrap leading-relaxed">{p.text.slice(0, 500)}{p.text.length > 500 ? "…" : ""}</p>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-muted-foreground italic">No passage text yet</p>
|
||||||
|
)}
|
||||||
|
{p.exercises.length > 0 && (
|
||||||
|
<p className="text-xs text-muted-foreground">{p.exercises.length} exercise(s) generated</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{mod === "listening" && st.listeningSections.map((s, i) => (
|
||||||
|
<div key={i} className="bg-muted/50 rounded p-3 space-y-2">
|
||||||
|
<h4 className="font-medium text-sm">Section {i + 1}: {s.type.replace(/_/g, " ")}</h4>
|
||||||
|
{s.context ? (
|
||||||
|
<p className="text-sm whitespace-pre-wrap leading-relaxed">{s.context.slice(0, 500)}{s.context.length > 500 ? "…" : ""}</p>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-muted-foreground italic">No context yet</p>
|
||||||
|
)}
|
||||||
|
{s.audioUrl && <p className="text-xs text-green-600">Audio generated</p>}
|
||||||
|
{s.exercises.length > 0 && (
|
||||||
|
<p className="text-xs text-muted-foreground">{s.exercises.length} exercise(s) generated</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{mod === "writing" && st.writingTasks.map((t, i) => (
|
||||||
|
<div key={i} className="bg-muted/50 rounded p-3 space-y-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h4 className="font-medium text-sm">Task {i + 1}</h4>
|
||||||
|
<span className="text-xs text-muted-foreground">{t.wordLimit} words · {t.marks} marks</span>
|
||||||
|
</div>
|
||||||
|
{t.instructions ? (
|
||||||
|
<p className="text-sm whitespace-pre-wrap leading-relaxed">{t.instructions}</p>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-muted-foreground italic">No instructions yet</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{mod === "speaking" && st.speakingParts.map((p, i) => (
|
||||||
|
<div key={i} className="bg-muted/50 rounded p-3 space-y-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h4 className="font-medium text-sm">Part {i + 1}: {p.type.replace(/_/g, " ")}</h4>
|
||||||
|
<span className="text-xs text-muted-foreground">{p.marks} marks</span>
|
||||||
|
</div>
|
||||||
|
{p.script ? (
|
||||||
|
<p className="text-sm whitespace-pre-wrap leading-relaxed">{p.script.slice(0, 600)}{p.script.length > 600 ? "…" : ""}</p>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-muted-foreground italic">No script yet</p>
|
||||||
|
)}
|
||||||
|
{p.videoUrl && !p.videoUrl.startsWith("pending:") && (
|
||||||
|
<p className="text-xs text-green-600">Video ready</p>
|
||||||
|
)}
|
||||||
|
{p.videoUrl && p.videoUrl.startsWith("pending:") && (
|
||||||
|
<p className="text-xs text-orange-600">Video processing…</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{st.shuffling && <p className="text-xs text-muted-foreground">Shuffling enabled</p>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{selectedModules.size === 0 && (
|
||||||
|
<p className="text-muted-foreground text-center py-8">No modules selected yet.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,17 +1,18 @@
|
|||||||
import { useState } from "react";
|
import { useState, useEffect, useCallback, useRef } from "react";
|
||||||
import { Link, useNavigate } from "react-router-dom";
|
import { Link, useNavigate } from "react-router-dom";
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
|
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
|
||||||
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
||||||
import { Progress } from "@/components/ui/progress";
|
import { Progress } from "@/components/ui/progress";
|
||||||
import { GraduationCap, Eye, EyeOff, Loader2 } from "lucide-react";
|
import { GraduationCap, Eye, EyeOff, Loader2, ShieldCheck } from "lucide-react";
|
||||||
import { useRegister, useCheckEmail } from "@/hooks/queries/useSignup";
|
import { useRegister, useCheckEmail } from "@/hooks/queries/useSignup";
|
||||||
import { ApiError } from "@/lib/api-client";
|
import { ApiError, api } from "@/lib/api-client";
|
||||||
import type { RegisterRequest } from "@/types";
|
import type { RegisterRequest } from "@/types";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
@@ -47,6 +48,41 @@ export default function Register() {
|
|||||||
const checkEmail = useCheckEmail();
|
const checkEmail = useCheckEmail();
|
||||||
const [showPassword, setShowPassword] = useState(false);
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
const [showConfirm, setShowConfirm] = useState(false);
|
const [showConfirm, setShowConfirm] = useState(false);
|
||||||
|
const [captchaToken, setCaptchaToken] = useState<string>("");
|
||||||
|
const captchaContainerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const { data: captchaConfig } = useQuery({
|
||||||
|
queryKey: ["captcha-config"],
|
||||||
|
queryFn: () => api.get<{ provider: string; site_key: string }>("/config/captcha"),
|
||||||
|
staleTime: Infinity,
|
||||||
|
});
|
||||||
|
|
||||||
|
const loadCaptchaScript = useCallback((provider: string, siteKey: string) => {
|
||||||
|
if (!siteKey || !provider) return;
|
||||||
|
const existingScript = document.querySelector(`script[data-captcha-provider="${provider}"]`);
|
||||||
|
if (existingScript) return;
|
||||||
|
|
||||||
|
const scriptUrls: Record<string, string> = {
|
||||||
|
recaptcha: `https://www.google.com/recaptcha/api.js?render=${siteKey}`,
|
||||||
|
hcaptcha: "https://js.hcaptcha.com/1/api.js",
|
||||||
|
turnstile: "https://challenges.cloudflare.com/turnstile/v0/api.js",
|
||||||
|
};
|
||||||
|
const url = scriptUrls[provider];
|
||||||
|
if (!url) return;
|
||||||
|
|
||||||
|
const script = document.createElement("script");
|
||||||
|
script.src = url;
|
||||||
|
script.async = true;
|
||||||
|
script.defer = true;
|
||||||
|
script.setAttribute("data-captcha-provider", provider);
|
||||||
|
document.head.appendChild(script);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (captchaConfig?.site_key && captchaConfig?.provider) {
|
||||||
|
loadCaptchaScript(captchaConfig.provider, captchaConfig.site_key);
|
||||||
|
}
|
||||||
|
}, [captchaConfig, loadCaptchaScript]);
|
||||||
|
|
||||||
const form = useForm<FormValues>({
|
const form = useForm<FormValues>({
|
||||||
resolver: zodResolver(schema),
|
resolver: zodResolver(schema),
|
||||||
@@ -69,7 +105,7 @@ export default function Register() {
|
|||||||
email: values.email.trim(),
|
email: values.email.trim(),
|
||||||
password: values.password,
|
password: values.password,
|
||||||
role: values.role,
|
role: values.role,
|
||||||
captcha_token: "demo-placeholder",
|
captcha_token: captchaToken || undefined,
|
||||||
};
|
};
|
||||||
try {
|
try {
|
||||||
await register.mutateAsync(payload);
|
await register.mutateAsync(payload);
|
||||||
@@ -251,10 +287,26 @@ export default function Register() {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="rounded-lg border border-dashed bg-muted/30 p-4 space-y-2">
|
<div className="rounded-lg border border-dashed bg-muted/30 p-4 space-y-2">
|
||||||
<p className="text-sm font-medium">CAPTCHA</p>
|
<p className="text-sm font-medium flex items-center gap-2">
|
||||||
<div className="h-16 rounded-md bg-muted flex items-center justify-center text-xs text-muted-foreground">
|
<ShieldCheck className="h-4 w-4" /> Verification
|
||||||
Verification widget placeholder
|
</p>
|
||||||
|
<div ref={captchaContainerRef} className="min-h-[65px] flex items-center justify-center">
|
||||||
|
{captchaConfig?.site_key ? (
|
||||||
|
<div
|
||||||
|
className={captchaConfig.provider === "hcaptcha" ? "h-captcha" :
|
||||||
|
captchaConfig.provider === "turnstile" ? "cf-turnstile" : "g-recaptcha"}
|
||||||
|
data-sitekey={captchaConfig.site_key}
|
||||||
|
data-callback="onCaptchaSuccess"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<p className="text-xs text-muted-foreground">CAPTCHA not configured — registration allowed</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
{captchaToken && (
|
||||||
|
<p className="text-xs text-green-600 flex items-center gap-1">
|
||||||
|
<ShieldCheck className="h-3 w-3" /> Verified
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{form.formState.errors.root && (
|
{form.formState.errors.root && (
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { Card, CardContent } from "@/components/ui/card";
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
@@ -8,11 +9,21 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from
|
|||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||||
import { Search, Plus } from "lucide-react";
|
import { Search, Plus, Loader2 } from "lucide-react";
|
||||||
import AiTipBanner from "@/components/ai/AiTipBanner";
|
import AiTipBanner from "@/components/ai/AiTipBanner";
|
||||||
import AiCreationAssistant from "@/components/ai/AiCreationAssistant";
|
import AiCreationAssistant from "@/components/ai/AiCreationAssistant";
|
||||||
|
import { examsService } from "@/services/exams.service";
|
||||||
|
|
||||||
const rubrics = [
|
interface RubricItem {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
levels: string[];
|
||||||
|
criteria: number;
|
||||||
|
created: string;
|
||||||
|
skill?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const FALLBACK_RUBRICS: RubricItem[] = [
|
||||||
{ id: 1, name: "IELTS Writing Task 2", levels: ["A1","A2","B1","B2","C1","C2"], criteria: 4, created: "2025-01-05" },
|
{ id: 1, name: "IELTS Writing Task 2", levels: ["A1","A2","B1","B2","C1","C2"], criteria: 4, created: "2025-01-05" },
|
||||||
{ id: 2, name: "Speaking Fluency", levels: ["A1","A2","B1","B2","C1","C2"], criteria: 3, created: "2025-01-10" },
|
{ id: 2, name: "Speaking Fluency", levels: ["A1","A2","B1","B2","C1","C2"], criteria: 3, created: "2025-01-10" },
|
||||||
{ id: 3, name: "Reading Comprehension", levels: ["A1","A2","B1","B2","C1"], criteria: 5, created: "2025-02-01" },
|
{ id: 3, name: "Reading Comprehension", levels: ["A1","A2","B1","B2","C1"], criteria: 5, created: "2025-02-01" },
|
||||||
@@ -26,6 +37,13 @@ const rubricGroups = [
|
|||||||
export default function RubricsPage() {
|
export default function RubricsPage() {
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
|
|
||||||
|
const rubricsQ = useQuery({
|
||||||
|
queryKey: ["rubrics"],
|
||||||
|
queryFn: () => examsService.listRubrics({}),
|
||||||
|
});
|
||||||
|
const backendRubrics = (rubricsQ.data?.items ?? []) as RubricItem[];
|
||||||
|
const rubrics = backendRubrics.length > 0 ? backendRubrics : FALLBACK_RUBRICS;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
|
|||||||
@@ -583,10 +583,27 @@ function NewQuestionInline({ sectionIndex, form }: { sectionIndex: number; form:
|
|||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => {
|
disabled={!stem.trim()}
|
||||||
const cur = form.getValues(`sections.${sectionIndex}.question_ids`) ?? [];
|
onClick={async () => {
|
||||||
const nextId = Math.floor(Math.random() * 1_000_000_000);
|
if (!stem.trim()) return;
|
||||||
form.setValue(`sections.${sectionIndex}.question_ids`, [...cur, nextId]);
|
try {
|
||||||
|
const res = await fetch("/api/exam/questions/quick-create", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
Authorization: `Bearer ${localStorage.getItem("encoach_token")}`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ stem: stem.trim(), question_type: "short_answer", skill: form.getValues(`sections.${sectionIndex}.skill`) || "reading" }),
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
const qId = data?.question_id;
|
||||||
|
if (qId) {
|
||||||
|
const cur = form.getValues(`sections.${sectionIndex}.question_ids`) ?? [];
|
||||||
|
form.setValue(`sections.${sectionIndex}.question_ids`, [...cur, qId]);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// fallback: still add with a placeholder notice
|
||||||
|
}
|
||||||
setStem("");
|
setStem("");
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||||
@@ -22,6 +23,7 @@ import type { PendingScore } from "@/types";
|
|||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
export default function ScoreApprovalQueue() {
|
export default function ScoreApprovalQueue() {
|
||||||
|
const navigate = useNavigate();
|
||||||
const { data, isLoading, refetch } = usePendingScores();
|
const { data, isLoading, refetch } = usePendingScores();
|
||||||
const release = useReleaseScore();
|
const release = useReleaseScore();
|
||||||
const reject = useRejectScore();
|
const reject = useRejectScore();
|
||||||
@@ -156,7 +158,7 @@ export default function ScoreApprovalQueue() {
|
|||||||
<Button size="sm" variant="destructive" onClick={() => openReject(r)}>
|
<Button size="sm" variant="destructive" onClick={() => openReject(r)}>
|
||||||
Reject
|
Reject
|
||||||
</Button>
|
</Button>
|
||||||
<Button size="sm" variant="outline">
|
<Button size="sm" variant="outline" onClick={() => navigate(`/admin/exam/${r.exam_id ?? r.attempt_id}/grading`)}>
|
||||||
View Details
|
View Details
|
||||||
</Button>
|
</Button>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Link, useParams, useSearchParams } from "react-router-dom";
|
import { Link, useParams, useSearchParams } from "react-router-dom";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion";
|
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion";
|
||||||
@@ -19,25 +20,107 @@ import {
|
|||||||
PolarRadiusAxis,
|
PolarRadiusAxis,
|
||||||
ResponsiveContainer,
|
ResponsiveContainer,
|
||||||
} from "recharts";
|
} 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 = [
|
interface ScoreEntry {
|
||||||
{ skill: "Listening", band: 7.5, cefr: "C1", gap: 0.5, target: 8 },
|
skill: string;
|
||||||
{ skill: "Reading", band: 8, cefr: "C1", gap: 0, target: 8 },
|
band_score: number;
|
||||||
{ skill: "Writing", band: 6.5, cefr: "B2", gap: 1.5, target: 8 },
|
raw_score: number;
|
||||||
{ skill: "Speaking", band: 7, cefr: "C1", gap: 1, target: 8 },
|
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() {
|
export default function ExamResults() {
|
||||||
const { examId } = useParams();
|
const { examId } = useParams();
|
||||||
const [searchParams] = useSearchParams();
|
const [searchParams] = useSearchParams();
|
||||||
const practice = searchParams.get("mode") === "practice";
|
const practice = searchParams.get("mode") === "practice";
|
||||||
const overall = 7.5;
|
const [downloading, setDownloading] = useState(false);
|
||||||
const cefr = "C1";
|
|
||||||
|
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 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 (
|
return (
|
||||||
<div className="mx-auto max-w-5xl space-y-8 p-6">
|
<div className="mx-auto max-w-5xl space-y-8 p-6">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
@@ -53,24 +136,26 @@ export default function ExamResults() {
|
|||||||
{practice ? <Badge className="mt-2">Practice mode</Badge> : null}
|
{practice ? <Badge className="mt-2">Practice mode</Badge> : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Card>
|
{RADAR_DATA.length > 0 && (
|
||||||
<CardHeader>
|
<Card>
|
||||||
<CardTitle>Skill profile</CardTitle>
|
<CardHeader>
|
||||||
<CardDescription>Per-skill performance vs maximum scale</CardDescription>
|
<CardTitle>Skill profile</CardTitle>
|
||||||
</CardHeader>
|
<CardDescription>Per-skill performance vs maximum scale</CardDescription>
|
||||||
<CardContent>
|
</CardHeader>
|
||||||
<div className="h-72 w-full">
|
<CardContent>
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
<div className="h-72 w-full">
|
||||||
<RadarChart data={RADAR_DATA} cx="50%" cy="50%" outerRadius="80%">
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
<PolarGrid />
|
<RadarChart data={RADAR_DATA} cx="50%" cy="50%" outerRadius="80%">
|
||||||
<PolarAngleAxis dataKey="skill" />
|
<PolarGrid />
|
||||||
<PolarRadiusAxis angle={30} domain={[0, 9]} tickCount={6} />
|
<PolarAngleAxis dataKey="skill" />
|
||||||
<Radar name="Band" dataKey="band" stroke="hsl(var(--primary))" fill="hsl(var(--primary))" fillOpacity={0.35} />
|
<PolarRadiusAxis angle={30} domain={[0, 9]} tickCount={6} />
|
||||||
</RadarChart>
|
<Radar name="Band" dataKey="band" stroke="hsl(var(--primary))" fill="hsl(var(--primary))" fillOpacity={0.35} />
|
||||||
</ResponsiveContainer>
|
</RadarChart>
|
||||||
</div>
|
</ResponsiveContainer>
|
||||||
</CardContent>
|
</div>
|
||||||
</Card>
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
@@ -100,19 +185,24 @@ export default function ExamResults() {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<div>
|
{results.feedback.length > 0 && (
|
||||||
<h2 className="mb-3 text-lg font-semibold">Section feedback</h2>
|
<div>
|
||||||
<Accordion type="multiple" className="w-full">
|
<h2 className="mb-3 text-lg font-semibold">Feedback</h2>
|
||||||
{["Listening", "Reading", "Writing", "Speaking"].map((name) => (
|
<Accordion type="multiple" className="w-full">
|
||||||
<AccordionItem key={name} value={name}>
|
{results.feedback.map((fb, i) => (
|
||||||
<AccordionTrigger>{name}</AccordionTrigger>
|
<AccordionItem key={i} value={`fb-${i}`}>
|
||||||
<AccordionContent className="text-muted-foreground">
|
<AccordionTrigger>
|
||||||
Detailed feedback for {name} will appear here once released by your instructor.
|
{fb.source === "ai" ? "AI Feedback" : fb.source === "teacher" ? "Teacher Feedback" : "Feedback"}{" "}
|
||||||
</AccordionContent>
|
{fb.question_id ? `(Q${fb.question_id})` : ""}
|
||||||
</AccordionItem>
|
</AccordionTrigger>
|
||||||
))}
|
<AccordionContent className="text-muted-foreground">
|
||||||
</Accordion>
|
{fb.feedback_text || "No detailed feedback available."}
|
||||||
</div>
|
</AccordionContent>
|
||||||
|
</AccordionItem>
|
||||||
|
))}
|
||||||
|
</Accordion>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
@@ -121,16 +211,21 @@ export default function ExamResults() {
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<ul className="list-inside list-disc space-y-2 text-sm">
|
<ul className="list-inside list-disc space-y-2 text-sm">
|
||||||
<li>Strengthen task response structure in Writing Task 2.</li>
|
{SKILLS.filter((s) => s.gap > 0)
|
||||||
<li>Extend range of cohesive devices in argumentative essays.</li>
|
.sort((a, b) => b.gap - a.gap)
|
||||||
<li>Maintain fluency while reducing hesitation in Speaking Part 2.</li>
|
.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>
|
</ul>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<div className="flex flex-wrap gap-3">
|
<div className="flex flex-wrap gap-3">
|
||||||
<Button type="button" variant="outline">
|
<Button type="button" variant="outline" onClick={handleDownloadPdf} disabled={downloading}>
|
||||||
<Download className="mr-2 h-4 w-4" />
|
{downloading ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <Download className="mr-2 h-4 w-4" />}
|
||||||
Download PDF Report
|
Download PDF Report
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="button" asChild>
|
<Button type="button" asChild>
|
||||||
|
|||||||
@@ -19,8 +19,9 @@ import {
|
|||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from "@/components/ui/dialog";
|
} from "@/components/ui/dialog";
|
||||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
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 { cn } from "@/lib/utils";
|
||||||
|
import { mediaService } from "@/services/media.service";
|
||||||
|
|
||||||
function normalizeType(t: string | null | undefined) {
|
function normalizeType(t: string | null | undefined) {
|
||||||
return (t ?? "").toLowerCase().replace(/\s+/g, "_");
|
return (t ?? "").toLowerCase().replace(/\s+/g, "_");
|
||||||
@@ -225,15 +226,7 @@ export default function ExamSession() {
|
|||||||
if (nt.includes("listen") || q.audio_url) {
|
if (nt.includes("listen") || q.audio_url) {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="flex items-center gap-3 rounded-lg border bg-muted/40 p-4">
|
<ListeningPlayer audioUrl={q.audio_url} audioBase64={q.audio_base64} />
|
||||||
<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>
|
|
||||||
{q.options?.length ? (
|
{q.options?.length ? (
|
||||||
<RadioGroup
|
<RadioGroup
|
||||||
value={typeof a.answer === "string" ? a.answer : ""}
|
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")) {
|
if (nt.includes("speak") || nt.includes("record") || nt.includes("audio")) {
|
||||||
return (
|
return (
|
||||||
<div className="rounded-lg border border-dashed p-8 text-center text-muted-foreground">
|
<SpeakingRecorder
|
||||||
Recording interface will appear here.
|
questionId={q.id}
|
||||||
</div>
|
onRecorded={(blob) => {
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
updateAnswer(q.id, { answer: url });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -481,3 +478,131 @@ export default function ExamSession() {
|
|||||||
</div>
|
</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 { useNavigate, useSearchParams, useLocation } from "react-router-dom";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
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 { Skeleton } from "@/components/ui/skeleton";
|
||||||
import { Progress } from "@/components/ui/progress";
|
import { Progress } from "@/components/ui/progress";
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
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 { usePlacementAnswer, usePlacementAutoSave } from "@/hooks/queries/usePlacement";
|
||||||
import type { CATQuestion, CATSection } from "@/types";
|
import type { CATQuestion, CATSection } from "@/types";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
@@ -324,15 +325,11 @@ export default function PlacementTest() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{question.type === "audio_recording" && (
|
{question.type === "audio_recording" && (
|
||||||
<div className="flex flex-col items-center gap-4 py-8 rounded-xl border border-dashed bg-muted/30">
|
<PlacementSpeakingRecorder
|
||||||
<Mic className="h-12 w-12 text-muted-foreground" />
|
sessionId={sessionId}
|
||||||
<p className="text-sm text-muted-foreground text-center max-w-sm">
|
promptId={question.id}
|
||||||
Recording will be available in a future update. Use the button below to proceed for now.
|
onUploaded={() => setSingleAnswer("audio_submitted")}
|
||||||
</p>
|
/>
|
||||||
<Button type="button" variant="secondary" size="lg" disabled>
|
|
||||||
Record (placeholder)
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex justify-end pt-4">
|
<div className="flex justify-end pt-4">
|
||||||
@@ -362,3 +359,93 @@ export default function PlacementTest() {
|
|||||||
</div>
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState } from "react";
|
import { useState, useMemo } from "react";
|
||||||
import { useParams } from "react-router-dom";
|
import { useParams } from "react-router-dom";
|
||||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
@@ -12,6 +12,23 @@ import { useGenerateOutline, useGenerateChapterContent, usePublishWorkbench } fr
|
|||||||
import { useToast } from "@/hooks/use-toast";
|
import { useToast } from "@/hooks/use-toast";
|
||||||
import type { WorkbenchGeneratedOutline, WorkbenchGeneratedChapter } from "@/types/courseware";
|
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";
|
type Complexity = "beginner" | "intermediate" | "advanced";
|
||||||
|
|
||||||
export default function AiWorkbench() {
|
export default function AiWorkbench() {
|
||||||
@@ -160,7 +177,7 @@ export default function AiWorkbench() {
|
|||||||
<CardDescription>Review the detailed content before publishing.</CardDescription>
|
<CardDescription>Review the detailed content before publishing.</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
<div className="prose prose-sm max-w-none p-4 rounded-lg border bg-muted/30" dangerouslySetInnerHTML={{ __html: generatedContent.content }} />
|
<div className="prose prose-sm max-w-none p-4 rounded-lg border bg-muted/30" dangerouslySetInnerHTML={{ __html: sanitizeHtml(generatedContent.content) }} />
|
||||||
{generatedContent.exercises.length > 0 && (
|
{generatedContent.exercises.length > 0 && (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<h3 className="font-medium">Exercises ({generatedContent.exercises.length})</h3>
|
<h3 className="font-medium">Exercises ({generatedContent.exercises.length})</h3>
|
||||||
|
|||||||
@@ -9,22 +9,22 @@ import type {
|
|||||||
import type { ApiSuccessResponse } from "@/types";
|
import type { ApiSuccessResponse } from "@/types";
|
||||||
|
|
||||||
export const adaptiveEngineService = {
|
export const adaptiveEngineService = {
|
||||||
getDashboard: () => api.get<AdaptiveDashboardMetrics>("/adaptive-engine/dashboard"),
|
getDashboard: () => api.get<AdaptiveDashboardMetrics>("/adaptive/dashboard"),
|
||||||
|
|
||||||
getStudents: (params?: { page?: number; limit?: number }) =>
|
getStudents: (params?: { page?: number; limit?: number }) =>
|
||||||
api.get<{ data: AdaptiveEngineStudentRow[]; pagination?: { total: number; page: number } }>(
|
api.get<{ data: AdaptiveEngineStudentRow[]; pagination?: { total: number; page: number } }>(
|
||||||
"/adaptive-engine/students",
|
"/adaptive/students",
|
||||||
params as Record<string, string | number | boolean | undefined>,
|
params as Record<string, string | number | boolean | undefined>,
|
||||||
),
|
),
|
||||||
|
|
||||||
getStudentSignals: (studentId: number) =>
|
getStudentSignals: (studentId: number) =>
|
||||||
api.get<StudentAdaptiveSignal[]>(`/adaptive-engine/students/${studentId}/signals`),
|
api.get<StudentAdaptiveSignal[]>(`/adaptive/student/${studentId}/signals`),
|
||||||
|
|
||||||
getStudentAbility: (studentId: number) =>
|
getStudentAbility: (studentId: number) =>
|
||||||
api.get<StudentAbilityModel>(`/adaptive-engine/students/${studentId}/ability`),
|
api.get<StudentAbilityModel>(`/adaptive/student/${studentId}/ability`),
|
||||||
|
|
||||||
getSettings: () => api.get<AdaptiveThresholdSettings>("/adaptive-engine/settings"),
|
getSettings: () => api.get<AdaptiveThresholdSettings>("/adaptive/settings"),
|
||||||
|
|
||||||
updateSettings: (data: AdaptiveThresholdSettings) =>
|
updateSettings: (data: AdaptiveThresholdSettings) =>
|
||||||
api.put<ApiSuccessResponse>("/adaptive-engine/settings", data),
|
api.put<ApiSuccessResponse>("/adaptive/settings", data),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -11,14 +11,14 @@ export const entityOnboardingService = {
|
|||||||
validateCsv: (file: File) => {
|
validateCsv: (file: File) => {
|
||||||
const fd = new FormData();
|
const fd = new FormData();
|
||||||
fd.append("file", file);
|
fd.append("file", file);
|
||||||
return api.upload<CSVValidationReport>("/entity/students/csv/validate", fd);
|
return api.upload<CSVValidationReport>("/entity/students/validate-csv", fd);
|
||||||
},
|
},
|
||||||
|
|
||||||
bulkCreate: (payload: { validate_session_id?: string }) =>
|
bulkCreate: (payload: { validate_session_id?: string }) =>
|
||||||
api.post<BulkCreateResult>("/entity/students/bulk-create", payload),
|
api.post<BulkCreateResult>("/entity/students/bulk-create", payload),
|
||||||
|
|
||||||
sendCredentials: (studentIds: number[]) =>
|
sendCredentials: (studentIds: number[]) =>
|
||||||
api.post<ApiSuccessResponse>("/entity/students/send-credentials", { student_ids: studentIds }),
|
api.post<ApiSuccessResponse>("/entity/students/send-credentials", { user_ids: studentIds }),
|
||||||
|
|
||||||
getCredentialStatuses: (
|
getCredentialStatuses: (
|
||||||
filters?: CredentialFilters & { page?: number; limit?: number },
|
filters?: CredentialFilters & { page?: number; limit?: number },
|
||||||
@@ -29,8 +29,8 @@ export const entityOnboardingService = {
|
|||||||
),
|
),
|
||||||
|
|
||||||
resendCredential: (studentId: number) =>
|
resendCredential: (studentId: number) =>
|
||||||
api.post<ApiSuccessResponse>(`/entity/students/${studentId}/resend-credential`),
|
api.post<ApiSuccessResponse>(`/entity/students/${studentId}/resend-credentials`),
|
||||||
|
|
||||||
resendAllPending: () =>
|
resendAllPending: () =>
|
||||||
api.post<ApiSuccessResponse>("/entity/students/resend-credentials/pending"),
|
api.post<ApiSuccessResponse>("/entity/students/resend-all-pending"),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -14,4 +14,7 @@ export const examSessionService = {
|
|||||||
|
|
||||||
getStatus: (examId: number) =>
|
getStatus: (examId: number) =>
|
||||||
api.get<{ status: string; scores_available: boolean }>(`/exam/${examId}/status`),
|
api.get<{ status: string; scores_available: boolean }>(`/exam/${examId}/status`),
|
||||||
|
|
||||||
|
getResults: (examId: number) =>
|
||||||
|
api.get(`/exam/${examId}/results`),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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 = {
|
export const reportService = {
|
||||||
downloadPdf: async (attemptId: number): Promise<void> => {
|
downloadPdf: async (attemptId: number): Promise<void> => {
|
||||||
const response = await fetch(`/api/reports/exam/${attemptId}/pdf`, {
|
const token = localStorage.getItem("encoach_token");
|
||||||
headers: {
|
const headers: Record<string, string> = {};
|
||||||
Authorization: `Bearer ${localStorage.getItem("encoach_token")}`,
|
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");
|
if (!response.ok) throw new Error("Failed to generate report");
|
||||||
const blob = await response.blob();
|
const blob = await response.blob();
|
||||||
@@ -13,7 +17,9 @@ export const reportService = {
|
|||||||
const link = document.createElement("a");
|
const link = document.createElement("a");
|
||||||
link.href = url;
|
link.href = url;
|
||||||
link.download = `exam-report-${attemptId}.pdf`;
|
link.download = `exam-report-${attemptId}.pdf`;
|
||||||
|
document.body.appendChild(link);
|
||||||
link.click();
|
link.click();
|
||||||
|
document.body.removeChild(link);
|
||||||
window.URL.revokeObjectURL(url);
|
window.URL.revokeObjectURL(url);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user