- Restructure: move backend from new_project/ to backend/ - Add full React/TypeScript frontend (37 pages, 17 services, 16 type defs, 11 query hooks) - Add docs/ with SRS specs, user stories, and workflow documentation - Update .gitignore for new directory layout Workflows implemented: WF1 User Signup, WF2 Placement Test, WF3 Exam Configuration, WF4 General English Exam, WF5 Course Generation, WF6 Entity Student Onboarding, AI Course Generation, Adaptive Learning Engine UI, White-Label Branding, Score Release Made-with: Cursor
336 lines
14 KiB
TypeScript
336 lines
14 KiB
TypeScript
import { useMemo, useState } from "react";
|
|
import { useParams } from "react-router-dom";
|
|
import { useCourseDetail, useUpdateCourse } from "@/hooks/queries/useCourseGeneration";
|
|
import { courseGenerationService } from "@/services/course-generation.service";
|
|
import type { ModuleConfig } from "@/types";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
|
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Label } from "@/components/ui/label";
|
|
import { Textarea } from "@/components/ui/textarea";
|
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogFooter,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
} from "@/components/ui/dialog";
|
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
|
import { Separator } from "@/components/ui/separator";
|
|
import { Skeleton } from "@/components/ui/skeleton";
|
|
import { ChevronDown, GripVertical, Search, Sparkles } from "lucide-react";
|
|
import { cn } from "@/lib/utils";
|
|
|
|
export default function ModuleBuilder() {
|
|
const { courseId: courseIdParam } = useParams();
|
|
const courseId = Number(courseIdParam);
|
|
const { data: course, isLoading } = useCourseDetail(courseId);
|
|
const updateCourse = useUpdateCourse();
|
|
|
|
const [modules, setModules] = useState<ModuleConfig[] | null>(null);
|
|
const [resourceOpen, setResourceOpen] = useState(false);
|
|
const [aiOpen, setAiOpen] = useState(false);
|
|
const [aiForm, setAiForm] = useState({ topic: "", difficulty: "B2", type: "reading" });
|
|
const [dragId, setDragId] = useState<number | null>(null);
|
|
|
|
const mergedModules = modules ?? course?.modules ?? [];
|
|
|
|
const bySkill = useMemo(() => {
|
|
const map = new Map<string, ModuleConfig[]>();
|
|
mergedModules.forEach((m) => {
|
|
const k = m.skill || "General";
|
|
if (!map.has(k)) map.set(k, []);
|
|
map.get(k)!.push(m);
|
|
});
|
|
return map;
|
|
}, [mergedModules]);
|
|
|
|
const totalHours = mergedModules.reduce((s, m) => s + m.estimated_hours, 0);
|
|
|
|
const syncModules = (next: ModuleConfig[]) => {
|
|
setModules(next);
|
|
if (course) {
|
|
updateCourse.mutate({
|
|
courseId,
|
|
payload: { modules: next },
|
|
});
|
|
}
|
|
};
|
|
|
|
const onDragStart = (id: number) => setDragId(id);
|
|
const onDrop = (targetId: number) => {
|
|
if (dragId === null || dragId === targetId) return;
|
|
const list = [...mergedModules];
|
|
const from = list.findIndex((m) => m.id === dragId);
|
|
const to = list.findIndex((m) => m.id === targetId);
|
|
if (from < 0 || to < 0) return;
|
|
const [item] = list.splice(from, 1);
|
|
list.splice(to, 0, item);
|
|
syncModules(list);
|
|
setDragId(null);
|
|
};
|
|
|
|
const publish = () => {
|
|
updateCourse.mutate({ courseId, payload: { status: "published" } });
|
|
};
|
|
|
|
if (isLoading) {
|
|
return (
|
|
<div className="p-6">
|
|
<Skeleton className="mb-4 h-10 w-64" />
|
|
<Skeleton className="h-96 w-full" />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="mx-auto max-w-5xl space-y-6 p-6">
|
|
<div className="flex flex-wrap items-center justify-between gap-4">
|
|
<div>
|
|
<h1 className="text-2xl font-bold">Module builder</h1>
|
|
<p className="text-muted-foreground">
|
|
{course?.title ?? `Course #${courseId}`} · {totalHours}h total · {mergedModules.length} modules
|
|
</p>
|
|
</div>
|
|
<Button type="button" onClick={publish} disabled={updateCourse.isPending}>
|
|
Publish Course
|
|
</Button>
|
|
</div>
|
|
|
|
<div className="space-y-4">
|
|
{mergedModules.length === 0 && (
|
|
<Card>
|
|
<CardContent className="flex flex-col items-center justify-center py-16 text-center">
|
|
<Sparkles className="h-10 w-10 text-muted-foreground mb-4" />
|
|
<h3 className="text-lg font-medium mb-1">No modules yet</h3>
|
|
<p className="text-sm text-muted-foreground mb-4">
|
|
Add modules manually or use the AI assistant to auto-generate a module structure.
|
|
</p>
|
|
<div className="flex gap-2">
|
|
<Button
|
|
type="button"
|
|
onClick={() => {
|
|
const next: ModuleConfig[] = [
|
|
{ id: Date.now(), title: "New Module", skill: "Listening", estimated_hours: 2, resources: [], assessment_type: "quiz" },
|
|
];
|
|
syncModules(next);
|
|
}}
|
|
>
|
|
Add Module
|
|
</Button>
|
|
<Button type="button" variant="outline" onClick={() => setAiOpen(true)}>
|
|
<Sparkles className="mr-2 h-4 w-4" />
|
|
AI Generate
|
|
</Button>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
{Array.from(bySkill.entries()).map(([skill, mods]) => (
|
|
<Collapsible key={skill} defaultOpen>
|
|
<Card>
|
|
<CollapsibleTrigger asChild>
|
|
<CardHeader className="flex cursor-pointer flex-row items-center justify-between">
|
|
<CardTitle className="text-lg">
|
|
{skill} · {mods.reduce((s, m) => s + m.estimated_hours, 0)}h · {mods.length} modules
|
|
</CardTitle>
|
|
<ChevronDown className="h-5 w-5" />
|
|
</CardHeader>
|
|
</CollapsibleTrigger>
|
|
<CollapsibleContent>
|
|
<CardContent className="space-y-4 pt-0">
|
|
{mods.map((mod, idx) => (
|
|
<div
|
|
key={mod.id}
|
|
draggable
|
|
onDragStart={() => onDragStart(mod.id)}
|
|
onDragOver={(e) => e.preventDefault()}
|
|
onDrop={() => onDrop(mod.id)}
|
|
className={cn("rounded-lg border p-4", dragId === mod.id && "opacity-60")}
|
|
>
|
|
<div className="mb-3 flex items-center gap-2">
|
|
<GripVertical className="h-5 w-5 text-muted-foreground" />
|
|
<span className="font-medium">
|
|
Module {idx + 1}: {mod.title}
|
|
</span>
|
|
</div>
|
|
<div className="grid gap-3 md:grid-cols-2">
|
|
<div className="space-y-2">
|
|
<Label htmlFor={`t-${mod.id}`}>Title</Label>
|
|
<Input
|
|
id={`t-${mod.id}`}
|
|
defaultValue={mod.title}
|
|
onBlur={(e) => {
|
|
const next = mergedModules.map((m) =>
|
|
m.id === mod.id ? { ...m, title: e.target.value } : m,
|
|
);
|
|
syncModules(next);
|
|
}}
|
|
/>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label htmlFor={`h-${mod.id}`}>Hours</Label>
|
|
<Input
|
|
id={`h-${mod.id}`}
|
|
type="number"
|
|
defaultValue={mod.estimated_hours}
|
|
onBlur={(e) => {
|
|
const v = Number(e.target.value);
|
|
const next = mergedModules.map((m) =>
|
|
m.id === mod.id ? { ...m, estimated_hours: v } : m,
|
|
);
|
|
syncModules(next);
|
|
}}
|
|
/>
|
|
</div>
|
|
</div>
|
|
<div className="mt-3 space-y-2">
|
|
<Label>Resources</Label>
|
|
<div className="flex flex-wrap gap-2">
|
|
{mod.resources.map((r) => (
|
|
<span key={r.id} className="rounded-md bg-muted px-2 py-1 text-xs">
|
|
{r.title} ({r.type})
|
|
</span>
|
|
))}
|
|
<Button type="button" size="sm" variant="outline" onClick={() => setResourceOpen(true)}>
|
|
<Search className="mr-1 h-3 w-3" />
|
|
Browse Resources
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
<div className="mt-3 space-y-2">
|
|
<Label htmlFor={`ex-${mod.id}`}>Practice exercises</Label>
|
|
<Textarea id={`ex-${mod.id}`} placeholder="Describe linked exercises" rows={2} />
|
|
</div>
|
|
<div className="mt-3 grid gap-3 md:grid-cols-2">
|
|
<div className="space-y-2">
|
|
<Label>Completion criteria</Label>
|
|
<Select
|
|
defaultValue={mod.completion_criteria}
|
|
onValueChange={(val) => {
|
|
const v = val as ModuleConfig["completion_criteria"];
|
|
const next = mergedModules.map((m) =>
|
|
m.id === mod.id ? { ...m, completion_criteria: v } : m,
|
|
);
|
|
syncModules(next);
|
|
}}
|
|
>
|
|
<SelectTrigger>
|
|
<SelectValue placeholder="Criteria" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="complete_all">complete_all</SelectItem>
|
|
<SelectItem value="score_threshold">score_threshold</SelectItem>
|
|
<SelectItem value="teacher_approval">teacher_approval</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label htmlFor={`pre-${mod.id}`}>Prerequisite module id</Label>
|
|
<Input
|
|
id={`pre-${mod.id}`}
|
|
type="number"
|
|
defaultValue={mod.prerequisite_module_id ?? ""}
|
|
onBlur={(e) => {
|
|
const v = e.target.value ? Number(e.target.value) : undefined;
|
|
const next = mergedModules.map((m) =>
|
|
m.id === mod.id ? { ...m, prerequisite_module_id: v } : m,
|
|
);
|
|
syncModules(next);
|
|
}}
|
|
/>
|
|
</div>
|
|
</div>
|
|
<Separator className="my-3" />
|
|
<Button
|
|
type="button"
|
|
size="sm"
|
|
variant="secondary"
|
|
onClick={() => setAiOpen(true)}
|
|
>
|
|
<Sparkles className="mr-1 h-3 w-3" />
|
|
Generate AI Content
|
|
</Button>
|
|
</div>
|
|
))}
|
|
</CardContent>
|
|
</CollapsibleContent>
|
|
</Card>
|
|
</Collapsible>
|
|
))}
|
|
</div>
|
|
|
|
<Dialog open={resourceOpen} onOpenChange={setResourceOpen}>
|
|
<DialogContent>
|
|
<DialogHeader>
|
|
<DialogTitle>Resource search</DialogTitle>
|
|
</DialogHeader>
|
|
<div className="space-y-2">
|
|
<Input placeholder="Search title, skill, or tag" />
|
|
<ScrollArea className="h-48 rounded-md border p-2 text-sm text-muted-foreground">
|
|
Matching resources will list here from your library.
|
|
</ScrollArea>
|
|
</div>
|
|
<DialogFooter>
|
|
<Button type="button" variant="outline" onClick={() => setResourceOpen(false)}>
|
|
Close
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
|
|
<Dialog open={aiOpen} onOpenChange={setAiOpen}>
|
|
<DialogContent>
|
|
<DialogHeader>
|
|
<DialogTitle>Generate AI content</DialogTitle>
|
|
</DialogHeader>
|
|
<div className="space-y-3">
|
|
<div className="space-y-2">
|
|
<Label htmlFor="ai-topic">Topic</Label>
|
|
<Input
|
|
id="ai-topic"
|
|
value={aiForm.topic}
|
|
onChange={(e) => setAiForm((f) => ({ ...f, topic: e.target.value }))}
|
|
/>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label htmlFor="ai-diff">Difficulty</Label>
|
|
<Input
|
|
id="ai-diff"
|
|
value={aiForm.difficulty}
|
|
onChange={(e) => setAiForm((f) => ({ ...f, difficulty: e.target.value }))}
|
|
/>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label htmlFor="ai-type">Type</Label>
|
|
<Input
|
|
id="ai-type"
|
|
value={aiForm.type}
|
|
onChange={(e) => setAiForm((f) => ({ ...f, type: e.target.value }))}
|
|
/>
|
|
</div>
|
|
</div>
|
|
<DialogFooter>
|
|
<Button
|
|
type="button"
|
|
onClick={() => {
|
|
void courseGenerationService
|
|
.generateAIResource({
|
|
topic: aiForm.topic,
|
|
difficulty: aiForm.difficulty,
|
|
type: aiForm.type,
|
|
})
|
|
.then(() => setAiOpen(false));
|
|
}}
|
|
>
|
|
Generate
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
</div>
|
|
);
|
|
}
|