import { useCallback, useEffect, useRef, useState } from "react"; import { useNavigate } from "react-router-dom"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { Card, CardContent } from "@/components/ui/card"; import { Input } from "@/components/ui/input"; import { Button } from "@/components/ui/button"; import { Label } from "@/components/ui/label"; import { Checkbox } from "@/components/ui/checkbox"; import { Textarea } from "@/components/ui/textarea"; import { Badge } from "@/components/ui/badge"; import { Switch } from "@/components/ui/switch"; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from "@/components/ui/dialog"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select"; import { Collapsible, CollapsibleContent, CollapsibleTrigger, } from "@/components/ui/collapsible"; import { Wand2, BookOpen, Headphones, PenTool, Mic, Layers, Briefcase, ChevronDown, Plus, Minus, X, Loader2, RotateCcw, Eye, Send, SkipForward, Upload, FileText, Play, Video, Sparkles, Settings2, ListChecks, GraduationCap, Dumbbell, Lock, } from "lucide-react"; import AiTipBanner from "@/components/ai/AiTipBanner"; import { generationService } from "@/services/generation.service"; import { mediaService, type Avatar } from "@/services/media.service"; import { examsService } from "@/services/exams.service"; import { api } from "@/lib/api-client"; import { useToast } from "@/hooks/use-toast"; import type { ExamStructure, ExamStructureConfig } from "@/types"; type ModuleKey = "reading" | "listening" | "writing" | "speaking" | "level" | "industry"; interface ModuleInfo { key: ModuleKey; label: string; icon: React.ReactNode; color: string; bgColor: string; } const MODULES: ModuleInfo[] = [ { key: "reading", label: "Reading", icon: , color: "text-blue-600", bgColor: "bg-blue-50 border-blue-200" }, { key: "listening", label: "Listening", icon: , color: "text-orange-600", bgColor: "bg-orange-50 border-orange-200" }, { key: "writing", label: "Writing", icon: , color: "text-green-600", bgColor: "bg-green-50 border-green-200" }, { key: "speaking", label: "Speaking", icon: , color: "text-pink-600", bgColor: "bg-pink-50 border-pink-200" }, { key: "level", label: "Level", icon: , color: "text-purple-600", bgColor: "bg-purple-50 border-purple-200" }, { key: "industry", label: "Industry", icon: , 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 READING_EXERCISE_TYPES = [ { key: "mcq", label: "Multiple Choice" }, { key: "fill_blanks", label: "Fill Blanks" }, { key: "write_blanks", label: "Write Blanks" }, { key: "true_false", label: "True False" }, { key: "paragraph_match", label: "Paragraph Match" }, ]; const LISTENING_SECTION_TYPES = [ { key: "social_conversation", label: "Social Conversation" }, { key: "social_monologue", label: "Social Monologue" }, { key: "academic_discussion", label: "Academic Discussion" }, { key: "academic_monologue", label: "Academic Monologue" }, ]; const LISTENING_EXERCISE_TYPES = [ { key: "mcq", label: "Multiple Choice" }, { key: "write_blanks_questions", label: "Write Blanks: Questions" }, { key: "true_false", label: "True False" }, { key: "write_blanks_fill", label: "Write Blanks: Fill" }, { key: "write_blanks_form", label: "Write Blanks: Form" }, ]; const WRITING_TYPES = [ { key: "general", label: "General" }, { key: "letter", label: "Letter" }, { key: "essay", label: "Essay" }, { key: "report", label: "Report" }, ]; const LEVEL_EXERCISE_TYPES = [ { key: "mc_blank", label: "Multiple Choice: Blank Space" }, { key: "mc_underline", label: "Multiple Choice: Underlined" }, { key: "fill_blanks", label: "Fill Blanks" }, { key: "fill_blanks_mc", label: "Fill Blanks: Multiple Choice" }, { key: "verb_tense", label: "Verb Tense" }, ]; const INDUSTRY_EXERCISE_TYPES = [ { key: "mc_blank_space", label: "Multiple Choice: Blank Space" }, { key: "mc_normal", label: "Multiple Choice: Normal" }, { key: "fill_blanks", label: "Fill Blanks" }, { key: "true_false", label: "True/False" }, { key: "technical_writing", label: "Technical Writing" }, ]; const INDUSTRY_DIFFICULTY_LEVELS = ["Beginner", "Intermediate", "Advanced"]; const DEFAULT_AVATARS: Avatar[] = [ { id: "gia", name: "Gia", gender: "female" }, { id: "vadim", name: "Vadim", gender: "male" }, { id: "orhan", name: "Orhan", gender: "male" }, { id: "flora", name: "Flora", gender: "female" }, { id: "scarlett", name: "Scarlett", gender: "female" }, { id: "parker", name: "Parker", gender: "male" }, { id: "ethan", name: "Ethan", gender: "male" }, ]; interface Exercise { type: string; instructions: string; prompt: string; options: string[]; correct_answer: string; explanation: string; marks: number; difficulty?: string; } interface PassageState { text: string; category: string; type: string; divider: string; exerciseTypes: string[]; exercises: Exercise[]; editing: boolean; } interface ListeningSectionState { type: string; category: string; divider: string; context: string; audioUrl: string; exerciseTypes: string[]; exercises: Exercise[]; editing: boolean; } interface WritingTaskState { instructions: string; category: string; type: string; divider: string; wordLimit: number; marks: number; graded: boolean; images: string[]; editing: boolean; } interface SpeakingPartState { type: string; category: string; divider: string; script: string; videoUrl: string; avatarId: string; marks: number; topics: string[]; editing: boolean; } interface LevelPartState { category: string; divider: string; levelExerciseTypes: string[]; levelExercises: Exercise[]; readingPassages: PassageState[]; listeningSections: ListeningSectionState[]; writingTasks: WritingTaskState[]; speakingParts: SpeakingPartState[]; } interface IndustryPartState { category: string; divider: string; exerciseTypes: string[]; exercises: Exercise[]; attachments: string[]; } interface ModuleState { timer: number; difficulty: string[]; accessType: string; entity: string; approvalWorkflow: string; rubricGroup: string; rubricCriteria: string; rubricId: string; totalMarks: number; gradingSystem: string; shuffling: boolean; numberOfParts: number; passages: PassageState[]; listeningSections: ListeningSectionState[]; writingTasks: WritingTaskState[]; speakingParts: SpeakingPartState[]; levelParts: LevelPartState[]; industryParts: IndustryPartState[]; } function defaultPassage(): PassageState { return { text: "", category: "", type: "general", divider: "", exerciseTypes: [], exercises: [], editing: false }; } function defaultListeningSection(): ListeningSectionState { return { type: "social_conversation", category: "", divider: "", context: "", audioUrl: "", exerciseTypes: [], exercises: [], editing: false }; } function defaultWritingTask(): WritingTaskState { return { instructions: "", category: "", type: "general", divider: "", wordLimit: 150, marks: 0, graded: true, images: [], editing: false }; } function defaultSpeakingPart(type = "speaking_1"): SpeakingPartState { return { type, category: "", divider: "", script: "", videoUrl: "", avatarId: "", marks: 0, topics: ["", ""], editing: false }; } function defaultLevelPart(): LevelPartState { return { category: "", divider: "", levelExerciseTypes: [], levelExercises: [], readingPassages: [defaultPassage(), defaultPassage(), defaultPassage()], listeningSections: [defaultListeningSection(), defaultListeningSection(), defaultListeningSection(), defaultListeningSection()], writingTasks: [defaultWritingTask(), { ...defaultWritingTask(), type: "essay", wordLimit: 250 }], speakingParts: [defaultSpeakingPart("speaking_1"), defaultSpeakingPart("speaking_2"), defaultSpeakingPart("interactive")], }; } function defaultIndustryPart(): IndustryPartState { return { category: "", divider: "", exerciseTypes: [], exercises: [], attachments: [] }; } function defaultModuleState(mod: ModuleKey): ModuleState { const diffMap: Record = { reading: ["B2"], listening: ["A2"], writing: ["B2"], speaking: ["B1"], level: ["A2"], industry: ["Intermediate"], }; return { timer: 5, difficulty: diffMap[mod] || ["B2"], accessType: "private", entity: "", approvalWorkflow: "", rubricGroup: "", rubricCriteria: "", rubricId: "", totalMarks: 0, gradingSystem: "", shuffling: false, numberOfParts: 1, passages: [defaultPassage()], listeningSections: [defaultListeningSection()], writingTasks: [defaultWritingTask()], speakingParts: [defaultSpeakingPart()], levelParts: [defaultLevelPart()], industryParts: [defaultIndustryPart()], }; } export default function GenerationPage() { const { toast } = useToast(); const navigate = useNavigate(); const queryClient = useQueryClient(); const [examMode, setExamMode] = useState<"official" | "practice">("official"); const [title, setTitle] = useState(""); const [examLabel, setExamLabel] = useState(""); const [examStructure, setExamStructure] = useState(""); const [newStructureDialog, setNewStructureDialog] = useState<{ open: boolean; name: string; modules: Set; }>({ open: false, name: "", modules: new Set() }); const [selectedModules, setSelectedModules] = useState>(new Set()); const [activeModule, setActiveModule] = useState(null); const [moduleStates, setModuleStates] = useState>({}); const [previewOpen, setPreviewOpen] = useState(false); const [editingExercise, setEditingExercise] = useState<{ module: "reading" | "listening"; itemIndex: number; exerciseIndex: number } | null>(null); const [exerciseDraft, setExerciseDraft] = useState(null); const [exerciseWizard, setExerciseWizard] = useState<{ open: boolean; module: "reading" | "listening"; itemIndex: number; step: number; types: Record; }>({ open: false, module: "reading", itemIndex: 0, step: 0, types: {}, }); const DEFAULT_INSTRUCTIONS: Record = { mcq: "Read the passage carefully and choose the best answer for each question.", fill_blanks: "Complete each sentence by selecting the correct word from the options provided.", write_blanks: "Fill in each blank with a suitable word or phrase based on the passage.", true_false: "Read each statement and decide whether it is True, False, or Not Given based on the passage.", paragraph_match: "Match each heading or statement to the correct paragraph from the passage.", write_blanks_questions: "Listen to the audio and write the correct answers to the questions.", write_blanks_fill: "Listen to the audio and fill in the missing words.", write_blanks_form: "Listen to the audio and complete the form with the correct information.", }; const openExerciseWizard = (pi: number, mod: "reading" | "listening" = "reading") => { const st = getModuleState(mod); const exerciseTypeList = mod === "listening" ? LISTENING_EXERCISE_TYPES : READING_EXERCISE_TYPES; const existing = mod === "listening" ? st.listeningSections[pi]?.exerciseTypes ?? [] : st.passages[pi]?.exerciseTypes ?? []; const types: Record = {}; for (const et of exerciseTypeList) { types[et.key] = { enabled: existing.includes(et.key) || existing.length === 0, count: 5, difficulty: st.difficulty[0] || "B2", instructions: DEFAULT_INSTRUCTIONS[et.key] || "", }; } setExerciseWizard({ open: true, module: mod, itemIndex: pi, step: 0, types }); }; const wizardTotalQuestions = Object.values(exerciseWizard.types).reduce( (sum, t) => sum + (t.enabled ? t.count : 0), 0 ); const wizardEnabledTypes = Object.entries(exerciseWizard.types).filter(([, t]) => t.enabled); const structuresQ = useQuery({ queryKey: ["exam-structures"], queryFn: () => examsService.listStructures({}), }); const structures = structuresQ.data?.items ?? []; const rubricsQ = useQuery({ queryKey: ["rubrics"], queryFn: () => examsService.listRubrics({}), }); const rubrics = rubricsQ.data?.items ?? []; const rubricGroupsQ = useQuery({ queryKey: ["rubric-groups"], queryFn: () => examsService.listRubricGroups({}), }); const rubricGroups = rubricGroupsQ.data?.items ?? []; const entitiesQ = useQuery({ queryKey: ["entities"], queryFn: () => api.get<{ items: { id: number; name: string; code: string; type: string }[] }>("/entities"), }); const entities = entitiesQ.data?.items ?? []; const workflowsQ = useQuery({ queryKey: ["approval-workflows"], queryFn: () => api.get<{ items: { id: number; name: string; type: string }[] }>("/approval-workflows"), }); const approvalWorkflows = workflowsQ.data?.items ?? []; const selectedStructure = structures.find((s) => String(s.id) === examStructure); const selectedStructureConfig = (selectedStructure?.config && typeof selectedStructure.config === "object" ? selectedStructure.config : null) as ExamStructureConfig | null; const isOfficialLocked = examMode === "official" && !!selectedStructure; const createStructureMut = useMutation({ mutationFn: (data: { name: string; modules: string[] }) => examsService.createStructure(data as unknown as Partial), onSuccess: (created) => { queryClient.invalidateQueries({ queryKey: ["exam-structures"] }); setExamStructure(String(created.id)); setNewStructureDialog({ open: false, name: "", modules: new Set() }); toast({ title: "Structure created", description: `"${created.name}" has been added.` }); }, onError: (err: Error) => toast({ variant: "destructive", title: "Failed to create structure", description: err.message }), }); const getModuleState = useCallback((mod: ModuleKey): ModuleState => { return moduleStates[mod] ?? defaultModuleState(mod); }, [moduleStates]); const updateModuleState = useCallback((mod: ModuleKey, patch: Partial) => { setModuleStates((prev) => ({ ...prev, [mod]: { ...(prev[mod] ?? defaultModuleState(mod)), ...patch }, })); }, []); const toggleModule = (mod: ModuleKey) => { setSelectedModules((prev) => { const next = new Set(prev); if (next.has(mod)) { next.delete(mod); if (activeModule === mod) setActiveModule(null); } else { next.add(mod); setActiveModule(mod); } return next; }); }; const currentState = activeModule ? getModuleState(activeModule) : null; /** * Build the rich "persona context" that the backend AI controller uses to * build its system prompt. Every parameter the user has configured in the * UI (exam mode, structure, entity, rubric, grading system, access type, * passage category/type, module, subject) flows through here so the * generated questions actually reflect those choices. */ const buildPersonaContext = useCallback( (mod: ModuleKey): Record => { const st = getModuleState(mod); const entityIdNum = st.entity && st.entity !== "" && st.entity !== "none" ? Number(st.entity) : undefined; const entityObj = entityIdNum ? entities.find((e) => e.id === entityIdNum) : undefined; let rubricIdNum: number | undefined; if (st.rubricId && st.rubricId.startsWith("rubric-")) { const parsed = Number(st.rubricId.split("-")[1]); if (!Number.isNaN(parsed)) rubricIdNum = parsed; } const rubricObj = rubricIdNum ? rubrics.find((r) => r.id === rubricIdNum) : undefined; const structureObj = selectedStructure; return { module: mod, exam_mode: examMode, exam_title: title || undefined, exam_label: examLabel || undefined, structure_name: structureObj?.name, entity_id: entityIdNum, entity_name: entityObj?.name, rubric_id: rubricIdNum, rubric_name: rubricObj?.name, grading_system: st.gradingSystem || undefined, access_type: st.accessType || undefined, }; }, [getModuleState, entities, rubrics, selectedStructure, examMode, title, examLabel], ); const generatePassageMut = useMutation({ mutationFn: (params: { index: number; topic: string; difficulty: string; wordCount: number; category: string; passageType: string }) => generationService.generatePassage({ ...buildPersonaContext(activeModule ?? "reading"), topic: params.topic, difficulty: params.difficulty, word_count: params.wordCount, category: params.category, passage_type: params.passageType, }), onSuccess: (res, vars) => { if (!activeModule) return; const st = getModuleState(activeModule); const passages = [...st.passages]; const r = res as Record; const passageText = (r.passage as string) ?? (r.text as string) ?? JSON.stringify(res); passages[vars.index] = { ...passages[vars.index], text: passageText }; updateModuleState(activeModule, { passages }); toast({ title: "Passage generated", description: `${passageText.length} characters` }); }, onError: (err: Error) => toast({ variant: "destructive", title: "Generation failed", description: err.message }), }); const generateExercisesMut = useMutation({ mutationFn: (params: { module: ModuleKey; passageIndex: number; types: string[]; typeCounts: Record; typeInstructions: Record; typeDifficulties?: Record; passageText: string; difficulty: string; }) => { const mod = params.module === "level" || params.module === "industry" ? "reading" : params.module; const totalCount = Object.values(params.typeCounts).reduce((s, n) => s + n, 0); const st = getModuleState(params.module); let category: string | undefined; let passageType: string | undefined; if (params.module === "listening") { const sec = st.listeningSections[params.passageIndex]; category = sec?.category; passageType = sec?.type; } else { const p = st.passages[params.passageIndex]; category = p?.category; passageType = p?.type; } return generationService.generateExercises(mod as "reading" | "listening" | "writing" | "speaking", { ...buildPersonaContext(params.module), passage_index: params.passageIndex, exercise_types: params.types, count_per_type: totalCount, passage_text: params.passageText, type_counts: params.typeCounts, type_instructions: params.typeInstructions, type_difficulties: params.typeDifficulties, difficulty: params.difficulty, category, passage_type: passageType, } as Record & { passage_index: number; exercise_types: string[] }); }, onSuccess: (res, vars) => { const st = getModuleState(vars.module); const raw = Array.isArray((res as Record).questions) ? (res as Record).questions as Record[] : []; const items: Exercise[] = raw.map((q) => ({ type: String(q.type || vars.types?.[0] || "mcq"), instructions: String(q.instructions || vars.typeInstructions?.[String(q.type)] || ""), prompt: String(q.prompt || q.question || ""), options: Array.isArray(q.options) ? q.options.map(String) : [], correct_answer: String(q.correct_answer || q.answer || ""), explanation: String(q.explanation || ""), marks: Number(q.marks) || 1, difficulty: q.difficulty ? String(q.difficulty) : undefined, })); if (vars.module === "reading") { const passages = [...st.passages]; passages[vars.passageIndex] = { ...passages[vars.passageIndex], exercises: [...passages[vars.passageIndex].exercises, ...items], exerciseTypes: vars.types, }; updateModuleState(vars.module, { passages }); } else if (vars.module === "listening") { const sections = [...st.listeningSections]; sections[vars.passageIndex] = { ...sections[vars.passageIndex], exercises: [...sections[vars.passageIndex].exercises, ...items], exerciseTypes: vars.types, }; updateModuleState(vars.module, { listeningSections: sections }); } setExerciseWizard((prev) => ({ ...prev, step: 1 })); toast({ title: `${items.length} exercises generated` }); }, onError: (err: Error) => { toast({ variant: "destructive", title: "Generation failed", description: err.message }); }, }); const generateAudioMut = useMutation({ mutationFn: (params: { text: string; sectionIndex: number }) => mediaService.generateListeningAudio({ text: params.text }), onSuccess: (res, vars) => { if (activeModule !== "listening") return; const st = getModuleState("listening"); const sections = [...st.listeningSections]; 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 }); toast({ title: "Audio generated" }); }, onError: (err: Error) => toast({ variant: "destructive", title: "Audio generation failed", description: err.message }), }); const generateWritingMut = useMutation({ mutationFn: (params: { topic: string; difficulty: string; taskIndex: number }) => { const st = getModuleState("writing"); const task = st.writingTasks[params.taskIndex]; return generationService.generateWritingInstructions({ ...buildPersonaContext("writing"), topic: params.topic, difficulty: params.difficulty, task_type: task?.type || "essay", category: task?.category || undefined, word_limit: task?.wordLimit, }); }, onSuccess: (res, vars) => { const st = getModuleState("writing"); const tasks = [...st.writingTasks]; const r = res as Record; const instructions = (r.instructions as string) ?? JSON.stringify(r.questions ?? res); tasks[vars.taskIndex] = { ...tasks[vars.taskIndex], instructions }; updateModuleState("writing", { writingTasks: tasks }); toast({ title: "Instructions generated" }); }, onError: (err: Error) => toast({ variant: "destructive", title: "Generation failed", description: err.message }), }); const generateSpeakingMut = useMutation({ mutationFn: (params: { topics: string[]; difficulty: string; partIndex: number }) => { const st = getModuleState("speaking"); const part = st.speakingParts[params.partIndex]; return generationService.generateSpeakingScript({ ...buildPersonaContext("speaking"), topics: params.topics.filter(Boolean), difficulty: params.difficulty, part: part?.type || "speaking_1", category: part?.category || undefined, }); }, onSuccess: (res, vars) => { const r = res as Record; if (r.error) { toast({ variant: "destructive", title: "Script generation failed", description: String(r.error) }); return; } const st = getModuleState("speaking"); const parts = [...st.speakingParts]; const script = (r.script as string) || ""; if (!script) { toast({ variant: "destructive", title: "No script returned", description: "AI returned empty response — try again." }); return; } parts[vars.partIndex] = { ...parts[vars.partIndex], script }; updateModuleState("speaking", { speakingParts: parts }); toast({ title: "Script generated" }); }, onError: (err: Error) => toast({ variant: "destructive", title: "Generation failed", description: err.message }), }); const generateVideoMut = useMutation({ mutationFn: (params: { script: string; avatarId: string; partIndex: number }) => mediaService.createAvatarVideo({ script: params.script, avatar_id: params.avatarId, title: title || "Speaking Video" }), onSuccess: (res, vars) => { const st = getModuleState("speaking"); const parts = [...st.speakingParts]; parts[vars.partIndex] = { ...parts[vars.partIndex], videoUrl: `pending:${res.video_id}` }; updateModuleState("speaking", { speakingParts: parts }); toast({ title: "Video generation started", description: `Job ID: ${res.video_id}` }); }, onError: (err: Error) => toast({ variant: "destructive", title: "Video generation failed", description: err.message }), }); const pollTimerRef = useRef | 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 || "" }; 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({ mutationFn: (skipApproval: boolean) => { const modulesPayload: Record = {}; for (const mod of selectedModules) { const st = getModuleState(mod); modulesPayload[mod] = { timer: st.timer, difficulty: st.difficulty, accessType: st.accessType, shuffling: st.shuffling, gradingSystem: st.gradingSystem, rubricId: st.rubricId, entity: st.entity, approvalWorkflow: st.approvalWorkflow, totalMarks: st.totalMarks, passages: mod === "reading" ? st.passages.map((p) => ({ text: p.text, category: p.category, type: p.type, exercises: p.exercises?.map((ex) => ({ type: ex.type, prompt: ex.prompt, options: ex.options, correct_answer: ex.correct_answer, explanation: ex.explanation, instructions: ex.instructions, marks: ex.marks || 1, difficulty: ex.difficulty || st.difficulty?.[0] || "B2", })), })) : undefined, sections: mod === "listening" ? st.listeningSections.map((s) => ({ type: s.type, context: s.context, audioUrl: s.audioUrl, exercises: s.exercises?.map((ex) => ({ type: ex.type, prompt: ex.prompt, options: ex.options, correct_answer: ex.correct_answer, explanation: ex.explanation, instructions: ex.instructions, marks: ex.marks || 1, difficulty: ex.difficulty || st.difficulty?.[0] || "B2", })), })) : undefined, tasks: mod === "writing" ? st.writingTasks.map((t) => ({ instructions: t.instructions, wordLimit: t.wordLimit, marks: t.marks, category: t.category, type: t.type, })) : undefined, parts: mod === "speaking" ? st.speakingParts.map((p) => ({ type: p.type, script: p.script, videoUrl: p.videoUrl, marks: p.marks, })) : undefined, }; } return generationService.submitExam({ title, label: examLabel, exam_mode: examMode, structure_id: examStructure ? Number(examStructure) : undefined, modules: modulesPayload, skip_approval: skipApproval, }); }, 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 }), }); const anyGenerating = generatePassageMut.isPending || generateExercisesMut.isPending || generateAudioMut.isPending || generateWritingMut.isPending || generateSpeakingMut.isPending || generateVideoMut.isPending || submitMut.isPending; const renderCommonConfig = (mod: ModuleKey) => { const st = getModuleState(mod); return (
updateModuleState(mod, { timer: Number(e.target.value) || 1 })} className="h-8 text-xs" />
{examMode === "official" && (
)}
{st.totalMarks}
updateModuleState(mod, { shuffling: v })} />
); }; const renderReadingModule = () => { if (!activeModule || activeModule !== "reading") return null; const st = getModuleState("reading"); return (
{renderCommonConfig("reading")}
{st.passages.map((_, i) => (
{st.passages.length > 1 && ( )}
))}
{st.passages.map((passage, pi) => (

Passage {pi + 1} Settings

Category { const p = [...st.passages]; p[pi] = { ...p[pi], category: e.target.value }; updateModuleState("reading", { passages: p }); }} /> Type Generate Passage { const p = [...st.passages]; p[pi] = { ...p[pi], category: e.target.value }; updateModuleState("reading", { passages: p }); }} /> { const p = [...st.passages]; p[pi] = { ...p[pi], divider: e.target.value }; updateModuleState("reading", { passages: p }); }} />

Reading Passage

The reading passage that the exercises will refer to.