Files
encoach_backend_new_v3/frontend/src/pages/student/StudentChapterView.tsx
Yamen Ahmad f1c4953a63 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
2026-04-10 17:26:42 +04:00

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>
);
}