999 lines
51 KiB
TypeScript
999 lines
51 KiB
TypeScript
import { useCallback, useState } from "react";
|
|
import { useMutation } 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 {
|
|
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,
|
|
X,
|
|
Loader2,
|
|
RotateCcw,
|
|
Eye,
|
|
Send,
|
|
SkipForward,
|
|
Upload,
|
|
FileText,
|
|
Play,
|
|
Video,
|
|
Sparkles,
|
|
} from "lucide-react";
|
|
import AiTipBanner from "@/components/ai/AiTipBanner";
|
|
import { generationService } from "@/services/generation.service";
|
|
import { mediaService, type Avatar } from "@/services/media.service";
|
|
import { useToast } from "@/hooks/use-toast";
|
|
|
|
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: <BookOpen className="h-4 w-4" />, color: "text-blue-600", bgColor: "bg-blue-50 border-blue-200" },
|
|
{ key: "listening", label: "Listening", icon: <Headphones className="h-4 w-4" />, color: "text-orange-600", bgColor: "bg-orange-50 border-orange-200" },
|
|
{ key: "writing", label: "Writing", icon: <PenTool className="h-4 w-4" />, color: "text-green-600", bgColor: "bg-green-50 border-green-200" },
|
|
{ key: "speaking", label: "Speaking", icon: <Mic className="h-4 w-4" />, color: "text-pink-600", bgColor: "bg-pink-50 border-pink-200" },
|
|
{ key: "level", label: "Level", icon: <Layers className="h-4 w-4" />, color: "text-purple-600", bgColor: "bg-purple-50 border-purple-200" },
|
|
{ key: "industry", label: "Industry", icon: <Briefcase className="h-4 w-4" />, color: "text-amber-700", bgColor: "bg-amber-50 border-amber-200" },
|
|
];
|
|
|
|
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 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 PassageState {
|
|
text: string;
|
|
category: string;
|
|
type: string;
|
|
divider: string;
|
|
exerciseTypes: string[];
|
|
exercises: unknown[];
|
|
editing: boolean;
|
|
}
|
|
|
|
interface ListeningSectionState {
|
|
type: string;
|
|
category: string;
|
|
divider: string;
|
|
context: string;
|
|
audioUrl: string;
|
|
exerciseTypes: string[];
|
|
exercises: unknown[];
|
|
editing: boolean;
|
|
}
|
|
|
|
interface WritingTaskState {
|
|
instructions: string;
|
|
category: string;
|
|
type: string;
|
|
divider: string;
|
|
wordLimit: number;
|
|
marks: number;
|
|
editing: boolean;
|
|
}
|
|
|
|
interface SpeakingPartState {
|
|
type: string;
|
|
category: string;
|
|
divider: string;
|
|
script: string;
|
|
videoUrl: string;
|
|
avatarId: string;
|
|
marks: number;
|
|
topics: string[];
|
|
editing: boolean;
|
|
}
|
|
|
|
interface ModuleState {
|
|
timer: number;
|
|
difficulty: string[];
|
|
accessType: string;
|
|
entity: string;
|
|
approvalWorkflow: string;
|
|
rubricGroup: string;
|
|
rubricCriteria: string;
|
|
totalMarks: number;
|
|
gradingSystem: string;
|
|
shuffling: boolean;
|
|
passages: PassageState[];
|
|
listeningSections: ListeningSectionState[];
|
|
writingTasks: WritingTaskState[];
|
|
speakingParts: SpeakingPartState[];
|
|
}
|
|
|
|
function defaultModuleState(mod: ModuleKey): ModuleState {
|
|
return {
|
|
timer: 5,
|
|
difficulty: [mod === "reading" ? "B2" : mod === "listening" ? "A2" : mod === "writing" ? "A1" : "B1"],
|
|
accessType: "private",
|
|
entity: "",
|
|
approvalWorkflow: "",
|
|
rubricGroup: "",
|
|
rubricCriteria: "",
|
|
totalMarks: 0,
|
|
gradingSystem: "",
|
|
shuffling: false,
|
|
passages: [{ text: "", category: "", type: "general", divider: "", exerciseTypes: [], exercises: [], editing: false }],
|
|
listeningSections: [{ type: "social_conversation", category: "", divider: "", context: "", audioUrl: "", exerciseTypes: [], exercises: [], editing: false }],
|
|
writingTasks: [{ instructions: "", category: "", type: "", divider: "", wordLimit: 150, marks: 0, editing: false }],
|
|
speakingParts: [{ type: "speaking_1", category: "", divider: "", script: "", videoUrl: "", avatarId: "", marks: 0, topics: ["", ""], editing: false }],
|
|
};
|
|
}
|
|
|
|
export default function GenerationPage() {
|
|
const { toast } = useToast();
|
|
const [title, setTitle] = useState("");
|
|
const [examLabel, setExamLabel] = useState("");
|
|
const [examStructure, setExamStructure] = useState("");
|
|
const [selectedModules, setSelectedModules] = useState<Set<ModuleKey>>(new Set());
|
|
const [activeModule, setActiveModule] = useState<ModuleKey | null>(null);
|
|
const [moduleStates, setModuleStates] = useState<Record<string, ModuleState>>({});
|
|
|
|
const getModuleState = useCallback((mod: ModuleKey): ModuleState => {
|
|
return moduleStates[mod] ?? defaultModuleState(mod);
|
|
}, [moduleStates]);
|
|
|
|
const updateModuleState = useCallback((mod: ModuleKey, patch: Partial<ModuleState>) => {
|
|
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;
|
|
|
|
const generatePassageMut = useMutation({
|
|
mutationFn: (params: { index: number; topic: string; difficulty: string; wordCount: number }) =>
|
|
generationService.generatePassage({
|
|
topic: params.topic,
|
|
difficulty: params.difficulty,
|
|
word_count: params.wordCount,
|
|
}),
|
|
onSuccess: (res, vars) => {
|
|
if (!activeModule) return;
|
|
const st = getModuleState(activeModule);
|
|
const passages = [...st.passages];
|
|
const r = res as Record<string, unknown>;
|
|
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[]; passageText: string }) =>
|
|
generationService.generate(params.module === "level" || params.module === "industry" ? "reading" : params.module, {
|
|
topic: params.passageText.slice(0, 200),
|
|
difficulty: getModuleState(params.module).difficulty[0] ?? "B2",
|
|
question_count: 5,
|
|
}),
|
|
onSuccess: (res, vars) => {
|
|
const st = getModuleState(vars.module);
|
|
const items = Array.isArray((res as Record<string, unknown>).questions) ? (res as Record<string, unknown>).questions as unknown[] : [];
|
|
if (vars.module === "reading") {
|
|
const passages = [...st.passages];
|
|
passages[vars.passageIndex] = { ...passages[vars.passageIndex], exercises: items };
|
|
updateModuleState(vars.module, { passages });
|
|
}
|
|
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];
|
|
sections[vars.sectionIndex] = { ...sections[vars.sectionIndex], audioUrl: res.audio_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 }) =>
|
|
generationService.generateWritingInstructions({ topic: params.topic, difficulty: params.difficulty, task_type: "letter" }),
|
|
onSuccess: (res, vars) => {
|
|
const st = getModuleState("writing");
|
|
const tasks = [...st.writingTasks];
|
|
const r = res as Record<string, unknown>;
|
|
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 }) =>
|
|
generationService.generateSpeakingScript({ topics: params.topics.filter(Boolean), difficulty: params.difficulty, part: "speaking_1" }),
|
|
onSuccess: (res, vars) => {
|
|
const st = getModuleState("speaking");
|
|
const parts = [...st.speakingParts];
|
|
const r = res as Record<string, unknown>;
|
|
const script = (r.script as string) ?? JSON.stringify(r.questions ?? res);
|
|
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 submitMut = useMutation({
|
|
mutationFn: (skipApproval: boolean) => {
|
|
const modulesPayload: Record<string, unknown> = {};
|
|
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,
|
|
passages: mod === "reading" ? st.passages.map((p) => ({ text: p.text, category: p.category, type: p.type, exercises: p.exercises })) : undefined,
|
|
sections: mod === "listening" ? st.listeningSections.map((s) => ({ type: s.type, context: s.context, audioUrl: s.audioUrl })) : undefined,
|
|
tasks: mod === "writing" ? st.writingTasks.map((t) => ({ instructions: t.instructions, wordLimit: t.wordLimit, marks: t.marks })) : 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, modules: modulesPayload, skip_approval: skipApproval });
|
|
},
|
|
onSuccess: (res) => toast({ title: "Exam submitted", description: `Exam #${res.exam_id} created (${res.status})` }),
|
|
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 renderDifficultyTags = (mod: ModuleKey) => {
|
|
const st = getModuleState(mod);
|
|
return (
|
|
<div className="space-y-1">
|
|
<Label className="text-xs">Difficulty</Label>
|
|
<div className="flex flex-wrap gap-1">
|
|
{st.difficulty.map((d) => (
|
|
<Badge key={d} variant="secondary" className="text-xs gap-1">
|
|
{d}
|
|
<button onClick={() => updateModuleState(mod, { difficulty: st.difficulty.filter((x) => x !== d) })} className="ml-0.5 hover:text-destructive"><X className="h-3 w-3" /></button>
|
|
</Badge>
|
|
))}
|
|
<Select onValueChange={(v) => { if (!st.difficulty.includes(v)) updateModuleState(mod, { difficulty: [...st.difficulty, v] }); }}>
|
|
<SelectTrigger className="h-6 w-16 text-xs"><Plus className="h-3 w-3" /></SelectTrigger>
|
|
<SelectContent>{CEFR_LEVELS.filter((l) => !st.difficulty.includes(l)).map((l) => <SelectItem key={l} value={l}>{l}</SelectItem>)}</SelectContent>
|
|
</Select>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
const renderCommonConfig = (mod: ModuleKey) => {
|
|
const st = getModuleState(mod);
|
|
return (
|
|
<div className="grid grid-cols-2 md:grid-cols-3 gap-3 text-sm">
|
|
<div className="space-y-1">
|
|
<Label className="text-xs">Timer (minutes)</Label>
|
|
<Input type="number" value={st.timer} min={1} onChange={(e) => updateModuleState(mod, { timer: Number(e.target.value) || 1 })} className="h-8 text-xs" />
|
|
</div>
|
|
{renderDifficultyTags(mod)}
|
|
<div className="space-y-1">
|
|
<Label className="text-xs">Access Type</Label>
|
|
<Select value={st.accessType} onValueChange={(v) => updateModuleState(mod, { accessType: v })}>
|
|
<SelectTrigger className="h-8 text-xs"><SelectValue /></SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="private">Private</SelectItem>
|
|
<SelectItem value="public">Public</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
<div className="space-y-1">
|
|
<Label className="text-xs">Entities</Label>
|
|
<Select value={st.entity} onValueChange={(v) => updateModuleState(mod, { entity: v })}>
|
|
<SelectTrigger className="h-8 text-xs"><SelectValue placeholder="Select..." /></SelectTrigger>
|
|
<SelectContent><SelectItem value="none">None</SelectItem></SelectContent>
|
|
</Select>
|
|
</div>
|
|
<div className="space-y-1">
|
|
<Label className="text-xs">Approval Workflow</Label>
|
|
<Select value={st.approvalWorkflow} onValueChange={(v) => updateModuleState(mod, { approvalWorkflow: v })}>
|
|
<SelectTrigger className="h-8 text-xs"><SelectValue placeholder="Select..." /></SelectTrigger>
|
|
<SelectContent><SelectItem value="default">Default</SelectItem><SelectItem value="none">None</SelectItem></SelectContent>
|
|
</Select>
|
|
</div>
|
|
<div className="space-y-1">
|
|
<Label className="text-xs">Grading System</Label>
|
|
<Select value={st.gradingSystem} onValueChange={(v) => updateModuleState(mod, { gradingSystem: v })}>
|
|
<SelectTrigger className="h-8 text-xs"><SelectValue placeholder="System Select" /></SelectTrigger>
|
|
<SelectContent><SelectItem value="ielts">IELTS Band</SelectItem><SelectItem value="percentage">Percentage</SelectItem></SelectContent>
|
|
</Select>
|
|
</div>
|
|
<div className="space-y-1">
|
|
<Label className="text-xs">Total Marks</Label>
|
|
<div className="h-8 flex items-center text-xs font-medium">{st.totalMarks}</div>
|
|
</div>
|
|
<div className="flex items-center gap-2 col-span-2 md:col-span-1">
|
|
<Switch checked={st.shuffling} onCheckedChange={(v) => updateModuleState(mod, { shuffling: v })} />
|
|
<Label className="text-xs">Shuffling Enabled</Label>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
const renderReadingModule = () => {
|
|
if (!activeModule || activeModule !== "reading") return null;
|
|
const st = getModuleState("reading");
|
|
return (
|
|
<div className="space-y-4">
|
|
{renderCommonConfig("reading")}
|
|
|
|
<div className="space-y-1">
|
|
<Label className="text-xs font-medium">Passages</Label>
|
|
<div className="flex gap-2 flex-wrap">
|
|
{st.passages.map((_, i) => (
|
|
<div key={i} className="flex items-center gap-1">
|
|
<Checkbox checked id={`p-${i}`} />
|
|
<Label htmlFor={`p-${i}`} className="text-xs">Passage {i + 1}</Label>
|
|
</div>
|
|
))}
|
|
<Button variant="ghost" size="sm" className="h-6 text-xs" onClick={() => {
|
|
const newP: PassageState = { text: "", category: "", type: "general", divider: "", exerciseTypes: [], exercises: [], editing: false };
|
|
updateModuleState("reading", { passages: [...st.passages, newP] });
|
|
}}><Plus className="h-3 w-3 mr-1" /> Add</Button>
|
|
</div>
|
|
</div>
|
|
|
|
<Button variant="ghost" size="sm" onClick={() => setModuleStates((prev) => ({ ...prev, reading: defaultModuleState("reading") }))} className="text-xs"><RotateCcw className="h-3 w-3 mr-1" /> Reset Module</Button>
|
|
|
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
|
{st.passages.map((passage, pi) => (
|
|
<div key={pi} className="space-y-3">
|
|
<Card className="border-blue-200 bg-blue-50/50">
|
|
<CardContent className="p-4 space-y-3">
|
|
<h4 className="text-sm font-semibold text-blue-700">Passage {pi + 1} Settings</h4>
|
|
<Collapsible>
|
|
<CollapsibleTrigger className="flex items-center gap-1 text-xs font-medium w-full justify-between bg-blue-100 rounded px-2 py-1">
|
|
Category <ChevronDown className="h-3 w-3" />
|
|
</CollapsibleTrigger>
|
|
<CollapsibleContent className="pt-2">
|
|
<Input value={passage.category} placeholder="Category" className="h-8 text-xs"
|
|
onChange={(e) => {
|
|
const p = [...st.passages]; p[pi] = { ...p[pi], category: e.target.value };
|
|
updateModuleState("reading", { passages: p });
|
|
}} />
|
|
</CollapsibleContent>
|
|
</Collapsible>
|
|
<Collapsible>
|
|
<CollapsibleTrigger className="flex items-center gap-1 text-xs font-medium w-full justify-between bg-blue-100 rounded px-2 py-1">
|
|
Type <ChevronDown className="h-3 w-3" />
|
|
</CollapsibleTrigger>
|
|
<CollapsibleContent className="pt-2">
|
|
<Select value={passage.type} onValueChange={(v) => {
|
|
const p = [...st.passages]; p[pi] = { ...p[pi], type: v };
|
|
updateModuleState("reading", { passages: p });
|
|
}}>
|
|
<SelectTrigger className="h-8 text-xs"><SelectValue /></SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="general">General</SelectItem>
|
|
<SelectItem value="academic">Academic</SelectItem>
|
|
<SelectItem value="technical">Technical</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</CollapsibleContent>
|
|
</Collapsible>
|
|
<Collapsible>
|
|
<CollapsibleTrigger className="flex items-center gap-1 text-xs font-medium w-full justify-between bg-blue-100 rounded px-2 py-1">
|
|
Generate Passage <ChevronDown className="h-3 w-3" />
|
|
</CollapsibleTrigger>
|
|
<CollapsibleContent className="pt-2 space-y-2">
|
|
<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>
|
|
</Select>
|
|
<Input placeholder="Topic (Optional)" className="h-8 text-xs"
|
|
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}
|
|
onClick={() => {
|
|
generatePassageMut.mutate({ index: pi, topic: passage.category || "", difficulty: st.difficulty[0] || "B2", wordCount: Number(passage.divider) || 300 });
|
|
}}>
|
|
{generatePassageMut.isPending ? <Loader2 className="h-3 w-3 mr-1 animate-spin" /> : <Sparkles className="h-3 w-3 mr-1" />}
|
|
Generate
|
|
</Button>
|
|
</CollapsibleContent>
|
|
</Collapsible>
|
|
<Collapsible>
|
|
<CollapsibleTrigger className="flex items-center gap-1 text-xs font-medium w-full justify-between bg-blue-100 rounded px-2 py-1">
|
|
Add Exercises <ChevronDown className="h-3 w-3" />
|
|
</CollapsibleTrigger>
|
|
<CollapsibleContent className="pt-2 space-y-1">
|
|
{READING_EXERCISE_TYPES.map((et) => (
|
|
<div key={et.key} className="flex items-center gap-2">
|
|
<Checkbox id={`ex-${pi}-${et.key}`} disabled={!passage.text}
|
|
checked={passage.exerciseTypes.includes(et.key)}
|
|
onCheckedChange={(checked) => {
|
|
const p = [...st.passages];
|
|
const types = checked ? [...p[pi].exerciseTypes, et.key] : p[pi].exerciseTypes.filter((t) => t !== et.key);
|
|
p[pi] = { ...p[pi], exerciseTypes: types };
|
|
updateModuleState("reading", { passages: p });
|
|
}} />
|
|
<Label htmlFor={`ex-${pi}-${et.key}`} className="text-xs">Passage {pi + 1} - {et.label}</Label>
|
|
</div>
|
|
))}
|
|
<Button size="sm" className="w-full mt-2 text-xs" disabled={!passage.text || passage.exerciseTypes.length === 0 || generateExercisesMut.isPending}
|
|
onClick={() => generateExercisesMut.mutate({ module: "reading", passageIndex: pi, types: passage.exerciseTypes, passageText: passage.text })}>
|
|
Set Up Exercises ({passage.exercises.length})
|
|
</Button>
|
|
</CollapsibleContent>
|
|
</Collapsible>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<Card className="border-blue-200">
|
|
<CardContent className="p-4 space-y-2">
|
|
<div className="flex items-center justify-between">
|
|
<h4 className="text-sm font-semibold">Reading Passage</h4>
|
|
<div className="flex gap-1">
|
|
<Button size="sm" variant="outline" className="h-6 text-xs" disabled={!passage.text}>Save</Button>
|
|
<Button size="sm" variant="outline" className="h-6 text-xs">Discard</Button>
|
|
<Button size="sm" variant="outline" className="h-6 text-xs"
|
|
onClick={() => {
|
|
const p = [...st.passages]; p[pi] = { ...p[pi], editing: !p[pi].editing };
|
|
updateModuleState("reading", { passages: p });
|
|
}}>Edit</Button>
|
|
</div>
|
|
</div>
|
|
<p className="text-xs text-muted-foreground">The reading passage that the exercises will refer to.</p>
|
|
<Textarea
|
|
value={passage.text}
|
|
onChange={(e) => {
|
|
const p = [...st.passages]; p[pi] = { ...p[pi], text: e.target.value };
|
|
updateModuleState("reading", { passages: p });
|
|
}}
|
|
placeholder="Generate or edit the passage to add exercises!"
|
|
className="min-h-[150px] text-xs"
|
|
readOnly={!passage.editing}
|
|
/>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
const renderListeningModule = () => {
|
|
if (activeModule !== "listening") return null;
|
|
const st = getModuleState("listening");
|
|
return (
|
|
<div className="space-y-4">
|
|
{renderCommonConfig("listening")}
|
|
<div className="space-y-1">
|
|
<Label className="text-xs font-medium">Sections</Label>
|
|
<div className="flex gap-2 flex-wrap">
|
|
{st.listeningSections.map((sec, i) => (
|
|
<div key={i} className="flex items-center gap-1">
|
|
<Checkbox checked id={`ls-${i}`} />
|
|
<Label htmlFor={`ls-${i}`} className="text-xs">{LISTENING_SECTION_TYPES.find((t) => t.key === sec.type)?.label || sec.type}</Label>
|
|
</div>
|
|
))}
|
|
<Button variant="ghost" size="sm" className="h-6 text-xs" onClick={() => {
|
|
const newS: ListeningSectionState = { type: "social_monologue", category: "", divider: "", context: "", audioUrl: "", exerciseTypes: [], exercises: [], editing: false };
|
|
updateModuleState("listening", { listeningSections: [...st.listeningSections, newS] });
|
|
}}><Plus className="h-3 w-3 mr-1" /> Add</Button>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
|
{st.listeningSections.map((sec, si) => (
|
|
<div key={si} className="space-y-3">
|
|
<Card className="border-orange-200 bg-orange-50/50">
|
|
<CardContent className="p-4 space-y-3">
|
|
<h4 className="text-sm font-semibold text-orange-700">{LISTENING_SECTION_TYPES.find((t) => t.key === sec.type)?.label} Settings</h4>
|
|
<Collapsible>
|
|
<CollapsibleTrigger className="flex items-center gap-1 text-xs font-medium w-full justify-between bg-orange-100 rounded px-2 py-1">
|
|
Audio Context <ChevronDown className="h-3 w-3" />
|
|
</CollapsibleTrigger>
|
|
<CollapsibleContent className="pt-2 space-y-2">
|
|
<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">
|
|
<Button size="sm" className="flex-1 text-xs" onClick={() => {
|
|
generationService.generateListeningContext({ topic: sec.category || "general conversation", section_type: sec.type })
|
|
.then((res) => {
|
|
const r = res as Record<string, unknown>;
|
|
const sections = [...st.listeningSections];
|
|
sections[si] = { ...sections[si], context: (r.context as string) ?? JSON.stringify(r) };
|
|
updateModuleState("listening", { listeningSections: sections });
|
|
toast({ title: "Context generated" });
|
|
}).catch((err) => toast({ variant: "destructive", title: "Failed", description: String(err) }));
|
|
}}><Sparkles className="h-3 w-3 mr-1" /> Generate</Button>
|
|
<Button size="sm" variant="outline" className="text-xs"><Upload className="h-3 w-3 mr-1" /> Upload</Button>
|
|
</div>
|
|
</CollapsibleContent>
|
|
</Collapsible>
|
|
<Collapsible>
|
|
<CollapsibleTrigger className="flex items-center gap-1 text-xs font-medium w-full justify-between bg-orange-100 rounded px-2 py-1">
|
|
Add Exercises <ChevronDown className="h-3 w-3" />
|
|
</CollapsibleTrigger>
|
|
<CollapsibleContent className="pt-2 space-y-1">
|
|
{LISTENING_EXERCISE_TYPES.map((et) => (
|
|
<div key={et.key} className="flex items-center gap-2">
|
|
<Checkbox id={`lex-${si}-${et.key}`} disabled={!sec.context}
|
|
checked={sec.exerciseTypes.includes(et.key)}
|
|
onCheckedChange={(checked) => {
|
|
const sections = [...st.listeningSections];
|
|
const types = checked ? [...sections[si].exerciseTypes, et.key] : sections[si].exerciseTypes.filter((t) => t !== et.key);
|
|
sections[si] = { ...sections[si], exerciseTypes: types };
|
|
updateModuleState("listening", { listeningSections: sections });
|
|
}} />
|
|
<Label htmlFor={`lex-${si}-${et.key}`} className="text-xs">Section {si + 1} - {et.label}</Label>
|
|
</div>
|
|
))}
|
|
</CollapsibleContent>
|
|
</Collapsible>
|
|
<Collapsible>
|
|
<CollapsibleTrigger className="flex items-center gap-1 text-xs font-medium w-full justify-between bg-orange-100 rounded px-2 py-1">
|
|
Generate Audio <ChevronDown className="h-3 w-3" />
|
|
</CollapsibleTrigger>
|
|
<CollapsibleContent className="pt-2">
|
|
<Button size="sm" className="w-full text-xs" disabled={!sec.context || generateAudioMut.isPending}
|
|
onClick={() => generateAudioMut.mutate({ text: sec.context, sectionIndex: si })}>
|
|
{generateAudioMut.isPending ? <Loader2 className="h-3 w-3 mr-1 animate-spin" /> : <Play className="h-3 w-3 mr-1" />}
|
|
Generate Audio
|
|
</Button>
|
|
{sec.audioUrl && <p className="text-xs text-green-600 mt-1">Audio ready</p>}
|
|
</CollapsibleContent>
|
|
</Collapsible>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<Card className="border-orange-200">
|
|
<CardContent className="p-4 space-y-2">
|
|
<h4 className="text-sm font-semibold">{LISTENING_SECTION_TYPES.find((t) => t.key === sec.type)?.label}</h4>
|
|
<p className="text-xs text-muted-foreground">Enter the section's conversation or import your own</p>
|
|
<Textarea value={sec.context} placeholder="Generate or type the conversation context..."
|
|
onChange={(e) => {
|
|
const sections = [...st.listeningSections]; sections[si] = { ...sections[si], context: e.target.value };
|
|
updateModuleState("listening", { listeningSections: sections });
|
|
}} className="min-h-[120px] text-xs" />
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
const renderWritingModule = () => {
|
|
if (activeModule !== "writing") return null;
|
|
const st = getModuleState("writing");
|
|
return (
|
|
<div className="space-y-4">
|
|
{renderCommonConfig("writing")}
|
|
<div className="space-y-1">
|
|
<Label className="text-xs font-medium">Tasks</Label>
|
|
<div className="flex gap-2 flex-wrap">
|
|
{st.writingTasks.map((_, i) => (
|
|
<div key={i} className="flex items-center gap-1">
|
|
<Checkbox checked id={`wt-${i}`} />
|
|
<Label htmlFor={`wt-${i}`} className="text-xs">Task {i + 1}</Label>
|
|
</div>
|
|
))}
|
|
{st.writingTasks.length < 2 && (
|
|
<Button variant="ghost" size="sm" className="h-6 text-xs" onClick={() => {
|
|
const newT: WritingTaskState = { instructions: "", category: "", type: "", divider: "", wordLimit: 250, marks: 0, editing: false };
|
|
updateModuleState("writing", { writingTasks: [...st.writingTasks, newT] });
|
|
}}><Plus className="h-3 w-3 mr-1" /> Add Task 2</Button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
|
{st.writingTasks.map((task, ti) => (
|
|
<div key={ti} className="space-y-3">
|
|
<Card className="border-green-200 bg-green-50/50">
|
|
<CardContent className="p-4 space-y-3">
|
|
<h4 className="text-sm font-semibold text-green-700">Task {ti + 1} Settings</h4>
|
|
<Collapsible>
|
|
<CollapsibleTrigger className="flex items-center gap-1 text-xs font-medium w-full justify-between bg-green-100 rounded px-2 py-1">
|
|
Generate Instructions <ChevronDown className="h-3 w-3" />
|
|
</CollapsibleTrigger>
|
|
<CollapsibleContent className="pt-2 space-y-2">
|
|
<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>
|
|
<SelectContent>{CEFR_LEVELS.map((l) => <SelectItem key={l} value={l}>{l}</SelectItem>)}</SelectContent>
|
|
</Select>
|
|
<Button size="sm" className="w-full text-xs" disabled={generateWritingMut.isPending}
|
|
onClick={() => {
|
|
generateWritingMut.mutate({ topic: task.category || "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" />}
|
|
Generate
|
|
</Button>
|
|
</CollapsibleContent>
|
|
</Collapsible>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<Card className="border-green-200">
|
|
<CardContent className="p-4 space-y-2">
|
|
<div className="flex items-center justify-between">
|
|
<h4 className="text-sm font-semibold">{ti === 0 ? "Letter Instructions" : "Essay Instructions"}</h4>
|
|
<div className="flex gap-1">
|
|
<Button size="sm" variant="outline" className="h-6 text-xs">Save</Button>
|
|
<Button size="sm" variant="outline" className="h-6 text-xs">Edit</Button>
|
|
<Button size="sm" variant="outline" className="h-6 text-xs">Graded</Button>
|
|
</div>
|
|
</div>
|
|
<Textarea value={task.instructions} placeholder="Instructions ..." className="min-h-[120px] text-xs"
|
|
onChange={(e) => {
|
|
const tasks = [...st.writingTasks]; tasks[ti] = { ...tasks[ti], instructions: e.target.value };
|
|
updateModuleState("writing", { writingTasks: tasks });
|
|
}} />
|
|
<div className="flex gap-4">
|
|
<div className="space-y-1">
|
|
<Label className="text-xs">Word Limit</Label>
|
|
<Input type="number" value={task.wordLimit} className="h-8 text-xs w-24"
|
|
onChange={(e) => {
|
|
const tasks = [...st.writingTasks]; tasks[ti] = { ...tasks[ti], wordLimit: Number(e.target.value) };
|
|
updateModuleState("writing", { writingTasks: tasks });
|
|
}} />
|
|
</div>
|
|
<div className="space-y-1">
|
|
<Label className="text-xs">Marks</Label>
|
|
<Input type="number" value={task.marks} className="h-8 text-xs w-24"
|
|
onChange={(e) => {
|
|
const tasks = [...st.writingTasks]; tasks[ti] = { ...tasks[ti], marks: Number(e.target.value) };
|
|
updateModuleState("writing", { writingTasks: tasks });
|
|
}} />
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
const renderSpeakingModule = () => {
|
|
if (activeModule !== "speaking") return null;
|
|
const st = getModuleState("speaking");
|
|
const partTypes = [
|
|
{ key: "speaking_1", label: "Speaking 1" },
|
|
{ key: "speaking_2", label: "Speaking 2" },
|
|
{ key: "interactive", label: "Interactive Speaking" },
|
|
];
|
|
return (
|
|
<div className="space-y-4">
|
|
{renderCommonConfig("speaking")}
|
|
<div className="space-y-1">
|
|
<Label className="text-xs font-medium">Speaking Parts</Label>
|
|
<div className="flex gap-2 flex-wrap">
|
|
{st.speakingParts.map((part, i) => (
|
|
<div key={i} className="flex items-center gap-1">
|
|
<Checkbox checked id={`sp-${i}`} />
|
|
<Label htmlFor={`sp-${i}`} className="text-xs">{partTypes.find((t) => t.key === part.type)?.label || part.type}</Label>
|
|
</div>
|
|
))}
|
|
<Button variant="ghost" size="sm" className="h-6 text-xs" onClick={() => {
|
|
const usedTypes = st.speakingParts.map((p) => p.type);
|
|
const nextType = partTypes.find((t) => !usedTypes.includes(t.key));
|
|
if (nextType) {
|
|
const newP: SpeakingPartState = { type: nextType.key, category: "", divider: "", script: "", videoUrl: "", avatarId: "", marks: 0, topics: ["", ""], editing: false };
|
|
updateModuleState("speaking", { speakingParts: [...st.speakingParts, newP] });
|
|
}
|
|
}}><Plus className="h-3 w-3 mr-1" /> Add</Button>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
|
{st.speakingParts.map((part, pi) => (
|
|
<div key={pi} className="space-y-3">
|
|
<Card className="border-pink-200 bg-pink-50/50">
|
|
<CardContent className="p-4 space-y-3">
|
|
<h4 className="text-sm font-semibold text-pink-700">{partTypes.find((t) => t.key === part.type)?.label} Settings</h4>
|
|
<Collapsible>
|
|
<CollapsibleTrigger className="flex items-center gap-1 text-xs font-medium w-full justify-between bg-pink-100 rounded px-2 py-1">
|
|
Generate Script <ChevronDown className="h-3 w-3" />
|
|
</CollapsibleTrigger>
|
|
<CollapsibleContent className="pt-2 space-y-2">
|
|
<Input placeholder="First Topic (Optional)" className="h-8 text-xs"
|
|
value={part.topics[0] || ""} onChange={(e) => {
|
|
const parts = [...st.speakingParts]; parts[pi] = { ...parts[pi], topics: [e.target.value, parts[pi].topics[1] || ""] };
|
|
updateModuleState("speaking", { speakingParts: parts });
|
|
}} />
|
|
<Input placeholder="Second Topic (Optional)" className="h-8 text-xs"
|
|
value={part.topics[1] || ""} onChange={(e) => {
|
|
const parts = [...st.speakingParts]; parts[pi] = { ...parts[pi], topics: [parts[pi].topics[0] || "", e.target.value] };
|
|
updateModuleState("speaking", { speakingParts: parts });
|
|
}} />
|
|
<Button size="sm" className="w-full text-xs" disabled={generateSpeakingMut.isPending}
|
|
onClick={() => generateSpeakingMut.mutate({ topics: part.topics, difficulty: st.difficulty[0] || "B1", partIndex: pi })}>
|
|
{generateSpeakingMut.isPending ? <Loader2 className="h-3 w-3 mr-1 animate-spin" /> : <Sparkles className="h-3 w-3 mr-1" />}
|
|
Generate
|
|
</Button>
|
|
</CollapsibleContent>
|
|
</Collapsible>
|
|
<Collapsible>
|
|
<CollapsibleTrigger className="flex items-center gap-1 text-xs font-medium w-full justify-between bg-pink-100 rounded px-2 py-1">
|
|
Generate Video <ChevronDown className="h-3 w-3" />
|
|
</CollapsibleTrigger>
|
|
<CollapsibleContent className="pt-2 space-y-2">
|
|
<Select value={part.avatarId} onValueChange={(v) => {
|
|
const parts = [...st.speakingParts]; parts[pi] = { ...parts[pi], avatarId: v };
|
|
updateModuleState("speaking", { speakingParts: parts });
|
|
}}>
|
|
<SelectTrigger className="h-8 text-xs"><SelectValue placeholder="Select an avatar (Optional)" /></SelectTrigger>
|
|
<SelectContent>
|
|
{DEFAULT_AVATARS.map((a) => <SelectItem key={String(a.id)} value={String(a.id)}>{a.name} ({a.gender})</SelectItem>)}
|
|
</SelectContent>
|
|
</Select>
|
|
<Button size="sm" className="w-full text-xs" disabled={!part.script || !part.avatarId || generateVideoMut.isPending}
|
|
onClick={() => generateVideoMut.mutate({ script: part.script, avatarId: part.avatarId, partIndex: pi })}>
|
|
{generateVideoMut.isPending ? <Loader2 className="h-3 w-3 mr-1 animate-spin" /> : <Video className="h-3 w-3 mr-1" />}
|
|
Generate Video
|
|
</Button>
|
|
{part.videoUrl && <p className="text-xs text-green-600">Video: {part.videoUrl.startsWith("pending:") ? "Processing..." : "Ready"}</p>}
|
|
</CollapsibleContent>
|
|
</Collapsible>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<Card className="border-pink-200">
|
|
<CardContent className="p-4 space-y-2">
|
|
<div className="flex items-center justify-between">
|
|
<h4 className="text-sm font-semibold">{partTypes.find((t) => t.key === part.type)?.label} Script</h4>
|
|
<div className="flex gap-1">
|
|
<Button size="sm" variant="outline" className="h-6 text-xs">Save</Button>
|
|
<Button size="sm" variant="outline" className="h-6 text-xs">Edit</Button>
|
|
<Button size="sm" variant="outline" className="h-6 text-xs">Graded</Button>
|
|
</div>
|
|
</div>
|
|
<p className="text-xs text-muted-foreground">Generate or write the scripts for the videos.</p>
|
|
<Textarea value={part.script} placeholder="Generate or edit the questions!" className="min-h-[120px] text-xs"
|
|
onChange={(e) => {
|
|
const parts = [...st.speakingParts]; parts[pi] = { ...parts[pi], script: e.target.value };
|
|
updateModuleState("speaking", { speakingParts: parts });
|
|
}} />
|
|
<div className="space-y-1">
|
|
<Label className="text-xs">Marks</Label>
|
|
<Input type="number" value={part.marks} className="h-8 text-xs w-24"
|
|
onChange={(e) => {
|
|
const parts = [...st.speakingParts]; parts[pi] = { ...parts[pi], marks: Number(e.target.value) };
|
|
updateModuleState("speaking", { speakingParts: parts });
|
|
}} />
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<h1 className="text-2xl font-bold tracking-tight flex items-center gap-2">
|
|
<Wand2 className="h-6 w-6 text-primary" /> Generation
|
|
</h1>
|
|
</div>
|
|
</div>
|
|
|
|
<AiTipBanner context="generation" variant="recommendation" />
|
|
|
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
|
<div className="space-y-1">
|
|
<Label>Title <span className="text-destructive">*</span></Label>
|
|
<Input value={title} onChange={(e) => setTitle(e.target.value)} placeholder="Insert title" />
|
|
</div>
|
|
<div className="space-y-1">
|
|
<Label>Exam Label</Label>
|
|
<Input value={examLabel} onChange={(e) => setExamLabel(e.target.value)} placeholder="Exam Label" />
|
|
</div>
|
|
<div className="space-y-1">
|
|
<Label>Exam Structure</Label>
|
|
<Select value={examStructure} onValueChange={setExamStructure}>
|
|
<SelectTrigger><SelectValue placeholder="Select an exam structure" /></SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="standard_ielts">Standard IELTS Academic</SelectItem>
|
|
<SelectItem value="corporate">Corporate English Assessment</SelectItem>
|
|
<SelectItem value="hospitality">Hospitality English Test</SelectItem>
|
|
<SelectItem value="medical">Medical English Proficiency</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
</div>
|
|
|
|
<div>
|
|
<p className="text-sm text-muted-foreground mb-3">Please select which subjects the exam is about</p>
|
|
<div className="flex flex-wrap gap-3">
|
|
{MODULES.map((m) => (
|
|
<button key={m.key}
|
|
className={`flex items-center gap-2 rounded-lg border-2 px-4 py-3 transition-all ${
|
|
selectedModules.has(m.key) ? `${m.bgColor} border-current ${m.color} shadow-sm` : "border-muted bg-background hover:bg-muted/50"
|
|
}`}
|
|
onClick={() => toggleModule(m.key)}>
|
|
<span className={m.color}>{m.icon}</span>
|
|
<span className="text-sm font-medium">{m.label}</span>
|
|
{selectedModules.has(m.key) && (
|
|
<Badge variant="default" className="ml-1 h-5 text-[10px]">Select</Badge>
|
|
)}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{activeModule && selectedModules.has(activeModule) && (
|
|
<div className="flex gap-3">
|
|
{MODULES.filter((m) => selectedModules.has(m.key)).map((m) => (
|
|
<Button key={m.key} variant={activeModule === m.key ? "default" : "outline"} size="sm"
|
|
onClick={() => setActiveModule(m.key)} className="text-xs">
|
|
{m.icon} <span className="ml-1">{m.label}</span>
|
|
</Button>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{!activeModule && selectedModules.size > 0 && (
|
|
<Card className="border-dashed">
|
|
<CardContent className="p-8 text-center space-y-4">
|
|
<div className="grid grid-cols-2 gap-4 max-w-md mx-auto">
|
|
<Card className="cursor-pointer hover:shadow-md transition-shadow border-2 hover:border-primary"
|
|
onClick={() => setActiveModule(Array.from(selectedModules)[0])}>
|
|
<CardContent className="p-6 text-center">
|
|
<FileText className="h-8 w-8 mx-auto mb-2 text-primary" />
|
|
<p className="text-sm font-medium">Start from Scratch</p>
|
|
</CardContent>
|
|
</Card>
|
|
<Card className="cursor-pointer hover:shadow-md transition-shadow border-2 hover:border-primary">
|
|
<CardContent className="p-6 text-center">
|
|
<Upload className="h-8 w-8 mx-auto mb-2 text-primary" />
|
|
<p className="text-sm font-medium">Upload Exam</p>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
|
|
{activeModule === "reading" && renderReadingModule()}
|
|
{activeModule === "listening" && renderListeningModule()}
|
|
{activeModule === "writing" && renderWritingModule()}
|
|
{activeModule === "speaking" && renderSpeakingModule()}
|
|
|
|
{selectedModules.size > 0 && activeModule && (
|
|
<div className="flex flex-wrap gap-3 pt-4 border-t">
|
|
<Button variant="outline" disabled={anyGenerating || !title} onClick={() => submitMut.mutate(false)}>
|
|
{submitMut.isPending ? <Loader2 className="h-4 w-4 mr-2 animate-spin" /> : <Send className="h-4 w-4 mr-2" />}
|
|
Submit module as exam for approval
|
|
</Button>
|
|
<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
|
|
</Button>
|
|
<Button variant="outline" disabled>
|
|
<Eye className="h-4 w-4 mr-2" /> Preview module
|
|
</Button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|