feat(v3): restructure project + add complete frontend
- 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
This commit is contained in:
335
src/pages/admin/ModuleBuilder.tsx
Normal file
335
src/pages/admin/ModuleBuilder.tsx
Normal file
@@ -0,0 +1,335 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user