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:
Yamen Ahmad
2026-04-10 17:26:42 +04:00
parent a3e12f62fa
commit f1c4953a63
731 changed files with 67205 additions and 139 deletions

View File

@@ -0,0 +1,458 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { useExamSession, useExamAutoSave, useExamSubmit } from "@/hooks/queries/useExamSession";
import type { ExamAnswer, ExamQuestion, ExamSessionSection } from "@/types";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { Progress } from "@/components/ui/progress";
import { Checkbox } from "@/components/ui/checkbox";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Flag, ChevronLeft, ChevronRight, Pause, Play } from "lucide-react";
import { cn } from "@/lib/utils";
function normalizeType(t: string) {
return t.toLowerCase().replace(/\s+/g, "_");
}
function countWords(s: string) {
return s.trim() ? s.trim().split(/\s+/).length : 0;
}
function buildAnswerMap(sections: ExamSessionSection[]) {
const map = new Map<number, ExamAnswer>();
for (const sec of sections) {
for (const q of sec.questions) {
map.set(q.id, {
question_id: q.id,
answer: null,
flagged: false,
time_spent_ms: 0,
});
}
}
return map;
}
export default function ExamSession() {
const { examId: examIdParam } = useParams();
const examId = Number(examIdParam);
const navigate = useNavigate();
const { data: session, isLoading, isError } = useExamSession(examId);
const autoSave = useExamAutoSave();
const submitMut = useExamSubmit();
const [sectionIdx, setSectionIdx] = useState(0);
const [questionIdx, setQuestionIdx] = useState(0);
const [answers, setAnswers] = useState<Map<number, ExamAnswer>>(new Map());
const [sectionGate, setSectionGate] = useState<{ open: boolean; sec: number; remaining: number } | null>(null);
const [reviewOpen, setReviewOpen] = useState(false);
const [playing, setPlaying] = useState(false);
const totalSecRef = useRef(0);
const qStartRef = useRef(Date.now());
useEffect(() => {
if (!session) return;
setAnswers(buildAnswerMap(session.sections));
totalSecRef.current = session.total_time_min * 60;
setTick(0);
}, [session]);
const section = session?.sections[sectionIdx];
const question = section?.questions[questionIdx];
const totalQuestions = useMemo(
() => session?.sections.reduce((n, s) => n + s.questions.length, 0) ?? 0,
[session],
);
const [tick, setTick] = useState(0);
useEffect(() => {
const id = window.setInterval(() => setTick((t) => t + 1), 1000);
return () => window.clearInterval(id);
}, []);
useEffect(() => {
qStartRef.current = Date.now();
}, [question?.id]);
const answeredCount = useMemo(() => {
let n = 0;
answers.forEach((a) => {
if (a.answer === null || a.answer === "") return;
if (Array.isArray(a.answer) && a.answer.length === 0) return;
n += 1;
});
return n;
}, [answers]);
const progressPct = totalQuestions ? (answeredCount / totalQuestions) * 100 : 0;
const updateAnswer = useCallback(
(questionId: number, patch: Partial<ExamAnswer>) => {
setAnswers((prev) => {
const next = new Map(prev);
const cur = next.get(questionId);
if (!cur) return prev;
const spent = Date.now() - qStartRef.current;
next.set(questionId, { ...cur, ...patch, time_spent_ms: cur.time_spent_ms + spent });
qStartRef.current = Date.now();
return next;
});
},
[],
);
const currentSectionAnswers = useCallback((): ExamAnswer[] => {
if (!section) return [];
return section.questions.map((q) => answers.get(q.id)!).filter(Boolean);
}, [section, answers]);
useEffect(() => {
if (!session || !section) return;
const id = window.setInterval(() => {
autoSave.mutate({
examId,
payload: { section_id: section.id, answers: currentSectionAnswers() },
});
}, 10000);
return () => window.clearInterval(id);
}, [session, section, examId, autoSave, currentSectionAnswers]);
const handleFlag = () => {
if (!question) return;
const cur = answers.get(question.id);
if (!cur) return;
updateAnswer(question.id, { flagged: !cur.flagged });
};
const goNext = () => {
if (!session || !section) return;
if (questionIdx < section.questions.length - 1) {
setQuestionIdx((i) => i + 1);
return;
}
if (sectionIdx < session.sections.length - 1) {
setSectionGate({ open: true, sec: sectionIdx + 1, remaining: 5 });
return;
}
setReviewOpen(true);
};
const goPrev = () => {
if (questionIdx > 0) {
setQuestionIdx((i) => i - 1);
return;
}
if (sectionIdx > 0) {
const prevSec = session!.sections[sectionIdx - 1];
setSectionIdx((s) => s - 1);
setQuestionIdx(prevSec.questions.length - 1);
}
};
useEffect(() => {
if (!sectionGate?.open) return;
if (sectionGate.remaining <= 0) {
setSectionIdx(sectionGate.sec);
setQuestionIdx(0);
setSectionGate(null);
return;
}
const t = window.setTimeout(
() => setSectionGate((g) => (g ? { ...g, remaining: g.remaining - 1 } : null)),
1000,
);
return () => window.clearTimeout(t);
}, [sectionGate]);
const summary = useMemo(() => {
let answered = 0;
let unanswered = 0;
let flagged = 0;
session?.sections.forEach((sec) => {
sec.questions.forEach((q) => {
const a = answers.get(q.id);
if (!a) {
unanswered += 1;
return;
}
if (a.flagged) flagged += 1;
const empty =
a.answer === null ||
a.answer === "" ||
(Array.isArray(a.answer) && a.answer.length === 0);
if (empty) unanswered += 1;
else answered += 1;
});
});
return { answered, unanswered, flagged, total: totalQuestions };
}, [session, answers, totalQuestions]);
const renderQuestion = (q: ExamQuestion) => {
const a = answers.get(q.id);
if (!a) return null;
const nt = normalizeType(q.type);
if (nt.includes("listen") || q.audio_url) {
return (
<div className="space-y-4">
<div className="flex items-center gap-3 rounded-lg border bg-muted/40 p-4">
<Button type="button" variant="outline" size="icon" onClick={() => setPlaying((p) => !p)}>
{playing ? <Pause className="h-5 w-5" /> : <Play className="h-5 w-5" />}
</Button>
<div className="h-2 flex-1 rounded-full bg-muted">
<div className="h-2 w-1/3 rounded-full bg-primary" />
</div>
<span className="text-sm text-muted-foreground">0:00 / 3:42</span>
</div>
{q.options?.length ? (
<RadioGroup
value={typeof a.answer === "string" ? a.answer : ""}
onValueChange={(v) => updateAnswer(q.id, { answer: v })}
className="space-y-2"
>
{q.options.map((o) => (
<div key={o.label} className="flex items-center space-x-2">
<RadioGroupItem value={o.label} id={`${q.id}-${o.label}`} />
<Label htmlFor={`${q.id}-${o.label}`}>{o.text}</Label>
</div>
))}
</RadioGroup>
) : null}
</div>
);
}
if (nt.includes("multi") || nt === "mcq_multi") {
const selected = Array.isArray(a.answer) ? a.answer : [];
return (
<div className="space-y-3">
{q.options?.map((o) => (
<div key={o.label} className="flex items-center space-x-2">
<Checkbox
id={`${q.id}-${o.label}`}
checked={selected.includes(o.label)}
onCheckedChange={(ck) => {
const on = ck === true;
const next = on ? [...selected, o.label] : selected.filter((x) => x !== o.label);
updateAnswer(q.id, { answer: next });
}}
/>
<Label htmlFor={`${q.id}-${o.label}`}>{o.text}</Label>
</div>
))}
</div>
);
}
if (nt.includes("gap")) {
return (
<Input
value={typeof a.answer === "string" ? a.answer : ""}
onChange={(e) => updateAnswer(q.id, { answer: e.target.value })}
placeholder="Your answer"
className="max-w-md"
/>
);
}
if (nt.includes("true") || nt.includes("tfng") || nt === "yes_no_not_given") {
const opts = q.options?.length
? q.options
: [
{ label: "T", text: "True" },
{ label: "F", text: "False" },
{ label: "NG", text: "Not Given" },
];
return (
<RadioGroup
value={typeof a.answer === "string" ? a.answer : ""}
onValueChange={(v) => updateAnswer(q.id, { answer: v })}
className="space-y-2"
>
{opts.map((o) => (
<div key={o.label} className="flex items-center space-x-2">
<RadioGroupItem value={o.label} id={`${q.id}-${o.label}`} />
<Label htmlFor={`${q.id}-${o.label}`}>{o.text}</Label>
</div>
))}
</RadioGroup>
);
}
if (nt.includes("writing") || nt.includes("essay")) {
const text = typeof a.answer === "string" ? a.answer : "";
const wc = countWords(text);
const min = q.min_words ?? 150;
return (
<div className="space-y-2">
<Textarea
value={text}
onChange={(e) => updateAnswer(q.id, { answer: e.target.value })}
className="min-h-[200px]"
/>
<p className={cn("text-sm", wc < min ? "text-amber-600" : "text-muted-foreground")}>
{wc} words · minimum {min}
</p>
</div>
);
}
if (nt.includes("speak") || nt.includes("record") || nt.includes("audio")) {
return (
<div className="rounded-lg border border-dashed p-8 text-center text-muted-foreground">
Recording interface will appear here.
</div>
);
}
return (
<RadioGroup
value={typeof a.answer === "string" ? a.answer : ""}
onValueChange={(v) => updateAnswer(q.id, { answer: v })}
className="space-y-2"
>
{q.options?.map((o) => (
<div key={o.label} className="flex items-center space-x-2">
<RadioGroupItem value={o.label} id={`${q.id}-${o.label}`} />
<Label htmlFor={`${q.id}-${o.label}`}>{o.text}</Label>
</div>
))}
</RadioGroup>
);
};
if (isLoading) {
return (
<div className="fixed inset-0 z-[100] flex items-center justify-center bg-background">
<div className="h-10 w-10 animate-spin rounded-full border-2 border-primary border-t-transparent" />
</div>
);
}
if (isError || !session) {
return (
<div className="fixed inset-0 z-[100] flex flex-col items-center justify-center gap-4 bg-background p-6">
<p className="text-destructive text-lg font-medium">Unable to load this exam session.</p>
<p className="text-muted-foreground text-sm">The exam may not exist or the server is unreachable.</p>
<Button variant="outline" onClick={() => navigate(-1)}>Go Back</Button>
</div>
);
}
const remainOverall = Math.max(0, totalSecRef.current - tick);
const mm = Math.floor(remainOverall / 60);
const ss = remainOverall % 60;
return (
<div className="fixed inset-0 z-[100] flex flex-col bg-background">
<header className="flex flex-wrap items-center justify-between gap-4 border-b px-6 py-3">
<div>
<h1 className="text-lg font-semibold leading-tight">{session.title}</h1>
<p className="text-sm text-muted-foreground">{section?.title}</p>
</div>
<div className="flex flex-wrap items-center gap-6">
<span className="font-mono text-lg tabular-nums">
{String(mm).padStart(2, "0")}:{String(ss).padStart(2, "0")}
</span>
<div className="w-48 space-y-1">
<Progress value={progressPct} className="h-2" />
<p className="text-xs text-muted-foreground">Overall progress</p>
</div>
</div>
</header>
<div className="flex flex-1 min-h-0 flex-col md:flex-row">
<ScrollArea className="flex-1 p-6">
{question ? (
<Card className="border-none shadow-none">
<CardContent className="space-y-6 p-0">
{question.passage_text ? (
<div className="rounded-lg bg-muted/50 p-4 text-sm leading-relaxed">{question.passage_text}</div>
) : null}
<div className="prose prose-sm dark:prose-invert max-w-none">
<p className="font-medium">{question.stem}</p>
</div>
{renderQuestion(question)}
</CardContent>
</Card>
) : null}
</ScrollArea>
</div>
<footer className="flex flex-wrap items-center justify-between gap-3 border-t px-6 py-4">
<div className="flex gap-2">
<Button type="button" variant="outline" onClick={goPrev} disabled={sectionIdx === 0 && questionIdx === 0}>
<ChevronLeft className="mr-1 h-4 w-4" />
Previous
</Button>
<Button type="button" variant="outline" onClick={handleFlag}>
<Flag className="mr-1 h-4 w-4" />
Flag for Review
</Button>
</div>
<div className="flex gap-2">
<Button type="button" onClick={goNext}>
Next
<ChevronRight className="ml-1 h-4 w-4" />
</Button>
</div>
</footer>
<Dialog open={!!sectionGate?.open} onOpenChange={() => setSectionGate(null)}>
<DialogContent className="sm:max-w-sm">
<DialogHeader>
<DialogTitle>Next section</DialogTitle>
<DialogDescription>
The next section begins in{" "}
<span className="font-mono font-semibold">{sectionGate?.remaining ?? 0}</span>s.
</DialogDescription>
</DialogHeader>
</DialogContent>
</Dialog>
<Dialog open={reviewOpen} onOpenChange={setReviewOpen}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>Review and submit</DialogTitle>
<DialogDescription>
{summary.answered} answered · {summary.unanswered} unanswered · {summary.flagged} flagged ·{" "}
{summary.total} total
</DialogDescription>
</DialogHeader>
<DialogFooter className="gap-2 sm:justify-end">
<Button type="button" variant="outline" onClick={() => setReviewOpen(false)}>
Back
</Button>
<Button
type="button"
onClick={() => {
submitMut.mutate(examId, {
onSuccess: () => {
setReviewOpen(false);
navigate(`/student/exam/${examId}/status`);
},
});
}}
disabled={submitMut.isPending}
>
Submit exam
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}