Added the exercises page which will work as the current exam page, while the exam page will mandatorily be the full exam

This commit is contained in:
Tiago Ribeiro
2023-06-29 00:18:39 +01:00
parent 139f527fdd
commit 3fac92b54d
4 changed files with 313 additions and 24 deletions

View File

@@ -46,7 +46,7 @@ export default function Sidebar({path}: Props) {
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
<Nav Icon={MdSpaceDashboard} label="Dashboard" path={path} keyPath="/" /> <Nav Icon={MdSpaceDashboard} label="Dashboard" path={path} keyPath="/" />
<Nav Icon={BsFileEarmarkText} label="Exams" path={path} keyPath="/exam" /> <Nav Icon={BsFileEarmarkText} label="Exams" path={path} keyPath="/exam" />
<Nav Icon={BsPencil} label="Exercises" path={path} keyPath="/#" /> <Nav Icon={BsPencil} label="Exercises" path={path} keyPath="/exercises" />
<Nav Icon={BsGraphUp} label="Stats" path={path} keyPath="/stats" /> <Nav Icon={BsGraphUp} label="Stats" path={path} keyPath="/stats" />
<Nav Icon={BsClockHistory} label="Record" path={path} keyPath="/record" /> <Nav Icon={BsClockHistory} label="Record" path={path} keyPath="/record" />
</div> </div>

View File

@@ -14,9 +14,10 @@ import {sortByModuleName} from "@/utils/moduleUtils";
interface Props { interface Props {
user: User; user: User;
onStart: (modules: Module[]) => void; onStart: (modules: Module[]) => void;
disableSelection?: boolean;
} }
export default function Selection({user, onStart}: Props) { export default function Selection({user, onStart, disableSelection = false}: Props) {
const [selectedModules, setSelectedModules] = useState<Module[]>([]); const [selectedModules, setSelectedModules] = useState<Module[]>([]);
const {stats} = useStats(user?.id); const {stats} = useStats(user?.id);
@@ -100,10 +101,10 @@ export default function Selection({user, onStart}: Props) {
</section> </section>
<section className="w-full flex justify-between gap-8 mt-8"> <section className="w-full flex justify-between gap-8 mt-8">
<div <div
onClick={() => toggleModule("reading")} onClick={!disableSelection ? () => toggleModule("reading") : undefined}
className={clsx( className={clsx(
"relative w-fit max-w-xs flex flex-col items-center bg-mti-white-alt transition duration-300 ease-in-out border p-5 rounded-xl gap-2 pt-12 cursor-pointer", "relative w-fit max-w-xs flex flex-col items-center bg-mti-white-alt transition duration-300 ease-in-out border p-5 rounded-xl gap-2 pt-12 cursor-pointer",
selectedModules.includes("reading") ? "border-mti-green-light" : "border-mti-gray-platinum", selectedModules.includes("reading") || disableSelection ? "border-mti-green-light" : "border-mti-gray-platinum",
)}> )}>
<div className="absolute w-16 h-16 flex items-center justify-center rounded-full bg-ielts-reading top-0 -translate-y-1/2"> <div className="absolute w-16 h-16 flex items-center justify-center rounded-full bg-ielts-reading top-0 -translate-y-1/2">
<BsBook className="text-white w-7 h-7" /> <BsBook className="text-white w-7 h-7" />
@@ -112,14 +113,16 @@ export default function Selection({user, onStart}: Props) {
<p className="text-center text-xs"> <p className="text-center text-xs">
Expand your vocabulary, improve your reading comprehension and improve your ability to interpret texts in English. Expand your vocabulary, improve your reading comprehension and improve your ability to interpret texts in English.
</p> </p>
{!selectedModules.includes("reading") && <div className="border border-mti-gray-platinum w-8 h-8 rounded-full mt-4" />} {!selectedModules.includes("reading") && !disableSelection && (
{selectedModules.includes("reading") && <BsCheckCircle className="mt-4 text-mti-green-light w-8 h-8" />} <div className="border border-mti-gray-platinum w-8 h-8 rounded-full mt-4" />
)}
{(selectedModules.includes("reading") || disableSelection) && <BsCheckCircle className="mt-4 text-mti-green-light w-8 h-8" />}
</div> </div>
<div <div
onClick={() => toggleModule("listening")} onClick={!disableSelection ? () => toggleModule("listening") : undefined}
className={clsx( className={clsx(
"relative w-fit max-w-xs flex flex-col items-center bg-mti-white-alt transition duration-300 ease-in-out border p-5 rounded-xl gap-2 pt-12 cursor-pointer", "relative w-fit max-w-xs flex flex-col items-center bg-mti-white-alt transition duration-300 ease-in-out border p-5 rounded-xl gap-2 pt-12 cursor-pointer",
selectedModules.includes("listening") ? "border-mti-green-light" : "border-mti-gray-platinum", selectedModules.includes("listening") || disableSelection ? "border-mti-green-light" : "border-mti-gray-platinum",
)}> )}>
<div className="absolute w-16 h-16 flex items-center justify-center rounded-full bg-ielts-listening top-0 -translate-y-1/2"> <div className="absolute w-16 h-16 flex items-center justify-center rounded-full bg-ielts-listening top-0 -translate-y-1/2">
<BsHeadphones className="text-white w-7 h-7" /> <BsHeadphones className="text-white w-7 h-7" />
@@ -128,14 +131,18 @@ export default function Selection({user, onStart}: Props) {
<p className="text-center text-xs"> <p className="text-center text-xs">
Improve your ability to follow conversations in English and your ability to understand different accents and intonations. Improve your ability to follow conversations in English and your ability to understand different accents and intonations.
</p> </p>
{!selectedModules.includes("listening") && <div className="border border-mti-gray-platinum w-8 h-8 rounded-full mt-4" />} {!selectedModules.includes("listening") && !disableSelection && (
{selectedModules.includes("listening") && <BsCheckCircle className="mt-4 text-mti-green-light w-8 h-8" />} <div className="border border-mti-gray-platinum w-8 h-8 rounded-full mt-4" />
)}
{(selectedModules.includes("listening") || disableSelection) && (
<BsCheckCircle className="mt-4 text-mti-green-light w-8 h-8" />
)}
</div> </div>
<div <div
onClick={() => toggleModule("writing")} onClick={!disableSelection ? () => toggleModule("writing") : undefined}
className={clsx( className={clsx(
"relative w-fit max-w-xs flex flex-col items-center bg-mti-white-alt transition duration-300 ease-in-out border p-5 rounded-xl gap-2 pt-12 cursor-pointer", "relative w-fit max-w-xs flex flex-col items-center bg-mti-white-alt transition duration-300 ease-in-out border p-5 rounded-xl gap-2 pt-12 cursor-pointer",
selectedModules.includes("writing") ? "border-mti-green-light" : "border-mti-gray-platinum", selectedModules.includes("writing") || disableSelection ? "border-mti-green-light" : "border-mti-gray-platinum",
)}> )}>
<div className="absolute w-16 h-16 flex items-center justify-center rounded-full bg-ielts-writing top-0 -translate-y-1/2"> <div className="absolute w-16 h-16 flex items-center justify-center rounded-full bg-ielts-writing top-0 -translate-y-1/2">
<BsPen className="text-white w-7 h-7" /> <BsPen className="text-white w-7 h-7" />
@@ -144,14 +151,16 @@ export default function Selection({user, onStart}: Props) {
<p className="text-center text-xs"> <p className="text-center text-xs">
Allow you to practice writing in a variety of formats, from simple paragraphs to complex essays. Allow you to practice writing in a variety of formats, from simple paragraphs to complex essays.
</p> </p>
{!selectedModules.includes("writing") && <div className="border border-mti-gray-platinum w-8 h-8 rounded-full mt-4" />} {!selectedModules.includes("writing") && !disableSelection && (
{selectedModules.includes("writing") && <BsCheckCircle className="mt-4 text-mti-green-light w-8 h-8" />} <div className="border border-mti-gray-platinum w-8 h-8 rounded-full mt-4" />
)}
{(selectedModules.includes("writing") || disableSelection) && <BsCheckCircle className="mt-4 text-mti-green-light w-8 h-8" />}
</div> </div>
<div <div
onClick={() => toggleModule("speaking")} onClick={!disableSelection ? () => toggleModule("speaking") : undefined}
className={clsx( className={clsx(
"relative w-fit max-w-xs flex flex-col items-center bg-mti-white-alt transition duration-300 ease-in-out border p-5 rounded-xl gap-2 pt-12 cursor-pointer", "relative w-fit max-w-xs flex flex-col items-center bg-mti-white-alt transition duration-300 ease-in-out border p-5 rounded-xl gap-2 pt-12 cursor-pointer",
selectedModules.includes("speaking") ? "border-mti-green-light" : "border-mti-gray-platinum", selectedModules.includes("speaking") || disableSelection ? "border-mti-green-light" : "border-mti-gray-platinum",
)}> )}>
<div className="absolute w-16 h-16 flex items-center justify-center rounded-full bg-ielts-speaking top-0 -translate-y-1/2"> <div className="absolute w-16 h-16 flex items-center justify-center rounded-full bg-ielts-speaking top-0 -translate-y-1/2">
<BsMegaphone className="text-white w-7 h-7" /> <BsMegaphone className="text-white w-7 h-7" />
@@ -160,15 +169,21 @@ export default function Selection({user, onStart}: Props) {
<p className="text-center text-xs"> <p className="text-center text-xs">
You&apos;ll have access to interactive dialogs, pronunciation exercises and speech recordings. You&apos;ll have access to interactive dialogs, pronunciation exercises and speech recordings.
</p> </p>
{!selectedModules.includes("speaking") && <div className="border border-mti-gray-platinum w-8 h-8 rounded-full mt-4" />} {!selectedModules.includes("speaking") && !disableSelection && (
{selectedModules.includes("speaking") && <BsCheckCircle className="mt-4 text-mti-green-light w-8 h-8" />} <div className="border border-mti-gray-platinum w-8 h-8 rounded-full mt-4" />
)}
{(selectedModules.includes("speaking") || disableSelection) && (
<BsCheckCircle className="mt-4 text-mti-green-light w-8 h-8" />
)}
</div> </div>
</section> </section>
<Button <Button
onClick={() => onStart(selectedModules.sort(sortByModuleName))} onClick={() =>
onStart(!disableSelection ? selectedModules.sort(sortByModuleName) : ["reading", "listening", "writing", "speaking"])
}
color="green" color="green"
className="px-12 w-full max-w-xs self-end" className="px-12 w-full max-w-xs self-end"
disabled={selectedModules.length === 0}> disabled={selectedModules.length === 0 && !disableSelection}>
Start Exam Start Exam
</Button> </Button>
</div> </div>

View File

@@ -1,6 +1,5 @@
/* eslint-disable @next/next/no-img-element */ /* eslint-disable @next/next/no-img-element */
import Head from "next/head"; import Head from "next/head";
import Navbar from "@/components/Navbar";
import {useEffect, useState} from "react"; import {useEffect, useState} from "react";
import {Module} from "@/interfaces"; import {Module} from "@/interfaces";
@@ -19,9 +18,7 @@ import Speaking from "@/exams/Speaking";
import {v4 as uuidv4} from "uuid"; import {v4 as uuidv4} from "uuid";
import useUser from "@/hooks/useUser"; import useUser from "@/hooks/useUser";
import useExamStore from "@/stores/examStore"; import useExamStore from "@/stores/examStore";
import Sidebar from "@/components/Sidebar";
import Layout from "@/components/High/Layout"; import Layout from "@/components/High/Layout";
import {sortByModule} from "@/utils/moduleUtils";
import {writingReverseMarking} from "@/utils/score"; import {writingReverseMarking} from "@/utils/score";
export const getServerSideProps = withIronSessionSsr(({req, res}) => { export const getServerSideProps = withIronSessionSsr(({req, res}) => {
@@ -212,7 +209,7 @@ export default function Page() {
const renderScreen = () => { const renderScreen = () => {
if (selectedModules.length === 0) { if (selectedModules.length === 0) {
return <Selection user={user!} onStart={setSelectedModules} />; return <Selection user={user!} onStart={setSelectedModules} disableSelection />;
} }
if (moduleIndex >= selectedModules.length) { if (moduleIndex >= selectedModules.length) {

277
src/pages/exercises.tsx Normal file
View File

@@ -0,0 +1,277 @@
/* eslint-disable @next/next/no-img-element */
import Head from "next/head";
import Navbar from "@/components/Navbar";
import {useEffect, useState} from "react";
import {Module} from "@/interfaces";
import Selection from "@/exams/Selection";
import Reading from "@/exams/Reading";
import {Exam, ListeningExam, ReadingExam, SpeakingExam, UserSolution, WritingEvaluation, WritingExam, WritingExercise} from "@/interfaces/exam";
import Listening from "@/exams/Listening";
import Writing from "@/exams/Writing";
import {ToastContainer, toast} from "react-toastify";
import Finish from "@/exams/Finish";
import axios from "axios";
import {withIronSessionSsr} from "iron-session/next";
import {sessionOptions} from "@/lib/session";
import {Stat} from "@/interfaces/user";
import Speaking from "@/exams/Speaking";
import {v4 as uuidv4} from "uuid";
import useUser from "@/hooks/useUser";
import useExamStore from "@/stores/examStore";
import Sidebar from "@/components/Sidebar";
import Layout from "@/components/High/Layout";
import {sortByModule} from "@/utils/moduleUtils";
import {writingReverseMarking} from "@/utils/score";
export const getServerSideProps = withIronSessionSsr(({req, res}) => {
const user = req.session.user;
if (!user) {
res.setHeader("location", "/login");
res.statusCode = 302;
res.end();
return {
props: {
user: null,
},
};
}
return {
props: {user: req.session.user},
};
}, sessionOptions);
export default function Page() {
const [hasBeenUploaded, setHasBeenUploaded] = useState(false);
const [moduleIndex, setModuleIndex] = useState(0);
const [sessionId, setSessionId] = useState("");
const [exam, setExam] = useState<Exam>();
const [isEvaluationLoading, setIsEvaluationLoading] = useState(false);
const [exams, setExams] = useExamStore((state) => [state.exams, state.setExams]);
const [userSolutions, setUserSolutions] = useExamStore((state) => [state.userSolutions, state.setUserSolutions]);
const [showSolutions, setShowSolutions] = useExamStore((state) => [state.showSolutions, state.setShowSolutions]);
const [selectedModules, setSelectedModules] = useExamStore((state) => [state.selectedModules, state.setSelectedModules]);
const {user} = useUser({redirectTo: "/login"});
useEffect(() => setSessionId(uuidv4()), []);
useEffect(() => {
(async () => {
if (selectedModules.length > 0 && exams.length > 0 && moduleIndex < selectedModules.length) {
const nextExam = exams[moduleIndex];
setExam(nextExam ? updateExamWithUserSolutions(nextExam) : undefined);
}
})();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedModules, moduleIndex, exams]);
useEffect(() => {
(async () => {
if (selectedModules.length > 0 && exams.length === 0) {
const examPromises = selectedModules.map(getExam);
Promise.all(examPromises).then((values) => {
if (values.every((x) => !!x)) {
setExams(values.map((x) => x!));
}
});
}
})();
}, [selectedModules, setExams, exams]);
useEffect(() => {
if (selectedModules.length > 0 && exams.length !== 0 && moduleIndex >= selectedModules.length && !hasBeenUploaded) {
const newStats: Stat[] = userSolutions.map((solution) => ({
...solution,
session: sessionId,
exam: solution.exam!,
module: solution.module!,
user: user?.id || "",
date: new Date().getTime(),
}));
axios
.post<{ok: boolean}>("/api/stats", newStats)
.then((response) => setHasBeenUploaded(response.data.ok))
.catch(() => setHasBeenUploaded(false));
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedModules, moduleIndex, hasBeenUploaded]);
const getExam = async (module: Module): Promise<Exam | undefined> => {
const examRequest = await axios<Exam[]>(`/api/exam/${module}`);
if (examRequest.status !== 200) {
toast.error("Something went wrong!");
return undefined;
}
const newExam = examRequest.data;
switch (module) {
case "reading":
return newExam.shift() as ReadingExam;
case "listening":
return newExam.shift() as ListeningExam;
case "writing":
return newExam.shift() as WritingExam;
case "speaking":
return newExam.shift() as SpeakingExam;
}
};
const evaluateWritingAnswer = async (examId: string, exerciseId: string, solution: UserSolution) => {
const writingExam = exams.find((x) => x.id === examId)!;
const exercise = writingExam.exercises.find((x) => x.id === exerciseId)! as WritingExercise;
const response = await axios.post<WritingEvaluation>("/api/exam/writing/evaluate", {
question: `${exercise.prompt} ${exercise.attachment ? exercise.attachment.description : ""}`.replaceAll("\n", ""),
answer: solution.solutions[0].solution.trim().replaceAll("\n", " "),
});
if (response.status === 200) {
setUserSolutions([
...userSolutions.filter((x) => x.exercise !== exerciseId),
{
...solution,
score: {
correct: writingReverseMarking[response.data.overall],
missing: 0,
total: 100,
},
solutions: [{id: exerciseId, solution: solution.solutions[0].solution, evaluation: response.data}],
},
]);
}
};
const updateExamWithUserSolutions = (exam: Exam): Exam => {
const exercises = exam.exercises.map((x) =>
Object.assign(x, !x.userSolutions ? {userSolutions: userSolutions.find((y) => x.id === y.exercise)?.solutions} : x.userSolutions),
);
return Object.assign(exam, exercises);
};
const onFinish = (solutions: UserSolution[]) => {
const solutionIds = solutions.map((x) => x.exercise);
if (exam && exam.module === "writing" && solutions.length > 0 && !showSolutions) {
setHasBeenUploaded(true);
setIsEvaluationLoading(true);
Promise.all(
exam.exercises.map((exercise) => evaluateWritingAnswer(exam.id, exercise.id, solutions.find((x) => x.exercise === exercise.id)!)),
).finally(() => {
setIsEvaluationLoading(false);
setHasBeenUploaded(false);
});
}
setUserSolutions([...userSolutions.filter((x) => !solutionIds.includes(x.exercise)), ...solutions]);
setModuleIndex((prev) => prev + 1);
};
const aggregateScoresByModule = (answers: UserSolution[]): {module: Module; total: number; missing: number; correct: number}[] => {
const scores: {[key in Module]: {total: number; missing: number; correct: number}} = {
reading: {
total: 0,
correct: 0,
missing: 0,
},
listening: {
total: 0,
correct: 0,
missing: 0,
},
writing: {
total: 0,
correct: 0,
missing: 0,
},
speaking: {
total: 0,
correct: 0,
missing: 0,
},
};
answers.forEach((x) => {
scores[x.module!] = {
total: scores[x.module!].total + x.score.total,
correct: scores[x.module!].correct + x.score.correct,
missing: scores[x.module!].missing + x.score.missing,
};
});
return Object.keys(scores)
.filter((x) => scores[x as Module].total > 0)
.map((x) => ({module: x as Module, ...scores[x as Module]}));
};
const renderScreen = () => {
if (selectedModules.length === 0) {
return <Selection user={user!} onStart={setSelectedModules} />;
}
if (moduleIndex >= selectedModules.length) {
return (
<Finish
isLoading={isEvaluationLoading}
user={user!}
modules={selectedModules}
onViewResults={() => {
setShowSolutions(true);
setModuleIndex(0);
setExam(exams[0]);
}}
scores={aggregateScoresByModule(userSolutions)}
/>
);
}
if (exam && exam.module === "reading") {
return <Reading exam={exam} onFinish={onFinish} showSolutions={showSolutions} />;
}
if (exam && exam.module === "listening") {
return <Listening exam={exam} onFinish={onFinish} showSolutions={showSolutions} />;
}
if (exam && exam.module === "writing") {
return <Writing exam={exam} onFinish={onFinish} showSolutions={showSolutions} />;
}
if (exam && exam.module === "speaking" && showSolutions) {
setModuleIndex((prev) => prev + 1);
return <></>;
}
if (exam && exam.module === "speaking") {
return <Speaking exam={exam} onFinish={onFinish} showSolutions={showSolutions} />;
}
return <>Loading...</>;
};
return (
<>
<Head>
<title>Exam | IELTS GPT</title>
<meta
name="description"
content="A training platform for the IELTS exam provided by the Muscat Training Institute and developed by eCrop."
/>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" href="/favicon.ico" />
</Head>
<ToastContainer />
{user && (
<Layout user={user} className="justify-between">
{renderScreen()}
</Layout>
)}
</>
);
}