290 lines
9.7 KiB
TypeScript
290 lines
9.7 KiB
TypeScript
import {ReadingExam, UserSolution} from "@/interfaces/exam";
|
|
import {Fragment, useEffect, useState} from "react";
|
|
import Icon from "@mdi/react";
|
|
import {mdiArrowRight, mdiNotebook} from "@mdi/js";
|
|
import clsx from "clsx";
|
|
import {infoButtonStyle} from "@/constants/buttonStyles";
|
|
import {convertCamelCaseToReadable} from "@/utils/string";
|
|
import {Dialog, Transition} from "@headlessui/react";
|
|
import {renderExercise} from "@/components/Exercises";
|
|
import {renderSolution} from "@/components/Solutions";
|
|
import {Panel} from "primereact/panel";
|
|
import {Steps} from "primereact/steps";
|
|
import {BsAlarm, BsBook, BsClock, BsStopwatch} from "react-icons/bs";
|
|
import ProgressBar from "@/components/Low/ProgressBar";
|
|
import ModuleTitle from "@/components/Medium/ModuleTitle";
|
|
import {Divider} from "primereact/divider";
|
|
import Button from "@/components/Low/Button";
|
|
import BlankQuestionsModal from "@/components/BlankQuestionsModal";
|
|
import useExamStore from "@/stores/examStore";
|
|
import {defaultUserSolutions} from "@/utils/exams";
|
|
import {countExercises} from "@/utils/moduleUtils";
|
|
|
|
interface Props {
|
|
exam: ReadingExam;
|
|
showSolutions?: boolean;
|
|
onFinish: (userSolutions: UserSolution[]) => void;
|
|
}
|
|
|
|
function TextModal({isOpen, title, content, onClose}: {isOpen: boolean; title: string; content: string; onClose: () => void}) {
|
|
return (
|
|
<Transition appear show={isOpen} as={Fragment}>
|
|
<Dialog as="div" className="relative z-10" onClose={onClose}>
|
|
<Transition.Child
|
|
as={Fragment}
|
|
enter="ease-out duration-300"
|
|
enterFrom="opacity-0"
|
|
enterTo="opacity-100"
|
|
leave="ease-in duration-200"
|
|
leaveFrom="opacity-100"
|
|
leaveTo="opacity-0">
|
|
<div className="fixed inset-0 bg-black bg-opacity-25" />
|
|
</Transition.Child>
|
|
|
|
<div className="fixed inset-0 overflow-y-auto">
|
|
<div className="flex min-h-full items-center justify-center p-4 text-center">
|
|
<Transition.Child
|
|
as={Fragment}
|
|
enter="ease-out duration-300"
|
|
enterFrom="opacity-0 scale-95"
|
|
enterTo="opacity-100 scale-100"
|
|
leave="ease-in duration-200"
|
|
leaveFrom="opacity-100 scale-100"
|
|
leaveTo="opacity-0 scale-95">
|
|
<Dialog.Panel className="w-full relative max-w-4xl transform rounded-2xl bg-white p-6 text-left align-middle shadow-xl transition-all">
|
|
<Dialog.Title as="h3" className="text-lg font-medium leading-6 text-gray-900">
|
|
{title}
|
|
</Dialog.Title>
|
|
<div className="mt-2 overflow-auto mb-28">
|
|
<p className="text-sm">
|
|
{content.split("\\n").map((line, index) => (
|
|
<Fragment key={index}>
|
|
{line}
|
|
<br />
|
|
</Fragment>
|
|
))}
|
|
</p>
|
|
</div>
|
|
|
|
<div className="absolute bottom-8 right-8 max-w-[200px] self-end w-full">
|
|
<Button color="purple" variant="outline" className="max-w-[200px] self-end w-full" onClick={onClose}>
|
|
Close
|
|
</Button>
|
|
</div>
|
|
</Dialog.Panel>
|
|
</Transition.Child>
|
|
</div>
|
|
</div>
|
|
</Dialog>
|
|
</Transition>
|
|
);
|
|
}
|
|
|
|
export default function Reading({exam, showSolutions = false, onFinish}: Props) {
|
|
const [questionIndex, setQuestionIndex] = useState(0);
|
|
const [currentQuestionIndex, setCurrentQuestionIndex] = useState(0);
|
|
const [showTextModal, setShowTextModal] = useState(false);
|
|
const [showBlankModal, setShowBlankModal] = useState(false);
|
|
|
|
const {userSolutions, setUserSolutions} = useExamStore((state) => state);
|
|
const {hasExamEnded, setHasExamEnded} = useExamStore((state) => state);
|
|
const {partIndex, setPartIndex} = useExamStore((state) => state);
|
|
const {exerciseIndex, setExerciseIndex} = useExamStore((state) => state);
|
|
const setStoreQuestionIndex = useExamStore((state) => state.setQuestionIndex);
|
|
|
|
const scrollToTop = () => Array.from(document.getElementsByTagName("body")).forEach((body) => body.scrollTo(0, 0));
|
|
|
|
useEffect(() => {
|
|
if (showSolutions) setExerciseIndex(-1);
|
|
}, [setExerciseIndex, showSolutions]);
|
|
|
|
useEffect(() => {
|
|
const listener = (e: KeyboardEvent) => {
|
|
if (e.key === "F3" || ((e.ctrlKey || e.metaKey) && e.key === "f")) {
|
|
e.preventDefault();
|
|
}
|
|
};
|
|
|
|
document.addEventListener("keydown", listener);
|
|
|
|
return () => {
|
|
document.removeEventListener("keydown", listener);
|
|
};
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
setCurrentQuestionIndex(0);
|
|
}, [questionIndex]);
|
|
|
|
useEffect(() => {
|
|
if (hasExamEnded && exerciseIndex === -1) {
|
|
setExerciseIndex(exerciseIndex + 1);
|
|
}
|
|
}, [hasExamEnded, exerciseIndex, setExerciseIndex]);
|
|
|
|
const confirmFinishModule = (keepGoing?: boolean) => {
|
|
if (!keepGoing) {
|
|
setShowBlankModal(false);
|
|
return;
|
|
}
|
|
|
|
onFinish(userSolutions.map((x) => ({...x, module: "reading", exam: exam.id})));
|
|
};
|
|
|
|
const nextExercise = (solution?: UserSolution) => {
|
|
scrollToTop();
|
|
if (solution) {
|
|
setUserSolutions([...userSolutions.filter((x) => x.exercise !== solution.exercise), solution]);
|
|
}
|
|
setQuestionIndex((prev) => prev + currentQuestionIndex);
|
|
setStoreQuestionIndex(0);
|
|
|
|
if (exerciseIndex + 1 < exam.parts[partIndex].exercises.length && !hasExamEnded) {
|
|
setExerciseIndex(exerciseIndex + 1);
|
|
return;
|
|
}
|
|
|
|
if (partIndex + 1 < exam.parts.length && !hasExamEnded) {
|
|
setPartIndex(partIndex + 1);
|
|
setExerciseIndex(showSolutions ? 0 : -1);
|
|
return;
|
|
}
|
|
|
|
if (
|
|
solution &&
|
|
![...userSolutions.filter((x) => x.exercise !== solution?.exercise).map((x) => x.score.missing), solution?.score.missing].every(
|
|
(x) => x === 0,
|
|
) &&
|
|
!showSolutions &&
|
|
!hasExamEnded
|
|
) {
|
|
setShowBlankModal(true);
|
|
return;
|
|
}
|
|
|
|
setHasExamEnded(false);
|
|
|
|
if (solution) {
|
|
onFinish(
|
|
[...userSolutions.filter((x) => x.exercise !== solution.exercise), solution].map((x) => ({...x, module: "reading", exam: exam.id})),
|
|
);
|
|
} else {
|
|
onFinish(userSolutions.map((x) => ({...x, module: "reading", exam: exam.id})));
|
|
}
|
|
};
|
|
|
|
const previousExercise = (solution?: UserSolution) => {
|
|
scrollToTop();
|
|
if (solution) {
|
|
setUserSolutions([...userSolutions.filter((x) => x.exercise !== solution.exercise), solution]);
|
|
}
|
|
setStoreQuestionIndex(0);
|
|
|
|
setExerciseIndex(exerciseIndex - 1);
|
|
};
|
|
|
|
const getExercise = () => {
|
|
const exercise = exam.parts[partIndex].exercises[exerciseIndex];
|
|
return {
|
|
...exercise,
|
|
userSolutions: userSolutions.find((x) => x.exercise === exercise.id)?.solutions || [],
|
|
};
|
|
};
|
|
|
|
const renderText = () => (
|
|
<div className="flex flex-col gap-6 w-full bg-mti-gray-seasalt rounded-xl py-8 px-16 mt-4">
|
|
<div className="flex flex-col w-full gap-2">
|
|
<h4 className="text-xl font-semibold">
|
|
Please read the following excerpt attentively, you will then be asked questions about the text you've read.
|
|
</h4>
|
|
<span className="text-base">You will be allowed to read the text while doing the exercises</span>
|
|
</div>
|
|
<div className="flex flex-col gap-2 w-full">
|
|
<h3 className="text-xl font-semibold">{exam.parts[partIndex].text.title}</h3>
|
|
<div className="border border-mti-gray-dim w-full rounded-full opacity-10" />
|
|
<span className="overflow-auto">
|
|
{exam.parts[partIndex].text.content.split("\\n").map((line, index) => (
|
|
<p key={index}>{line}</p>
|
|
))}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
);
|
|
|
|
return (
|
|
<>
|
|
<div className="flex flex-col h-full w-full gap-8">
|
|
<BlankQuestionsModal isOpen={showBlankModal} onClose={confirmFinishModule} />
|
|
{partIndex > -1 && <TextModal {...exam.parts[partIndex].text} isOpen={showTextModal} onClose={() => setShowTextModal(false)} />}
|
|
<ModuleTitle
|
|
minTimer={exam.minTimer}
|
|
exerciseIndex={
|
|
(exam.parts
|
|
.flatMap((x) => x.exercises)
|
|
.findIndex(
|
|
(x) =>
|
|
x.id ===
|
|
exam.parts[partIndex > -1 ? partIndex : 0].exercises[exerciseIndex === -1 ? exerciseIndex + 1 : exerciseIndex]
|
|
?.id,
|
|
) || 0) +
|
|
(exerciseIndex === -1 ? 0 : 1) +
|
|
questionIndex +
|
|
currentQuestionIndex
|
|
}
|
|
module="reading"
|
|
totalExercises={countExercises(exam.parts.flatMap((x) => x.exercises))}
|
|
disableTimer={showSolutions}
|
|
label={exerciseIndex === -1 ? undefined : convertCamelCaseToReadable(exam.parts[partIndex].exercises[exerciseIndex].type)}
|
|
/>
|
|
<div className={clsx("mb-20 w-full", exerciseIndex > -1 && "grid grid-cols-2 gap-4")}>
|
|
{partIndex > -1 && renderText()}
|
|
|
|
{exerciseIndex > -1 &&
|
|
partIndex > -1 &&
|
|
exerciseIndex < exam.parts[partIndex].exercises.length &&
|
|
!showSolutions &&
|
|
renderExercise(getExercise(), exam.id, nextExercise, previousExercise, setCurrentQuestionIndex)}
|
|
|
|
{exerciseIndex > -1 &&
|
|
partIndex > -1 &&
|
|
exerciseIndex < exam.parts[partIndex].exercises.length &&
|
|
showSolutions &&
|
|
renderSolution(exam.parts[partIndex].exercises[exerciseIndex], nextExercise, previousExercise, setCurrentQuestionIndex)}
|
|
</div>
|
|
{exerciseIndex > -1 && partIndex > -1 && exerciseIndex < exam.parts[partIndex].exercises.length && (
|
|
<Button
|
|
color="purple"
|
|
variant="outline"
|
|
onClick={() => setShowTextModal(true)}
|
|
className="max-w-[200px] self-end w-full absolute bottom-[31px] right-64">
|
|
Read text
|
|
</Button>
|
|
)}
|
|
</div>
|
|
{exerciseIndex === -1 && partIndex > 0 && (
|
|
<div className="self-end flex justify-between w-full gap-8 absolute bottom-8 left-0 px-8">
|
|
<Button
|
|
color="purple"
|
|
variant="outline"
|
|
onClick={() => {
|
|
setExerciseIndex(exam.parts[partIndex - 1].exercises.length - 1);
|
|
setPartIndex(partIndex - 1);
|
|
}}
|
|
className="max-w-[200px] w-full">
|
|
Back
|
|
</Button>
|
|
|
|
<Button color="purple" onClick={() => nextExercise()} className="max-w-[200px] self-end w-full">
|
|
Next
|
|
</Button>
|
|
</div>
|
|
)}
|
|
{exerciseIndex === -1 && partIndex === 0 && (
|
|
<Button color="purple" onClick={() => nextExercise()} className="max-w-[200px] self-end w-full">
|
|
Start now
|
|
</Button>
|
|
)}
|
|
</>
|
|
);
|
|
}
|