Navigation rework, added prompt edit to components that were missing
This commit is contained in:
@@ -3,12 +3,11 @@ import ModuleTitle from "@/components/Medium/ModuleTitle";
|
||||
import { moduleResultText } from "@/constants/ielts";
|
||||
import { Module } from "@/interfaces";
|
||||
import { User } from "@/interfaces/user";
|
||||
import useExamStore from "@/stores/examStore";
|
||||
import useExamStore from "@/stores/exam";
|
||||
import { calculateBandScore, getGradingLabel } from "@/utils/score";
|
||||
import clsx from "clsx";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
import { Fragment, useEffect, useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
BsArrowCounterclockwise,
|
||||
BsBan,
|
||||
@@ -56,9 +55,9 @@ export default function Finish({ user, scores, modules, information, solutions,
|
||||
const [selectedModule, setSelectedModule] = useState(modules[0]);
|
||||
const [selectedScore, setSelectedScore] = useState<Score>(scores.find((x) => x.module === modules[0])!);
|
||||
const [isExtraInformationOpen, setIsExtraInformationOpen] = useState(false);
|
||||
const {selectedModules, exams, dispatch} = useExamStore((s) => s);
|
||||
|
||||
const aiUsage = Math.round(ai_usage(solutions) * 100);
|
||||
const exams = useExamStore((state) => state.exams);
|
||||
const { gradingSystem } = useGradingSystem();
|
||||
|
||||
const router = useRouter()
|
||||
@@ -112,6 +111,11 @@ export default function Finish({ user, scores, modules, information, solutions,
|
||||
return <span className="text-3xl font-bold">{level}</span>;
|
||||
};
|
||||
|
||||
const handlePlayAgain = () => {
|
||||
dispatch({type: "INIT_EXAM", payload: {exams, modules: selectedModules}})
|
||||
router.push(destination || "/exam")
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Modal title="Extra Information" isOpen={isExtraInformationOpen} onClose={() => setIsExtraInformationOpen(false)}>
|
||||
@@ -135,6 +139,7 @@ export default function Finish({ user, scores, modules, information, solutions,
|
||||
totalExercises={getTotalExercises()}
|
||||
exerciseIndex={getTotalExercises()}
|
||||
minTimer={exams.find((x) => x.module === selectedModule)!.minTimer}
|
||||
preview={false}
|
||||
disableTimer
|
||||
/>
|
||||
<div className="flex gap-4 self-start w-full">
|
||||
@@ -289,7 +294,7 @@ export default function Finish({ user, scores, modules, information, solutions,
|
||||
<div className="flex gap-8">
|
||||
<div className="flex w-fit cursor-pointer flex-col items-center gap-1">
|
||||
<button
|
||||
onClick={() => router.push(destination || "/exam")}
|
||||
onClick={handlePlayAgain}
|
||||
disabled={!!assignment}
|
||||
className="bg-mti-purple-light hover:bg-mti-purple flex h-11 w-11 items-center justify-center rounded-full transition duration-300 ease-in-out">
|
||||
<BsArrowCounterclockwise className="h-7 w-7 text-white" />
|
||||
|
||||
@@ -5,11 +5,10 @@ import ModuleTitle from "@/components/Medium/ModuleTitle";
|
||||
import { renderSolution } from "@/components/Solutions";
|
||||
import { Module } from "@/interfaces";
|
||||
import { Exercise, FillBlanksMCOption, LevelExam, MultipleChoiceExercise, UserSolution } from "@/interfaces/exam";
|
||||
import useExamStore from "@/stores/examStore";
|
||||
import { usePersistentExamStore } from "@/stores/examStore";
|
||||
import useExamStore, { usePersistentExamStore } from "@/stores/exam";
|
||||
import { countExercises } from "@/utils/moduleUtils";
|
||||
import clsx from "clsx";
|
||||
import { use, useEffect, useMemo, useState } from "react";
|
||||
import { use, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import TextComponent from "./TextComponent";
|
||||
import PartDivider from "../Navigation/SectionDivider";
|
||||
import Timer from "@/components/Medium/Timer";
|
||||
@@ -19,39 +18,44 @@ import Modal from "@/components/Modal";
|
||||
import { typeCheckWordsMC } from "@/utils/type.check";
|
||||
import SectionNavbar from "../Navigation/SectionNavbar";
|
||||
import AudioPlayer from "@/components/Low/AudioPlayer";
|
||||
import { ExamProps } from "../types";
|
||||
import {answeredEveryQuestionInPart} from "../utils/answeredEveryQuestion";
|
||||
import useExamTimer from "@/hooks/useExamTimer";
|
||||
import ProgressButtons from "../components/ProgressButtons";
|
||||
|
||||
interface Props {
|
||||
exam: LevelExam;
|
||||
showSolutions?: boolean;
|
||||
onFinish: (userSolutions: UserSolution[]) => void;
|
||||
preview?: boolean;
|
||||
partDividers?: boolean;
|
||||
}
|
||||
|
||||
export default function Level({ exam, showSolutions = false, onFinish, preview = false }: Props) {
|
||||
const Level: React.FC<ExamProps<LevelExam>> = ({ exam, showSolutions = false, preview = false }) => {
|
||||
const levelBgColor = "bg-ielts-level-light";
|
||||
|
||||
const updateTimers = useExamTimer(exam.module, preview || showSolutions);
|
||||
const userSolutionRef = useRef<(() => UserSolution) | null>(null);
|
||||
const [solutionWasUpdated, setSolutionWasUpdated] = useState(false);
|
||||
|
||||
const examState = useExamStore((state) => state);
|
||||
const persistentExamState = usePersistentExamStore((state) => state);
|
||||
|
||||
const {
|
||||
userSolutions,
|
||||
hasExamEnded,
|
||||
partIndex,
|
||||
exerciseIndex,
|
||||
questionIndex,
|
||||
shuffles,
|
||||
currentSolution,
|
||||
setTimeIsUp,
|
||||
setBgColor,
|
||||
setUserSolutions,
|
||||
setHasExamEnded,
|
||||
setPartIndex,
|
||||
setExerciseIndex,
|
||||
setQuestionIndex,
|
||||
setShuffles,
|
||||
setCurrentSolution
|
||||
flags,
|
||||
timeSpentCurrentModule,
|
||||
dispatch,
|
||||
} = !preview ? examState : persistentExamState;
|
||||
|
||||
const { finalizeModule, timeIsUp } = flags;
|
||||
|
||||
const timer = useRef(exam.minTimer - timeSpentCurrentModule / 60);
|
||||
|
||||
// In case client want to switch back
|
||||
const textRenderDisabled = true;
|
||||
|
||||
@@ -61,8 +65,6 @@ export default function Level({ exam, showSolutions = false, onFinish, preview =
|
||||
const [continueAnyways, setContinueAnyways] = useState(false);
|
||||
const [textRender, setTextRender] = useState(false);
|
||||
const [changedPrompt, setChangedPrompt] = useState(false);
|
||||
const [nextExerciseCalled, setNextExerciseCalled] = useState(false);
|
||||
const [currentSolutionSet, setCurrentSolutionSet] = useState(false);
|
||||
|
||||
const [seenParts, setSeenParts] = useState<Set<number>>(new Set(showSolutions ? exam.parts.map((_, index) => index) : [0]));
|
||||
|
||||
@@ -72,8 +74,6 @@ export default function Level({ exam, showSolutions = false, onFinish, preview =
|
||||
type: "blankQuestions",
|
||||
onClose: function (x: boolean | undefined) { if (x) { setShowQuestionsModal(false); nextExercise(); } else { setShowQuestionsModal(false) } }
|
||||
});
|
||||
|
||||
const [currentExercise, setCurrentExercise] = useState<Exercise | undefined>(undefined);
|
||||
const [showPartDivider, setShowPartDivider] = useState<boolean>(typeof exam.parts[0].intro === "string" && !showSolutions);
|
||||
const [startNow, setStartNow] = useState<boolean>(!showSolutions);
|
||||
|
||||
@@ -86,12 +86,10 @@ export default function Level({ exam, showSolutions = false, onFinish, preview =
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [exerciseIndex]);
|
||||
|
||||
useEffect(() => {
|
||||
if (currentExercise === undefined && partIndex === 0 && exerciseIndex === 0) {
|
||||
setCurrentExercise(exam.parts[0].exercises[0]);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [currentExercise, partIndex, exerciseIndex]);
|
||||
const registerSolution = useCallback((updateSolution: () => UserSolution) => {
|
||||
userSolutionRef.current = updateSolution;
|
||||
setSolutionWasUpdated(true);
|
||||
}, []);
|
||||
|
||||
const scrollToTop = () => Array.from(document.getElementsByTagName("body")).forEach((body) => body.scrollTo(0, 0));
|
||||
|
||||
@@ -99,23 +97,6 @@ export default function Level({ exam, showSolutions = false, onFinish, preview =
|
||||
const [contextWordLines, setContextWordLines] = useState<number[] | undefined>(undefined);
|
||||
const [totalLines, setTotalLines] = useState<number>(0);
|
||||
|
||||
const [showSolutionsSave, setShowSolutionsSave] = useState(showSolutions ? userSolutions.filter((x) => x.module === "level") : undefined)
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof currentSolution !== "undefined") {
|
||||
setUserSolutions([...userSolutions.filter((x) => x.exercise !== currentSolution.exercise), { ...currentSolution, module: "level" as Module, exam: exam.id, shuffleMaps: exam.shuffle ? [...shuffles.find((x) => x.exerciseID == currentExercise?.id)?.shuffles!] : [] }]);
|
||||
setCurrentSolutionSet(true);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [currentSolution, exam.id, exam.shuffle, shuffles, currentExercise])
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof currentSolution !== "undefined") {
|
||||
setCurrentSolution(undefined);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [currentSolution]);
|
||||
|
||||
useEffect(() => {
|
||||
if (showSolutions) {
|
||||
const solutionShuffles = userSolutions.map(solution => ({
|
||||
@@ -127,42 +108,53 @@ export default function Level({ exam, showSolutions = false, onFinish, preview =
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const getExercise = () => {
|
||||
let exercise = exam.parts[partIndex]?.exercises[exerciseIndex];
|
||||
|
||||
const currentExercise = useMemo<Exercise>(() => {
|
||||
let exercise = exam.parts[partIndex].exercises[exerciseIndex];
|
||||
exercise = {
|
||||
...exercise,
|
||||
userSolutions: userSolutions.find((x) => x.exercise == exercise.id)?.solutions || [],
|
||||
userSolutions: userSolutions.find((x) => x.exercise === exercise.id)?.solutions || [],
|
||||
};
|
||||
exercise = shuffleExamExercise(exam.shuffle, exercise, showSolutions, userSolutions, shuffles, setShuffles);
|
||||
return exercise;
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [partIndex, exerciseIndex]);
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
setCurrentExercise(getExercise());
|
||||
if (solutionWasUpdated && userSolutionRef.current) {
|
||||
const solution = userSolutionRef.current();
|
||||
setUserSolutions([...userSolutions.filter((x) => x.exercise !== solution.exercise), { ...solution, module: "level" as Module, exam: exam.id, shuffleMaps: exam.shuffle ? [...shuffles.find((x) => x.exerciseID == currentExercise?.id)?.shuffles!] : [] }]);
|
||||
setSolutionWasUpdated(false);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [partIndex, exerciseIndex, questionIndex]);
|
||||
}, [solutionWasUpdated]);
|
||||
|
||||
const next = () => {
|
||||
setNextExerciseCalled(true);
|
||||
}
|
||||
useEffect(() => {
|
||||
if (finalizeModule || timeIsUp) {
|
||||
updateTimers();
|
||||
if (timeIsUp) setTimeIsUp(false);
|
||||
dispatch({ type: "FINALIZE_MODULE", payload: { updateTimers: false } })
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [finalizeModule, timeIsUp])
|
||||
|
||||
const nextExercise = () => {
|
||||
scrollToTop();
|
||||
|
||||
if (exerciseIndex + 1 < exam.parts[partIndex].exercises.length && !hasExamEnded) {
|
||||
if (exerciseIndex + 1 < exam.parts[partIndex].exercises.length) {
|
||||
setExerciseIndex(exerciseIndex + 1);
|
||||
setCurrentSolutionSet(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (partIndex + 1 === exam.parts.length && !hasExamEnded && !showQuestionsModal && !showSolutions && !continueAnyways) {
|
||||
if (partIndex + 1 === exam.parts.length && !showQuestionsModal && !showSolutions && !continueAnyways) {
|
||||
modalKwargs();
|
||||
setShowQuestionsModal(true);
|
||||
return;
|
||||
}
|
||||
|
||||
if (partIndex + 1 < exam.parts.length && !hasExamEnded) {
|
||||
if (!answeredEveryQuestion(partIndex) && !continueAnyways && !showSolutions && !seenParts.has(partIndex + 1)) {
|
||||
if (partIndex + 1 < exam.parts.length) {
|
||||
if (!answeredEveryQuestionInPart(exam, partIndex, userSolutions) && !continueAnyways && !showSolutions && !seenParts.has(partIndex + 1)) {
|
||||
modalKwargs();
|
||||
setShowQuestionsModal(true);
|
||||
return;
|
||||
@@ -181,7 +173,6 @@ export default function Level({ exam, showSolutions = false, onFinish, preview =
|
||||
setPartIndex(partIndex + 1);
|
||||
setExerciseIndex(0);
|
||||
setQuestionIndex(0);
|
||||
setCurrentSolutionSet(false);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -190,24 +181,14 @@ export default function Level({ exam, showSolutions = false, onFinish, preview =
|
||||
setShowQuestionsModal(true);
|
||||
}
|
||||
|
||||
setHasExamEnded(false);
|
||||
setCurrentSolutionSet(false);
|
||||
if (typeof showSolutionsSave !== "undefined") {
|
||||
onFinish(showSolutionsSave);
|
||||
} else {
|
||||
onFinish(userSolutions);
|
||||
}
|
||||
if (!showSolutions) {
|
||||
dispatch({ type: "FINALIZE_MODULE", payload: { updateTimers: true } })
|
||||
} else {
|
||||
dispatch({ type: "FINALIZE_MODULE_SOLUTIONS"})
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (nextExerciseCalled && currentSolutionSet) {
|
||||
nextExercise();
|
||||
setNextExerciseCalled(false);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [nextExerciseCalled, currentSolutionSet])
|
||||
|
||||
const previousExercise = (solution?: UserSolution) => {
|
||||
const previousExercise = () => {
|
||||
scrollToTop();
|
||||
|
||||
if (exam.parts[partIndex].context && questionIndex === 0 && !textRender && !textRenderDisabled) {
|
||||
@@ -353,25 +334,6 @@ export default function Level({ exam, showSolutions = false, onFinish, preview =
|
||||
}
|
||||
}
|
||||
|
||||
const answeredEveryQuestion = (partIndex: number) => {
|
||||
return exam.parts[partIndex].exercises.every((exercise) => {
|
||||
const userSolution = userSolutions.find(x => x.exercise === exercise.id);
|
||||
switch (exercise.type) {
|
||||
case 'multipleChoice':
|
||||
return userSolution?.solutions.length === exercise.questions.length;
|
||||
case 'fillBlanks':
|
||||
return userSolution?.solutions.length === exercise.words.length;
|
||||
case 'writeBlanks':
|
||||
return userSolution?.solutions.length === exercise.solutions.length;
|
||||
case 'matchSentences':
|
||||
return userSolution?.solutions.length === exercise.sentences.length;
|
||||
case 'trueFalse':
|
||||
return userSolution?.solutions.length === exercise.questions.length;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const regex = /.*?['"](.*?)['"] in line (\d+)\?$/;
|
||||
|
||||
@@ -408,8 +370,7 @@ export default function Level({ exam, showSolutions = false, onFinish, preview =
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
exerciseIndex !== -1 && currentExercise &&
|
||||
currentExercise.type === "multipleChoice" &&
|
||||
currentExercise && currentExercise.type === "multipleChoice" &&
|
||||
exam.parts[partIndex].context && contextWordLines
|
||||
) {
|
||||
if (contextWordLines.length > 0) {
|
||||
@@ -446,7 +407,7 @@ export default function Level({ exam, showSolutions = false, onFinish, preview =
|
||||
|
||||
if (partIndex === exam.parts.length - 1) {
|
||||
kwargs.type = "submit"
|
||||
kwargs.unanswered = !exam.parts.every((_, partIndex) => answeredEveryQuestion(partIndex));
|
||||
kwargs.unanswered = !exam.parts.every((_, partIndex) => answeredEveryQuestionInPart(exam, partIndex, userSolutions));
|
||||
kwargs.onClose = function (x: boolean | undefined) { if (x) { setShowSubmissionModal(true); setShowQuestionsModal(false); } else { setShowQuestionsModal(false) } };
|
||||
}
|
||||
setQuestionModalKwargs(kwargs);
|
||||
@@ -462,6 +423,7 @@ export default function Level({ exam, showSolutions = false, onFinish, preview =
|
||||
runOnClick: setQuestionIndex
|
||||
}
|
||||
|
||||
const progressButtons = <ProgressButtons handlePrevious={previousExercise} handleNext={nextExercise} />;
|
||||
|
||||
const memoizedRender = useMemo(() => {
|
||||
setChangedPrompt(false);
|
||||
@@ -473,8 +435,8 @@ export default function Level({ exam, showSolutions = false, onFinish, preview =
|
||||
{exam.parts[partIndex]?.context && renderText()}
|
||||
{exam.parts[partIndex]?.audio && renderAudioPlayer()}
|
||||
{(showSolutions) ?
|
||||
currentExercise && renderSolution(currentExercise, nextExercise, previousExercise) :
|
||||
currentExercise && renderExercise(currentExercise, exam.id, next, previousExercise)
|
||||
currentExercise && renderSolution(currentExercise, progressButtons, progressButtons) :
|
||||
currentExercise && renderExercise(currentExercise, exam.id, registerSolution, preview, progressButtons, progressButtons)
|
||||
}
|
||||
</>
|
||||
}
|
||||
@@ -520,34 +482,24 @@ export default function Level({ exam, showSolutions = false, onFinish, preview =
|
||||
onNext={() => { setShowPartDivider(false); setStartNow(false); setBgColor("bg-white"); setSeenParts(prev => new Set(prev).add(partIndex)); }}
|
||||
/> : (
|
||||
<>
|
||||
{exam.parts[0].intro && (
|
||||
<SectionNavbar
|
||||
module="level"
|
||||
sections={exam.parts}
|
||||
sectionLabel="Part"
|
||||
sectionIndex={partIndex}
|
||||
setSectionIndex={setPartIndex}
|
||||
onClick={
|
||||
(index: number) => {
|
||||
setExerciseIndex(0);
|
||||
setQuestionIndex(0);
|
||||
if (!seenParts.has(index)) {
|
||||
setShowPartDivider(true);
|
||||
setBgColor(levelBgColor);
|
||||
setSeenParts(prev => new Set(prev).add(index));
|
||||
}
|
||||
}
|
||||
} />
|
||||
)}
|
||||
<SectionNavbar
|
||||
module="level"
|
||||
sectionLabel="Part"
|
||||
seenParts={seenParts}
|
||||
setShowPartDivider={setShowPartDivider}
|
||||
setSeenParts={setSeenParts}
|
||||
preview={preview}
|
||||
/>
|
||||
<ModuleTitle
|
||||
examLabel={exam.label}
|
||||
partLabel={partLabel()}
|
||||
minTimer={exam.minTimer}
|
||||
minTimer={timer.current}
|
||||
exerciseIndex={calculateExerciseIndex()}
|
||||
module="level"
|
||||
totalExercises={countExercises(exam.parts.flatMap((x) => x.exercises))}
|
||||
disableTimer={showSolutions}
|
||||
showTimer={false}
|
||||
preview={preview}
|
||||
{...mcNavKwargs}
|
||||
/>
|
||||
<div
|
||||
@@ -563,3 +515,5 @@ export default function Level({ exam, showSolutions = false, onFinish, preview =
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default Level;
|
||||
|
||||
@@ -1,287 +1,127 @@
|
||||
import { ListeningExam, MultipleChoiceExercise, Script, UserSolution } from "@/interfaces/exam";
|
||||
import { Fragment, useEffect, useState } from "react";
|
||||
import { Exercise, ListeningExam, MultipleChoiceExercise, Script, UserSolution } from "@/interfaces/exam";
|
||||
import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { renderExercise } from "@/components/Exercises";
|
||||
import { renderSolution } from "@/components/Solutions";
|
||||
import ModuleTitle from "@/components/Medium/ModuleTitle";
|
||||
import AudioPlayer from "@/components/Low/AudioPlayer";
|
||||
import Button from "@/components/Low/Button";
|
||||
import BlankQuestionsModal from "@/components/QuestionsModal";
|
||||
import useExamStore, { usePersistentExamStore } from "@/stores/examStore";
|
||||
import useExamStore, { usePersistentExamStore } from "@/stores/exam";
|
||||
import PartDivider from "./Navigation/SectionDivider";
|
||||
import { Dialog, Transition } from "@headlessui/react";
|
||||
import { capitalize } from "lodash";
|
||||
import { mapBy } from "@/utils";
|
||||
import ScriptModal from "./components/ScriptModal";
|
||||
import { ExamProps } from "./types";
|
||||
import useExamTimer from "@/hooks/useExamTimer";
|
||||
import useExamNavigation from "./Navigation/useExamNavigation";
|
||||
import RenderAudioInstructionsPlayer from "./components/RenderAudioInstructionsPlayer";
|
||||
import RenderAudioPlayer from "./components/RenderAudioPlayer";
|
||||
import SectionNavbar from "./Navigation/SectionNavbar";
|
||||
import ProgressButtons from "./components/ProgressButtons";
|
||||
import { countExercises } from "@/utils/moduleUtils";
|
||||
import { calculateExerciseIndex } from "./utils/calculateExerciseIndex";
|
||||
|
||||
interface Props {
|
||||
exam: ListeningExam;
|
||||
showSolutions?: boolean;
|
||||
preview?: boolean;
|
||||
onFinish: (userSolutions: UserSolution[]) => void;
|
||||
}
|
||||
|
||||
function ScriptModal({ isOpen, script, onClose }: { isOpen: boolean; script: Script; 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">
|
||||
<div className="mt-2 overflow-auto mb-28">
|
||||
<p className="text-sm">
|
||||
{typeof script === "string" && script.split("\\n").map((line, index) => (
|
||||
<Fragment key={index}>
|
||||
{line}
|
||||
<br />
|
||||
</Fragment>
|
||||
))}
|
||||
|
||||
{typeof script === "object" && script.map((line, index) => (
|
||||
<span key={index}>
|
||||
<b>{line.name} ({capitalize(line.gender)})</b>: {line.text}
|
||||
<br />
|
||||
<br />
|
||||
</span>
|
||||
))}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
const INSTRUCTIONS_AUDIO_SRC =
|
||||
"https://firebasestorage.googleapis.com/v0/b/storied-phalanx-349916.appspot.com/o/generic_listening_intro_v2.mp3?alt=media&token=16769f5f-1e9b-4a72-86a9-45a6f0fa9f82";
|
||||
|
||||
export default function Listening({ exam, showSolutions = false, preview = false, onFinish }: Props) {
|
||||
const listeningBgColor = "bg-ielts-listening-light";
|
||||
const Listening: React.FC<ExamProps<ListeningExam>> = ({ exam, showSolutions = false, preview = false }) => {
|
||||
const updateTimers = useExamTimer(exam.module, preview || showSolutions);
|
||||
const userSolutionRef = useRef<(() => UserSolution) | null>(null);
|
||||
const [solutionWasUpdated, setSolutionWasUpdated] = useState(false);
|
||||
|
||||
const [showTextModal, setShowTextModal] = useState(false);
|
||||
const [timesListened, setTimesListened] = useState(0);
|
||||
const [showBlankModal, setShowBlankModal] = useState(false);
|
||||
const [multipleChoicesDone, setMultipleChoicesDone] = useState<{ id: string; amount: number }[]>([]);
|
||||
|
||||
const [seenParts, setSeenParts] = useState<Set<number>>(new Set(showSolutions ? exam.parts.map((_, index) => index) : []));
|
||||
const [showPartDivider, setShowPartDivider] = useState<boolean>(typeof exam.parts[0].intro === "string" && exam.parts[0].intro !== "");
|
||||
|
||||
const examState = useExamStore((state) => state);
|
||||
const persistentExamState = usePersistentExamStore((state) => state);
|
||||
|
||||
const {
|
||||
hasExamEnded,
|
||||
userSolutions,
|
||||
exerciseIndex,
|
||||
partIndex,
|
||||
questionIndex: storeQuestionIndex,
|
||||
setBgColor,
|
||||
setUserSolutions,
|
||||
setHasExamEnded,
|
||||
setExerciseIndex,
|
||||
setPartIndex,
|
||||
setQuestionIndex: setStoreQuestionIndex
|
||||
exerciseIndex, partIndex, assignment,
|
||||
userSolutions, flags, timeSpentCurrentModule,
|
||||
questionIndex,
|
||||
setBgColor, setUserSolutions, setTimeIsUp,
|
||||
dispatch
|
||||
} = !preview ? examState : persistentExamState;
|
||||
|
||||
const scrollToTop = () => Array.from(document.getElementsByTagName("body")).forEach((body) => body.scrollTo(0, 0));
|
||||
const { finalizeModule, timeIsUp } = flags;
|
||||
|
||||
const timer = useRef(exam.minTimer - timeSpentCurrentModule / 60);
|
||||
|
||||
const [isFirstTimeRender, setIsFirstTimeRender] = useState(partIndex === 0 && exerciseIndex == 0 && !showSolutions);
|
||||
|
||||
const {
|
||||
nextExercise, previousExercise,
|
||||
showPartDivider, setShowPartDivider,
|
||||
seenParts, setSeenParts
|
||||
} = useExamNavigation({ exam, module: "listening", showBlankModal, setShowBlankModal, showSolutions, preview, disableBetweenParts: true });
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
if (!showSolutions && exam.parts[partIndex]?.intro !== undefined && exam.parts[partIndex]?.intro !== "" && !seenParts.has(exerciseIndex)) {
|
||||
setShowPartDivider(true);
|
||||
setBgColor(listeningBgColor);
|
||||
if (finalizeModule || timeIsUp) {
|
||||
updateTimers();
|
||||
if (timeIsUp) setTimeIsUp(false);
|
||||
dispatch({ type: "FINALIZE_MODULE", payload: { updateTimers: false } })
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [partIndex]);
|
||||
}, [finalizeModule, timeIsUp])
|
||||
|
||||
useEffect(() => {
|
||||
if (showSolutions) return setExerciseIndex(-1);
|
||||
}, [setExerciseIndex, showSolutions]);
|
||||
|
||||
useEffect(() => {
|
||||
if (partIndex === -1 && exam.variant === "partial") {
|
||||
setPartIndex(0);
|
||||
}
|
||||
}, [partIndex, exam, setPartIndex]);
|
||||
|
||||
useEffect(() => {
|
||||
const previousParts = exam.parts.filter((_, index) => index < partIndex);
|
||||
let previousMultipleChoice = previousParts.flatMap((x) => x.exercises).filter((x) => x.type === "multipleChoice") as MultipleChoiceExercise[];
|
||||
|
||||
if (partIndex > -1 && exerciseIndex > -1) {
|
||||
const previousPartExercises = exam.parts[partIndex].exercises.filter((_, index) => index < exerciseIndex);
|
||||
const partMultipleChoice = previousPartExercises.filter((x) => x.type === "multipleChoice") as MultipleChoiceExercise[];
|
||||
|
||||
previousMultipleChoice = [...previousMultipleChoice, ...partMultipleChoice];
|
||||
}
|
||||
|
||||
setMultipleChoicesDone(previousMultipleChoice.map((x) => ({ id: x.id, amount: x.questions.length - 1 })));
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
const registerSolution = useCallback((updateSolution: () => UserSolution) => {
|
||||
userSolutionRef.current = updateSolution;
|
||||
setSolutionWasUpdated(true);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (hasExamEnded && exerciseIndex === -1) {
|
||||
setExerciseIndex(exerciseIndex + 1);
|
||||
if (solutionWasUpdated && userSolutionRef.current) {
|
||||
const solution = userSolutionRef.current();
|
||||
setUserSolutions([...userSolutions.filter((x) => x.exercise !== solution.exercise), { ...solution, module: "listening", exam: exam.id }]);
|
||||
setSolutionWasUpdated(false);
|
||||
}
|
||||
}, [hasExamEnded, exerciseIndex, setExerciseIndex]);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [solutionWasUpdated])
|
||||
|
||||
const currentExercise = useMemo<Exercise>(() => {
|
||||
const exercise = exam.parts[partIndex].exercises[exerciseIndex];
|
||||
return {
|
||||
...exercise,
|
||||
userSolutions: userSolutions.find((x) => x.exercise === exercise.id)?.solutions || [],
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [partIndex, exerciseIndex]);
|
||||
|
||||
|
||||
const confirmFinishModule = (keepGoing?: boolean) => {
|
||||
if (!keepGoing) {
|
||||
setShowBlankModal(false);
|
||||
return;
|
||||
} else {
|
||||
nextExercise(true);
|
||||
setShowBlankModal(false);
|
||||
}
|
||||
|
||||
onFinish(userSolutions);
|
||||
};
|
||||
|
||||
const nextExercise = (solution?: UserSolution) => {
|
||||
if (solution)
|
||||
setUserSolutions([
|
||||
...userSolutions.filter((x) => x.exercise !== solution.exercise),
|
||||
{ ...solution, module: "listening", exam: exam.id }
|
||||
]);
|
||||
};
|
||||
const memoizedExerciseIndex = useMemo(() =>
|
||||
calculateExerciseIndex(exam, partIndex, exerciseIndex, questionIndex)
|
||||
|
||||
const previousExercise = (solution?: UserSolution) => { };
|
||||
|
||||
const nextPart = () => {
|
||||
scrollToTop()
|
||||
if (partIndex + 1 < exam.parts.length && !hasExamEnded) {
|
||||
setPartIndex(partIndex + 1);
|
||||
setExerciseIndex(0);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!showSolutions && !hasExamEnded) {
|
||||
const exercises = partIndex < exam.parts.length ? exam.parts[partIndex].exercises : []
|
||||
const exerciseIDs = mapBy(exercises, 'id')
|
||||
|
||||
const hasMissing = userSolutions.filter(x => exerciseIDs.includes(x.exercise)).map(x => x.score.missing).some(x => x > 0)
|
||||
|
||||
if (hasMissing) return setShowBlankModal(true);
|
||||
|
||||
}
|
||||
|
||||
setHasExamEnded(false);
|
||||
onFinish(userSolutions);
|
||||
}
|
||||
|
||||
const renderPartExercises = () => {
|
||||
const exercises = partIndex > -1 ? exam.parts[partIndex].exercises : []
|
||||
const formattedExercises = exercises.map(exercise => ({
|
||||
...exercise,
|
||||
userSolutions: userSolutions.find((x) => x.exercise === exercise.id)?.solutions || [],
|
||||
}))
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
{formattedExercises.map(e => showSolutions
|
||||
? renderSolution(e, nextExercise, previousExercise, undefined, true)
|
||||
: renderExercise(e, exam.id, nextExercise, previousExercise, undefined, true))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const renderAudioInstructionsPlayer = () => (
|
||||
<div className="flex flex-col gap-8 w-full bg-mti-gray-seasalt rounded-xl py-8 px-16">
|
||||
<div className="flex flex-col w-full gap-2">
|
||||
<h4 className="text-xl font-semibold">Please listen to the instructions audio attentively.</h4>
|
||||
</div>
|
||||
<div className="rounded-xl flex flex-col gap-4 items-center w-full h-fit">
|
||||
<AudioPlayer key={partIndex} src={INSTRUCTIONS_AUDIO_SRC} color="listening" />
|
||||
</div>
|
||||
</div>
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
, [partIndex, exerciseIndex, questionIndex]
|
||||
);
|
||||
|
||||
const renderAudioPlayer = () => (
|
||||
<div className="flex flex-col gap-8 w-full bg-mti-gray-seasalt rounded-xl py-8 px-16">
|
||||
{exam?.parts[partIndex]?.audio?.source ? (
|
||||
<>
|
||||
<div className="w-full items-start flex justify-between">
|
||||
<div className="flex flex-col w-full gap-2">
|
||||
<h4 className="text-xl font-semibold">Please listen to the following audio attentively.</h4>
|
||||
<span className="text-base">
|
||||
{(() => {
|
||||
const audioRepeatTimes = exam?.parts[partIndex]?.audio?.repeatableTimes;
|
||||
return audioRepeatTimes && audioRepeatTimes > 0
|
||||
? `You will only be allowed to listen to the audio ${audioRepeatTimes - timesListened} time(s).`
|
||||
: "You may listen to the audio as many times as you would like.";
|
||||
})()}
|
||||
</span>
|
||||
</div>
|
||||
{partIndex > -1 && !examState.assignment && !!exam.parts[partIndex].script && (
|
||||
<Button
|
||||
onClick={() => setShowTextModal(true)}
|
||||
variant="outline"
|
||||
color="gray"
|
||||
className="w-full max-w-[200px]"
|
||||
>
|
||||
View Transcript
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className="rounded-xl flex flex-col gap-4 items-center w-full h-fit">
|
||||
<AudioPlayer
|
||||
key={partIndex}
|
||||
src={exam?.parts[partIndex]?.audio?.source ?? ''}
|
||||
color="listening"
|
||||
onEnd={() => setTimesListened((prev) => prev + 1)}
|
||||
disabled={exam?.parts[partIndex]?.audio?.repeatableTimes != null &&
|
||||
timesListened === exam.parts[partIndex]?.audio?.repeatableTimes}
|
||||
disablePause
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<span>This section will be displayed the audio once it has been generated.</span>
|
||||
)}
|
||||
const handlePartDividerClick = () => {
|
||||
setShowPartDivider(false);
|
||||
setBgColor("bg-white");
|
||||
setSeenParts((prev) => new Set(prev).add(partIndex));
|
||||
if (isFirstTimeRender) setIsFirstTimeRender(false);
|
||||
}
|
||||
|
||||
</div>
|
||||
);
|
||||
useEffect(() => {
|
||||
setTimesListened(0);
|
||||
}, [partIndex])
|
||||
|
||||
const progressButtons = () => (
|
||||
<div className="flex justify-between w-full gap-8">
|
||||
<Button
|
||||
color="purple"
|
||||
variant="outline"
|
||||
onClick={previousExercise}
|
||||
className="max-w-[200px] w-full">
|
||||
Back
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
color="purple"
|
||||
onClick={nextPart}
|
||||
className="max-w-[200px] self-end w-full">
|
||||
Next
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
const progressButtons = useMemo(() =>
|
||||
// Do not remove the ()=> in handle next
|
||||
<ProgressButtons handlePrevious={previousExercise} handleNext={() => nextExercise()} />
|
||||
, [nextExercise, previousExercise]);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -292,73 +132,64 @@ export default function Listening({ exam, showSolutions = false, preview = false
|
||||
defaultTitle="Listening exam"
|
||||
section={exam.parts[partIndex]}
|
||||
sectionIndex={partIndex}
|
||||
onNext={() => { setShowPartDivider(false); setBgColor("bg-white"); setSeenParts((prev) => new Set(prev).add(exerciseIndex)) }}
|
||||
onNext={handlePartDividerClick}
|
||||
/> : (
|
||||
<>
|
||||
<BlankQuestionsModal isOpen={showBlankModal} onClose={confirmFinishModule} />
|
||||
{partIndex > -1 && exam.parts[partIndex].script &&
|
||||
{!isFirstTimeRender && exam.parts[partIndex].script &&
|
||||
<ScriptModal script={exam.parts[partIndex].script!} isOpen={showTextModal} onClose={() => setShowTextModal(false)} />
|
||||
}
|
||||
<div className="flex flex-col h-full w-full gap-8 justify-between">
|
||||
<ModuleTitle
|
||||
exerciseIndex={partIndex + 1}
|
||||
minTimer={exam.minTimer}
|
||||
{exam.parts.length > 1 && <SectionNavbar
|
||||
module="listening"
|
||||
totalExercises={exam.parts.length}
|
||||
sectionLabel="Section"
|
||||
seenParts={seenParts}
|
||||
setShowPartDivider={setShowPartDivider}
|
||||
setSeenParts={setSeenParts}
|
||||
preview={preview}
|
||||
/>
|
||||
}
|
||||
<ModuleTitle
|
||||
minTimer={timer.current}
|
||||
module="listening"
|
||||
exerciseIndex={memoizedExerciseIndex}
|
||||
totalExercises={countExercises(exam.parts.flatMap((x) => x.exercises))}
|
||||
disableTimer={showSolutions || preview}
|
||||
indexLabel="Part"
|
||||
indexLabel="Exercise"
|
||||
preview={preview}
|
||||
/>
|
||||
|
||||
{/* Audio Player for the Instructions */}
|
||||
{partIndex === -1 && renderAudioInstructionsPlayer()}
|
||||
{isFirstTimeRender && <RenderAudioInstructionsPlayer />}
|
||||
|
||||
{/* Part's audio player */}
|
||||
{partIndex > -1 && renderAudioPlayer()}
|
||||
{!isFirstTimeRender &&
|
||||
<RenderAudioPlayer
|
||||
audioSource={exam?.parts[partIndex]?.audio?.source}
|
||||
repeatableTimes={exam?.parts[partIndex]?.audio?.repeatableTimes}
|
||||
script={exam?.parts[partIndex]?.script}
|
||||
assignment={assignment}
|
||||
timesListened={timesListened}
|
||||
setShowTextModal={setShowTextModal}
|
||||
setTimesListened={setTimesListened}
|
||||
/>}
|
||||
|
||||
{/* Exercise renderer */}
|
||||
|
||||
{exerciseIndex > -1 && partIndex > -1 && (
|
||||
<>
|
||||
{progressButtons()}
|
||||
{renderPartExercises()}
|
||||
{progressButtons()}
|
||||
</>
|
||||
)}
|
||||
{!isFirstTimeRender && !showPartDivider && !showSolutions && renderExercise(currentExercise, exam.id, registerSolution, preview, progressButtons, progressButtons)}
|
||||
{showSolutions && renderSolution(currentExercise, progressButtons, progressButtons)}
|
||||
</div>
|
||||
|
||||
{exerciseIndex === -1 && partIndex > -1 && exam.variant !== "partial" && (
|
||||
<div className="self-end flex justify-between w-full gap-8 absolute bottom-8 left-0 px-8">
|
||||
<Button
|
||||
color="purple"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
if (partIndex === 0) return setPartIndex(-1);
|
||||
|
||||
setExerciseIndex(exam.parts[partIndex - 1].exercises.length - 1);
|
||||
setPartIndex(partIndex - 1);
|
||||
}}
|
||||
className="max-w-[200px] w-full">
|
||||
Back
|
||||
</Button>
|
||||
|
||||
<Button color="purple" onClick={() => setExerciseIndex(0)} className="max-w-[200px] self-end w-full">
|
||||
Next
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{partIndex === -1 && exam.variant !== "partial" && (
|
||||
<Button color="purple" onClick={() => setPartIndex(0)} className="max-w-[200px] self-end w-full justify-self-end">
|
||||
Start now
|
||||
</Button>
|
||||
)}
|
||||
{exerciseIndex === -1 && partIndex === 0 && exam.variant === "partial" && (
|
||||
<Button color="purple" onClick={() => setExerciseIndex(0)} className="max-w-[200px] self-end w-full justify-self-end">
|
||||
Start now
|
||||
</Button>
|
||||
)}
|
||||
{((isFirstTimeRender) && !showPartDivider && !showSolutions) &&
|
||||
<ProgressButtons
|
||||
hidePrevious={partIndex == 0 && isFirstTimeRender}
|
||||
nextLabel={isFirstTimeRender ? "Start now" : "Next Page"}
|
||||
handlePrevious={previousExercise}
|
||||
handleNext={() => isFirstTimeRender ? setIsFirstTimeRender(false) : nextExercise()} />
|
||||
}
|
||||
</>)
|
||||
}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default Listening;
|
||||
|
||||
@@ -1,26 +1,64 @@
|
||||
import { Module } from "@/interfaces";
|
||||
import { LevelPart, ListeningPart, ReadingPart, SpeakingExercise, WritingExercise } from "@/interfaces/exam";
|
||||
import { ExerciseOnlyExam, LevelPart, ListeningPart, PartExam, ReadingPart, SpeakingExercise, WritingExercise } from "@/interfaces/exam";
|
||||
import useExamStore, { usePersistentExamStore } from "@/stores/exam";
|
||||
import { Tab, TabGroup, TabList } from "@headlessui/react";
|
||||
import clsx from "clsx";
|
||||
import React from "react";
|
||||
import hasDivider from "../utils/hasDivider";
|
||||
|
||||
interface Props {
|
||||
module: Module;
|
||||
sections: LevelPart[] | ReadingPart[] | ListeningPart[] | WritingExercise[] | SpeakingExercise[];
|
||||
sectionIndex: number;
|
||||
sectionLabel: string;
|
||||
setSectionIndex: (index: number) => void;
|
||||
onClick: (index: number) => void;
|
||||
seenParts: Set<number>;
|
||||
setShowPartDivider: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
setSeenParts: React.Dispatch<React.SetStateAction<Set<number>>>;
|
||||
preview: boolean;
|
||||
setIsBetweenParts?: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
}
|
||||
|
||||
const SectionNavbar: React.FC<Props> = ({module, sections, sectionIndex, sectionLabel, setSectionIndex, onClick}) => {
|
||||
const SectionNavbar: React.FC<Props> = ({ module, sectionLabel, seenParts, setSeenParts, setShowPartDivider, setIsBetweenParts, preview }) => {
|
||||
|
||||
const isPartExam = ["reading", "listening", "level"].includes(module);
|
||||
const examState = useExamStore((state) => state);
|
||||
const persistentExamState = usePersistentExamStore((state) => state);
|
||||
|
||||
const {
|
||||
exam,
|
||||
partIndex, setPartIndex,
|
||||
exerciseIndex, setExerciseIndex,
|
||||
setQuestionIndex,
|
||||
setBgColor
|
||||
} = !preview ? examState : persistentExamState;
|
||||
|
||||
const sections = isPartExam ? (exam as PartExam).parts : (exam as ExerciseOnlyExam).exercises;
|
||||
const sectionIndex = isPartExam ? partIndex : exerciseIndex;
|
||||
|
||||
const handleSectionChange = (index: number) => {
|
||||
const changeSection = isPartExam ? setPartIndex : setExerciseIndex;
|
||||
changeSection(index);
|
||||
}
|
||||
|
||||
const handleClick = (index: number) => {
|
||||
setExerciseIndex(0);
|
||||
setQuestionIndex(0);
|
||||
if (!seenParts.has(index)) {
|
||||
if (hasDivider(exam!, index)) {
|
||||
setShowPartDivider(true);
|
||||
setBgColor(`bg-ielts-${module}-light`);
|
||||
} else if(setIsBetweenParts) {
|
||||
setIsBetweenParts(true);
|
||||
}
|
||||
setSeenParts(prev => new Set(prev).add(index));
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
<div className="w-full">
|
||||
<TabGroup className="w-[90%]" selectedIndex={sectionIndex} onChange={setSectionIndex}>
|
||||
<TabGroup className="w-[90%]" selectedIndex={sectionIndex} onChange={handleSectionChange}>
|
||||
<TabList className={`flex space-x-1 rounded-xl bg-ielts-${module}/20 p-1`}>
|
||||
{sections.map((_, index) =>
|
||||
<Tab key={index} onClick={() => onClick(index)}
|
||||
<Tab key={index} onClick={() => handleClick(index)}
|
||||
className={({ selected }) =>
|
||||
clsx(
|
||||
`w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-ielts-${module}/80`,
|
||||
|
||||
217
src/exams/Navigation/useExamNavigation.tsx
Normal file
217
src/exams/Navigation/useExamNavigation.tsx
Normal file
@@ -0,0 +1,217 @@
|
||||
import { ExerciseOnlyExam, ModuleExam, PartExam } from "@/interfaces/exam";
|
||||
import { useState, useEffect } from "react";
|
||||
import scrollToTop from "../utils/scrollToTop";
|
||||
import { Module } from "@/interfaces";
|
||||
import { answeredEveryQuestion } from "../utils/answeredEveryQuestion";
|
||||
import useExamStore, { usePersistentExamStore } from "@/stores/exam";
|
||||
import hasDivider from "../utils/hasDivider";
|
||||
|
||||
const MC_PER_PAGE = 2;
|
||||
|
||||
type UseExamNavigation = (props: {
|
||||
exam: ModuleExam;
|
||||
module: Module;
|
||||
showBlankModal?: boolean;
|
||||
setShowBlankModal?: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
showSolutions: boolean;
|
||||
preview: boolean;
|
||||
disableBetweenParts?: boolean;
|
||||
}) => {
|
||||
showPartDivider: boolean;
|
||||
seenParts: Set<number>;
|
||||
isBetweenParts: boolean;
|
||||
nextExercise: (isBetweenParts?: boolean) => void;
|
||||
previousExercise: (isBetweenParts?: boolean) => void;
|
||||
setShowPartDivider: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
setSeenParts: React.Dispatch<React.SetStateAction<Set<number>>>;
|
||||
setIsBetweenParts: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
};
|
||||
|
||||
const useExamNavigation: UseExamNavigation = ({
|
||||
exam,
|
||||
module,
|
||||
setShowBlankModal,
|
||||
showSolutions,
|
||||
preview,
|
||||
disableBetweenParts = false,
|
||||
}) => {
|
||||
|
||||
const examState = useExamStore((state) => state);
|
||||
const persistentExamState = usePersistentExamStore((state) => state);
|
||||
|
||||
const {
|
||||
exerciseIndex, setExerciseIndex,
|
||||
partIndex, setPartIndex,
|
||||
questionIndex, setQuestionIndex,
|
||||
userSolutions, setModuleIndex,
|
||||
setBgColor,
|
||||
dispatch,
|
||||
} = !preview ? examState : persistentExamState;
|
||||
|
||||
const [isBetweenParts, setIsBetweenParts] = useState(partIndex !== 0 && exerciseIndex == 0 && !disableBetweenParts);
|
||||
const isPartExam = ["reading", "listening", "level"].includes(exam.module);
|
||||
|
||||
const [seenParts, setSeenParts] = useState<Set<number>>(
|
||||
new Set(showSolutions ?
|
||||
(isPartExam ?
|
||||
(exam as PartExam).parts.map((_, index) => index) :
|
||||
(exam as ExerciseOnlyExam).exercises.map((_, index) => index)
|
||||
) :
|
||||
[]
|
||||
)
|
||||
);
|
||||
const [showPartDivider, setShowPartDivider] = useState<boolean>(hasDivider(exam, 0));
|
||||
|
||||
useEffect(() => {
|
||||
if (!showSolutions && hasDivider(exam, isPartExam ? partIndex : exerciseIndex) && !seenParts.has(partIndex)) {
|
||||
setShowPartDivider(true);
|
||||
setBgColor(`bg-ielts-${module}-light`);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [partIndex]);
|
||||
|
||||
const nextExercise = (keepGoing: boolean = false) => {
|
||||
scrollToTop();
|
||||
|
||||
if (isPartExam) {
|
||||
nextPartExam(keepGoing);
|
||||
} else {
|
||||
nextExerciseOnlyExam();
|
||||
}
|
||||
};
|
||||
|
||||
const previousExercise = () => {
|
||||
scrollToTop();
|
||||
|
||||
if (isPartExam) {
|
||||
previousPartExam();
|
||||
} else {
|
||||
previousExerciseOnlyExam();
|
||||
}
|
||||
};
|
||||
|
||||
const nextPartExam = (keepGoing: boolean) => {
|
||||
const partExam = (exam as PartExam);
|
||||
|
||||
const reachedFinalExercise = exerciseIndex + 1 === partExam.parts[partIndex].exercises.length;
|
||||
const currentExercise = partExam.parts[partIndex].exercises[exerciseIndex];
|
||||
|
||||
if (isBetweenParts) {
|
||||
setIsBetweenParts(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (currentExercise.type === "multipleChoice" && questionIndex < currentExercise.questions.length - 1) {
|
||||
setQuestionIndex(questionIndex + MC_PER_PAGE);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!reachedFinalExercise) {
|
||||
setExerciseIndex(exerciseIndex + 1);
|
||||
setQuestionIndex(0);
|
||||
return;
|
||||
}
|
||||
|
||||
if (partIndex < partExam.parts.length - 1) {
|
||||
if (!disableBetweenParts) setIsBetweenParts(true);
|
||||
setPartIndex(partIndex + 1);
|
||||
setExerciseIndex(0);
|
||||
setQuestionIndex(0);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!answeredEveryQuestion(exam as PartExam, userSolutions) && !keepGoing && setShowBlankModal && !showSolutions && !preview) {
|
||||
setShowBlankModal(true);
|
||||
return;
|
||||
}
|
||||
|
||||
if (preview) {
|
||||
setPartIndex(0);
|
||||
setExerciseIndex(0);
|
||||
setQuestionIndex(0);
|
||||
}
|
||||
|
||||
if (!showSolutions) {
|
||||
dispatch({ type: "FINALIZE_MODULE", payload: { updateTimers: true } });
|
||||
} else {
|
||||
dispatch({ type: "FINALIZE_MODULE_SOLUTIONS"});
|
||||
}
|
||||
}
|
||||
|
||||
const previousPartExam = () => {
|
||||
if (partIndex !== 0) {
|
||||
setPartIndex(partIndex - 1);
|
||||
setExerciseIndex((exam as PartExam).parts[partIndex].exercises.length - 1);
|
||||
if (isBetweenParts) setIsBetweenParts(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setQuestionIndex(0);
|
||||
|
||||
if (exerciseIndex === 0 && !disableBetweenParts) {
|
||||
setIsBetweenParts(true);
|
||||
return;
|
||||
}
|
||||
|
||||
if (exerciseIndex !== 0) {
|
||||
setExerciseIndex(exerciseIndex - 1);
|
||||
}
|
||||
};
|
||||
|
||||
const nextExerciseOnlyExam = () => {
|
||||
const exerciseOnlyExam = (exam as ExerciseOnlyExam);
|
||||
const reachedFinalExercise = exerciseIndex + 1 === exerciseOnlyExam.exercises.length;
|
||||
const currentExercise = exerciseOnlyExam.exercises[exerciseIndex];
|
||||
|
||||
if (currentExercise.type === "interactiveSpeaking" && questionIndex < currentExercise.prompts.length - 1) {
|
||||
setQuestionIndex(questionIndex + 1)
|
||||
return;
|
||||
}
|
||||
|
||||
if (!reachedFinalExercise) {
|
||||
setQuestionIndex(0);
|
||||
setExerciseIndex(exerciseIndex + 1);
|
||||
return;
|
||||
}
|
||||
|
||||
if (preview) {
|
||||
setPartIndex(0);
|
||||
setExerciseIndex(0);
|
||||
setQuestionIndex(0);
|
||||
}
|
||||
|
||||
if (!showSolutions) {
|
||||
dispatch({ type: "FINALIZE_MODULE", payload: { updateTimers: true } });
|
||||
} else {
|
||||
dispatch({ type: "FINALIZE_MODULE_SOLUTIONS"});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
const previousExerciseOnlyExam = () => {
|
||||
const currentExercise = (exam as ExerciseOnlyExam).exercises[exerciseIndex];
|
||||
|
||||
if (currentExercise.type === "interactiveSpeaking" && questionIndex !== 0) {
|
||||
setQuestionIndex(questionIndex - 1);
|
||||
return;
|
||||
}
|
||||
|
||||
if (exerciseIndex !== 0) {
|
||||
setExerciseIndex(exerciseIndex - 1);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
seenParts,
|
||||
showPartDivider,
|
||||
nextExercise,
|
||||
previousExercise,
|
||||
setShowPartDivider,
|
||||
setSeenParts,
|
||||
isBetweenParts,
|
||||
setIsBetweenParts,
|
||||
};
|
||||
}
|
||||
|
||||
export default useExamNavigation;
|
||||
@@ -1,162 +1,66 @@
|
||||
import { MultipleChoiceExercise, ReadingExam, ReadingPart, UserSolution } from "@/interfaces/exam";
|
||||
import { Fragment, useEffect, useState } from "react";
|
||||
import { Exercise, ReadingExam, UserSolution } from "@/interfaces/exam";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import clsx from "clsx";
|
||||
import { convertCamelCaseToReadable } from "@/utils/string";
|
||||
import { Dialog, Transition } from "@headlessui/react";
|
||||
import { renderExercise } from "@/components/Exercises";
|
||||
import { renderSolution } from "@/components/Solutions";
|
||||
import { BsChevronDown, BsChevronUp } from "react-icons/bs";
|
||||
import ModuleTitle from "@/components/Medium/ModuleTitle";
|
||||
import Button from "@/components/Low/Button";
|
||||
import BlankQuestionsModal from "@/components/QuestionsModal";
|
||||
import useExamStore, { usePersistentExamStore } from "@/stores/examStore";
|
||||
import { countExercises } from "@/utils/moduleUtils";
|
||||
import PartDivider from "./Navigation/SectionDivider";
|
||||
import ReadingPassage from "./components/ReadingPassage";
|
||||
//import ReadingPassageModal from "./components/ReadingPassageModal";
|
||||
import {calculateExerciseIndex} from "./utils/calculateExerciseIndex";
|
||||
import useExamNavigation from "./Navigation/useExamNavigation";
|
||||
import { ExamProps } from "./types";
|
||||
import useExamStore, { usePersistentExamStore } from "@/stores/exam";
|
||||
import useExamTimer from "@/hooks/useExamTimer";
|
||||
import SectionNavbar from "./Navigation/SectionNavbar";
|
||||
import ProgressButtons from "./components/ProgressButtons";
|
||||
|
||||
interface Props {
|
||||
exam: ReadingExam;
|
||||
showSolutions?: boolean;
|
||||
preview?: boolean;
|
||||
onFinish: (userSolutions: UserSolution[]) => void;
|
||||
}
|
||||
const Reading: React.FC<ExamProps<ReadingExam>> = ({ exam, showSolutions = false, preview = false }) => {
|
||||
const updateTimers = useExamTimer(exam.module, preview || showSolutions);
|
||||
const userSolutionRef = useRef<(() => UserSolution) | null>(null);
|
||||
const [solutionWasUpdated, setSolutionWasUpdated] = useState(false);
|
||||
|
||||
const numberToLetter = (number: number) => (number + 9).toString(36).toUpperCase();
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
function TextComponent({ part, exerciseType }: { part: ReadingPart; exerciseType: string }) {
|
||||
return (
|
||||
<div className="flex flex-col gap-2 w-full">
|
||||
<h3 className="text-xl font-semibold">{part.text.title}</h3>
|
||||
<div className="border border-mti-gray-dim w-full rounded-full opacity-10" />
|
||||
{part.text.content
|
||||
.split(/\n|(\\n)/g)
|
||||
.filter((x) => x && x.length > 0 && x !== "\\n")
|
||||
.map((line, index) => (
|
||||
<Fragment key={index}>
|
||||
{exerciseType === "matchSentences" && (
|
||||
<div className="flex gap-3 border border-transparent hover:border-mti-purple-light rounded-lg transition ease-in-out duration-300 p-2 px-3 cursor-pointer">
|
||||
<span className="font-bold text-mti-purple-dark">{numberToLetter(index + 1)}</span>
|
||||
<p>{line}</p>
|
||||
</div>
|
||||
)}
|
||||
{exerciseType !== "matchSentences" && <p key={index}>{line}</p>}
|
||||
</Fragment>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Reading({ exam, showSolutions = false, preview = false, onFinish }: Props) {
|
||||
const readingBgColor = "bg-ielts-reading-light";
|
||||
|
||||
const [showTextModal, setShowTextModal] = useState(false);
|
||||
const [showBlankModal, setShowBlankModal] = useState(false);
|
||||
const [multipleChoicesDone, setMultipleChoicesDone] = useState<{ id: string; amount: number }[]>([]);
|
||||
//const [showTextModal, setShowTextModal] = useState(false);
|
||||
const [isTextMinimized, setIsTextMinimzed] = useState(false);
|
||||
const [exerciseType, setExerciseType] = useState("");
|
||||
|
||||
const [seenParts, setSeenParts] = useState<Set<number>>(new Set(showSolutions ? exam.parts.map((_, index) => index) : []));
|
||||
const [showPartDivider, setShowPartDivider] = useState<boolean>(typeof exam.parts[0].intro === "string" && exam.parts[0].intro !== "");
|
||||
|
||||
const examState = useExamStore((state) => state);
|
||||
const persistentExamState = usePersistentExamStore((state) => state);
|
||||
|
||||
const {
|
||||
hasExamEnded,
|
||||
userSolutions,
|
||||
exerciseIndex,
|
||||
partIndex,
|
||||
questionIndex: storeQuestionIndex,
|
||||
setBgColor,
|
||||
setUserSolutions,
|
||||
setHasExamEnded,
|
||||
setExerciseIndex,
|
||||
setPartIndex,
|
||||
setQuestionIndex: setStoreQuestionIndex
|
||||
exerciseIndex, partIndex, questionIndex,
|
||||
userSolutions, flags, timeSpentCurrentModule,
|
||||
setBgColor, setUserSolutions, setTimeIsUp,
|
||||
dispatch
|
||||
} = !preview ? examState : persistentExamState;
|
||||
|
||||
|
||||
const scrollToTop = () => Array.from(document.getElementsByTagName("body")).forEach((body) => body.scrollTo(0, 0));
|
||||
const timer = useRef(exam.minTimer - timeSpentCurrentModule / 60);
|
||||
|
||||
const { finalizeModule, timeIsUp } = flags;
|
||||
const [isFirstTimeRender, setIsFirstTimeRender] = useState(partIndex === 0 && exerciseIndex == 0 && !showSolutions);
|
||||
|
||||
const {
|
||||
nextExercise, previousExercise,
|
||||
showPartDivider, setShowPartDivider,
|
||||
seenParts, setSeenParts,
|
||||
isBetweenParts, setIsBetweenParts
|
||||
} = useExamNavigation({ exam, module: "reading", showBlankModal, setShowBlankModal, showSolutions, preview, disableBetweenParts: showSolutions });
|
||||
|
||||
useEffect(() => {
|
||||
if (!showSolutions && exam.parts[partIndex]?.intro !== undefined && exam.parts[partIndex]?.intro !== "" && !seenParts.has(exerciseIndex)) {
|
||||
setShowPartDivider(true);
|
||||
setBgColor(readingBgColor);
|
||||
if (finalizeModule || timeIsUp) {
|
||||
updateTimers();
|
||||
|
||||
if (timeIsUp) {
|
||||
setTimeIsUp(false);
|
||||
}
|
||||
dispatch({ type: "FINALIZE_MODULE", payload: { updateTimers: false } })
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [partIndex]);
|
||||
|
||||
useEffect(() => {
|
||||
if (showSolutions) setExerciseIndex(-1);
|
||||
}, [setExerciseIndex, showSolutions]);
|
||||
|
||||
useEffect(() => {
|
||||
const previousParts = exam.parts.filter((_, index) => index < partIndex);
|
||||
let previousMultipleChoice = previousParts.flatMap((x) => x.exercises).filter((x) => x.type === "multipleChoice") as MultipleChoiceExercise[];
|
||||
|
||||
if (partIndex > -1 && exerciseIndex > -1) {
|
||||
const previousPartExercises = exam.parts[partIndex].exercises.filter((_, index) => index < exerciseIndex);
|
||||
const partMultipleChoice = previousPartExercises.filter((x) => x.type === "multipleChoice") as MultipleChoiceExercise[];
|
||||
|
||||
previousMultipleChoice = [...previousMultipleChoice, ...partMultipleChoice];
|
||||
}
|
||||
|
||||
setMultipleChoicesDone(previousMultipleChoice.map((x) => ({ id: x.id, amount: x.questions.length - 1 })));
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
}, [finalizeModule, timeIsUp])
|
||||
|
||||
useEffect(() => {
|
||||
const listener = (e: KeyboardEvent) => {
|
||||
@@ -164,144 +68,70 @@ export default function Reading({ exam, showSolutions = false, preview = false,
|
||||
e.preventDefault();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("keydown", listener);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("keydown", listener);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const registerSolution = useCallback((updateSolution: () => UserSolution) => {
|
||||
userSolutionRef.current = updateSolution;
|
||||
setSolutionWasUpdated(true);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (hasExamEnded && exerciseIndex === -1) {
|
||||
setExerciseIndex(exerciseIndex + 1);
|
||||
}
|
||||
}, [hasExamEnded, exerciseIndex, setExerciseIndex]);
|
||||
|
||||
const confirmFinishModule = (keepGoing?: boolean) => {
|
||||
if (!keepGoing) {
|
||||
setShowBlankModal(false);
|
||||
return;
|
||||
}
|
||||
|
||||
onFinish(userSolutions);
|
||||
};
|
||||
|
||||
const nextExercise = (solution?: UserSolution) => {
|
||||
scrollToTop();
|
||||
if (solution) {
|
||||
if (solutionWasUpdated && userSolutionRef.current) {
|
||||
const solution = userSolutionRef.current();
|
||||
setUserSolutions([...userSolutions.filter((x) => x.exercise !== solution.exercise), { ...solution, module: "reading", exam: exam.id }]);
|
||||
setSolutionWasUpdated(false);
|
||||
}
|
||||
if (storeQuestionIndex > 0) {
|
||||
const exercise = getExercise();
|
||||
setExerciseType(exercise.type);
|
||||
setMultipleChoicesDone((prev) => [...prev.filter((x) => x.id !== exercise.id), { id: exercise.id, amount: storeQuestionIndex }]);
|
||||
}
|
||||
setStoreQuestionIndex(0);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [solutionWasUpdated])
|
||||
|
||||
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 &&
|
||||
!preview &&
|
||||
!hasExamEnded
|
||||
) {
|
||||
setShowBlankModal(true);
|
||||
return;
|
||||
}
|
||||
|
||||
setHasExamEnded(false);
|
||||
|
||||
if (solution) {
|
||||
onFinish([...userSolutions.filter((x) => x.exercise !== solution.exercise), { ...solution, module: "reading", exam: exam.id }]);
|
||||
} else {
|
||||
onFinish(userSolutions);
|
||||
}
|
||||
};
|
||||
|
||||
const previousExercise = (solution?: UserSolution) => {
|
||||
scrollToTop();
|
||||
if (solution) {
|
||||
setUserSolutions([...userSolutions.filter((x) => x.exercise !== solution.exercise), { ...solution, module: "reading", exam: exam.id }]);
|
||||
}
|
||||
setStoreQuestionIndex(0);
|
||||
|
||||
setExerciseIndex(exerciseIndex - 1);
|
||||
};
|
||||
|
||||
const getExercise = () => {
|
||||
const currentExercise = useMemo<Exercise>(() => {
|
||||
const exercise = exam.parts[partIndex].exercises[exerciseIndex];
|
||||
return {
|
||||
...exercise,
|
||||
userSolutions: userSolutions.find((x) => x.exercise === exercise.id)?.solutions || [],
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [partIndex, exerciseIndex]);
|
||||
|
||||
|
||||
const confirmFinishModule = (keepGoing?: boolean) => {
|
||||
if (!keepGoing) {
|
||||
setShowBlankModal(false);
|
||||
return;
|
||||
} else {
|
||||
nextExercise(true);
|
||||
setShowBlankModal(false);
|
||||
}
|
||||
};
|
||||
|
||||
const memoizedExerciseIndex = useMemo(() =>
|
||||
calculateExerciseIndex(exam, partIndex, exerciseIndex, questionIndex)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
, [partIndex, exerciseIndex, questionIndex]
|
||||
);
|
||||
|
||||
const handlePartDividerClick = () => {
|
||||
setShowPartDivider(false);
|
||||
setBgColor("bg-white");
|
||||
setSeenParts((prev) => new Set(prev).add(partIndex));
|
||||
if (isFirstTimeRender) setIsFirstTimeRender(false);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (partIndex > -1 && exerciseIndex > -1) {
|
||||
const exercise = getExercise();
|
||||
setExerciseType(exercise.type);
|
||||
setMultipleChoicesDone((prev) => prev.filter((x) => x.id !== exercise.id));
|
||||
if (partIndex !== 0 && !showSolutions) {
|
||||
setIsBetweenParts(true);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [exerciseIndex, partIndex]);
|
||||
}, [partIndex, setIsBetweenParts, showSolutions])
|
||||
|
||||
const calculateExerciseIndex = () => {
|
||||
if (partIndex === -1) return 0;
|
||||
if (partIndex === 0)
|
||||
return (
|
||||
(exerciseIndex === -1 ? 0 : exerciseIndex + 1) + storeQuestionIndex + multipleChoicesDone.reduce((acc, curr) => acc + curr.amount, 0)
|
||||
);
|
||||
|
||||
const exercisesPerPart = exam.parts.map((x) => x.exercises.length);
|
||||
const exercisesDone = exercisesPerPart.filter((_, index) => index < partIndex).reduce((acc, curr) => curr + acc, 0);
|
||||
return (
|
||||
exercisesDone +
|
||||
(exerciseIndex === -1 ? 0 : exerciseIndex + 1) +
|
||||
storeQuestionIndex +
|
||||
multipleChoicesDone.reduce((acc, curr) => acc + curr.amount, 0)
|
||||
);
|
||||
};
|
||||
|
||||
const renderText = () => (
|
||||
<div className={clsx("flex flex-col gap-6 w-full bg-mti-gray-seasalt rounded-xl mt-4 relative", isTextMinimized ? "p-2 px-8" : "py-8 px-16")}>
|
||||
<button
|
||||
data-tip={isTextMinimized ? "Maximise" : "Minimize"}
|
||||
className={clsx("absolute right-8 tooltip", isTextMinimized ? "top-1/2 -translate-y-1/2" : "top-8")}
|
||||
onClick={() => setIsTextMinimzed((prev) => !prev)}>
|
||||
{isTextMinimized ? (
|
||||
<BsChevronDown className="text-mti-purple-dark text-lg" />
|
||||
) : (
|
||||
<BsChevronUp className="text-mti-purple-dark text-lg" />
|
||||
)}
|
||||
</button>
|
||||
{!isTextMinimized && (
|
||||
<>
|
||||
<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>
|
||||
<TextComponent part={exam.parts[partIndex]} exerciseType={exerciseType} />
|
||||
</>
|
||||
)}
|
||||
{isTextMinimized && <span className="font-semibold">Reading Passage</span>}
|
||||
</div>
|
||||
);
|
||||
const progressButtons = useMemo(() =>
|
||||
// Do not remove the ()=> in handle next
|
||||
<ProgressButtons handlePrevious={previousExercise} handleNext={() => nextExercise()} />
|
||||
, [nextExercise, previousExercise]);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -313,42 +143,47 @@ export default function Reading({ exam, showSolutions = false, preview = false,
|
||||
defaultTitle="Reading exam"
|
||||
section={exam.parts[partIndex]}
|
||||
sectionIndex={partIndex}
|
||||
onNext={() => { setShowPartDivider(false); setBgColor("bg-white"); setSeenParts((prev) => new Set(prev).add(exerciseIndex)) }}
|
||||
onNext={() => handlePartDividerClick()}
|
||||
/>
|
||||
</div> : (
|
||||
<>
|
||||
<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)} />}
|
||||
{/*<ReadingPassageModal text={exam.parts[partIndex].text} isOpen={showTextModal} onClose={() => setShowTextModal(false)} />*/}
|
||||
{exam.parts.length > 1 && <SectionNavbar
|
||||
module="reading"
|
||||
sectionLabel="Part"
|
||||
seenParts={seenParts}
|
||||
setShowPartDivider={setShowPartDivider}
|
||||
setSeenParts={setSeenParts}
|
||||
setIsBetweenParts={setIsBetweenParts}
|
||||
preview={preview}
|
||||
/>}
|
||||
<ModuleTitle
|
||||
minTimer={exam.minTimer}
|
||||
exerciseIndex={calculateExerciseIndex()}
|
||||
minTimer={timer.current}
|
||||
exerciseIndex={memoizedExerciseIndex}
|
||||
module="reading"
|
||||
totalExercises={countExercises(exam.parts.flatMap((x) => x.exercises))}
|
||||
disableTimer={showSolutions || preview}
|
||||
label={exerciseIndex === -1 ? undefined : convertCamelCaseToReadable(exam.parts[partIndex].exercises[exerciseIndex].type)}
|
||||
label={convertCamelCaseToReadable(exam.parts[partIndex].exercises[exerciseIndex].type)}
|
||||
preview={preview}
|
||||
/>
|
||||
<div
|
||||
className={clsx(
|
||||
"mb-20 w-full",
|
||||
exerciseIndex > -1 && !isTextMinimized && "grid grid-cols-2 gap-4",
|
||||
exerciseIndex > -1 && isTextMinimized && "flex flex-col gap-2",
|
||||
((isFirstTimeRender || isBetweenParts) && !showSolutions) ? "flex flex-col gap-2" : "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, undefined, preview)}
|
||||
|
||||
{exerciseIndex > -1 &&
|
||||
partIndex > -1 &&
|
||||
exerciseIndex < exam.parts[partIndex].exercises.length &&
|
||||
showSolutions &&
|
||||
renderSolution(exam.parts[partIndex].exercises[exerciseIndex], nextExercise, previousExercise)}
|
||||
<ReadingPassage
|
||||
exam={exam}
|
||||
partIndex={partIndex}
|
||||
exerciseType={currentExercise.type}
|
||||
isTextMinimized={isTextMinimized}
|
||||
setIsTextMinimized={setIsTextMinimzed}
|
||||
/>
|
||||
{!isFirstTimeRender && !showPartDivider && !showSolutions && !isBetweenParts && renderExercise(currentExercise, exam.id, registerSolution, preview, progressButtons, progressButtons)}
|
||||
{showSolutions && renderSolution(currentExercise, progressButtons, progressButtons)}
|
||||
</div>
|
||||
{exerciseIndex > -1 && partIndex > -1 && exerciseIndex < exam.parts[partIndex].exercises.length && (
|
||||
{/*exerciseIndex > -1 && partIndex > -1 && exerciseIndex < exam.parts[partIndex].exercises.length && (
|
||||
<Button
|
||||
color="purple"
|
||||
variant="outline"
|
||||
@@ -356,33 +191,19 @@ export default function Reading({ exam, showSolutions = false, preview = false,
|
||||
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>
|
||||
)}
|
||||
{((isFirstTimeRender || isBetweenParts) && !showPartDivider && !showSolutions) &&
|
||||
<ProgressButtons
|
||||
hidePrevious={partIndex == 0 && isBetweenParts || isFirstTimeRender}
|
||||
nextLabel={isFirstTimeRender ? "Start now" : "Next Page"}
|
||||
handlePrevious={previousExercise}
|
||||
handleNext={() => isFirstTimeRender ? setIsFirstTimeRender(false) : nextExercise()} />
|
||||
}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default Reading;
|
||||
|
||||
@@ -15,7 +15,7 @@ import ProfileSummary from "@/components/ProfileSummary";
|
||||
import {ShuffleMap, Shuffles, Variant} from "@/interfaces/exam";
|
||||
import useSessions, {Session} from "@/hooks/useSessions";
|
||||
import SessionCard from "@/components/Medium/SessionCard";
|
||||
import useExamStore from "@/stores/examStore";
|
||||
import useExamStore from "@/stores/exam";
|
||||
import moment from "moment";
|
||||
|
||||
interface Props {
|
||||
@@ -32,7 +32,7 @@ export default function Selection({user, page, onStart}: Props) {
|
||||
const {data: stats} = useFilterRecordsByUser<Stat[]>(user?.id);
|
||||
const {sessions, isLoading, reload} = useSessions(user.id);
|
||||
|
||||
const state = useExamStore((state) => state);
|
||||
const dispatch = useExamStore((state) => state.dispatch);
|
||||
|
||||
const toggleModule = (module: Module) => {
|
||||
const modules = selectedModules.filter((x) => x !== module);
|
||||
@@ -44,19 +44,7 @@ export default function Selection({user, page, onStart}: Props) {
|
||||
)
|
||||
|
||||
const loadSession = async (session: Session) => {
|
||||
state.setShuffles(session.userSolutions.map((x) => ({exerciseID: x.exercise, shuffles: x.shuffleMaps ? x.shuffleMaps : []})));
|
||||
state.setSelectedModules(session.selectedModules);
|
||||
state.setExam(session.exam);
|
||||
state.setExams(session.exams);
|
||||
state.setSessionId(session.sessionId);
|
||||
state.setAssignment(session.assignment);
|
||||
state.setExerciseIndex(session.exerciseIndex);
|
||||
state.setPartIndex(session.partIndex);
|
||||
state.setModuleIndex(session.moduleIndex);
|
||||
state.setTimeSpent(session.timeSpent);
|
||||
state.setUserSolutions(session.userSolutions);
|
||||
state.setShowSolutions(false);
|
||||
state.setQuestionIndex(session.questionIndex);
|
||||
dispatch({type: "SET_SESSION", payload: { session }})
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,112 +1,104 @@
|
||||
import { renderExercise } from "@/components/Exercises";
|
||||
import ModuleTitle from "@/components/Medium/ModuleTitle";
|
||||
import { renderSolution } from "@/components/Solutions";
|
||||
import { infoButtonStyle } from "@/constants/buttonStyles";
|
||||
import { UserSolution, SpeakingExam, SpeakingExercise, InteractiveSpeakingExercise } from "@/interfaces/exam";
|
||||
import useExamStore, { usePersistentExamStore } from "@/stores/examStore";
|
||||
import { defaultUserSolutions } from "@/utils/exams";
|
||||
import { UserSolution, SpeakingExam, SpeakingExercise, InteractiveSpeakingExercise, Exercise } from "@/interfaces/exam";
|
||||
import useExamStore, { usePersistentExamStore } from "@/stores/exam";
|
||||
import { countExercises } from "@/utils/moduleUtils";
|
||||
import { convertCamelCaseToReadable } from "@/utils/string";
|
||||
import { mdiArrowRight } from "@mdi/js";
|
||||
import Icon from "@mdi/react";
|
||||
import clsx from "clsx";
|
||||
import { Fragment, useEffect, useState } from "react";
|
||||
import { toast } from "react-toastify";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import PartDivider from "./Navigation/SectionDivider";
|
||||
import { ExamProps } from "./types";
|
||||
import useExamTimer from "@/hooks/useExamTimer";
|
||||
import useExamNavigation from "./Navigation/useExamNavigation";
|
||||
import ProgressButtons from "./components/ProgressButtons";
|
||||
import { calculateExerciseIndexSpeaking } from "./utils/calculateExerciseIndex";
|
||||
|
||||
interface Props {
|
||||
exam: SpeakingExam;
|
||||
showSolutions?: boolean;
|
||||
onFinish: (userSolutions: UserSolution[]) => void;
|
||||
preview?: boolean;
|
||||
}
|
||||
|
||||
export default function Speaking({ exam, showSolutions = false, onFinish, preview = false }: Props) {
|
||||
const [speakingPromptsDone, setSpeakingPromptsDone] = useState<{ id: string; amount: number }[]>([]);
|
||||
|
||||
const speakingBgColor = "bg-ielts-speaking-light";
|
||||
const Speaking: React.FC<ExamProps<SpeakingExam>> = ({ exam, showSolutions = false, preview = false }) => {
|
||||
const updateTimers = useExamTimer(exam.module, preview);
|
||||
const userSolutionRef = useRef<(() => UserSolution) | null>(null);
|
||||
const [solutionWasUpdated, setSolutionWasUpdated] = useState(false);
|
||||
|
||||
const examState = useExamStore((state) => state);
|
||||
const persistentExamState = usePersistentExamStore((state) => state);
|
||||
|
||||
const {
|
||||
userSolutions,
|
||||
questionIndex,
|
||||
exerciseIndex,
|
||||
hasExamEnded,
|
||||
setBgColor,
|
||||
setUserSolutions,
|
||||
setHasExamEnded,
|
||||
setQuestionIndex,
|
||||
setExerciseIndex,
|
||||
exerciseIndex, userSolutions, flags,
|
||||
timeSpentCurrentModule, questionIndex,
|
||||
setBgColor, setUserSolutions, setTimeIsUp,
|
||||
dispatch,
|
||||
} = !preview ? examState : persistentExamState;
|
||||
|
||||
const [seenParts, setSeenParts] = useState<Set<number>>(new Set(showSolutions ? exam.exercises.map((_, index) => index) : []));
|
||||
const [showPartDivider, setShowPartDivider] = useState<boolean>(typeof exam.exercises[0].intro === "string" && exam.exercises[0].intro !== "");
|
||||
const { finalizeModule, timeIsUp } = flags;
|
||||
|
||||
const timer = useRef(exam.minTimer - timeSpentCurrentModule / 60);
|
||||
|
||||
const {
|
||||
nextExercise, previousExercise,
|
||||
showPartDivider, setShowPartDivider,
|
||||
setSeenParts,
|
||||
} = useExamNavigation({ exam, module: "speaking", showSolutions, preview, disableBetweenParts: true });
|
||||
|
||||
useEffect(() => {
|
||||
if (!showSolutions && exam.exercises[exerciseIndex]?.intro !== undefined && exam.exercises[exerciseIndex]?.intro !== "" && !seenParts.has(exerciseIndex)) {
|
||||
setShowPartDivider(true);
|
||||
setBgColor(speakingBgColor);
|
||||
if (finalizeModule || timeIsUp) {
|
||||
updateTimers();
|
||||
|
||||
if (timeIsUp) {
|
||||
setTimeIsUp(false);
|
||||
}
|
||||
dispatch({ type: "FINALIZE_MODULE", payload: { updateTimers: false } })
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [exerciseIndex]);
|
||||
}, [finalizeModule, timeIsUp])
|
||||
|
||||
|
||||
const registerSolution = useCallback((updateSolution: () => UserSolution) => {
|
||||
userSolutionRef.current = updateSolution;
|
||||
setSolutionWasUpdated(true);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (hasExamEnded && exerciseIndex === -1) {
|
||||
setExerciseIndex(exerciseIndex + 1);
|
||||
}
|
||||
}, [hasExamEnded, exerciseIndex, setExerciseIndex]);
|
||||
|
||||
const scrollToTop = () => Array.from(document.getElementsByTagName("body")).forEach((body) => body.scrollTo(0, 0));
|
||||
|
||||
const nextExercise = (solution?: UserSolution) => {
|
||||
scrollToTop();
|
||||
if (solution) {
|
||||
if (solutionWasUpdated && userSolutionRef.current) {
|
||||
const solution = userSolutionRef.current();
|
||||
setUserSolutions([...userSolutions.filter((x) => x.exercise !== solution.exercise), { ...solution, module: "speaking", exam: exam.id }]);
|
||||
setSolutionWasUpdated(false);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [solutionWasUpdated])
|
||||
|
||||
if (questionIndex > 0) {
|
||||
const exercise = getExercise();
|
||||
setSpeakingPromptsDone((prev) => [...prev.filter((x) => x.id !== exercise.id), { id: exercise.id, amount: questionIndex }]);
|
||||
}
|
||||
setQuestionIndex(0);
|
||||
|
||||
if (exerciseIndex + 1 < exam.exercises.length) {
|
||||
setExerciseIndex(exerciseIndex + 1);
|
||||
return;
|
||||
}
|
||||
|
||||
if (exerciseIndex >= exam.exercises.length) return;
|
||||
|
||||
setHasExamEnded(false);
|
||||
|
||||
if (solution) {
|
||||
onFinish([...userSolutions.filter((x) => x.exercise !== solution.exercise), { ...solution, module: "speaking", exam: exam.id }]);
|
||||
} else {
|
||||
onFinish(userSolutions);
|
||||
}
|
||||
};
|
||||
|
||||
const previousExercise = (solution?: UserSolution) => {
|
||||
scrollToTop();
|
||||
if (solution) {
|
||||
setUserSolutions([...userSolutions.filter((x) => x.exercise !== solution.exercise), { ...solution, module: "speaking", exam: exam.id }]);
|
||||
}
|
||||
|
||||
if (exerciseIndex > 0) {
|
||||
setExerciseIndex(exerciseIndex - 1);
|
||||
}
|
||||
};
|
||||
|
||||
const getExercise = () => {
|
||||
const currentExercise = useMemo<Exercise>(() => {
|
||||
const exercise = exam.exercises[exerciseIndex];
|
||||
return {
|
||||
...exercise,
|
||||
variant: exerciseIndex < 2 && exercise.type === "interactiveSpeaking" ? "initial" : undefined,
|
||||
userSolutions: userSolutions.find((x) => x.exercise === exercise.id)?.solutions || [],
|
||||
} as SpeakingExercise | InteractiveSpeakingExercise;
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [exerciseIndex]);
|
||||
|
||||
|
||||
const progressButtons = useMemo(() =>
|
||||
// Do not remove the ()=> in handle next
|
||||
<ProgressButtons handlePrevious={previousExercise} handleNext={() => nextExercise()} />
|
||||
, [nextExercise, previousExercise]);
|
||||
|
||||
|
||||
const handlePartDividerClick = () => {
|
||||
setShowPartDivider(false);
|
||||
setBgColor("bg-white");
|
||||
setSeenParts((prev) => new Set(prev).add(exerciseIndex));
|
||||
}
|
||||
|
||||
const memoizedExerciseIndex = useMemo(() => {
|
||||
const bruh = calculateExerciseIndexSpeaking(exam, exerciseIndex, questionIndex)
|
||||
console.log(bruh);
|
||||
return bruh;
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
, [exerciseIndex, questionIndex]
|
||||
);
|
||||
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -117,27 +109,24 @@ export default function Speaking({ exam, showSolutions = false, onFinish, previe
|
||||
defaultTitle="Speaking exam"
|
||||
section={exam.exercises[exerciseIndex]}
|
||||
sectionIndex={exerciseIndex}
|
||||
onNext={() => { setShowPartDivider(false); setBgColor("bg-white"); setSeenParts((prev) => new Set(prev).add(exerciseIndex)) }}
|
||||
onNext={handlePartDividerClick}
|
||||
/> : (
|
||||
<div className="flex flex-col h-full w-full gap-8 items-center">
|
||||
<ModuleTitle
|
||||
label={convertCamelCaseToReadable(exam.exercises[exerciseIndex].type)}
|
||||
minTimer={exam.minTimer}
|
||||
exerciseIndex={exerciseIndex + 1 + questionIndex + speakingPromptsDone.reduce((acc, curr) => acc + curr.amount, 0)}
|
||||
minTimer={timer.current}
|
||||
exerciseIndex={memoizedExerciseIndex}
|
||||
module="speaking"
|
||||
totalExercises={countExercises(exam.exercises)}
|
||||
disableTimer={showSolutions || preview}
|
||||
preview={preview}
|
||||
/>
|
||||
{exerciseIndex > -1 &&
|
||||
exerciseIndex < exam.exercises.length &&
|
||||
!showSolutions &&
|
||||
renderExercise(getExercise(), exam.id, nextExercise, previousExercise, undefined, preview)}
|
||||
{exerciseIndex > -1 &&
|
||||
exerciseIndex < exam.exercises.length &&
|
||||
showSolutions &&
|
||||
renderSolution(exam.exercises[exerciseIndex], nextExercise, previousExercise)}
|
||||
{!showPartDivider && !showSolutions && renderExercise(currentExercise, exam.id, registerSolution, preview, progressButtons, progressButtons)}
|
||||
{showSolutions && renderSolution(currentExercise, progressButtons, progressButtons)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default Speaking;
|
||||
|
||||
@@ -1,95 +1,88 @@
|
||||
import { renderExercise } from "@/components/Exercises";
|
||||
import ModuleTitle from "@/components/Medium/ModuleTitle";
|
||||
import { renderSolution } from "@/components/Solutions";
|
||||
import { UserSolution, WritingExam } from "@/interfaces/exam";
|
||||
import useExamStore, { usePersistentExamStore } from "@/stores/examStore";
|
||||
import { Exercise, UserSolution, WritingExam } from "@/interfaces/exam";
|
||||
import useExamStore, { usePersistentExamStore } from "@/stores/exam";
|
||||
import { countExercises } from "@/utils/moduleUtils";
|
||||
import PartDivider from "./Navigation/SectionDivider";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { ExamProps } from "./types";
|
||||
import useExamTimer from "@/hooks/useExamTimer";
|
||||
import useExamNavigation from "./Navigation/useExamNavigation";
|
||||
import SectionNavbar from "./Navigation/SectionNavbar";
|
||||
import ProgressButtons from "./components/ProgressButtons";
|
||||
|
||||
interface Props {
|
||||
exam: WritingExam;
|
||||
showSolutions?: boolean;
|
||||
preview?: boolean;
|
||||
onFinish: (userSolutions: UserSolution[]) => void;
|
||||
}
|
||||
|
||||
export default function Writing({ exam, showSolutions = false, preview = false, onFinish }: Props) {
|
||||
const writingBgColor = "bg-ielts-writing-light";
|
||||
const Writing: React.FC<ExamProps<WritingExam>> = ({ exam, showSolutions = false, preview = false }) => {
|
||||
const updateTimers = useExamTimer(exam.module, preview);
|
||||
const userSolutionRef = useRef<(() => UserSolution) | null>(null);
|
||||
const [solutionWasUpdated, setSolutionWasUpdated] = useState(false);
|
||||
|
||||
const examState = useExamStore((state) => state);
|
||||
const persistentExamState = usePersistentExamStore((state) => state);
|
||||
|
||||
const {
|
||||
userSolutions,
|
||||
exerciseIndex,
|
||||
hasExamEnded,
|
||||
setBgColor,
|
||||
setUserSolutions,
|
||||
setHasExamEnded,
|
||||
setExerciseIndex,
|
||||
exerciseIndex, flags, setBgColor,
|
||||
setUserSolutions, setTimeIsUp, userSolutions,
|
||||
dispatch, navigation, timeSpentCurrentModule
|
||||
} = !preview ? examState : persistentExamState;
|
||||
|
||||
const [seenParts, setSeenParts] = useState<Set<number>>(new Set(showSolutions ? exam.exercises.map((_, index) => index) : []));
|
||||
const [showPartDivider, setShowPartDivider] = useState<boolean>(typeof exam.exercises[0].intro === "string" && exam.exercises[0].intro !== "");
|
||||
const timer = useRef(exam.minTimer - timeSpentCurrentModule / 60);
|
||||
|
||||
const { finalizeModule, timeIsUp } = flags;
|
||||
const { nextDisabled } = navigation;
|
||||
|
||||
useEffect(() => {
|
||||
if (hasExamEnded && exerciseIndex === -1) {
|
||||
setExerciseIndex(exerciseIndex + 1);
|
||||
}
|
||||
}, [hasExamEnded, exerciseIndex, setExerciseIndex]);
|
||||
if (finalizeModule || timeIsUp) {
|
||||
updateTimers();
|
||||
|
||||
const scrollToTop = () => Array.from(document.getElementsByTagName("body")).forEach((body) => body.scrollTo(0, 0));
|
||||
|
||||
useEffect(() => {
|
||||
if (!showSolutions && exam.exercises[exerciseIndex]?.intro !== undefined && exam.exercises[exerciseIndex]?.intro !== "" && !seenParts.has(exerciseIndex)) {
|
||||
setShowPartDivider(true);
|
||||
setBgColor(writingBgColor);
|
||||
if (timeIsUp) {
|
||||
setTimeIsUp(false);
|
||||
}
|
||||
dispatch({ type: "FINALIZE_MODULE", payload: { updateTimers: false } })
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [exerciseIndex]);
|
||||
}, [finalizeModule, timeIsUp])
|
||||
const {
|
||||
nextExercise, previousExercise,
|
||||
showPartDivider, setShowPartDivider,
|
||||
seenParts, setSeenParts,
|
||||
} = useExamNavigation({ exam, module: "writing", showSolutions, preview });
|
||||
|
||||
const registerSolution = useCallback((updateSolution: () => UserSolution) => {
|
||||
userSolutionRef.current = updateSolution;
|
||||
setSolutionWasUpdated(true);
|
||||
}, []);
|
||||
|
||||
const nextExercise = (solution?: UserSolution) => {
|
||||
scrollToTop();
|
||||
if (solution) {
|
||||
useEffect(() => {
|
||||
if (solutionWasUpdated && userSolutionRef.current) {
|
||||
const solution = userSolutionRef.current();
|
||||
setUserSolutions([...userSolutions.filter((x) => x.exercise !== solution.exercise), { ...solution, module: "writing", exam: exam.id }]);
|
||||
setSolutionWasUpdated(false);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [solutionWasUpdated])
|
||||
|
||||
if (exerciseIndex + 1 < exam.exercises.length) {
|
||||
setExerciseIndex(exerciseIndex + 1);
|
||||
return;
|
||||
}
|
||||
|
||||
if (exerciseIndex >= exam.exercises.length) return;
|
||||
|
||||
setHasExamEnded(false);
|
||||
|
||||
if (solution) {
|
||||
onFinish([...userSolutions.filter((x) => x.exercise !== solution.exercise), { ...solution, module: "writing", exam: exam.id }]);
|
||||
} else {
|
||||
onFinish(userSolutions);
|
||||
}
|
||||
};
|
||||
|
||||
const previousExercise = (solution?: UserSolution) => {
|
||||
scrollToTop();
|
||||
if (solution) {
|
||||
setUserSolutions([...userSolutions.filter((x) => x.exercise !== solution.exercise), { ...solution, module: "writing", exam: exam.id }]);
|
||||
}
|
||||
|
||||
if (exerciseIndex > 0) {
|
||||
setExerciseIndex(exerciseIndex - 1);
|
||||
}
|
||||
};
|
||||
|
||||
const getExercise = () => {
|
||||
const currentExercise = useMemo<Exercise>(() => {
|
||||
const exercise = exam.exercises[exerciseIndex];
|
||||
return {
|
||||
...exercise,
|
||||
userSolutions: userSolutions.find((x) => x.exercise === exercise.id)?.solutions || [],
|
||||
};
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [exerciseIndex]);
|
||||
|
||||
const handlePartDividerClick = () => {
|
||||
setShowPartDivider(false);
|
||||
setBgColor("bg-white");
|
||||
setSeenParts((prev) => new Set(prev).add(exerciseIndex));
|
||||
}
|
||||
|
||||
const progressButtons = useMemo(() =>
|
||||
// Do not remove the ()=> in handle next
|
||||
<ProgressButtons handlePrevious={previousExercise} handleNext={() => nextExercise()} nextDisabled={nextDisabled}/>
|
||||
, [nextExercise, previousExercise, nextDisabled]);
|
||||
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -100,27 +93,32 @@ export default function Writing({ exam, showSolutions = false, preview = false,
|
||||
defaultTitle="Writing exam"
|
||||
section={exam.exercises[exerciseIndex]}
|
||||
sectionIndex={exerciseIndex}
|
||||
onNext={() => { setShowPartDivider(false); setBgColor("bg-white"); setSeenParts((prev) => new Set(prev).add(exerciseIndex))}}
|
||||
onNext={handlePartDividerClick}
|
||||
/> : (
|
||||
<div className="flex flex-col h-full w-full gap-8 items-center">
|
||||
{exam.exercises.length > 1 && <SectionNavbar
|
||||
module="writing"
|
||||
sectionLabel="Part"
|
||||
seenParts={seenParts}
|
||||
setShowPartDivider={setShowPartDivider}
|
||||
setSeenParts={setSeenParts}
|
||||
preview={preview}
|
||||
/>}
|
||||
<ModuleTitle
|
||||
minTimer={exam.minTimer}
|
||||
minTimer={timer.current}
|
||||
exerciseIndex={exerciseIndex + 1}
|
||||
module="writing"
|
||||
totalExercises={countExercises(exam.exercises)}
|
||||
disableTimer={showSolutions || preview}
|
||||
preview={preview}
|
||||
/>
|
||||
{exerciseIndex > -1 &&
|
||||
exerciseIndex < exam.exercises.length &&
|
||||
!showSolutions &&
|
||||
renderExercise(getExercise(), exam.id, nextExercise, previousExercise, preview)}
|
||||
{exerciseIndex > -1 &&
|
||||
exerciseIndex < exam.exercises.length &&
|
||||
showSolutions &&
|
||||
renderSolution(exam.exercises[exerciseIndex], nextExercise, previousExercise)}
|
||||
{!showPartDivider && !showSolutions && renderExercise(currentExercise, exam.id, registerSolution, preview, progressButtons, progressButtons)}
|
||||
{showSolutions && renderSolution(currentExercise, progressButtons, progressButtons)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default Writing;
|
||||
|
||||
56
src/exams/components/ProgressButtons.tsx
Normal file
56
src/exams/components/ProgressButtons.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
import Button from "@/components/Low/Button";
|
||||
import clsx from "clsx";
|
||||
|
||||
interface Props {
|
||||
handlePrevious: () => void;
|
||||
handleNext: () => void;
|
||||
hidePrevious?: boolean;
|
||||
nextLabel?: string;
|
||||
isLoading?: boolean;
|
||||
isDisabled?: boolean;
|
||||
previousDisabled?: boolean;
|
||||
nextDisabled?: boolean;
|
||||
previousLoading?: boolean;
|
||||
nextLoading?: boolean;
|
||||
}
|
||||
|
||||
const ProgressButtons: React.FC<Props> = ({
|
||||
handlePrevious,
|
||||
handleNext,
|
||||
hidePrevious = false,
|
||||
isLoading = false,
|
||||
isDisabled = false,
|
||||
previousDisabled = false,
|
||||
nextDisabled = false,
|
||||
previousLoading = false,
|
||||
nextLoading = false,
|
||||
nextLabel = "Next Page"
|
||||
}) => {
|
||||
return (
|
||||
<div className={clsx("flex w-full gap-8", !hidePrevious ? "justify-between" : "flex-row-reverse")}>
|
||||
{!hidePrevious && (
|
||||
<Button
|
||||
disabled={isDisabled || previousDisabled}
|
||||
isLoading={isLoading || previousLoading}
|
||||
color="purple"
|
||||
variant="outline"
|
||||
onClick={handlePrevious}
|
||||
className="max-w-[200px] w-full"
|
||||
>
|
||||
Previous Page
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
disabled={isDisabled || nextDisabled}
|
||||
isLoading={isLoading || nextLoading}
|
||||
color="purple"
|
||||
onClick={handleNext}
|
||||
className="max-w-[200px] self-end w-full"
|
||||
>
|
||||
{nextLabel}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProgressButtons;
|
||||
68
src/exams/components/ReadingPassage.tsx
Normal file
68
src/exams/components/ReadingPassage.tsx
Normal file
@@ -0,0 +1,68 @@
|
||||
import { LevelExam, LevelPart, ReadingExam, ReadingPart } from "@/interfaces/exam";
|
||||
import clsx from "clsx";
|
||||
import { Fragment, useState } from "react";
|
||||
import { BsChevronDown, BsChevronUp } from "react-icons/bs";
|
||||
|
||||
const TextComponent: React.FC<{ part: ReadingPart | LevelPart; exerciseType: string }> = ({ part, exerciseType }) => {
|
||||
const numberToLetter = (number: number) => (number + 9).toString(36).toUpperCase();
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2 w-full">
|
||||
<h3 className="text-xl font-semibold">{part.text?.title}</h3>
|
||||
<div className="border border-mti-gray-dim w-full rounded-full opacity-10" />
|
||||
{part.text?.content
|
||||
.split(/\n|(\\n)/g)
|
||||
.filter((x) => x && x.length > 0 && x !== "\\n")
|
||||
.map((line, index) => (
|
||||
<Fragment key={index}>
|
||||
{exerciseType === "matchSentences" && (
|
||||
<div className="flex gap-3 border border-transparent hover:border-mti-purple-light rounded-lg transition ease-in-out duration-300 p-2 px-3 cursor-pointer">
|
||||
<span className="font-bold text-mti-purple-dark">{numberToLetter(index + 1)}</span>
|
||||
<p>{line}</p>
|
||||
</div>
|
||||
)}
|
||||
{exerciseType !== "matchSentences" && <p key={index}>{line}</p>}
|
||||
</Fragment>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface Props {
|
||||
exam: ReadingExam | LevelExam;
|
||||
partIndex: number;
|
||||
exerciseType: string;
|
||||
isTextMinimized: boolean;
|
||||
setIsTextMinimized: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
}
|
||||
|
||||
const ReadingPassage: React.FC<Props> = ({exam, partIndex, exerciseType, isTextMinimized, setIsTextMinimized}) => {
|
||||
return (
|
||||
<div className={clsx("flex flex-col gap-6 w-full bg-mti-gray-seasalt rounded-xl mt-4 relative", isTextMinimized ? "p-2 px-8" : "py-8 px-16")}>
|
||||
<button
|
||||
data-tip={isTextMinimized ? "Maximise" : "Minimize"}
|
||||
className={clsx("absolute right-8 tooltip", isTextMinimized ? "top-1/2 -translate-y-1/2" : "top-8")}
|
||||
onClick={() => setIsTextMinimized((prev) => !prev)}>
|
||||
{isTextMinimized ? (
|
||||
<BsChevronDown className="text-mti-purple-dark text-lg" />
|
||||
) : (
|
||||
<BsChevronUp className="text-mti-purple-dark text-lg" />
|
||||
)}
|
||||
</button>
|
||||
{!isTextMinimized && (
|
||||
<>
|
||||
<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>
|
||||
<TextComponent part={exam.parts[partIndex]} exerciseType={exerciseType} />
|
||||
</>
|
||||
)}
|
||||
{isTextMinimized && <span className="font-semibold">Reading Passage</span>}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ReadingPassage;
|
||||
70
src/exams/components/ReadingPassageModal.tsx
Normal file
70
src/exams/components/ReadingPassageModal.tsx
Normal file
@@ -0,0 +1,70 @@
|
||||
import Button from "@/components/Low/Button";
|
||||
import { Dialog, DialogPanel, DialogTitle, Transition, TransitionChild } from "@headlessui/react";
|
||||
import { Fragment } from "react";
|
||||
|
||||
interface Props {
|
||||
isOpen: boolean;
|
||||
text: {
|
||||
title?: string;
|
||||
content: string;
|
||||
}
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
const ReadingPassageModal: React.FC<Props> = ({ isOpen, text, onClose }) => {
|
||||
return (
|
||||
<Transition appear show={isOpen} as={Fragment}>
|
||||
<Dialog as="div" className="relative z-10" onClose={onClose}>
|
||||
<TransitionChild
|
||||
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" />
|
||||
</TransitionChild>
|
||||
|
||||
<div className="fixed inset-0 overflow-y-auto">
|
||||
<div className="flex min-h-full items-center justify-center p-4 text-center">
|
||||
<TransitionChild
|
||||
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">
|
||||
<DialogPanel className="w-full relative max-w-4xl transform rounded-2xl bg-white p-6 text-left align-middle shadow-xl transition-all">
|
||||
{text.title &&
|
||||
<DialogTitle as="h3" className="text-lg font-medium leading-6 text-gray-900">
|
||||
{text.title}
|
||||
</DialogTitle>
|
||||
}
|
||||
<div className="mt-2 overflow-auto mb-28">
|
||||
<p className="text-sm">
|
||||
{text.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>
|
||||
</DialogPanel>
|
||||
</TransitionChild>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
</Transition>
|
||||
);
|
||||
}
|
||||
|
||||
export default ReadingPassageModal;
|
||||
19
src/exams/components/RenderAudioInstructionsPlayer.tsx
Normal file
19
src/exams/components/RenderAudioInstructionsPlayer.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import AudioPlayer from "@/components/Low/AudioPlayer";
|
||||
import { v4 } from "uuid";
|
||||
|
||||
|
||||
const INSTRUCTIONS_AUDIO_SRC =
|
||||
"https://firebasestorage.googleapis.com/v0/b/storied-phalanx-349916.appspot.com/o/generic_listening_intro_v2.mp3?alt=media&token=16769f5f-1e9b-4a72-86a9-45a6f0fa9f82";
|
||||
|
||||
const RenderAudioInstructionsPlayer: React.FC = () => (
|
||||
<div className="flex flex-col gap-8 w-full bg-mti-gray-seasalt rounded-xl py-8 px-16">
|
||||
<div className="flex flex-col w-full gap-2">
|
||||
<h4 className="text-xl font-semibold">Please listen to the instructions audio attentively.</h4>
|
||||
</div>
|
||||
<div className="rounded-xl flex flex-col gap-4 items-center w-full h-fit">
|
||||
<AudioPlayer key={v4()} src={INSTRUCTIONS_AUDIO_SRC} color="listening" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
export default RenderAudioInstructionsPlayer;
|
||||
65
src/exams/components/RenderAudioPlayer.tsx
Normal file
65
src/exams/components/RenderAudioPlayer.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
import AudioPlayer from "@/components/Low/AudioPlayer";
|
||||
import Button from "@/components/Low/Button";
|
||||
import { Script } from "@/interfaces/exam";
|
||||
import { Assignment } from "@/interfaces/results";
|
||||
import { v4 } from "uuid";
|
||||
|
||||
interface Props {
|
||||
audioSource?: string;
|
||||
repeatableTimes?: number;
|
||||
timesListened: number;
|
||||
script?: Script;
|
||||
assignment?: Assignment;
|
||||
setShowTextModal: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
setTimesListened: React.Dispatch<React.SetStateAction<number>>;
|
||||
}
|
||||
|
||||
const RenderAudioPlayer: React.FC<Props> = ({
|
||||
audioSource, repeatableTimes, timesListened,
|
||||
script, assignment, setShowTextModal, setTimesListened
|
||||
}) => (
|
||||
<div className="flex flex-col gap-8 w-full bg-mti-gray-seasalt rounded-xl py-8 px-16">
|
||||
{audioSource ? (
|
||||
<>
|
||||
<div className="w-full items-start flex justify-between">
|
||||
<div className="flex flex-col w-full gap-2">
|
||||
<h4 className="text-xl font-semibold">Please listen to the following audio attentively.</h4>
|
||||
<span className="text-base">
|
||||
{(() => {
|
||||
return repeatableTimes && repeatableTimes > 0
|
||||
? `You will only be allowed to listen to the audio ${repeatableTimes - timesListened} time(s).`
|
||||
: "You may listen to the audio as many times as you would like.";
|
||||
})()}
|
||||
</span>
|
||||
</div>
|
||||
{!assignment && !!script && (
|
||||
<Button
|
||||
onClick={() => setShowTextModal(true)}
|
||||
variant="outline"
|
||||
color="gray"
|
||||
className="w-full max-w-[200px]"
|
||||
>
|
||||
View Transcript
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className="rounded-xl flex flex-col gap-4 items-center w-full h-fit">
|
||||
<AudioPlayer
|
||||
key={v4()}
|
||||
src={audioSource ?? ''}
|
||||
color="listening"
|
||||
onEnd={() => setTimesListened((prev) => prev + 1)}
|
||||
disabled={repeatableTimes != null &&
|
||||
timesListened === repeatableTimes}
|
||||
disablePause
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<span>This section will display the audio once it has been generated.</span>
|
||||
)}
|
||||
|
||||
</div>
|
||||
);
|
||||
|
||||
export default RenderAudioPlayer;
|
||||
72
src/exams/components/ScriptModal.tsx
Normal file
72
src/exams/components/ScriptModal.tsx
Normal file
@@ -0,0 +1,72 @@
|
||||
import Button from "@/components/Low/Button";
|
||||
import { Script } from "@/interfaces/exam";
|
||||
import { Dialog, DialogPanel, Transition, TransitionChild } from "@headlessui/react";
|
||||
import { capitalize } from "lodash";
|
||||
import { Fragment } from "react";
|
||||
|
||||
interface Props {
|
||||
isOpen: boolean;
|
||||
script: Script;
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
const ScriptModal: React.FC<Props> = ({ isOpen, script, onClose }) => {
|
||||
return (
|
||||
<Transition appear show={isOpen} as={Fragment}>
|
||||
<Dialog as="div" className="relative z-10" onClose={onClose}>
|
||||
<TransitionChild
|
||||
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" />
|
||||
</TransitionChild>
|
||||
|
||||
<div className="fixed inset-0 overflow-y-auto">
|
||||
<div className="flex min-h-full items-center justify-center p-4 text-center">
|
||||
<TransitionChild
|
||||
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">
|
||||
<DialogPanel className="w-full relative max-w-4xl transform rounded-2xl bg-white p-6 text-left align-middle shadow-xl transition-all">
|
||||
<div className="mt-2 overflow-auto mb-28">
|
||||
<p className="text-sm">
|
||||
{typeof script === "string" && script.split("\\n").map((line, index) => (
|
||||
<Fragment key={index}>
|
||||
{line}
|
||||
<br />
|
||||
</Fragment>
|
||||
))}
|
||||
|
||||
{typeof script === "object" && script.map((line, index) => (
|
||||
<span key={index}>
|
||||
<b>{line.name} ({capitalize(line.gender)})</b>: {line.text}
|
||||
<br />
|
||||
<br />
|
||||
</span>
|
||||
))}
|
||||
</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>
|
||||
</DialogPanel>
|
||||
</TransitionChild>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
</Transition>
|
||||
);
|
||||
}
|
||||
|
||||
export default ScriptModal;
|
||||
7
src/exams/types.ts
Normal file
7
src/exams/types.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { Exam } from "@/interfaces/exam";
|
||||
|
||||
export type ExamProps<T extends Exam> = {
|
||||
exam: T;
|
||||
showSolutions?: boolean;
|
||||
preview?: boolean;
|
||||
};
|
||||
47
src/exams/utils/answeredEveryQuestion.ts
Normal file
47
src/exams/utils/answeredEveryQuestion.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { LevelExam, ListeningExam, ReadingExam, UserSolution } from "@/interfaces/exam";
|
||||
|
||||
// so that the compiler doesnt complain
|
||||
interface Part {
|
||||
exercises: Array<{
|
||||
id: string;
|
||||
type: string;
|
||||
questions?: Array<any>;
|
||||
score?: { total: number };
|
||||
}>;
|
||||
}
|
||||
|
||||
type PartExam = {
|
||||
parts: Part[];
|
||||
} & (ReadingExam | ListeningExam | LevelExam)
|
||||
|
||||
const answeredEveryQuestionInPart = (exam: PartExam, partIndex: number, userSolutions: UserSolution[]) => {
|
||||
return exam.parts[partIndex].exercises.every((exercise) => {
|
||||
const userSolution = userSolutions.find(x => x.exercise === exercise.id);
|
||||
|
||||
switch (exercise.type) {
|
||||
case 'multipleChoice':
|
||||
return userSolution?.solutions.length === exercise.questions!.length;
|
||||
case 'fillBlanks':
|
||||
return userSolution?.solutions.length === userSolution?.score.total;
|
||||
case 'writeBlanks':
|
||||
return userSolution?.solutions.length === userSolution?.score.total;
|
||||
case 'matchSentences':
|
||||
return userSolution?.solutions.length === userSolution?.score.total;
|
||||
case 'trueFalse':
|
||||
return userSolution?.solutions.length === userSolution?.score.total;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
const answeredEveryQuestion = (exam: PartExam, userSolutions: UserSolution[]) => {
|
||||
return exam.parts.every((_, index) => {
|
||||
return answeredEveryQuestionInPart(exam, index, userSolutions);
|
||||
});
|
||||
}
|
||||
|
||||
export {
|
||||
answeredEveryQuestion,
|
||||
answeredEveryQuestionInPart
|
||||
};
|
||||
61
src/exams/utils/calculateExerciseIndex.ts
Normal file
61
src/exams/utils/calculateExerciseIndex.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { PartExam, SpeakingExam } from "@/interfaces/exam";
|
||||
import { countCurrentExercises } from "@/utils/moduleUtils";
|
||||
|
||||
const calculateExerciseIndex = (
|
||||
exam: PartExam,
|
||||
partIndex: number,
|
||||
exerciseIndex: number,
|
||||
questionIndex: number
|
||||
) => {
|
||||
let total = 0;
|
||||
// Count all exercises in previous parts
|
||||
for (let i = 0; i < partIndex; i++) {
|
||||
total += countCurrentExercises(
|
||||
exam.parts[i].exercises,
|
||||
exam.parts[i].exercises.length - 1
|
||||
);
|
||||
}
|
||||
// Count previous exercises in current part
|
||||
if (partIndex < exam.parts.length && exerciseIndex > 0) {
|
||||
total += countCurrentExercises(
|
||||
exam.parts[partIndex].exercises.slice(0, exerciseIndex),
|
||||
exerciseIndex - 1
|
||||
);
|
||||
}
|
||||
|
||||
// Only pass questionIndex if current exercise is multiple choice
|
||||
const currentExercise = exam.parts[partIndex].exercises[exerciseIndex];
|
||||
if (currentExercise.type === "multipleChoice") {
|
||||
total += countCurrentExercises(
|
||||
[currentExercise],
|
||||
0,
|
||||
questionIndex
|
||||
);
|
||||
return total;
|
||||
}
|
||||
|
||||
// Add 1 for non-MC exercises
|
||||
total += 1;
|
||||
return total;
|
||||
};
|
||||
|
||||
const calculateExerciseIndexSpeaking = (
|
||||
exam: SpeakingExam,
|
||||
exerciseIndex: number,
|
||||
questionIndex: number
|
||||
) => {
|
||||
let total = 0;
|
||||
|
||||
for (let i = 0; i < exerciseIndex; i++) {
|
||||
total += exam.exercises[i].type === "speaking" ? 1 : exam.exercises[i].prompts.length;
|
||||
}
|
||||
|
||||
total += exam.exercises[exerciseIndex].type === "speaking" ? 1 : questionIndex + 1;
|
||||
|
||||
return total;
|
||||
};
|
||||
|
||||
export {
|
||||
calculateExerciseIndex,
|
||||
calculateExerciseIndexSpeaking
|
||||
};
|
||||
11
src/exams/utils/hasDivider.ts
Normal file
11
src/exams/utils/hasDivider.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { Exam, ExerciseOnlyExam, PartExam } from "@/interfaces/exam";
|
||||
|
||||
const hasDivider = (exam: Exam, index: number) => {
|
||||
const isPartExam = ["reading", "listening", "level"].includes(exam.module);
|
||||
if (isPartExam) {
|
||||
return typeof (exam as PartExam).parts[index].intro === "string" && (exam as PartExam).parts[index].intro !== "";
|
||||
}
|
||||
return typeof (exam as ExerciseOnlyExam).exercises[index].intro === "string" && (exam as ExerciseOnlyExam).exercises[index].intro !== ""
|
||||
}
|
||||
|
||||
export default hasDivider;
|
||||
7
src/exams/utils/scrollToTop.ts
Normal file
7
src/exams/utils/scrollToTop.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
const scrollToTop = () => {
|
||||
Array.from(document.getElementsByTagName("body")).forEach((body) =>
|
||||
body.scrollTo(0, 0)
|
||||
);
|
||||
};
|
||||
|
||||
export default scrollToTop;
|
||||
Reference in New Issue
Block a user