Roadmap P0
- Ship /logo.svg fallback and rewire asset references (superseded later by
the project-manager PNG in a separate commit).
Roadmap P1
- Response envelope alignment ({items,total,page,size}) across services
and query hooks.
- Approval/ticket UX updates tied to the new backend rollback semantics.
Roadmap P2
- React.lazy + Vite manualChunks to split vendor-radix/charts/icons/forms
from the main bundle and cut initial JS.
- Fix the 181 tsc errors (PaginationParams class + per-service generics).
- Auto-refresh flow for JWT refresh tokens wired into api-client.ts.
Roadmap P3
- i18n: i18next + language detector, en/ar locales, RTL auto-switch,
LanguageToggle component in RoleLayout and AdminLmsLayout.
- GDPR: PrivacyCenter page for data export and right-to-erasure, backed
by gdpr.service.ts; logout via AuthContext after erasure.
- Human-in-the-loop exam review UI: admin ExamReviewQueue + ExamReviewDetail
pages, useExamReview hooks, exam-review.service.ts.
- AI quality loop: AIPromptEditor (versioning + render preview),
AIFeedbackButtons for students, AIFeedbackTriage admin page, dedicated
services, hooks, and types.
- Dark mode: ThemeProvider + ThemeToggle + chart color tokens.
- Accessibility: DialogDescription on every DialogContent; alert-dialog
and sheet updates.
- Retire dead pages: ExamPage marketing, Index, ProfilePage import.
- Playwright e2e scaffolding: playwright.config.ts + e2e/login.spec.ts
smoke tests, npm scripts test:e2e / test:e2e:install.
Made-with: Cursor
3003 lines
154 KiB
TypeScript
3003 lines
154 KiB
TypeScript
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: <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 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<string, string[]> = {
|
|
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<ModuleKey>;
|
|
}>({ open: false, name: "", modules: new Set() });
|
|
const [selectedModules, setSelectedModules] = useState<Set<ModuleKey>>(new Set());
|
|
const [activeModule, setActiveModule] = useState<ModuleKey | null>(null);
|
|
const [moduleStates, setModuleStates] = useState<Record<string, ModuleState>>({});
|
|
const [previewOpen, setPreviewOpen] = useState(false);
|
|
const [editingExercise, setEditingExercise] = useState<{ module: "reading" | "listening"; itemIndex: number; exerciseIndex: number } | null>(null);
|
|
const [exerciseDraft, setExerciseDraft] = useState<Exercise | null>(null);
|
|
|
|
const [exerciseWizard, setExerciseWizard] = useState<{
|
|
open: boolean;
|
|
module: "reading" | "listening";
|
|
itemIndex: number;
|
|
step: number;
|
|
types: Record<string, { enabled: boolean; count: number; difficulty: string; instructions: string }>;
|
|
}>({
|
|
open: false,
|
|
module: "reading",
|
|
itemIndex: 0,
|
|
step: 0,
|
|
types: {},
|
|
});
|
|
|
|
const DEFAULT_INSTRUCTIONS: Record<string, string> = {
|
|
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<string, { enabled: boolean; count: number; difficulty: string; instructions: string }> = {};
|
|
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<ExamStructure>),
|
|
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<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;
|
|
|
|
/**
|
|
* 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<string, unknown> => {
|
|
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<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[];
|
|
typeCounts: Record<string, number>;
|
|
typeInstructions: Record<string, string>;
|
|
typeDifficulties?: Record<string, string>;
|
|
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<string, unknown> & { passage_index: number; exercise_types: string[] });
|
|
},
|
|
onSuccess: (res, vars) => {
|
|
const st = getModuleState(vars.module);
|
|
const raw = Array.isArray((res as Record<string, unknown>).questions) ? (res as Record<string, unknown>).questions as Record<string, unknown>[] : [];
|
|
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<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 }) => {
|
|
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<string, unknown>;
|
|
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<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 || "" };
|
|
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<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,
|
|
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 (
|
|
<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>
|
|
{examMode === "official" && (
|
|
<div className="space-y-1">
|
|
<Label className="text-xs">Rubric</Label>
|
|
<Select value={st.rubricId} onValueChange={(v) => updateModuleState(mod, { rubricId: v })}>
|
|
<SelectTrigger className="h-8 text-xs"><SelectValue placeholder="Select rubric..." /></SelectTrigger>
|
|
<SelectContent>
|
|
{rubrics.length > 0 && (
|
|
<div className="px-2 py-1 text-[10px] font-semibold text-muted-foreground uppercase tracking-wider">Rubrics</div>
|
|
)}
|
|
{rubrics.map((r) => (
|
|
<SelectItem key={`r-${r.id}`} value={`rubric-${r.id}`}>{r.name}</SelectItem>
|
|
))}
|
|
{rubricGroups.length > 0 && (
|
|
<>
|
|
<div className="border-t my-1" />
|
|
<div className="px-2 py-1 text-[10px] font-semibold text-muted-foreground uppercase tracking-wider">Groups</div>
|
|
</>
|
|
)}
|
|
{rubricGroups.map((g) => (
|
|
<SelectItem key={`g-${g.id}`} value={`group-${g.id}`}>{g.name}</SelectItem>
|
|
))}
|
|
{rubrics.length === 0 && rubricGroups.length === 0 && (
|
|
<SelectItem value="_none" disabled>No rubrics available</SelectItem>
|
|
)}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
)}
|
|
<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>
|
|
{entities.map((e) => (
|
|
<SelectItem key={e.id} value={String(e.id)}>{e.name}</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="none">None</SelectItem>
|
|
{approvalWorkflows.map((w) => (
|
|
<SelectItem key={w.id} value={String(w.id)}>{w.name}</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 (1-9)</SelectItem>
|
|
<SelectItem value="percentage">Percentage (0-100%)</SelectItem>
|
|
<SelectItem value="pass_fail">Pass / Fail</SelectItem>
|
|
<SelectItem value="cefr">CEFR Level (A1-C2)</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 items-center">
|
|
{st.passages.map((_, i) => (
|
|
<div key={i} className="flex items-center gap-1 bg-muted/50 rounded px-2 py-0.5">
|
|
<Checkbox checked id={`p-${i}`} />
|
|
<Label htmlFor={`p-${i}`} className="text-xs">Passage {i + 1}</Label>
|
|
{st.passages.length > 1 && (
|
|
<button
|
|
className="ml-1 text-muted-foreground/60 hover:text-destructive transition-colors"
|
|
onClick={() => {
|
|
const passages = st.passages.filter((_, idx) => idx !== i);
|
|
updateModuleState("reading", { passages });
|
|
}}
|
|
title={`Remove Passage ${i + 1}`}
|
|
><X className="h-3 w-3" /></button>
|
|
)}
|
|
</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,
|
|
category: passage.category || "",
|
|
passageType: passage.type || "academic",
|
|
});
|
|
}}>
|
|
{generatePassageMut.isPending ? <Loader2 className="h-3 w-3 mr-1 animate-spin" /> : <Sparkles className="h-3 w-3 mr-1" />}
|
|
Generate
|
|
</Button>
|
|
</CollapsibleContent>
|
|
</Collapsible>
|
|
<Button size="sm" className="w-full text-xs" variant="outline" disabled={!passage.text}
|
|
onClick={() => openExerciseWizard(pi, "reading")}>
|
|
<ListChecks className="h-3 w-3 mr-1" />
|
|
{passage.exercises.length > 0
|
|
? `Exercises (${passage.exercises.length}) — Add More`
|
|
: "Set Up Exercises"}
|
|
</Button>
|
|
</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>
|
|
|
|
{passage.exercises.length > 0 && (
|
|
<Card className="border-blue-200 bg-blue-50/30">
|
|
<CardContent className="p-4 space-y-3">
|
|
<div className="flex items-center justify-between">
|
|
<h4 className="text-sm font-semibold text-blue-700">Exercises ({passage.exercises.length})</h4>
|
|
<Button size="sm" variant="ghost" className="h-6 text-xs text-destructive hover:text-destructive"
|
|
onClick={() => {
|
|
const p = [...st.passages];
|
|
p[pi] = { ...p[pi], exercises: [] };
|
|
updateModuleState("reading", { passages: p });
|
|
}}>
|
|
<X className="h-3 w-3 mr-1" /> Clear All
|
|
</Button>
|
|
</div>
|
|
<div className="space-y-4">
|
|
{(() => {
|
|
const grouped: Record<string, { exercises: Exercise[]; indices: number[] }> = {};
|
|
passage.exercises.forEach((ex, ei) => {
|
|
if (!grouped[ex.type]) grouped[ex.type] = { exercises: [], indices: [] };
|
|
grouped[ex.type].exercises.push(ex);
|
|
grouped[ex.type].indices.push(ei);
|
|
});
|
|
return Object.entries(grouped).map(([type, group]) => {
|
|
const typeLabel = READING_EXERCISE_TYPES.find((t) => t.key === type)?.label || type;
|
|
const sectionInstructions = group.exercises[0]?.instructions;
|
|
return (
|
|
<div key={type} className="space-y-2">
|
|
<div className="border-b border-blue-200 pb-1">
|
|
<div className="flex items-center justify-between">
|
|
<h5 className="text-xs font-semibold text-blue-700 flex items-center gap-1.5">
|
|
{typeLabel}
|
|
<Badge variant="outline" className="text-[10px] font-normal">{group.exercises.length}</Badge>
|
|
</h5>
|
|
</div>
|
|
{sectionInstructions && (
|
|
<p className="text-[11px] text-muted-foreground italic mt-0.5">{sectionInstructions}</p>
|
|
)}
|
|
</div>
|
|
{group.exercises.map((ex, gi) => {
|
|
const ei = group.indices[gi];
|
|
return (
|
|
<div key={ei} className="bg-white rounded-lg border p-3 space-y-2">
|
|
<div className="flex items-start justify-between gap-2">
|
|
<div className="flex-1 min-w-0">
|
|
<div className="flex items-center gap-2 mb-1">
|
|
<span className="text-[10px] font-medium text-muted-foreground">Q{gi + 1}</span>
|
|
<Badge variant="outline" className="text-[10px] shrink-0">{ex.marks} mark{ex.marks !== 1 ? "s" : ""}</Badge>
|
|
</div>
|
|
<p className="text-xs font-medium leading-relaxed">{ex.prompt}</p>
|
|
</div>
|
|
<div className="flex gap-1 shrink-0">
|
|
<Button size="sm" variant="ghost" className="h-6 w-6 p-0"
|
|
title="Edit exercise"
|
|
onClick={() => {
|
|
setEditingExercise({ module: "reading", itemIndex: pi, exerciseIndex: ei });
|
|
setExerciseDraft({ ...ex });
|
|
}}>
|
|
<PenTool className="h-3 w-3" />
|
|
</Button>
|
|
<Button size="sm" variant="ghost" className="h-6 w-6 p-0 text-destructive hover:text-destructive"
|
|
title="Delete exercise"
|
|
onClick={() => {
|
|
const p = [...st.passages];
|
|
p[pi] = { ...p[pi], exercises: p[pi].exercises.filter((_, idx) => idx !== ei) };
|
|
updateModuleState("reading", { passages: p });
|
|
}}>
|
|
<X className="h-3 w-3" />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
{ex.options.length > 0 && (
|
|
<div className="grid grid-cols-2 gap-1">
|
|
{ex.options.map((opt, oi) => (
|
|
<div key={oi} className={`text-[11px] px-2 py-1 rounded border ${opt === ex.correct_answer ? "bg-green-50 border-green-300 text-green-700 font-medium" : "bg-gray-50 border-gray-200"}`}>
|
|
{String.fromCharCode(65 + oi)}. {opt}
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
{!ex.options.length && ex.correct_answer && (
|
|
<p className="text-[11px] text-green-700 bg-green-50 rounded px-2 py-1 border border-green-200">
|
|
Answer: {ex.correct_answer}
|
|
</p>
|
|
)}
|
|
{ex.explanation && (
|
|
<p className="text-[11px] text-muted-foreground italic">
|
|
{ex.explanation}
|
|
</p>
|
|
)}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
);
|
|
});
|
|
})()}
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
const renderListeningModule = () => {
|
|
if (activeModule !== "listening") return null;
|
|
const st = getModuleState("listening");
|
|
const activeSectionTypes = new Set(st.listeningSections.map((s) => s.type));
|
|
return (
|
|
<div className="space-y-4">
|
|
{renderCommonConfig("listening")}
|
|
|
|
<div className="space-y-1">
|
|
<Label className="text-xs font-medium">Section</Label>
|
|
<div className="flex gap-3 flex-wrap items-center">
|
|
{LISTENING_SECTION_TYPES.map((sType) => (
|
|
<div key={sType.key} className="flex items-center gap-1.5">
|
|
<Checkbox
|
|
id={`lst-${sType.key}`}
|
|
checked={activeSectionTypes.has(sType.key)}
|
|
onCheckedChange={(checked) => {
|
|
if (checked) {
|
|
const newS = defaultListeningSection();
|
|
newS.type = sType.key;
|
|
updateModuleState("listening", { listeningSections: [...st.listeningSections, newS] });
|
|
} else {
|
|
const filtered = st.listeningSections.filter((s) => s.type !== sType.key);
|
|
if (filtered.length === 0) return;
|
|
updateModuleState("listening", { listeningSections: filtered });
|
|
}
|
|
}}
|
|
/>
|
|
<Label htmlFor={`lst-${sType.key}`} className="text-xs cursor-pointer">{sType.label}</Label>
|
|
</div>
|
|
))}
|
|
<Button variant="ghost" size="sm" className="h-6 text-xs" onClick={() => {
|
|
const usedTypes = st.listeningSections.map((s) => s.type);
|
|
const next = LISTENING_SECTION_TYPES.find((t) => !usedTypes.includes(t.key)) || LISTENING_SECTION_TYPES[0];
|
|
const newS = defaultListeningSection();
|
|
newS.type = next.key;
|
|
updateModuleState("listening", { listeningSections: [...st.listeningSections, newS] });
|
|
}}><Plus className="h-3 w-3 mr-1" /> Add</Button>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex gap-2">
|
|
<Button variant="outline" size="sm" className="text-xs text-orange-700 border-orange-300 hover:bg-orange-50">
|
|
<Headphones className="h-3 w-3 mr-1" /> Audio Instructions
|
|
</Button>
|
|
<Button variant="ghost" size="sm" onClick={() => setModuleStates((prev) => ({ ...prev, listening: defaultModuleState("listening") }))} className="text-xs">
|
|
<RotateCcw className="h-3 w-3 mr-1" /> Reset Module
|
|
</Button>
|
|
</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">
|
|
Category <ChevronDown className="h-3 w-3" />
|
|
</CollapsibleTrigger>
|
|
<CollapsibleContent className="pt-2">
|
|
<Input value={sec.category} placeholder="Category" className="h-8 text-xs"
|
|
onChange={(e) => {
|
|
const sections = [...st.listeningSections]; sections[si] = { ...sections[si], category: e.target.value };
|
|
updateModuleState("listening", { listeningSections: sections });
|
|
}} />
|
|
</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">
|
|
Divider <ChevronDown className="h-3 w-3" />
|
|
</CollapsibleTrigger>
|
|
<CollapsibleContent className="pt-2">
|
|
<Select value={sec.divider} onValueChange={(v) => {
|
|
const sections = [...st.listeningSections]; sections[si] = { ...sections[si], divider: v };
|
|
updateModuleState("listening", { listeningSections: sections });
|
|
}}>
|
|
<SelectTrigger className="h-8 text-xs"><SelectValue placeholder="Select divider..." /></SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="none">None</SelectItem>
|
|
<SelectItem value="line">Line</SelectItem>
|
|
<SelectItem value="space">Space</SelectItem>
|
|
<SelectItem value="page_break">Page Break</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</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">
|
|
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({
|
|
...buildPersonaContext("listening"),
|
|
topic: sec.category || "general conversation",
|
|
section_type: sec.type,
|
|
category: sec.category || undefined,
|
|
difficulty: st.difficulty[0] || "B1",
|
|
})
|
|
.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">
|
|
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 && (
|
|
<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>
|
|
</Collapsible>
|
|
<Button size="sm" className="w-full text-xs" variant="outline" disabled={!sec.context}
|
|
onClick={() => openExerciseWizard(si, "listening")}>
|
|
<ListChecks className="h-3 w-3 mr-1" />
|
|
{sec.exercises.length > 0
|
|
? `Exercises (${sec.exercises.length}) — Add More`
|
|
: "Set Up Exercises"}
|
|
</Button>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<Card className="border-orange-200">
|
|
<CardContent className="p-4 space-y-2">
|
|
<div className="flex items-center justify-between">
|
|
<h4 className="text-sm font-semibold">{LISTENING_SECTION_TYPES.find((t) => t.key === sec.type)?.label}</h4>
|
|
<div className="flex gap-1">
|
|
<Button size="sm" variant="outline" className="h-6 text-xs" disabled={!sec.context}>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 sections = [...st.listeningSections]; sections[si] = { ...sections[si], editing: !sections[si].editing };
|
|
updateModuleState("listening", { listeningSections: sections });
|
|
}}>Edit</Button>
|
|
</div>
|
|
</div>
|
|
<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" readOnly={!sec.editing} />
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{sec.exercises.length > 0 && (
|
|
<Card className="border-orange-200 bg-orange-50/30">
|
|
<CardContent className="p-4 space-y-3">
|
|
<div className="flex items-center justify-between">
|
|
<h4 className="text-sm font-semibold text-orange-700">Exercises ({sec.exercises.length})</h4>
|
|
<Button size="sm" variant="ghost" className="h-6 text-xs text-destructive hover:text-destructive"
|
|
onClick={() => {
|
|
const sections = [...st.listeningSections];
|
|
sections[si] = { ...sections[si], exercises: [] };
|
|
updateModuleState("listening", { listeningSections: sections });
|
|
}}>
|
|
<X className="h-3 w-3 mr-1" /> Clear All
|
|
</Button>
|
|
</div>
|
|
<div className="space-y-4">
|
|
{(() => {
|
|
const grouped: Record<string, { exercises: Exercise[]; indices: number[] }> = {};
|
|
sec.exercises.forEach((ex, ei) => {
|
|
if (!grouped[ex.type]) grouped[ex.type] = { exercises: [], indices: [] };
|
|
grouped[ex.type].exercises.push(ex);
|
|
grouped[ex.type].indices.push(ei);
|
|
});
|
|
return Object.entries(grouped).map(([type, group]) => {
|
|
const typeLabel = LISTENING_EXERCISE_TYPES.find((t) => t.key === type)?.label || type;
|
|
const sectionInstructions = group.exercises[0]?.instructions;
|
|
return (
|
|
<div key={type} className="space-y-2">
|
|
<div className="border-b border-orange-200 pb-1">
|
|
<div className="flex items-center justify-between">
|
|
<h5 className="text-xs font-semibold text-orange-700 flex items-center gap-1.5">
|
|
{typeLabel}
|
|
<Badge variant="outline" className="text-[10px] font-normal">{group.exercises.length}</Badge>
|
|
</h5>
|
|
</div>
|
|
{sectionInstructions && (
|
|
<p className="text-[11px] text-muted-foreground italic mt-0.5">{sectionInstructions}</p>
|
|
)}
|
|
</div>
|
|
{group.exercises.map((ex, gi) => {
|
|
const ei = group.indices[gi];
|
|
return (
|
|
<div key={ei} className="bg-white rounded-lg border p-3 space-y-2">
|
|
<div className="flex items-start justify-between gap-2">
|
|
<div className="flex-1 min-w-0">
|
|
<div className="flex items-center gap-2 mb-1">
|
|
<span className="text-[10px] font-medium text-muted-foreground">Q{gi + 1}</span>
|
|
<Badge variant="outline" className="text-[10px] shrink-0">{ex.marks} mark{ex.marks !== 1 ? "s" : ""}</Badge>
|
|
</div>
|
|
<p className="text-xs font-medium leading-relaxed">{ex.prompt}</p>
|
|
</div>
|
|
<div className="flex gap-1 shrink-0">
|
|
<Button size="sm" variant="ghost" className="h-6 w-6 p-0"
|
|
title="Edit exercise"
|
|
onClick={() => {
|
|
setEditingExercise({ module: "listening", itemIndex: si, exerciseIndex: ei });
|
|
setExerciseDraft({ ...ex });
|
|
}}>
|
|
<PenTool className="h-3 w-3" />
|
|
</Button>
|
|
<Button size="sm" variant="ghost" className="h-6 w-6 p-0 text-destructive hover:text-destructive"
|
|
title="Delete exercise"
|
|
onClick={() => {
|
|
const sections = [...st.listeningSections];
|
|
sections[si] = { ...sections[si], exercises: sections[si].exercises.filter((_, idx) => idx !== ei) };
|
|
updateModuleState("listening", { listeningSections: sections });
|
|
}}>
|
|
<X className="h-3 w-3" />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
{ex.options.length > 0 && (
|
|
<div className="grid grid-cols-2 gap-1">
|
|
{ex.options.map((opt, oi) => (
|
|
<div key={oi} className={`text-[11px] px-2 py-1 rounded border ${opt === ex.correct_answer ? "bg-green-50 border-green-300 text-green-700 font-medium" : "bg-gray-50 border-gray-200"}`}>
|
|
{String.fromCharCode(65 + oi)}. {opt}
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
{!ex.options.length && ex.correct_answer && (
|
|
<p className="text-[11px] text-green-700 bg-green-50 rounded px-2 py-1 border border-green-200">
|
|
Answer: {ex.correct_answer}
|
|
</p>
|
|
)}
|
|
{ex.explanation && (
|
|
<p className="text-[11px] text-muted-foreground italic">
|
|
{ex.explanation}
|
|
</p>
|
|
)}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
);
|
|
});
|
|
})()}
|
|
</div>
|
|
</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 items-center">
|
|
{st.writingTasks.map((_, i) => (
|
|
<div key={i} className="flex items-center gap-1 bg-muted/50 rounded px-2 py-0.5">
|
|
<Checkbox checked id={`wt-${i}`} />
|
|
<Label htmlFor={`wt-${i}`} className="text-xs">Task {i + 1}</Label>
|
|
{st.writingTasks.length > 1 && (
|
|
<button
|
|
className="ml-1 text-muted-foreground/60 hover:text-destructive transition-colors"
|
|
onClick={() => {
|
|
const tasks = st.writingTasks.filter((_, idx) => idx !== i);
|
|
updateModuleState("writing", { writingTasks: tasks });
|
|
}}
|
|
title={`Remove Task ${i + 1}`}
|
|
><X className="h-3 w-3" /></button>
|
|
)}
|
|
</div>
|
|
))}
|
|
{st.writingTasks.length < 2 && (
|
|
<Button variant="ghost" size="sm" className="h-6 text-xs" onClick={() => {
|
|
const newT = defaultWritingTask();
|
|
newT.type = "essay";
|
|
newT.wordLimit = 250;
|
|
updateModuleState("writing", { writingTasks: [...st.writingTasks, newT] });
|
|
}}><Plus className="h-3 w-3 mr-1" /> Add Task 2</Button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<Button variant="ghost" size="sm" onClick={() => setModuleStates((prev) => ({ ...prev, writing: defaultModuleState("writing") }))} 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.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">
|
|
Category <ChevronDown className="h-3 w-3" />
|
|
</CollapsibleTrigger>
|
|
<CollapsibleContent className="pt-2">
|
|
<Input value={task.category} placeholder="Category" className="h-8 text-xs"
|
|
onChange={(e) => {
|
|
const tasks = [...st.writingTasks]; tasks[ti] = { ...tasks[ti], category: e.target.value };
|
|
updateModuleState("writing", { writingTasks: tasks });
|
|
}} />
|
|
</CollapsibleContent>
|
|
</Collapsible>
|
|
<Collapsible>
|
|
<CollapsibleTrigger className="flex items-center gap-1 text-xs font-medium w-full justify-between bg-green-100 rounded px-2 py-1">
|
|
Type <ChevronDown className="h-3 w-3" />
|
|
</CollapsibleTrigger>
|
|
<CollapsibleContent className="pt-2">
|
|
<Select value={task.type} onValueChange={(v) => {
|
|
const tasks = [...st.writingTasks]; tasks[ti] = { ...tasks[ti], type: v };
|
|
updateModuleState("writing", { writingTasks: tasks });
|
|
}}>
|
|
<SelectTrigger className="h-8 text-xs"><SelectValue /></SelectTrigger>
|
|
<SelectContent>
|
|
{WRITING_TYPES.map((wt) => <SelectItem key={wt.key} value={wt.key}>{wt.label}</SelectItem>)}
|
|
</SelectContent>
|
|
</Select>
|
|
</CollapsibleContent>
|
|
</Collapsible>
|
|
<Collapsible>
|
|
<CollapsibleTrigger className="flex items-center gap-1 text-xs font-medium w-full justify-between bg-green-100 rounded px-2 py-1">
|
|
Divider <ChevronDown className="h-3 w-3" />
|
|
</CollapsibleTrigger>
|
|
<CollapsibleContent className="pt-2">
|
|
<Select value={task.divider} onValueChange={(v) => {
|
|
const tasks = [...st.writingTasks]; tasks[ti] = { ...tasks[ti], divider: v };
|
|
updateModuleState("writing", { writingTasks: tasks });
|
|
}}>
|
|
<SelectTrigger className="h-8 text-xs"><SelectValue placeholder="Select divider..." /></SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="none">None</SelectItem>
|
|
<SelectItem value="line">Line</SelectItem>
|
|
<SelectItem value="space">Space</SelectItem>
|
|
<SelectItem value="page_break">Page Break</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</CollapsibleContent>
|
|
</Collapsible>
|
|
<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-3">
|
|
<div className="flex items-center justify-between flex-wrap gap-2">
|
|
<div>
|
|
<h4 className="text-sm font-semibold">{WRITING_TYPES.find((wt) => wt.key === task.type)?.label || "Letter"} Instructions</h4>
|
|
<p className="text-xs text-muted-foreground">Generate or edit the instructions for the task</p>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<div className="flex items-center gap-1">
|
|
<Label className="text-xs">Marks</Label>
|
|
<Input type="number" value={task.marks} className="h-7 text-xs w-16"
|
|
onChange={(e) => {
|
|
const tasks = [...st.writingTasks]; tasks[ti] = { ...tasks[ti], marks: Number(e.target.value) };
|
|
updateModuleState("writing", { writingTasks: tasks });
|
|
}} />
|
|
</div>
|
|
<Button size="sm" variant="outline" className="h-7 text-xs">Save</Button>
|
|
<Button size="sm" variant="outline" className="h-7 text-xs">Discard</Button>
|
|
<Button size="sm" variant="outline" className="h-7 text-xs"
|
|
onClick={() => {
|
|
const tasks = [...st.writingTasks]; tasks[ti] = { ...tasks[ti], editing: !tasks[ti].editing };
|
|
updateModuleState("writing", { writingTasks: tasks });
|
|
}}>Edit</Button>
|
|
</div>
|
|
</div>
|
|
<div className="flex justify-end">
|
|
<Button size="sm" variant={task.graded ? "default" : "outline"}
|
|
className={`h-7 text-xs ${task.graded ? "bg-orange-500 hover:bg-orange-600" : ""}`}
|
|
onClick={() => {
|
|
const tasks = [...st.writingTasks]; tasks[ti] = { ...tasks[ti], graded: !tasks[ti].graded };
|
|
updateModuleState("writing", { writingTasks: tasks });
|
|
}}>
|
|
Graded
|
|
</Button>
|
|
</div>
|
|
<div className="space-y-1">
|
|
<Label className="text-xs font-medium">Exercise Images</Label>
|
|
<div className="flex gap-2 flex-wrap">
|
|
{task.images.map((img, ii) => (
|
|
<div key={ii} className="relative w-20 h-20 rounded border border-dashed border-green-300 bg-green-50 flex items-center justify-center">
|
|
<img src={img} alt={`img-${ii}`} className="w-full h-full object-cover rounded" />
|
|
<button className="absolute -top-1 -right-1 bg-white rounded-full shadow"
|
|
onClick={() => {
|
|
const tasks = [...st.writingTasks];
|
|
tasks[ti] = { ...tasks[ti], images: tasks[ti].images.filter((_, idx) => idx !== ii) };
|
|
updateModuleState("writing", { writingTasks: tasks });
|
|
}}><X className="h-3 w-3 text-destructive" /></button>
|
|
</div>
|
|
))}
|
|
<button className="w-20 h-20 rounded border-2 border-dashed border-green-300 bg-green-50/50 flex flex-col items-center justify-center text-green-600 hover:bg-green-100 transition-colors"
|
|
onClick={() => {
|
|
const input = document.createElement("input");
|
|
input.type = "file";
|
|
input.accept = "image/*";
|
|
input.onchange = (e) => {
|
|
const file = (e.target as HTMLInputElement).files?.[0];
|
|
if (!file) return;
|
|
const reader = new FileReader();
|
|
reader.onload = () => {
|
|
const tasks = [...st.writingTasks];
|
|
tasks[ti] = { ...tasks[ti], images: [...tasks[ti].images, reader.result as string] };
|
|
updateModuleState("writing", { writingTasks: tasks });
|
|
};
|
|
reader.readAsDataURL(file);
|
|
};
|
|
input.click();
|
|
}}>
|
|
<Plus className="h-4 w-4" />
|
|
<span className="text-[10px]">Add Image</span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<Textarea value={task.instructions} placeholder="Instructions ..." className="min-h-[100px] text-xs"
|
|
readOnly={!task.editing}
|
|
onChange={(e) => {
|
|
const tasks = [...st.writingTasks]; tasks[ti] = { ...tasks[ti], instructions: e.target.value };
|
|
updateModuleState("writing", { writingTasks: tasks });
|
|
}} />
|
|
<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>
|
|
</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 items-center">
|
|
{st.speakingParts.map((part, i) => (
|
|
<div key={i} className="flex items-center gap-1 bg-muted/50 rounded px-2 py-0.5">
|
|
<Checkbox checked id={`sp-${i}`} />
|
|
<Label htmlFor={`sp-${i}`} className="text-xs">{partTypes.find((t) => t.key === part.type)?.label || part.type}</Label>
|
|
{st.speakingParts.length > 1 && (
|
|
<button
|
|
className="ml-1 text-muted-foreground/60 hover:text-destructive transition-colors"
|
|
onClick={() => {
|
|
const parts = st.speakingParts.filter((_, idx) => idx !== i);
|
|
updateModuleState("speaking", { speakingParts: parts });
|
|
}}
|
|
title={`Remove ${partTypes.find((t) => t.key === part.type)?.label || part.type}`}
|
|
><X className="h-3 w-3" /></button>
|
|
)}
|
|
</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) {
|
|
updateModuleState("speaking", { speakingParts: [...st.speakingParts, defaultSpeakingPart(nextType.key)] });
|
|
}
|
|
}}><Plus className="h-3 w-3 mr-1" /> Add</Button>
|
|
</div>
|
|
</div>
|
|
|
|
<Button variant="ghost" size="sm" onClick={() => setModuleStates((prev) => ({ ...prev, speaking: defaultModuleState("speaking") }))} 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.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">
|
|
Category <ChevronDown className="h-3 w-3" />
|
|
</CollapsibleTrigger>
|
|
<CollapsibleContent className="pt-2">
|
|
<Input value={part.category} placeholder="Category" className="h-8 text-xs"
|
|
onChange={(e) => {
|
|
const parts = [...st.speakingParts]; parts[pi] = { ...parts[pi], category: e.target.value };
|
|
updateModuleState("speaking", { speakingParts: parts });
|
|
}} />
|
|
</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">
|
|
Divider <ChevronDown className="h-3 w-3" />
|
|
</CollapsibleTrigger>
|
|
<CollapsibleContent className="pt-2">
|
|
<Select value={part.divider} onValueChange={(v) => {
|
|
const parts = [...st.speakingParts]; parts[pi] = { ...parts[pi], divider: v };
|
|
updateModuleState("speaking", { speakingParts: parts });
|
|
}}>
|
|
<SelectTrigger className="h-8 text-xs"><SelectValue placeholder="Select divider..." /></SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="none">None</SelectItem>
|
|
<SelectItem value="line">Line</SelectItem>
|
|
<SelectItem value="space">Space</SelectItem>
|
|
<SelectItem value="page_break">Page Break</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</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 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 && 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>
|
|
</Collapsible>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<Card className="border-pink-200">
|
|
<CardContent className="p-4 space-y-2">
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<h4 className="text-sm font-semibold">{partTypes.find((t) => t.key === part.type)?.label} Script</h4>
|
|
<p className="text-xs text-muted-foreground">Generate or write the scripts for the videos.</p>
|
|
</div>
|
|
<div className="flex items-center gap-1">
|
|
<div className="flex items-center gap-1 mr-2">
|
|
<Label className="text-xs">Marks</Label>
|
|
<Input type="number" value={part.marks} className="h-7 text-xs w-16"
|
|
onChange={(e) => {
|
|
const parts = [...st.speakingParts]; parts[pi] = { ...parts[pi], marks: Number(e.target.value) };
|
|
updateModuleState("speaking", { speakingParts: parts });
|
|
}} />
|
|
</div>
|
|
<Button size="sm" variant="outline" className="h-7 text-xs">Save</Button>
|
|
<Button size="sm" variant="outline" className="h-7 text-xs">Discard</Button>
|
|
<Button size="sm" variant="outline" className="h-7 text-xs"
|
|
onClick={() => {
|
|
const parts = [...st.speakingParts]; parts[pi] = { ...parts[pi], editing: !parts[pi].editing };
|
|
updateModuleState("speaking", { speakingParts: parts });
|
|
}}>Edit</Button>
|
|
</div>
|
|
</div>
|
|
<div className="flex justify-end">
|
|
<Button size="sm" variant="default" className="h-7 text-xs bg-orange-500 hover:bg-orange-600">
|
|
Graded
|
|
</Button>
|
|
</div>
|
|
<Textarea value={part.script} placeholder="Generate or edit the questions!" className="min-h-[120px] text-xs"
|
|
readOnly={!part.editing}
|
|
onChange={(e) => {
|
|
const parts = [...st.speakingParts]; parts[pi] = { ...parts[pi], script: e.target.value };
|
|
updateModuleState("speaking", { speakingParts: parts });
|
|
}} />
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
const renderLevelModule = () => {
|
|
if (activeModule !== "level") return null;
|
|
const st = getModuleState("level");
|
|
return (
|
|
<div className="space-y-4">
|
|
<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("level", { timer: Number(e.target.value) || 1 })} className="h-8 text-xs" />
|
|
</div>
|
|
{examMode === "official" && (
|
|
<div className="space-y-1">
|
|
<Label className="text-xs">Rubric</Label>
|
|
<Select value={st.rubricId} onValueChange={(v) => updateModuleState("level", { rubricId: v })}>
|
|
<SelectTrigger className="h-8 text-xs"><SelectValue placeholder="Select rubric..." /></SelectTrigger>
|
|
<SelectContent>
|
|
{rubrics.length > 0 && (
|
|
<div className="px-2 py-1 text-[10px] font-semibold text-muted-foreground uppercase tracking-wider">Rubrics</div>
|
|
)}
|
|
{rubrics.map((r) => (
|
|
<SelectItem key={`r-${r.id}`} value={`rubric-${r.id}`}>{r.name}</SelectItem>
|
|
))}
|
|
{rubricGroups.length > 0 && (
|
|
<>
|
|
<div className="border-t my-1" />
|
|
<div className="px-2 py-1 text-[10px] font-semibold text-muted-foreground uppercase tracking-wider">Groups</div>
|
|
</>
|
|
)}
|
|
{rubricGroups.map((g) => (
|
|
<SelectItem key={`g-${g.id}`} value={`group-${g.id}`}>{g.name}</SelectItem>
|
|
))}
|
|
{rubrics.length === 0 && rubricGroups.length === 0 && (
|
|
<SelectItem value="_none" disabled>No rubrics available</SelectItem>
|
|
)}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
)}
|
|
<div className="space-y-1">
|
|
<Label className="text-xs">Number of Parts</Label>
|
|
<Input type="number" value={st.numberOfParts} min={1} max={5} className="h-8 text-xs"
|
|
onChange={(e) => {
|
|
const n = Math.max(1, Math.min(5, Number(e.target.value) || 1));
|
|
const parts = [...st.levelParts];
|
|
while (parts.length < n) parts.push(defaultLevelPart());
|
|
updateModuleState("level", { numberOfParts: n, levelParts: parts.slice(0, n) });
|
|
}} />
|
|
</div>
|
|
<div className="space-y-1">
|
|
<Label className="text-xs">Access Type</Label>
|
|
<Select value={st.accessType} onValueChange={(v) => updateModuleState("level", { 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("level", { entity: v })}>
|
|
<SelectTrigger className="h-8 text-xs"><SelectValue placeholder="Select..." /></SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="none">None</SelectItem>
|
|
{entities.map((e) => (
|
|
<SelectItem key={e.id} value={String(e.id)}>{e.name}</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
<div className="flex items-center gap-2 col-span-2 md:col-span-1">
|
|
<Switch checked={st.shuffling} onCheckedChange={(v) => updateModuleState("level", { shuffling: v })} />
|
|
<Label className="text-xs">Shuffling Enabled</Label>
|
|
</div>
|
|
</div>
|
|
|
|
<Button variant="ghost" size="sm" onClick={() => setModuleStates((prev) => ({ ...prev, level: defaultModuleState("level") }))} className="text-xs">
|
|
<RotateCcw className="h-3 w-3 mr-1" /> Reset Module
|
|
</Button>
|
|
|
|
{st.levelParts.map((part, partIdx) => (
|
|
<Card key={partIdx} className="border-purple-200 bg-purple-50/30">
|
|
<CardContent className="p-4 space-y-3">
|
|
<h4 className="text-sm font-semibold text-purple-700 text-center">Part {partIdx + 1} Settings</h4>
|
|
|
|
<Collapsible>
|
|
<CollapsibleTrigger className="flex items-center gap-1 text-xs font-medium w-full justify-between bg-purple-200 text-purple-800 rounded px-2 py-1.5">
|
|
Category <ChevronDown className="h-3 w-3" />
|
|
</CollapsibleTrigger>
|
|
<CollapsibleContent className="pt-2">
|
|
<Input value={part.category} placeholder="Category" className="h-8 text-xs"
|
|
onChange={(e) => {
|
|
const parts = [...st.levelParts]; parts[partIdx] = { ...parts[partIdx], category: e.target.value };
|
|
updateModuleState("level", { levelParts: parts });
|
|
}} />
|
|
</CollapsibleContent>
|
|
</Collapsible>
|
|
|
|
<Collapsible>
|
|
<CollapsibleTrigger className="flex items-center gap-1 text-xs font-medium w-full justify-between bg-purple-200 text-purple-800 rounded px-2 py-1.5">
|
|
Divider <ChevronDown className="h-3 w-3" />
|
|
</CollapsibleTrigger>
|
|
<CollapsibleContent className="pt-2">
|
|
<Select value={part.divider} onValueChange={(v) => {
|
|
const parts = [...st.levelParts]; parts[partIdx] = { ...parts[partIdx], divider: v };
|
|
updateModuleState("level", { levelParts: parts });
|
|
}}>
|
|
<SelectTrigger className="h-8 text-xs"><SelectValue placeholder="Select divider..." /></SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="none">None</SelectItem><SelectItem value="line">Line</SelectItem>
|
|
<SelectItem value="space">Space</SelectItem><SelectItem value="page_break">Page Break</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</CollapsibleContent>
|
|
</Collapsible>
|
|
|
|
<Collapsible>
|
|
<CollapsibleTrigger className="flex items-center gap-1 text-xs font-medium w-full justify-between bg-purple-300 text-purple-900 rounded px-2 py-1.5">
|
|
Add Level Exercises <ChevronDown className="h-3 w-3" />
|
|
</CollapsibleTrigger>
|
|
<CollapsibleContent className="pt-2 space-y-1">
|
|
{LEVEL_EXERCISE_TYPES.map((et) => (
|
|
<div key={et.key} className="flex items-center gap-2">
|
|
<Checkbox id={`lvl-${partIdx}-${et.key}`}
|
|
checked={part.levelExerciseTypes.includes(et.key)}
|
|
onCheckedChange={(checked) => {
|
|
const parts = [...st.levelParts];
|
|
const types = checked ? [...parts[partIdx].levelExerciseTypes, et.key] : parts[partIdx].levelExerciseTypes.filter((t) => t !== et.key);
|
|
parts[partIdx] = { ...parts[partIdx], levelExerciseTypes: types };
|
|
updateModuleState("level", { levelParts: parts });
|
|
}} />
|
|
<Label htmlFor={`lvl-${partIdx}-${et.key}`} className="text-xs">{et.label}</Label>
|
|
</div>
|
|
))}
|
|
<Button size="sm" variant="outline" className="w-full text-xs mt-2" disabled={part.levelExerciseTypes.length === 0}>
|
|
<Settings2 className="h-3 w-3 mr-1" /> Set Up Exercises ({part.levelExercises.length})
|
|
</Button>
|
|
</CollapsibleContent>
|
|
</Collapsible>
|
|
|
|
<Collapsible>
|
|
<CollapsibleTrigger className="flex items-center gap-1 text-xs font-medium w-full justify-between bg-blue-200 text-blue-800 rounded px-2 py-1.5">
|
|
Add Reading Exercises <ChevronDown className="h-3 w-3" />
|
|
</CollapsibleTrigger>
|
|
<CollapsibleContent className="pt-2 space-y-2">
|
|
<div className="flex gap-1 flex-wrap">
|
|
{part.readingPassages.map((_, pi) => (
|
|
<Badge key={pi} variant="outline" className="text-[10px]">
|
|
<Checkbox className="mr-1 h-3 w-3" checked />
|
|
Passage {pi + 1}
|
|
</Badge>
|
|
))}
|
|
</div>
|
|
<p className="text-[10px] text-muted-foreground">Reading exercises: Multiple Choice, Fill Blanks, Write Blanks, True False, Paragraph Match</p>
|
|
</CollapsibleContent>
|
|
</Collapsible>
|
|
|
|
<Collapsible>
|
|
<CollapsibleTrigger className="flex items-center gap-1 text-xs font-medium w-full justify-between bg-orange-200 text-orange-800 rounded px-2 py-1.5">
|
|
Add Listening Exercises <ChevronDown className="h-3 w-3" />
|
|
</CollapsibleTrigger>
|
|
<CollapsibleContent className="pt-2 space-y-2">
|
|
<div className="flex gap-1 flex-wrap">
|
|
{part.listeningSections.map((_, si) => (
|
|
<Badge key={si} variant="outline" className="text-[10px]">
|
|
<Checkbox className="mr-1 h-3 w-3" checked />
|
|
Section {si + 1}
|
|
</Badge>
|
|
))}
|
|
</div>
|
|
<p className="text-[10px] text-muted-foreground">Listening exercises: MC, Write Blanks, True False, Fill, Form</p>
|
|
</CollapsibleContent>
|
|
</Collapsible>
|
|
|
|
<Collapsible>
|
|
<CollapsibleTrigger className="flex items-center gap-1 text-xs font-medium w-full justify-between bg-green-200 text-green-800 rounded px-2 py-1.5">
|
|
Add Writing Exercises <ChevronDown className="h-3 w-3" />
|
|
</CollapsibleTrigger>
|
|
<CollapsibleContent className="pt-2 space-y-1">
|
|
<div className="flex items-center gap-2">
|
|
<Checkbox checked />
|
|
<Label className="text-xs">Task 1 - Letter</Label>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<Checkbox checked />
|
|
<Label className="text-xs">Task 2 - Essay</Label>
|
|
</div>
|
|
</CollapsibleContent>
|
|
</Collapsible>
|
|
|
|
<Collapsible>
|
|
<CollapsibleTrigger className="flex items-center gap-1 text-xs font-medium w-full justify-between bg-pink-200 text-pink-800 rounded px-2 py-1.5">
|
|
Add Speaking Exercises <ChevronDown className="h-3 w-3" />
|
|
</CollapsibleTrigger>
|
|
<CollapsibleContent className="pt-2 space-y-1">
|
|
<div className="flex items-center gap-2">
|
|
<Checkbox checked />
|
|
<Label className="text-xs">Exercise 1</Label>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<Checkbox checked />
|
|
<Label className="text-xs">Exercise 2</Label>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<Checkbox checked />
|
|
<Label className="text-xs">Interactive</Label>
|
|
</div>
|
|
</CollapsibleContent>
|
|
</Collapsible>
|
|
</CardContent>
|
|
</Card>
|
|
))}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
const renderIndustryModule = () => {
|
|
if (activeModule !== "industry") return null;
|
|
const st = getModuleState("industry");
|
|
return (
|
|
<div className="space-y-4">
|
|
<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("industry", { timer: Number(e.target.value) || 1 })} className="h-8 text-xs" />
|
|
</div>
|
|
{examMode === "official" && (
|
|
<div className="space-y-1">
|
|
<Label className="text-xs">Rubric</Label>
|
|
<Select value={st.rubricId} onValueChange={(v) => updateModuleState("industry", { rubricId: v })}>
|
|
<SelectTrigger className="h-8 text-xs"><SelectValue placeholder="Select rubric..." /></SelectTrigger>
|
|
<SelectContent>
|
|
{rubrics.length > 0 && (
|
|
<div className="px-2 py-1 text-[10px] font-semibold text-muted-foreground uppercase tracking-wider">Rubrics</div>
|
|
)}
|
|
{rubrics.map((r) => (
|
|
<SelectItem key={`r-${r.id}`} value={`rubric-${r.id}`}>{r.name}</SelectItem>
|
|
))}
|
|
{rubricGroups.length > 0 && (
|
|
<>
|
|
<div className="border-t my-1" />
|
|
<div className="px-2 py-1 text-[10px] font-semibold text-muted-foreground uppercase tracking-wider">Groups</div>
|
|
</>
|
|
)}
|
|
{rubricGroups.map((g) => (
|
|
<SelectItem key={`g-${g.id}`} value={`group-${g.id}`}>{g.name}</SelectItem>
|
|
))}
|
|
{rubrics.length === 0 && rubricGroups.length === 0 && (
|
|
<SelectItem value="_none" disabled>No rubrics available</SelectItem>
|
|
)}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
)}
|
|
<div className="space-y-1">
|
|
<Label className="text-xs">Number of Parts</Label>
|
|
<Input type="number" value={st.numberOfParts} min={1} max={5} className="h-8 text-xs"
|
|
onChange={(e) => {
|
|
const n = Math.max(1, Math.min(5, Number(e.target.value) || 1));
|
|
const parts = [...st.industryParts];
|
|
while (parts.length < n) parts.push(defaultIndustryPart());
|
|
updateModuleState("industry", { numberOfParts: n, industryParts: parts.slice(0, n) });
|
|
}} />
|
|
</div>
|
|
<div className="space-y-1">
|
|
<Label className="text-xs">Access Type</Label>
|
|
<Select value={st.accessType} onValueChange={(v) => updateModuleState("industry", { 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("industry", { entity: v })}>
|
|
<SelectTrigger className="h-8 text-xs"><SelectValue placeholder="Select..." /></SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="none">None</SelectItem>
|
|
{entities.map((e) => (
|
|
<SelectItem key={e.id} value={String(e.id)}>{e.name}</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
<div className="flex items-center gap-2 col-span-2 md:col-span-1">
|
|
<Switch checked={st.shuffling} onCheckedChange={(v) => updateModuleState("industry", { shuffling: v })} />
|
|
<Label className="text-xs">Shuffling Enabled</Label>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<Label className="text-xs font-medium">Attachments for context</Label>
|
|
<div className="flex gap-2 flex-wrap">
|
|
{st.industryParts[0]?.attachments.map((att, ai) => (
|
|
<Badge key={ai} variant="secondary" className="text-xs gap-1">
|
|
<FileText className="h-3 w-3" /> {att.split("/").pop() || `File ${ai + 1}`}
|
|
<button onClick={() => {
|
|
const parts = [...st.industryParts];
|
|
parts[0] = { ...parts[0], attachments: parts[0].attachments.filter((_, idx) => idx !== ai) };
|
|
updateModuleState("industry", { industryParts: parts });
|
|
}} className="ml-0.5 hover:text-destructive"><X className="h-3 w-3" /></button>
|
|
</Badge>
|
|
))}
|
|
<Button variant="outline" size="sm" className="text-xs" onClick={() => {
|
|
const input = document.createElement("input");
|
|
input.type = "file";
|
|
input.onchange = (e) => {
|
|
const file = (e.target as HTMLInputElement).files?.[0];
|
|
if (!file) return;
|
|
const parts = [...st.industryParts];
|
|
parts[0] = { ...parts[0], attachments: [...parts[0].attachments, file.name] };
|
|
updateModuleState("industry", { industryParts: parts });
|
|
toast({ title: "Attachment added", description: file.name });
|
|
};
|
|
input.click();
|
|
}}>
|
|
<Plus className="h-3 w-3 mr-1" /> Add Attachment
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
<Button variant="ghost" size="sm" onClick={() => setModuleStates((prev) => ({ ...prev, industry: defaultModuleState("industry") }))} className="text-xs">
|
|
<RotateCcw className="h-3 w-3 mr-1" /> Reset Module
|
|
</Button>
|
|
|
|
{st.industryParts.map((part, partIdx) => (
|
|
<Card key={partIdx} className="border-amber-300 bg-amber-50/30">
|
|
<CardContent className="p-4 space-y-3">
|
|
<h4 className="text-sm font-semibold text-amber-800 text-center">Part {partIdx + 1} Settings</h4>
|
|
|
|
<Collapsible>
|
|
<CollapsibleTrigger className="flex items-center gap-1 text-xs font-medium w-full justify-between bg-amber-200 text-amber-800 rounded px-2 py-1.5">
|
|
Category <ChevronDown className="h-3 w-3" />
|
|
</CollapsibleTrigger>
|
|
<CollapsibleContent className="pt-2">
|
|
<Input value={part.category} placeholder="Category" className="h-8 text-xs"
|
|
onChange={(e) => {
|
|
const parts = [...st.industryParts]; parts[partIdx] = { ...parts[partIdx], category: e.target.value };
|
|
updateModuleState("industry", { industryParts: parts });
|
|
}} />
|
|
</CollapsibleContent>
|
|
</Collapsible>
|
|
|
|
<Collapsible>
|
|
<CollapsibleTrigger className="flex items-center gap-1 text-xs font-medium w-full justify-between bg-amber-200 text-amber-800 rounded px-2 py-1.5">
|
|
Divider <ChevronDown className="h-3 w-3" />
|
|
</CollapsibleTrigger>
|
|
<CollapsibleContent className="pt-2">
|
|
<Select value={part.divider} onValueChange={(v) => {
|
|
const parts = [...st.industryParts]; parts[partIdx] = { ...parts[partIdx], divider: v };
|
|
updateModuleState("industry", { industryParts: parts });
|
|
}}>
|
|
<SelectTrigger className="h-8 text-xs"><SelectValue placeholder="Select divider..." /></SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="none">None</SelectItem><SelectItem value="line">Line</SelectItem>
|
|
<SelectItem value="space">Space</SelectItem><SelectItem value="page_break">Page Break</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</CollapsibleContent>
|
|
</Collapsible>
|
|
|
|
<Collapsible>
|
|
<CollapsibleTrigger className="flex items-center gap-1 text-xs font-medium w-full justify-between bg-amber-300 text-amber-900 rounded px-2 py-1.5">
|
|
Add Industry Exercises <ChevronDown className="h-3 w-3" />
|
|
</CollapsibleTrigger>
|
|
<CollapsibleContent className="pt-2 space-y-1">
|
|
{INDUSTRY_EXERCISE_TYPES.map((et) => (
|
|
<div key={et.key} className="flex items-center gap-2">
|
|
<Checkbox id={`ind-${partIdx}-${et.key}`}
|
|
checked={part.exerciseTypes.includes(et.key)}
|
|
onCheckedChange={(checked) => {
|
|
const parts = [...st.industryParts];
|
|
const types = checked ? [...parts[partIdx].exerciseTypes, et.key] : parts[partIdx].exerciseTypes.filter((t) => t !== et.key);
|
|
parts[partIdx] = { ...parts[partIdx], exerciseTypes: types };
|
|
updateModuleState("industry", { industryParts: parts });
|
|
}} />
|
|
<Label htmlFor={`ind-${partIdx}-${et.key}`} className="text-xs">{et.label}</Label>
|
|
</div>
|
|
))}
|
|
<Button size="sm" variant="outline" className="w-full text-xs mt-2" disabled={part.exerciseTypes.length === 0}>
|
|
<Settings2 className="h-3 w-3 mr-1" /> Set Up Exercises ({part.exercises.length})
|
|
</Button>
|
|
</CollapsibleContent>
|
|
</Collapsible>
|
|
</CardContent>
|
|
</Card>
|
|
))}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
const handleStructureSelect = (val: string) => {
|
|
setExamStructure(val);
|
|
const s = structures.find((st) => String(st.id) === val);
|
|
if (s) {
|
|
const mods = (Array.isArray(s.modules) ? s.modules : []) as string[];
|
|
if (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]);
|
|
}
|
|
}
|
|
};
|
|
|
|
const handleModeSwitch = (mode: "official" | "practice") => {
|
|
setExamMode(mode);
|
|
if (mode === "practice") {
|
|
setExamStructure("");
|
|
}
|
|
setSelectedModules(new Set());
|
|
setActiveModule(null);
|
|
};
|
|
|
|
const renderOfficialStructurePreview = () => {
|
|
if (!selectedStructure || !selectedStructureConfig) return null;
|
|
const cfg = selectedStructureConfig;
|
|
const mods = (Array.isArray(selectedStructure.modules) ? selectedStructure.modules : []) as string[];
|
|
return (
|
|
<Card className="border bg-muted/30">
|
|
<CardContent className="p-4 space-y-3">
|
|
<div className="flex items-center justify-between">
|
|
<div className="flex items-center gap-2">
|
|
<Lock className="h-4 w-4 text-muted-foreground" />
|
|
<span className="text-sm font-semibold">{selectedStructure.name}</span>
|
|
{cfg.exam_type && <Badge variant="outline" className="capitalize text-xs">{cfg.exam_type}</Badge>}
|
|
{selectedStructure.industry && <Badge variant="secondary" className="text-xs">{selectedStructure.industry}</Badge>}
|
|
</div>
|
|
<span className="text-xs text-muted-foreground">Read-only structure</span>
|
|
</div>
|
|
|
|
<div className="flex gap-1.5 flex-wrap">
|
|
{mods.map((m) => (
|
|
<Badge key={m} variant="secondary" className="capitalize text-xs">{m}</Badge>
|
|
))}
|
|
</div>
|
|
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3 pt-1">
|
|
{cfg.listening && mods.includes("listening") && (
|
|
<div className="rounded-md border bg-background p-3 space-y-1.5">
|
|
<div className="flex items-center gap-1.5 text-sm font-medium"><Headphones className="h-3.5 w-3.5 text-teal-600" /> Listening</div>
|
|
<p className="text-xs text-muted-foreground">{cfg.listening.parts?.length ?? 0} parts · {cfg.listening.total_questions ?? 0} questions</p>
|
|
{cfg.listening.parts?.map((p, i) => (
|
|
<p key={i} className="text-xs text-muted-foreground">P{i + 1}: {p.label}</p>
|
|
))}
|
|
</div>
|
|
)}
|
|
{cfg.reading && mods.includes("reading") && (
|
|
<div className="rounded-md border bg-background p-3 space-y-1.5">
|
|
<div className="flex items-center gap-1.5 text-sm font-medium"><BookOpen className="h-3.5 w-3.5 text-blue-600" /> Reading</div>
|
|
<p className="text-xs text-muted-foreground">{cfg.reading.passages?.length ?? 0} passages · {cfg.reading.total_questions ?? 0} questions</p>
|
|
{cfg.reading.passages?.map((p, i) => (
|
|
<p key={i} className="text-xs text-muted-foreground">{cfg.exam_type === "academic" ? "Passage" : "Section"} {i + 1}: {p.style}</p>
|
|
))}
|
|
</div>
|
|
)}
|
|
{cfg.writing && mods.includes("writing") && (
|
|
<div className="rounded-md border bg-background p-3 space-y-1.5">
|
|
<div className="flex items-center gap-1.5 text-sm font-medium"><PenTool className="h-3.5 w-3.5 text-orange-600" /> Writing</div>
|
|
{cfg.writing.tasks?.map((t, i) => (
|
|
<p key={i} className="text-xs text-muted-foreground">{t.label || `Task ${i + 1}`}: {t.type} (min {t.min_words} words)</p>
|
|
))}
|
|
{!cfg.writing.tasks?.length && cfg.writing.task1 && (
|
|
<p className="text-xs text-muted-foreground">Task 1: {cfg.writing.task1.type} · Task 2: {cfg.writing.task2?.type}</p>
|
|
)}
|
|
</div>
|
|
)}
|
|
{cfg.speaking && mods.includes("speaking") && (
|
|
<div className="rounded-md border bg-background p-3 space-y-1.5">
|
|
<div className="flex items-center gap-1.5 text-sm font-medium"><Mic className="h-3.5 w-3.5 text-purple-600" /> Speaking</div>
|
|
{cfg.speaking.parts?.map((p, i) => (
|
|
<p key={i} className="text-xs text-muted-foreground">Part {i + 1}: {p.label} ({p.duration_min}-{p.duration_max} min)</p>
|
|
))}
|
|
</div>
|
|
)}
|
|
{cfg.level && mods.includes("level") && (
|
|
<div className="rounded-md border bg-background p-3 space-y-1.5">
|
|
<div className="flex items-center gap-1.5 text-sm font-medium"><Layers className="h-3.5 w-3.5 text-indigo-600" /> Level</div>
|
|
<p className="text-xs text-muted-foreground">
|
|
{Object.values(cfg.level.exercise_types || {}).reduce((s, v) => s + v, 0)} total questions
|
|
</p>
|
|
</div>
|
|
)}
|
|
{cfg.industry && mods.includes("industry") && (
|
|
<div className="rounded-md border bg-background p-3 space-y-1.5">
|
|
<div className="flex items-center gap-1.5 text-sm font-medium"><Briefcase className="h-3.5 w-3.5 text-amber-700" /> Industry</div>
|
|
<p className="text-xs text-muted-foreground">Difficulty: {cfg.industry.difficulty}</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
};
|
|
|
|
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>
|
|
|
|
{/* Mode tabs */}
|
|
<div className="flex gap-2 border-b pb-0">
|
|
<button
|
|
className={`flex items-center gap-2 px-4 py-2.5 text-sm font-medium border-b-2 transition-colors -mb-px ${
|
|
examMode === "official"
|
|
? "border-primary text-primary"
|
|
: "border-transparent text-muted-foreground hover:text-foreground hover:border-muted-foreground/30"
|
|
}`}
|
|
onClick={() => handleModeSwitch("official")}
|
|
>
|
|
<GraduationCap className="h-4 w-4" /> Official Exam
|
|
</button>
|
|
<button
|
|
className={`flex items-center gap-2 px-4 py-2.5 text-sm font-medium border-b-2 transition-colors -mb-px ${
|
|
examMode === "practice"
|
|
? "border-primary text-primary"
|
|
: "border-transparent text-muted-foreground hover:text-foreground hover:border-muted-foreground/30"
|
|
}`}
|
|
onClick={() => handleModeSwitch("practice")}
|
|
>
|
|
<Dumbbell className="h-4 w-4" /> Practice Exam
|
|
</button>
|
|
</div>
|
|
|
|
<AiTipBanner context="generation" variant="recommendation" />
|
|
|
|
{/* Title, Label, Structure row */}
|
|
<div className={`grid grid-cols-1 gap-4 ${examMode === "official" ? "md:grid-cols-3" : "md:grid-cols-2"}`}>
|
|
<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>
|
|
{examMode === "official" && (
|
|
<div className="space-y-1">
|
|
<Label>Exam Structure <span className="text-destructive">*</span></Label>
|
|
<Select value={examStructure} onValueChange={(val) => {
|
|
if (val === "__add_new__") {
|
|
navigate("/admin/exam-structures");
|
|
return;
|
|
}
|
|
handleStructureSelect(val);
|
|
}}>
|
|
<SelectTrigger><SelectValue placeholder="Select an exam structure" /></SelectTrigger>
|
|
<SelectContent>
|
|
{structures.map((s) => (
|
|
<SelectItem key={s.id} value={String(s.id)}>{s.name}</SelectItem>
|
|
))}
|
|
{structures.length === 0 && (
|
|
<SelectItem value="_none" disabled>No structures available</SelectItem>
|
|
)}
|
|
<div className="border-t my-1" />
|
|
<SelectItem value="__add_new__" className="text-primary font-medium">
|
|
<span className="flex items-center gap-1.5"><Plus className="h-3.5 w-3.5" /> Add New</span>
|
|
</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Official mode: read-only structure preview */}
|
|
{examMode === "official" && selectedStructure && renderOfficialStructurePreview()}
|
|
{examMode === "official" && !selectedStructure && (
|
|
<Card className="border-dashed">
|
|
<CardContent className="p-8 text-center text-muted-foreground">
|
|
<GraduationCap className="h-8 w-8 mx-auto mb-2 opacity-50" />
|
|
<p className="text-sm">Select an Exam Structure above to view its configuration and start generating.</p>
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
|
|
{/* Practice mode: free module selection */}
|
|
{examMode === "practice" && (
|
|
<div>
|
|
<p className="text-sm text-muted-foreground mb-3">Select which modules you want to practice</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>
|
|
)}
|
|
|
|
{/* Official mode: module selector from structure's modules (locked, non-toggleable) */}
|
|
{examMode === "official" && isOfficialLocked && selectedModules.size > 0 && (
|
|
<div>
|
|
<p className="text-sm text-muted-foreground mb-3 flex items-center gap-1.5">
|
|
<Lock className="h-3.5 w-3.5" /> Modules defined by structure — select one to configure
|
|
</p>
|
|
<div className="flex flex-wrap gap-3">
|
|
{MODULES.filter((m) => selectedModules.has(m.key)).map((m) => (
|
|
<button key={m.key}
|
|
className={`flex items-center gap-2 rounded-lg border-2 px-4 py-3 transition-all ${
|
|
activeModule === m.key ? `${m.bgColor} border-current ${m.color} shadow-sm` : "border-muted bg-background hover:bg-muted/50"
|
|
}`}
|
|
onClick={() => setActiveModule(m.key)}>
|
|
<span className={m.color}>{m.icon}</span>
|
|
<span className="text-sm font-medium">{m.label}</span>
|
|
</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()}
|
|
{activeModule === "level" && renderLevelModule()}
|
|
{activeModule === "industry" && renderIndustryModule()}
|
|
|
|
{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={!activeModule} onClick={() => setPreviewOpen(true)}>
|
|
<Eye className="h-4 w-4 mr-2" /> Preview module
|
|
</Button>
|
|
</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 && (
|
|
<div className="space-y-2 mt-2">
|
|
<p className="text-xs font-medium text-blue-700">{p.exercises.length} exercise(s)</p>
|
|
{p.exercises.map((ex, ei) => (
|
|
<div key={ei} className="bg-white rounded border p-2 space-y-1">
|
|
<div className="flex items-center gap-2">
|
|
<Badge variant="secondary" className="text-[10px]">
|
|
{READING_EXERCISE_TYPES.find((t) => t.key === ex.type)?.label || ex.type}
|
|
</Badge>
|
|
<Badge variant="outline" className="text-[10px]">{ex.marks} mark{ex.marks !== 1 ? "s" : ""}</Badge>
|
|
</div>
|
|
<p className="text-xs">{ex.prompt}</p>
|
|
{ex.options.length > 0 && (
|
|
<div className="grid grid-cols-2 gap-1">
|
|
{ex.options.map((opt, oi) => (
|
|
<span key={oi} className={`text-[10px] px-1.5 py-0.5 rounded ${opt === ex.correct_answer ? "bg-green-100 text-green-700 font-medium" : "text-muted-foreground"}`}>
|
|
{String.fromCharCode(65 + oi)}. {opt}
|
|
</span>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</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>
|
|
))}
|
|
|
|
{mod === "level" && st.levelParts.map((lp, i) => (
|
|
<div key={i} className="bg-muted/50 rounded p-3 space-y-2">
|
|
<h4 className="font-medium text-sm">Part {i + 1}</h4>
|
|
{lp.levelExerciseTypes.length > 0 && (
|
|
<p className="text-xs text-purple-600">Level exercises: {lp.levelExerciseTypes.join(", ")}</p>
|
|
)}
|
|
<p className="text-xs text-muted-foreground">
|
|
{lp.readingPassages.length} passages · {lp.listeningSections.length} listening sections · {lp.writingTasks.length} writing tasks · {lp.speakingParts.length} speaking parts
|
|
</p>
|
|
</div>
|
|
))}
|
|
|
|
{mod === "industry" && st.industryParts.map((ip, i) => (
|
|
<div key={i} className="bg-muted/50 rounded p-3 space-y-2">
|
|
<h4 className="font-medium text-sm">Part {i + 1}</h4>
|
|
{ip.exerciseTypes.length > 0 && (
|
|
<p className="text-xs text-amber-700">Industry exercises: {ip.exerciseTypes.map((t) => INDUSTRY_EXERCISE_TYPES.find((et) => et.key === t)?.label || t).join(", ")}</p>
|
|
)}
|
|
{ip.attachments.length > 0 && (
|
|
<p className="text-xs text-muted-foreground">{ip.attachments.length} attachment(s)</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>
|
|
|
|
<Dialog open={exerciseWizard.open} onOpenChange={(open) => {
|
|
if (!open) setExerciseWizard((prev) => ({ ...prev, open: false, step: 0 }));
|
|
}}>
|
|
<DialogContent className="max-w-lg max-h-[85vh] overflow-y-auto">
|
|
<DialogHeader>
|
|
<DialogTitle className="flex items-center gap-2">
|
|
<Settings2 className={`h-5 w-5 ${exerciseWizard.module === "listening" ? "text-orange-600" : "text-blue-600"}`} />
|
|
{exerciseWizard.step === 0 ? "Configure Exercises" : "Exercises Generated"}
|
|
</DialogTitle>
|
|
<DialogDescription>
|
|
{exerciseWizard.step === 0
|
|
? `Choose exercise types, set how many of each, and customize difficulty for ${exerciseWizard.module}.`
|
|
: `Exercises have been generated and added to your ${exerciseWizard.module === "listening" ? "section" : "passage"}.`}
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
|
|
{exerciseWizard.step === 0 && (
|
|
<div className="space-y-3 pt-2">
|
|
{(exerciseWizard.module === "listening" ? LISTENING_EXERCISE_TYPES : READING_EXERCISE_TYPES).map((et) => {
|
|
const cfg = exerciseWizard.types[et.key];
|
|
if (!cfg) return null;
|
|
return (
|
|
<div key={et.key} className={`rounded-lg border p-3 transition-colors ${cfg.enabled ? (exerciseWizard.module === "listening" ? "bg-orange-50/60 border-orange-200" : "bg-blue-50/60 border-blue-200") : "bg-muted/30 border-muted"}`}>
|
|
<div className="flex items-center justify-between">
|
|
<div className="flex items-center gap-3">
|
|
<Checkbox
|
|
checked={cfg.enabled}
|
|
onCheckedChange={(checked) =>
|
|
setExerciseWizard((prev) => ({
|
|
...prev,
|
|
types: { ...prev.types, [et.key]: { ...prev.types[et.key], enabled: !!checked } },
|
|
}))
|
|
}
|
|
/>
|
|
<div>
|
|
<p className={`text-sm font-medium ${cfg.enabled ? "text-foreground" : "text-muted-foreground"}`}>{et.label}</p>
|
|
</div>
|
|
</div>
|
|
{cfg.enabled && (
|
|
<div className="flex items-center gap-2">
|
|
<Label className="text-[10px] text-muted-foreground whitespace-nowrap">Questions</Label>
|
|
<div className="flex items-center border rounded-md">
|
|
<button className="px-1.5 py-0.5 hover:bg-muted transition-colors rounded-l-md"
|
|
onClick={() => setExerciseWizard((prev) => ({
|
|
...prev,
|
|
types: { ...prev.types, [et.key]: { ...prev.types[et.key], count: Math.max(1, prev.types[et.key].count - 1) } },
|
|
}))}>
|
|
<Minus className="h-3 w-3" />
|
|
</button>
|
|
<span className="px-2 text-xs font-medium min-w-[24px] text-center">{cfg.count}</span>
|
|
<button className="px-1.5 py-0.5 hover:bg-muted transition-colors rounded-r-md"
|
|
onClick={() => setExerciseWizard((prev) => ({
|
|
...prev,
|
|
types: { ...prev.types, [et.key]: { ...prev.types[et.key], count: Math.min(20, prev.types[et.key].count + 1) } },
|
|
}))}>
|
|
<Plus className="h-3 w-3" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
{cfg.enabled && (
|
|
<div className="mt-2 ml-8 space-y-2">
|
|
<div className="flex items-center gap-2">
|
|
<Label className="text-[10px] text-muted-foreground">Difficulty</Label>
|
|
<Select value={cfg.difficulty} onValueChange={(v) =>
|
|
setExerciseWizard((prev) => ({
|
|
...prev,
|
|
types: { ...prev.types, [et.key]: { ...prev.types[et.key], difficulty: v } },
|
|
}))
|
|
}>
|
|
<SelectTrigger className="h-6 w-16 text-[11px]"><SelectValue /></SelectTrigger>
|
|
<SelectContent>
|
|
{CEFR_LEVELS.map((l) => <SelectItem key={l} value={l}>{l}</SelectItem>)}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
<div className="space-y-1">
|
|
<Label className="text-[10px] text-muted-foreground">Instructions for students</Label>
|
|
<Textarea
|
|
value={cfg.instructions}
|
|
onChange={(e) =>
|
|
setExerciseWizard((prev) => ({
|
|
...prev,
|
|
types: { ...prev.types, [et.key]: { ...prev.types[et.key], instructions: e.target.value } },
|
|
}))
|
|
}
|
|
placeholder={`Instructions for ${et.label} section...`}
|
|
className="min-h-[48px] text-[11px] resize-none"
|
|
/>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
})}
|
|
|
|
<div className="rounded-lg bg-muted/50 p-3 flex items-center justify-between">
|
|
<span className="text-xs text-muted-foreground">Total questions to generate</span>
|
|
<Badge variant="secondary" className="text-sm font-bold">{wizardTotalQuestions}</Badge>
|
|
</div>
|
|
|
|
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
|
<button className="underline hover:text-foreground" onClick={() => {
|
|
setExerciseWizard((prev) => {
|
|
const types = { ...prev.types };
|
|
for (const key of Object.keys(types)) types[key] = { ...types[key], enabled: true };
|
|
return { ...prev, types };
|
|
});
|
|
}}>Select All</button>
|
|
<span>·</span>
|
|
<button className="underline hover:text-foreground" onClick={() => {
|
|
setExerciseWizard((prev) => {
|
|
const types = { ...prev.types };
|
|
for (const key of Object.keys(types)) types[key] = { ...types[key], enabled: false };
|
|
return { ...prev, types };
|
|
});
|
|
}}>Deselect All</button>
|
|
<span>·</span>
|
|
<button className="underline hover:text-foreground" onClick={() => {
|
|
setExerciseWizard((prev) => {
|
|
const types = { ...prev.types };
|
|
for (const key of Object.keys(types)) types[key] = { ...types[key], count: 5 };
|
|
return { ...prev, types };
|
|
});
|
|
}}>Reset to Default (5 each)</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{exerciseWizard.step === 1 && (
|
|
<div className="space-y-3 pt-2">
|
|
<div className="flex items-center justify-center py-6">
|
|
<div className="text-center space-y-2">
|
|
<div className="h-12 w-12 rounded-full bg-green-100 flex items-center justify-center mx-auto">
|
|
<ListChecks className="h-6 w-6 text-green-600" />
|
|
</div>
|
|
<p className="text-sm font-medium">Exercises added successfully!</p>
|
|
<p className="text-xs text-muted-foreground">
|
|
You can now edit, delete, or preview individual exercises below the passage.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<DialogFooter className="gap-2">
|
|
{exerciseWizard.step === 0 && (
|
|
<>
|
|
<Button variant="outline" size="sm" onClick={() => setExerciseWizard((prev) => ({ ...prev, open: false }))}>
|
|
Cancel
|
|
</Button>
|
|
<Button size="sm" disabled={wizardEnabledTypes.length === 0 || generateExercisesMut.isPending}
|
|
onClick={() => {
|
|
const mod = exerciseWizard.module;
|
|
const st = getModuleState(mod);
|
|
const text = mod === "listening"
|
|
? st.listeningSections[exerciseWizard.itemIndex]?.context
|
|
: st.passages[exerciseWizard.itemIndex]?.text;
|
|
if (!text) return;
|
|
const types = wizardEnabledTypes.map(([key]) => key);
|
|
const typeCounts: Record<string, number> = {};
|
|
const typeInstructions: Record<string, string> = {};
|
|
const typeDifficulties: Record<string, string> = {};
|
|
for (const [key, cfg] of wizardEnabledTypes) {
|
|
typeCounts[key] = cfg.count;
|
|
if (cfg.instructions.trim()) typeInstructions[key] = cfg.instructions.trim();
|
|
if (cfg.difficulty) typeDifficulties[key] = cfg.difficulty;
|
|
}
|
|
const difficulty = wizardEnabledTypes[0]?.[1]?.difficulty || st.difficulty[0] || "B2";
|
|
generateExercisesMut.mutate({
|
|
module: mod,
|
|
passageIndex: exerciseWizard.itemIndex,
|
|
types,
|
|
typeCounts,
|
|
typeInstructions,
|
|
typeDifficulties,
|
|
passageText: text,
|
|
difficulty,
|
|
});
|
|
}}>
|
|
{generateExercisesMut.isPending ? (
|
|
<><Loader2 className="h-3 w-3 mr-1 animate-spin" /> Generating...</>
|
|
) : (
|
|
<><Sparkles className="h-3 w-3 mr-1" /> Generate {wizardTotalQuestions} Exercises</>
|
|
)}
|
|
</Button>
|
|
</>
|
|
)}
|
|
{exerciseWizard.step === 1 && (
|
|
<Button size="sm" onClick={() => setExerciseWizard((prev) => ({ ...prev, open: false, step: 0 }))}>
|
|
Done
|
|
</Button>
|
|
)}
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
|
|
<Dialog open={!!editingExercise} onOpenChange={(open) => { if (!open) { setEditingExercise(null); setExerciseDraft(null); } }}>
|
|
<DialogContent className="max-w-lg max-h-[85vh] overflow-y-auto">
|
|
<DialogHeader>
|
|
<DialogTitle>Edit Exercise</DialogTitle>
|
|
<DialogDescription>Modify the exercise details below.</DialogDescription>
|
|
</DialogHeader>
|
|
{exerciseDraft && (
|
|
<div className="space-y-4 pt-2">
|
|
<div className="space-y-1">
|
|
<Label className="text-xs font-medium">Exercise Type</Label>
|
|
<Select value={exerciseDraft.type} onValueChange={(v) => setExerciseDraft({ ...exerciseDraft, type: v })}>
|
|
<SelectTrigger className="h-8 text-xs"><SelectValue /></SelectTrigger>
|
|
<SelectContent>
|
|
{(editingExercise?.module === "listening" ? LISTENING_EXERCISE_TYPES : READING_EXERCISE_TYPES).map((et) => (
|
|
<SelectItem key={et.key} value={et.key}>{et.label}</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
|
|
<div className="space-y-1">
|
|
<Label className="text-xs font-medium">Section Instructions</Label>
|
|
<Textarea value={exerciseDraft.instructions} className="min-h-[40px] text-xs"
|
|
placeholder="Instructions shown to the student for this section..."
|
|
onChange={(e) => setExerciseDraft({ ...exerciseDraft, instructions: e.target.value })} />
|
|
</div>
|
|
|
|
<div className="space-y-1">
|
|
<Label className="text-xs font-medium">Question / Prompt</Label>
|
|
<Textarea value={exerciseDraft.prompt} className="min-h-[60px] text-xs"
|
|
onChange={(e) => setExerciseDraft({ ...exerciseDraft, prompt: e.target.value })} />
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<div className="flex items-center justify-between">
|
|
<Label className="text-xs font-medium">Options</Label>
|
|
<Button size="sm" variant="ghost" className="h-6 text-xs"
|
|
onClick={() => setExerciseDraft({ ...exerciseDraft, options: [...exerciseDraft.options, ""] })}>
|
|
<Plus className="h-3 w-3 mr-1" /> Add
|
|
</Button>
|
|
</div>
|
|
{exerciseDraft.options.map((opt, oi) => (
|
|
<div key={oi} className="flex gap-2 items-center">
|
|
<span className="text-xs font-medium w-5 text-center shrink-0">{String.fromCharCode(65 + oi)}.</span>
|
|
<Input value={opt} className="h-8 text-xs flex-1"
|
|
onChange={(e) => {
|
|
const opts = [...exerciseDraft.options];
|
|
opts[oi] = e.target.value;
|
|
setExerciseDraft({ ...exerciseDraft, options: opts });
|
|
}} />
|
|
<button className={`shrink-0 h-6 w-6 rounded-full border-2 flex items-center justify-center text-xs transition-colors ${
|
|
opt === exerciseDraft.correct_answer ? "bg-green-500 border-green-500 text-white" : "border-gray-300 hover:border-green-400"
|
|
}`}
|
|
title="Set as correct answer"
|
|
onClick={() => setExerciseDraft({ ...exerciseDraft, correct_answer: opt })}>
|
|
{opt === exerciseDraft.correct_answer ? "✓" : ""}
|
|
</button>
|
|
{exerciseDraft.options.length > 2 && (
|
|
<button className="shrink-0 text-muted-foreground/60 hover:text-destructive transition-colors"
|
|
onClick={() => {
|
|
const opts = exerciseDraft.options.filter((_, idx) => idx !== oi);
|
|
const corrected = opts.includes(exerciseDraft.correct_answer) ? exerciseDraft.correct_answer : "";
|
|
setExerciseDraft({ ...exerciseDraft, options: opts, correct_answer: corrected });
|
|
}}>
|
|
<X className="h-3 w-3" />
|
|
</button>
|
|
)}
|
|
</div>
|
|
))}
|
|
{exerciseDraft.options.length === 0 && (
|
|
<div className="space-y-1">
|
|
<Label className="text-xs font-medium">Correct Answer</Label>
|
|
<Input value={exerciseDraft.correct_answer} className="h-8 text-xs"
|
|
onChange={(e) => setExerciseDraft({ ...exerciseDraft, correct_answer: e.target.value })} />
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className="space-y-1">
|
|
<Label className="text-xs font-medium">Explanation</Label>
|
|
<Textarea value={exerciseDraft.explanation} className="min-h-[40px] text-xs"
|
|
onChange={(e) => setExerciseDraft({ ...exerciseDraft, explanation: e.target.value })} />
|
|
</div>
|
|
|
|
<div className="flex gap-4">
|
|
<div className="space-y-1">
|
|
<Label className="text-xs font-medium">Marks</Label>
|
|
<Input type="number" value={exerciseDraft.marks} className="h-8 text-xs w-20"
|
|
onChange={(e) => setExerciseDraft({ ...exerciseDraft, marks: Number(e.target.value) || 1 })} />
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex gap-2 justify-end pt-2">
|
|
<Button variant="outline" size="sm" onClick={() => { setEditingExercise(null); setExerciseDraft(null); }}>
|
|
Cancel
|
|
</Button>
|
|
<Button size="sm" onClick={() => {
|
|
if (!editingExercise || !exerciseDraft) return;
|
|
const mod = editingExercise.module;
|
|
const st = getModuleState(mod);
|
|
if (mod === "listening") {
|
|
const sections = [...st.listeningSections];
|
|
const exercises = [...sections[editingExercise.itemIndex].exercises];
|
|
exercises[editingExercise.exerciseIndex] = exerciseDraft;
|
|
sections[editingExercise.itemIndex] = { ...sections[editingExercise.itemIndex], exercises };
|
|
updateModuleState(mod, { listeningSections: sections });
|
|
} else {
|
|
const passages = [...st.passages];
|
|
const exercises = [...passages[editingExercise.itemIndex].exercises];
|
|
exercises[editingExercise.exerciseIndex] = exerciseDraft;
|
|
passages[editingExercise.itemIndex] = { ...passages[editingExercise.itemIndex], exercises };
|
|
updateModuleState(mod, { passages });
|
|
}
|
|
setEditingExercise(null);
|
|
setExerciseDraft(null);
|
|
toast({ title: "Exercise updated" });
|
|
}}>
|
|
Save Changes
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</DialogContent>
|
|
</Dialog>
|
|
|
|
<Dialog open={newStructureDialog.open} onOpenChange={(open) => {
|
|
if (!open) setNewStructureDialog({ open: false, name: "", modules: new Set() });
|
|
}}>
|
|
<DialogContent className="max-w-md">
|
|
<DialogHeader>
|
|
<DialogTitle>New Exam Structure</DialogTitle>
|
|
<DialogDescription>Create a new exam structure with selected modules.</DialogDescription>
|
|
</DialogHeader>
|
|
<div className="space-y-4 py-2">
|
|
<div className="space-y-1">
|
|
<Label>Name <span className="text-destructive">*</span></Label>
|
|
<Input
|
|
value={newStructureDialog.name}
|
|
onChange={(e) => setNewStructureDialog((prev) => ({ ...prev, name: e.target.value }))}
|
|
placeholder="e.g. Business English Assessment"
|
|
autoFocus
|
|
/>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label>Modules</Label>
|
|
<div className="grid grid-cols-2 gap-2">
|
|
{MODULES.map((m) => (
|
|
<label key={m.key} className={`flex items-center gap-2 rounded-lg border px-3 py-2 cursor-pointer transition-all ${
|
|
newStructureDialog.modules.has(m.key) ? `${m.bgColor} ${m.color}` : "hover:bg-muted/50"
|
|
}`}>
|
|
<Checkbox
|
|
checked={newStructureDialog.modules.has(m.key)}
|
|
onCheckedChange={(checked) => {
|
|
setNewStructureDialog((prev) => {
|
|
const next = new Set(prev.modules);
|
|
if (checked) next.add(m.key); else next.delete(m.key);
|
|
return { ...prev, modules: next };
|
|
});
|
|
}}
|
|
/>
|
|
{m.icon}
|
|
<span className="text-sm font-medium">{m.label}</span>
|
|
</label>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<DialogFooter>
|
|
<Button variant="outline" onClick={() => setNewStructureDialog({ open: false, name: "", modules: new Set() })}>
|
|
Cancel
|
|
</Button>
|
|
<Button
|
|
disabled={!newStructureDialog.name.trim() || newStructureDialog.modules.size === 0 || createStructureMut.isPending}
|
|
onClick={() => {
|
|
createStructureMut.mutate({
|
|
name: newStructureDialog.name.trim(),
|
|
modules: [...newStructureDialog.modules],
|
|
});
|
|
}}
|
|
>
|
|
{createStructureMut.isPending ? <><Loader2 className="h-4 w-4 animate-spin mr-2" /> Creating...</> : "Create"}
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
</div>
|
|
);
|
|
}
|