- 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
127 lines
5.6 KiB
TypeScript
127 lines
5.6 KiB
TypeScript
import { useParams } from "react-router-dom";
|
|
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Badge } from "@/components/ui/badge";
|
|
import { Progress } from "@/components/ui/progress";
|
|
import { FileText, Video, Image, Link2, Music, Download, CheckCircle2, Loader2 } from "lucide-react";
|
|
import { useChapter, useChapterMaterials, useChapterProgress, useCompleteChapter, useMarkMaterialViewed } from "@/hooks/queries";
|
|
import { coursewareService } from "@/services/courseware.service";
|
|
import { useToast } from "@/hooks/use-toast";
|
|
import type { MaterialType } from "@/types/courseware";
|
|
|
|
const typeIcons: Record<MaterialType, React.ReactNode> = {
|
|
pdf: <FileText className="h-5 w-5" />,
|
|
document: <FileText className="h-5 w-5" />,
|
|
video: <Video className="h-5 w-5" />,
|
|
audio: <Music className="h-5 w-5" />,
|
|
image: <Image className="h-5 w-5" />,
|
|
link: <Link2 className="h-5 w-5" />,
|
|
article: <FileText className="h-5 w-5" />,
|
|
};
|
|
|
|
export default function StudentChapterView() {
|
|
const { courseId, chapterId } = useParams<{ courseId: string; chapterId: string }>();
|
|
const chId = Number(chapterId);
|
|
const { toast } = useToast();
|
|
const { data: chapter, isLoading: lc } = useChapter(chId);
|
|
const { data: materials = [], isLoading: lm } = useChapterMaterials(chId);
|
|
const { data: progress, isLoading: lp } = useChapterProgress(chId);
|
|
const completeChapter = useCompleteChapter();
|
|
const markViewed = useMarkMaterialViewed();
|
|
|
|
const completionPct = progress ? Math.round((progress.materials_completed / Math.max(progress.materials_total, 1)) * 100) : 0;
|
|
|
|
const handleDownload = async (materialId: number, name: string) => {
|
|
try {
|
|
const blob = await coursewareService.downloadMaterial(materialId);
|
|
const url = URL.createObjectURL(blob);
|
|
const a = document.createElement("a");
|
|
a.href = url;
|
|
a.download = name;
|
|
a.click();
|
|
URL.revokeObjectURL(url);
|
|
} catch {
|
|
toast({ title: "Error", description: "Download failed", variant: "destructive" });
|
|
}
|
|
};
|
|
|
|
const handleMarkComplete = () => {
|
|
completeChapter.mutate(chId, {
|
|
onSuccess: () => toast({ title: "Chapter Completed!" }),
|
|
onError: () => toast({ title: "Error", description: "Failed to mark complete", variant: "destructive" }),
|
|
});
|
|
};
|
|
|
|
const handleOpenMaterial = (materialId: number) => {
|
|
markViewed.mutate({ id: materialId, chapterId: chId });
|
|
};
|
|
|
|
if (lc || lm || lp) return <div className="flex items-center justify-center min-h-[400px]"><div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" /></div>;
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
{chapter && (
|
|
<div>
|
|
<h1 className="text-2xl font-bold">{chapter.name}</h1>
|
|
{chapter.description && <p className="text-muted-foreground mt-1">{chapter.description}</p>}
|
|
</div>
|
|
)}
|
|
|
|
{progress && (
|
|
<Card>
|
|
<CardContent className="py-4">
|
|
<div className="flex items-center justify-between mb-2">
|
|
<span className="text-sm font-medium">Progress</span>
|
|
<span className="text-sm text-muted-foreground">{progress.materials_completed} / {progress.materials_total} materials</span>
|
|
</div>
|
|
<Progress value={completionPct} className="h-2" />
|
|
<div className="flex justify-between items-center mt-3">
|
|
<span className="text-sm text-muted-foreground">{completionPct}% complete</span>
|
|
{progress.status !== "completed" && (
|
|
<Button size="sm" onClick={handleMarkComplete} disabled={completeChapter.isPending}>
|
|
{completeChapter.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
|
<CheckCircle2 className="mr-2 h-4 w-4" /> Mark Complete
|
|
</Button>
|
|
)}
|
|
{progress.status === "completed" && (
|
|
<Badge variant="default"><CheckCircle2 className="mr-1 h-3 w-3" /> Completed</Badge>
|
|
)}
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
|
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
|
{materials
|
|
.sort((a, b) => a.sequence - b.sequence)
|
|
.map(m => (
|
|
<Card key={m.id} className="hover:shadow-md transition-shadow cursor-pointer" onClick={() => handleOpenMaterial(m.id)}>
|
|
<CardContent className="py-4">
|
|
<div className="flex items-start gap-3">
|
|
<div className="h-10 w-10 rounded-lg bg-primary/10 flex items-center justify-center shrink-0 text-primary">
|
|
{typeIcons[m.type]}
|
|
</div>
|
|
<div className="flex-1 min-w-0">
|
|
<p className="font-medium text-sm truncate">{m.name}</p>
|
|
<Badge variant="outline" className="mt-1 text-xs">{m.type}</Badge>
|
|
</div>
|
|
{m.allow_download && (
|
|
<Button variant="ghost" size="icon" className="shrink-0" onClick={(e) => { e.stopPropagation(); handleDownload(m.id, m.name); }}>
|
|
<Download className="h-4 w-4" />
|
|
</Button>
|
|
)}
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
))}
|
|
{materials.length === 0 && (
|
|
<div className="col-span-full text-center py-12 text-muted-foreground">
|
|
<FileText className="h-12 w-12 mx-auto mb-3 opacity-40" />
|
|
<p>No materials available in this chapter.</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|