- 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
205 lines
8.4 KiB
TypeScript
205 lines
8.4 KiB
TypeScript
import { useMemo, useState } from "react";
|
|
import { useParams } from "react-router-dom";
|
|
import { useCourseDetail, useTrackProgress } from "@/hooks/queries/useCourseGeneration";
|
|
import type { ModuleConfig, ModuleResource } from "@/types";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
|
import { Badge } from "@/components/ui/badge";
|
|
import { Progress } from "@/components/ui/progress";
|
|
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
|
|
import { Separator } from "@/components/ui/separator";
|
|
import { Skeleton } from "@/components/ui/skeleton";
|
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
|
import {
|
|
BookOpen,
|
|
Check,
|
|
ChevronDown,
|
|
FileText,
|
|
Headphones,
|
|
Lock,
|
|
Sparkles,
|
|
Video,
|
|
} from "lucide-react";
|
|
import { cn } from "@/lib/utils";
|
|
|
|
function ResourceIcon({ type }: { type: ModuleResource["type"] }) {
|
|
switch (type) {
|
|
case "pdf":
|
|
return <FileText className="h-4 w-4" />;
|
|
case "video":
|
|
return <Video className="h-4 w-4" />;
|
|
case "audio":
|
|
return <Headphones className="h-4 w-4" />;
|
|
default:
|
|
return <BookOpen className="h-4 w-4" />;
|
|
}
|
|
}
|
|
|
|
export default function CourseDelivery() {
|
|
const { courseId: courseIdParam } = useParams();
|
|
const courseId = Number(courseIdParam);
|
|
const { data: course, isLoading } = useCourseDetail(courseId);
|
|
const track = useTrackProgress();
|
|
|
|
const [activeResource, setActiveResource] = useState<{ module: ModuleConfig; resource: ModuleResource } | null>(null);
|
|
|
|
const bySkill = useMemo(() => {
|
|
const map = new Map<string, ModuleConfig[]>();
|
|
course?.modules.forEach((m) => {
|
|
const k = m.skill || "General";
|
|
if (!map.has(k)) map.set(k, []);
|
|
map.get(k)!.push(m);
|
|
});
|
|
return map;
|
|
}, [course]);
|
|
|
|
const overallProgress = useMemo(() => {
|
|
if (!course?.modules.length) return 0;
|
|
const sum = course.modules.reduce((s, m) => s + (m.progress_percent ?? 0), 0);
|
|
return Math.round(sum / course.modules.length);
|
|
}, [course]);
|
|
|
|
const weeksLeft = course ? Math.max(0, course.duration_weeks - 2) : 0;
|
|
|
|
const markResource = (mod: ModuleConfig, res: ModuleResource) => {
|
|
track.mutate({
|
|
courseId,
|
|
progress: {
|
|
resource_id: res.id,
|
|
completion_percent: 100,
|
|
time_spent_ms: 60000,
|
|
},
|
|
});
|
|
setActiveResource({ module: mod, resource: res });
|
|
};
|
|
|
|
if (isLoading) {
|
|
return (
|
|
<div className="p-6">
|
|
<Skeleton className="mb-4 h-12 w-full" />
|
|
<Skeleton className="h-96 w-full" />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="mx-auto max-w-5xl space-y-8 p-6">
|
|
<Card>
|
|
<CardHeader>
|
|
<div className="flex flex-wrap items-start justify-between gap-4">
|
|
<div>
|
|
<CardTitle className="text-2xl">{course?.title ?? `Course #${courseId}`}</CardTitle>
|
|
<CardDescription>
|
|
{course?.exam_type ?? "IELTS"} · Target {course?.target_band ?? course?.target_level ?? "—"}
|
|
</CardDescription>
|
|
</div>
|
|
<Badge variant="secondary">{course?.progression_model ?? "linear"}</Badge>
|
|
</div>
|
|
<div className="mt-4 space-y-2">
|
|
<div className="flex justify-between text-sm">
|
|
<span>Overall progress</span>
|
|
<span>{overallProgress}%</span>
|
|
</div>
|
|
<Progress value={overallProgress} />
|
|
<p className="text-sm text-muted-foreground">About {weeksLeft} weeks remaining at your current pace</p>
|
|
</div>
|
|
</CardHeader>
|
|
</Card>
|
|
|
|
<div className="grid gap-6 lg:grid-cols-2">
|
|
<div className="space-y-4">
|
|
{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 py-3">
|
|
<CardTitle className="text-base">{skill}</CardTitle>
|
|
<ChevronDown className="h-5 w-5" />
|
|
</CardHeader>
|
|
</CollapsibleTrigger>
|
|
<CollapsibleContent>
|
|
<CardContent className="space-y-4 pt-0">
|
|
{mods.map((mod) => (
|
|
<div key={mod.id} className="rounded-lg border p-4">
|
|
<div className="mb-2 flex flex-wrap items-center gap-2">
|
|
<h3 className="font-semibold">{mod.title}</h3>
|
|
{mod.status === "locked" ? <Lock className="h-4 w-4 text-muted-foreground" /> : null}
|
|
{mod.id === course?.modules.find((m) => m.status === "in_progress")?.id ? (
|
|
<Badge>Recommended Next</Badge>
|
|
) : null}
|
|
{mod.status === "skipped" ? <Badge variant="outline">Skipped</Badge> : null}
|
|
</div>
|
|
<div className="mb-2 text-xs text-muted-foreground">
|
|
{mod.estimated_hours}h · {mod.cefr_target ?? "CEFR target"}
|
|
</div>
|
|
<Progress value={mod.progress_percent ?? 0} className="mb-3 h-2" />
|
|
<div className="space-y-2">
|
|
{mod.resources.map((r) => (
|
|
<button
|
|
key={r.id}
|
|
type="button"
|
|
onClick={() => markResource(mod, r)}
|
|
className="flex w-full items-center justify-between rounded-md border bg-background px-3 py-2 text-left text-sm hover:bg-muted/50"
|
|
>
|
|
<span className="flex items-center gap-2">
|
|
<ResourceIcon type={r.type} />
|
|
{r.title}
|
|
</span>
|
|
{r.completed ? <Check className="h-4 w-4 text-green-600" /> : null}
|
|
</button>
|
|
))}
|
|
</div>
|
|
<div className="mt-3 flex gap-2">
|
|
<Button type="button" size="sm" disabled={mod.status === "locked"}>
|
|
{mod.status === "in_progress" || mod.progress_percent ? "Continue" : "Start"}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</CardContent>
|
|
</CollapsibleContent>
|
|
</Card>
|
|
</Collapsible>
|
|
))}
|
|
</div>
|
|
|
|
<Card className="min-h-[320px]">
|
|
<CardHeader>
|
|
<CardTitle className="text-base">Resource viewer</CardTitle>
|
|
<CardDescription>
|
|
{activeResource ? activeResource.resource.title : "Select a resource to open the viewer"}
|
|
</CardDescription>
|
|
</CardHeader>
|
|
<CardContent>
|
|
{!activeResource ? (
|
|
<p className="text-sm text-muted-foreground">PDF, video, audio, and exercise viewers render here.</p>
|
|
) : (
|
|
<div className="space-y-3">
|
|
<div
|
|
className={cn(
|
|
"flex min-h-[200px] items-center justify-center rounded-lg border bg-muted/30 p-6 text-center text-sm text-muted-foreground",
|
|
)}
|
|
>
|
|
{activeResource.resource.type === "pdf" && "PDF viewer placeholder"}
|
|
{activeResource.resource.type === "video" && "Video player placeholder"}
|
|
{activeResource.resource.type === "audio" && "Audio player placeholder"}
|
|
{activeResource.resource.type === "exercise" && "Interactive exercise placeholder"}
|
|
{activeResource.resource.type === "ai_generated" && (
|
|
<span className="flex items-center gap-2">
|
|
<Sparkles className="h-4 w-4" /> AI-generated content placeholder
|
|
</span>
|
|
)}
|
|
</div>
|
|
<Separator />
|
|
<ScrollArea className="h-32 text-xs text-muted-foreground">
|
|
Module: {activeResource.module.title}. Completion: {activeResource.module.completion_criteria}.
|
|
</ScrollArea>
|
|
</div>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|