185 lines
6.8 KiB
TypeScript
185 lines
6.8 KiB
TypeScript
import { Module } from "@/interfaces";
|
|
import { moduleLabels } from "@/utils/moduleUtils";
|
|
import clsx from "clsx";
|
|
import { Fragment, ReactNode, useCallback, useState } from "react";
|
|
import { BsBook, BsClipboard, BsHeadphones, BsMegaphone, BsPen, BsStopwatch } from "react-icons/bs";
|
|
import ProgressBar from "../Low/ProgressBar";
|
|
import Timer from "./Timer";
|
|
import { Exam, LevelExam, MultipleChoiceExercise, ShuffleMap, UserSolution } from "@/interfaces/exam";
|
|
import { BsFillGrid3X3GapFill } from "react-icons/bs";
|
|
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
|
import Button from "../Low/Button";
|
|
import { Dialog, Transition } from "@headlessui/react";
|
|
import useExamStore from "@/stores/examStore";
|
|
import Modal from "../Modal";
|
|
|
|
interface Props {
|
|
minTimer: number;
|
|
module: Module;
|
|
label?: string;
|
|
exerciseIndex: number;
|
|
totalExercises: number;
|
|
disableTimer?: boolean;
|
|
partLabel?: string;
|
|
showTimer?: boolean;
|
|
showSolutions?: boolean;
|
|
runOnClick?: ((questionIndex: number) => void) | undefined;
|
|
}
|
|
|
|
export default function ModuleTitle({
|
|
minTimer,
|
|
module,
|
|
label,
|
|
exerciseIndex,
|
|
totalExercises,
|
|
disableTimer = false,
|
|
partLabel,
|
|
showTimer = true,
|
|
showSolutions = false,
|
|
runOnClick = undefined
|
|
}: Props) {
|
|
const {
|
|
userSolutions,
|
|
partIndex,
|
|
exam
|
|
} = useExamStore((state) => state);
|
|
const examExerciseIndex = useExamStore((state) => state.exerciseIndex)
|
|
|
|
const [isOpen, setIsOpen] = useState(false);
|
|
|
|
const moduleIcon: { [key in Module]: ReactNode } = {
|
|
reading: <BsBook className="text-ielts-reading w-6 h-6" />,
|
|
listening: <BsHeadphones className="text-ielts-listening w-6 h-6" />,
|
|
writing: <BsPen className="text-ielts-writing w-6 h-6" />,
|
|
speaking: <BsMegaphone className="text-ielts-speaking w-6 h-6" />,
|
|
level: <BsClipboard className="text-ielts-level w-6 h-6" />,
|
|
};
|
|
|
|
const isMultipleChoiceLevelExercise = () => {
|
|
if (exam?.module === 'level' && typeof partIndex === "number" && partIndex > -1) {
|
|
const currentExercise = (exam as LevelExam).parts[partIndex].exercises[examExerciseIndex];
|
|
return currentExercise && currentExercise.type === 'multipleChoice';
|
|
}
|
|
return false;
|
|
};
|
|
|
|
const renderMCQuestionGrid = () => {
|
|
if (!isMultipleChoiceLevelExercise() && !userSolutions) return null;
|
|
|
|
const currentExercise = (exam as LevelExam).parts[partIndex!].exercises[examExerciseIndex] as MultipleChoiceExercise;
|
|
const userSolution = userSolutions!.find((x) => x.exercise == currentExercise.id)!;
|
|
const answeredQuestions = new Set(userSolution.solutions.map(sol => sol.question));
|
|
const exerciseOffset = currentExercise.questions[0].id;
|
|
const lastExercise = exerciseOffset + (currentExercise.questions.length - 1);
|
|
|
|
const getQuestionColor = (questionId: string, solution: string, userQuestionSolution: string | undefined) => {
|
|
const questionShuffleMap = userSolutions.reduce((foundMap, userSolution) => {
|
|
if (foundMap) return foundMap;
|
|
return userSolution.shuffleMaps?.find(map => map.questionID.toString() === questionId.toString()) || null;
|
|
}, null as ShuffleMap | null);
|
|
const newSolution = questionShuffleMap ? questionShuffleMap?.map[solution] : solution;
|
|
|
|
if (!userSolutions) return "";
|
|
|
|
if (!userQuestionSolution) {
|
|
return "!bg-mti-gray-davy !border--mti-gray-davy !text-mti-gray-davy !text-white hover:!bg-gray-700";
|
|
}
|
|
|
|
return userQuestionSolution === newSolution ?
|
|
"!bg-mti-purple-light !text-mti-purple-light !text-white hover:!bg-mti-purple-dark" :
|
|
"!bg-mti-rose-light !border-mti-rose-light !text-mti-rose-light !text-white hover:!bg-mti-rose-dark";
|
|
}
|
|
return (
|
|
<>
|
|
<h3 className="text-xl font-semibold mb-4 text-center">{`Part ${partIndex + 1} (Questions ${exerciseOffset} - ${lastExercise})`}</h3>
|
|
<div className="grid grid-cols-5 gap-3 px-4 py-2">
|
|
{currentExercise.questions.map((_, index) => {
|
|
const questionNumber = exerciseOffset + index;
|
|
const isAnswered = answeredQuestions.has(questionNumber);
|
|
const solution = currentExercise.questions.find((x) => x.id == questionNumber)!.solution;
|
|
|
|
const userQuestionSolution = currentExercise.userSolutions?.find((x) => x.question == questionNumber)?.option;
|
|
return (
|
|
<Button
|
|
variant={showSolutions ? "solid" : (isAnswered ? "solid" : "outline")}
|
|
key={index}
|
|
className={clsx(
|
|
"w-12 h-12 flex items-center justify-center rounded-lg text-sm font-bold transition-all duration-200 ease-in-out",
|
|
(showSolutions ?
|
|
getQuestionColor(questionNumber.toString(), solution, userQuestionSolution) :
|
|
(isAnswered ?
|
|
"bg-mti-purple-light border-mti-purple-light text-white hover:bg-mti-purple-dark hover:border-mti-purple-dark":
|
|
"bg-white border-gray-400 hover:bg-gray-100 hover:text-gray-700"
|
|
)
|
|
)
|
|
)}
|
|
onClick={() => { if (typeof runOnClick !== "undefined") { runOnClick(index); } setIsOpen(false); }}
|
|
>
|
|
{questionNumber}
|
|
</Button>
|
|
);
|
|
})}
|
|
</div>
|
|
<p className="mt-4 text-sm text-gray-600 text-center">
|
|
Click a question number to jump to that question
|
|
</p>
|
|
</>
|
|
);
|
|
};
|
|
|
|
return (
|
|
<>
|
|
{showTimer && <Timer minTimer={minTimer} disableTimer={disableTimer} />}
|
|
<div className="w-full">
|
|
{partLabel && (
|
|
<div className="text-3xl space-y-4">
|
|
{partLabel.split("\n\n").map((line, index) => {
|
|
if (index == 0)
|
|
return (
|
|
<p key={index} className="font-bold">
|
|
{line}
|
|
</p>
|
|
);
|
|
else
|
|
return (
|
|
<p key={index} className="text-2xl font-semibold">
|
|
{line}
|
|
</p>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
<div className={clsx("flex gap-6 w-full h-fit items-center", partLabel ? "mt-10" : "mt-5")}>
|
|
<div className="w-12 h-12 bg-mti-gray-smoke flex items-center justify-center rounded-lg">{moduleIcon[module]}</div>
|
|
<div className="flex flex-col gap-3 w-full">
|
|
<div className="w-full flex justify-between">
|
|
<span className="text-base font-semibold">
|
|
{moduleLabels[module]} exam {label && `- ${label}`}
|
|
</span>
|
|
<span className="text-sm font-semibold self-end">
|
|
Question {exerciseIndex}/{totalExercises}
|
|
</span>
|
|
</div>
|
|
<ProgressBar color={module} label="" percentage={(exerciseIndex * 100) / totalExercises} className="h-2 w-full" />
|
|
</div>
|
|
{isMultipleChoiceLevelExercise() && (
|
|
<>
|
|
<Button variant="outline" onClick={() => setIsOpen(true)} padding="p-2" className="rounded-lg">
|
|
<BsFillGrid3X3GapFill size={24} />
|
|
</Button>
|
|
<Modal
|
|
isOpen={isOpen}
|
|
onClose={() => setIsOpen(false)}
|
|
className="w-full max-w-md transform overflow-hidden rounded-2xl bg-white shadow-xl transition-all"
|
|
>
|
|
<>
|
|
{renderMCQuestionGrid()}
|
|
</>
|
|
</Modal>
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</>
|
|
);
|
|
} |