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:
204
frontend/src/pages/student/CourseDelivery.tsx
Normal file
204
frontend/src/pages/student/CourseDelivery.tsx
Normal file
@@ -0,0 +1,204 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user