diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index 0349e114..8b80faba 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -46,7 +46,7 @@ export default function Sidebar({path}: Props) {
diff --git a/src/exams/Selection.tsx b/src/exams/Selection.tsx index 4973c654..5d1c6c68 100644 --- a/src/exams/Selection.tsx +++ b/src/exams/Selection.tsx @@ -14,9 +14,10 @@ import {sortByModuleName} from "@/utils/moduleUtils"; interface Props { user: User; 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([]); const {stats} = useStats(user?.id); @@ -100,10 +101,10 @@ export default function Selection({user, onStart}: Props) {
toggleModule("reading")} + onClick={!disableSelection ? () => toggleModule("reading") : undefined} 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", - selectedModules.includes("reading") ? "border-mti-green-light" : "border-mti-gray-platinum", + selectedModules.includes("reading") || disableSelection ? "border-mti-green-light" : "border-mti-gray-platinum", )}>
@@ -112,14 +113,16 @@ export default function Selection({user, onStart}: Props) {

Expand your vocabulary, improve your reading comprehension and improve your ability to interpret texts in English.

- {!selectedModules.includes("reading") &&
} - {selectedModules.includes("reading") && } + {!selectedModules.includes("reading") && !disableSelection && ( +
+ )} + {(selectedModules.includes("reading") || disableSelection) && }
toggleModule("listening")} + onClick={!disableSelection ? () => toggleModule("listening") : undefined} 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", - selectedModules.includes("listening") ? "border-mti-green-light" : "border-mti-gray-platinum", + selectedModules.includes("listening") || disableSelection ? "border-mti-green-light" : "border-mti-gray-platinum", )}>
@@ -128,14 +131,18 @@ export default function Selection({user, onStart}: Props) {

Improve your ability to follow conversations in English and your ability to understand different accents and intonations.

- {!selectedModules.includes("listening") &&
} - {selectedModules.includes("listening") && } + {!selectedModules.includes("listening") && !disableSelection && ( +
+ )} + {(selectedModules.includes("listening") || disableSelection) && ( + + )}
toggleModule("writing")} + onClick={!disableSelection ? () => toggleModule("writing") : undefined} 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", - selectedModules.includes("writing") ? "border-mti-green-light" : "border-mti-gray-platinum", + selectedModules.includes("writing") || disableSelection ? "border-mti-green-light" : "border-mti-gray-platinum", )}>
@@ -144,14 +151,16 @@ export default function Selection({user, onStart}: Props) {

Allow you to practice writing in a variety of formats, from simple paragraphs to complex essays.

- {!selectedModules.includes("writing") &&
} - {selectedModules.includes("writing") && } + {!selectedModules.includes("writing") && !disableSelection && ( +
+ )} + {(selectedModules.includes("writing") || disableSelection) && }
toggleModule("speaking")} + onClick={!disableSelection ? () => toggleModule("speaking") : undefined} 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", - selectedModules.includes("speaking") ? "border-mti-green-light" : "border-mti-gray-platinum", + selectedModules.includes("speaking") || disableSelection ? "border-mti-green-light" : "border-mti-gray-platinum", )}>
@@ -160,15 +169,21 @@ export default function Selection({user, onStart}: Props) {

You'll have access to interactive dialogs, pronunciation exercises and speech recordings.

- {!selectedModules.includes("speaking") &&
} - {selectedModules.includes("speaking") && } + {!selectedModules.includes("speaking") && !disableSelection && ( +
+ )} + {(selectedModules.includes("speaking") || disableSelection) && ( + + )}
diff --git a/src/pages/exam.tsx b/src/pages/exam.tsx index 4a21142c..803a020a 100644 --- a/src/pages/exam.tsx +++ b/src/pages/exam.tsx @@ -1,6 +1,5 @@ /* 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"; @@ -19,9 +18,7 @@ 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}) => { @@ -212,7 +209,7 @@ export default function Page() { const renderScreen = () => { if (selectedModules.length === 0) { - return ; + return ; } if (moduleIndex >= selectedModules.length) { diff --git a/src/pages/exercises.tsx b/src/pages/exercises.tsx new file mode 100644 index 00000000..4a21142c --- /dev/null +++ b/src/pages/exercises.tsx @@ -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(); + 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 => { + const examRequest = await axios(`/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("/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 ; + } + + if (moduleIndex >= selectedModules.length) { + return ( + { + setShowSolutions(true); + setModuleIndex(0); + setExam(exams[0]); + }} + scores={aggregateScoresByModule(userSolutions)} + /> + ); + } + + if (exam && exam.module === "reading") { + return ; + } + + if (exam && exam.module === "listening") { + return ; + } + + if (exam && exam.module === "writing") { + return ; + } + + if (exam && exam.module === "speaking" && showSolutions) { + setModuleIndex((prev) => prev + 1); + return <>; + } + + if (exam && exam.module === "speaking") { + return ; + } + + return <>Loading...; + }; + + return ( + <> + + Exam | IELTS GPT + + + + + + {user && ( + + {renderScreen()} + + )} + + ); +}