diff --git a/src/components/BlankQuestionsModal.tsx b/src/components/BlankQuestionsModal.tsx deleted file mode 100644 index 1a0b50aa..00000000 --- a/src/components/BlankQuestionsModal.tsx +++ /dev/null @@ -1,56 +0,0 @@ -import {Dialog, Transition} from "@headlessui/react"; -import {Fragment} from "react"; -import Button from "./Low/Button"; - -interface Props { - isOpen: boolean; - onClose: (next?: boolean) => void; -} - -export default function BlankQuestionsModal({isOpen, onClose}: Props) { - return ( - - onClose(false)} className="relative z-50"> - -
- - - -
- - Questions Unanswered - - Please note that you are finishing the current module and once you proceed to the next module, you will no longer be - able to change the answers in the current one, including your unanswered questions.
-
- Are you sure you want to continue without completing those questions? -
-
- - -
-
-
-
-
-
- ); -} diff --git a/src/components/Exercises/FillBlanks/index.tsx b/src/components/Exercises/FillBlanks/index.tsx index fe95bb51..96316496 100644 --- a/src/components/Exercises/FillBlanks/index.tsx +++ b/src/components/Exercises/FillBlanks/index.tsx @@ -20,7 +20,7 @@ const FillBlanks: React.FC = ({ onNext, onBack, }) => { - //const { shuffleMaps } = useExamStore((state) => state); + const { shuffleMaps, exam, partIndex, questionIndex, exerciseIndex } = useExamStore((state) => state); const [answers, setAnswers] = useState<{ id: string; solution: string }[]>(userSolutions); const hasExamEnded = useExamStore((state) => state.hasExamEnded); @@ -41,47 +41,40 @@ const FillBlanks: React.FC = ({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [hasExamEnded]); - const calculateScore = () => { - const total = text.match(/({{\d+}})/g)?.length || 0; - const correct = userSolutions.filter((x) => { - const solution = solutions.find((y) => x.id.toString() === y.id.toString())?.solution; - if (!solution) return false; - const option = words.find((w) => { - if (typeof w === "string") { - return w.toLowerCase() === x.solution.toLowerCase(); - } else if ('letter' in w) { - return w.word.toLowerCase() === x.solution.toLowerCase(); - } else { - return w.id === x.id; - } - }); - if (!option) return false; + let correctWords: any; + if (exam && exam.module === "level" && exam.parts[partIndex].exercises[exerciseIndex].type === "fillBlanks") { + correctWords = (exam.parts[partIndex].exercises[exerciseIndex] as FillBlanksExercise).words; + } - if (typeof option === "string") { - return solution.toLowerCase() === option.toLowerCase(); - } else if ('letter' in option) { - return solution.toLowerCase() === option.word.toLowerCase(); - } else if ('options' in option) { - /* - if (shuffleMaps.length !== 0) { - const shuffleMap = shuffleMaps.find((map) => map.id == x.id) - if (!shuffleMap) { - return false; - } - const original = shuffleMap[x.solution as keyof typeof shuffleMap]; - return solution.toLowerCase() === (option.options[original as keyof typeof option.options] || '').toLowerCase(); - }*/ - return solution.toLowerCase() === (option.options[x.solution as keyof typeof option.options] || '').toLowerCase(); - } - return false; - }).length; - - const missing = total - userSolutions.filter((x) => solutions.find((y) => x.id.toString() === y.id.toString())).length; + const calculateScore = () => { + const total = text.match(/({{\d+}})/g)?.length || 0; + const correct = answers!.filter((x) => { + const solution = solutions.find((y) => x.id.toString() === y.id.toString())?.solution; + if (!solution) return false; + const option = correctWords!.find((w: any) => { + if (typeof w === "string") { + return w.toLowerCase() === x.solution.toLowerCase(); + } else if ('letter' in w) { + return w.word.toLowerCase() === x.solution.toLowerCase(); + } else { + return w.id.toString() === x.id.toString(); + } + }); + if (!option) return false; + if (typeof option === "string") { + return solution.toLowerCase() === option.toLowerCase(); + } else if ('letter' in option) { + return solution.toLowerCase() === option.word.toLowerCase(); + } else if ('options' in option) { + return option.options[solution as keyof typeof option.options] == x.solution; + } + return false; + }).length; + const missing = total - answers!.filter((x) => solutions.find((y) => x.id.toString() === y.id.toString())).length; return { total, correct, missing }; - }; - + }; const renderLines = (line: string) => { return (
@@ -132,7 +125,7 @@ const FillBlanks: React.FC = ({ setAnswers((prev) => [...prev.filter((x) => x.id !== id), { id: id, solution: value }]); } - /*const getShuffles = () => { + const getShuffles = () => { let shuffle = {}; if (shuffleMaps.length !== 0) { shuffle = { @@ -142,7 +135,7 @@ const FillBlanks: React.FC = ({ } } return shuffle; - }*/ + } return ( <> @@ -227,14 +220,18 @@ const FillBlanks: React.FC = ({ diff --git a/src/components/Exercises/MultipleChoice.tsx b/src/components/Exercises/MultipleChoice.tsx index e818a60b..e72896a0 100644 --- a/src/components/Exercises/MultipleChoice.tsx +++ b/src/components/Exercises/MultipleChoice.tsx @@ -6,6 +6,7 @@ import { useEffect, useState } from "react"; import reactStringReplace from "react-string-replace"; import { CommonProps } from "."; import Button from "../Low/Button"; +import { v4 } from "uuid"; function Question({ id, @@ -30,9 +31,9 @@ function Question({ return (
{isNaN(Number(id)) ? ( - {renderPrompt(prompt).filter((x) => x?.toString() !== "")} + {renderPrompt(prompt).filter((x) => x?.toString() !== "")} ) : ( - + <> {id} - {renderPrompt(prompt).filter((x) => x?.toString() !== "")} @@ -42,10 +43,10 @@ function Question({ {variant === "image" && options.map((option) => (
(onSelectOption ? onSelectOption(option.id.toString()) : null)} className={clsx( - "flex flex-col items-center border border-mti-gray-platinum p-4 px-8 rounded-xl gap-4 cursor-pointer bg-white relative", + "flex flex-col items-center border border-mti-gray-platinum p-4 px-8 rounded-xl gap-4 cursor-pointer bg-white relative select-none", userSolution === option.id.toString() && "border-mti-purple-light", )}> {option.id.toString()} @@ -55,10 +56,10 @@ function Question({ {variant === "text" && options.map((option) => (
(onSelectOption ? onSelectOption(option.id.toString()) : null)} className={clsx( - "flex border p-4 rounded-xl gap-2 cursor-pointer bg-white text-base", + "flex border p-4 rounded-xl gap-2 cursor-pointer bg-white text-base select-none", userSolution === option.id.toString() && "border-mti-purple-light", )}> {option.id.toString()}. @@ -73,10 +74,15 @@ function Question({ export default function MultipleChoice({ id, prompt, type, questions, userSolutions, onNext, onBack }: MultipleChoiceExercise & CommonProps) { const [answers, setAnswers] = useState<{ question: string; option: string }[]>(userSolutions); - //const { shuffleMaps } = useExamStore((state) => state); - const { questionIndex, setQuestionIndex } = useExamStore((state) => state); - const { userSolutions: storeUserSolutions, setUserSolutions } = useExamStore((state) => state); - const hasExamEnded = useExamStore((state) => state.hasExamEnded); + const { + questionIndex, + exam, + shuffleMaps, + hasExamEnded, + userSolutions: storeUserSolutions, + setQuestionIndex, + setUserSolutions + } = useExamStore((state) => state); const scrollToTop = () => Array.from(document.getElementsByTagName("body")).forEach((body) => body.scrollTo(0, 0)); @@ -106,12 +112,12 @@ export default function MultipleChoice({ id, prompt, type, questions, userSoluti }); let isSolutionCorrect; - //if (shuffleMaps.length == 0) { - isSolutionCorrect = matchingQuestion?.solution === x.option; - //} else { - // const shuffleMap = shuffleMaps.find((map) => map.id == x.question) - // isSolutionCorrect = shuffleMap?.map[x.option] == matchingQuestion?.solution; - //} + if (shuffleMaps.length == 0) { + isSolutionCorrect = matchingQuestion?.solution === x.option; + } else { + const shuffleMap = shuffleMaps.find((map) => map.id == x.question) + isSolutionCorrect = shuffleMap?.map[x.option] == matchingQuestion?.solution; + } return isSolutionCorrect || false; }).length; const missing = total - correct; @@ -119,7 +125,7 @@ export default function MultipleChoice({ id, prompt, type, questions, userSoluti return { total, correct, missing }; }; - /*const getShuffles = () => { + const getShuffles = () => { let shuffle = {}; if (shuffleMaps.length !== 0) { shuffle = { @@ -129,11 +135,11 @@ export default function MultipleChoice({ id, prompt, type, questions, userSoluti } } return shuffle; - }*/ + } const next = () => { if (questionIndex === questions.length - 1) { - onNext({ exercise: id, solutions: answers, score: calculateScore(), type, });//...getShuffles() }); + onNext({ exercise: id, solutions: answers, score: calculateScore(), type, ...getShuffles() }); } else { setQuestionIndex(questionIndex + 1); } @@ -142,7 +148,7 @@ export default function MultipleChoice({ id, prompt, type, questions, userSoluti const back = () => { if (questionIndex === 0) { - onBack({ exercise: id, solutions: answers, score: calculateScore(), type, });// ...getShuffles() }); + onBack({ exercise: id, solutions: answers, score: calculateScore(), type, ...getShuffles() }); } else { setQuestionIndex(questionIndex - 1); } @@ -164,7 +170,10 @@ export default function MultipleChoice({ id, prompt, type, questions, userSoluti
- diff --git a/src/components/High/Layout.tsx b/src/components/High/Layout.tsx index 38a971db..f148146f 100644 --- a/src/components/High/Layout.tsx +++ b/src/components/High/Layout.tsx @@ -11,14 +11,15 @@ interface Props { className?: string; navDisabled?: boolean; focusMode?: boolean; + bgColor?: string; onFocusLayerMouseEnter?: () => void; } -export default function Layout({user, children, className, navDisabled = false, focusMode = false, onFocusLayerMouseEnter}: Props) { +export default function Layout({user, children, className, bgColor="bg-white", navDisabled = false, focusMode = false, onFocusLayerMouseEnter}: Props) { const router = useRouter(); return ( -
+
{children} diff --git a/src/components/Medium/ModuleTitle.tsx b/src/components/Medium/ModuleTitle.tsx index 4fe6548d..50f01894 100644 --- a/src/components/Medium/ModuleTitle.tsx +++ b/src/components/Medium/ModuleTitle.tsx @@ -7,6 +7,7 @@ import { ReactNode, useEffect, useState } from "react"; import { BsBook, BsClipboard, BsHeadphones, BsMegaphone, BsPen, BsStopwatch } from "react-icons/bs"; import ProgressBar from "../Low/ProgressBar"; import TimerEndedModal from "../TimerEndedModal"; +import Timer from "./Timer"; interface Props { minTimer: number; @@ -16,37 +17,12 @@ interface Props { totalExercises: number; disableTimer?: boolean; partLabel?: string; + showTimer?: boolean; } export default function ModuleTitle({ - minTimer, module, label, exerciseIndex, totalExercises, disableTimer = false, partLabel + minTimer, module, label, exerciseIndex, totalExercises, disableTimer = false, partLabel, showTimer = true }: Props) { - const [timer, setTimer] = useState(minTimer * 60); - const [showModal, setShowModal] = useState(false); - const [warningMode, setWarningMode] = useState(false); - - const setHasExamEnded = useExamStore((state) => state.setHasExamEnded); - const { timeSpent } = useExamStore((state) => state); - - useEffect(() => setTimer((prev) => prev - timeSpent), [timeSpent]); - - useEffect(() => { - if (!disableTimer) { - const timerInterval = setInterval(() => setTimer((prev) => prev - 1), 1000); - - return () => { - clearInterval(timerInterval); - }; - } - }, [disableTimer, minTimer]); - - useEffect(() => { - if (timer <= 0) setShowModal(true); - }, [timer]); - - useEffect(() => { - if (timer < 300 && !warningMode) setWarningMode(true); - }, [timer, warningMode]); const moduleIcon: { [key in Module]: ReactNode } = { reading: , @@ -58,37 +34,7 @@ export default function ModuleTitle({ return ( <> - { - setHasExamEnded(true); - setShowModal(false); - }} - /> - - - - {timer > 0 && ( - <> - {Math.floor(timer / 60) - .toString(10) - .padStart(2, "0")} - : - {Math.floor(timer % 60) - .toString(10) - .padStart(2, "0")} - - )} - {timer <= 0 && <>00:00} - - + {showTimer && }
{partLabel &&
{partLabel.split('\n\n').map((line, index) => { if(index == 0) return

{line}

diff --git a/src/components/Medium/Timer.tsx b/src/components/Medium/Timer.tsx new file mode 100644 index 00000000..86f1b68b --- /dev/null +++ b/src/components/Medium/Timer.tsx @@ -0,0 +1,80 @@ +import useExamStore from "@/stores/examStore"; +import { useEffect, useState } from "react"; +import { motion } from "framer-motion"; +import TimerEndedModal from "../TimerEndedModal"; +import clsx from "clsx"; +import { BsStopwatch } from "react-icons/bs"; + +interface Props { + minTimer: number; + disableTimer?: boolean; + standalone?: boolean; +} + +const Timer: React.FC = ({minTimer, disableTimer, standalone = false}) => { + const [timer, setTimer] = useState(minTimer * 60); + const [showModal, setShowModal] = useState(false); + const [warningMode, setWarningMode] = useState(false); + + const setHasExamEnded = useExamStore((state) => state.setHasExamEnded); + const { timeSpent } = useExamStore((state) => state); + + useEffect(() => setTimer((prev) => prev - timeSpent), [timeSpent]); + + useEffect(() => { + if (!disableTimer) { + const timerInterval = setInterval(() => setTimer((prev) => prev - 1), 1000); + + return () => { + clearInterval(timerInterval); + }; + } + }, [disableTimer, minTimer]); + + useEffect(() => { + if (timer <= 0) setShowModal(true); + }, [timer]); + + useEffect(() => { + if (timer < 300 && !warningMode) setWarningMode(true); + }, [timer, warningMode]); + + return ( + <> + { + setHasExamEnded(true); + setShowModal(false); + }} + /> + + + + {timer > 0 && ( + <> + {Math.floor(timer / 60) + .toString(10) + .padStart(2, "0")} + : + {Math.floor(timer % 60) + .toString(10) + .padStart(2, "0")} + + )} + {timer <= 0 && <>00:00} + + + + ); +} + +export default Timer; diff --git a/src/components/QuestionsModal.tsx b/src/components/QuestionsModal.tsx new file mode 100644 index 00000000..1530a018 --- /dev/null +++ b/src/components/QuestionsModal.tsx @@ -0,0 +1,80 @@ +import { Dialog, Transition } from "@headlessui/react"; +import { Fragment } from "react"; +import Button from "./Low/Button"; + +interface Props { + isOpen: boolean; + blankQuestions?: boolean; + finishingWhat? : string; + onClose: (next?: boolean) => void; +} + +export default function QuestionsModal({ isOpen, onClose, blankQuestions = true, finishingWhat = "module" }: Props) { + return ( + + onClose(false)} className="relative z-50"> + +
+ + + +
+ + {blankQuestions ? ( + <> + Questions Unanswered + + Please note that you are finishing the current {finishingWhat} and once you proceed to the next {finishingWhat}, you will no longer be + able to change the answers of the current one, including your unanswered questions.
+
+ Are you sure you want to continue without completing those questions? +
+
+ + +
+ + ): ( + <> + Confirm Submission + + Please note that you are finishing the current {finishingWhat} and once you proceed to the next {finishingWhat}, you will no longer be + able to review the answers of the current one.
+
+ Are you sure you want to continue? +
+
+ + +
+ + )} +
+
+
+
+
+ ); +} diff --git a/src/components/Solutions/FillBlanks.tsx b/src/components/Solutions/FillBlanks.tsx index 807536f9..3b14789b 100644 --- a/src/components/Solutions/FillBlanks.tsx +++ b/src/components/Solutions/FillBlanks.tsx @@ -4,6 +4,7 @@ import reactStringReplace from "react-string-replace"; import { CommonProps } from "."; import { Fragment } from "react"; import Button from "../Low/Button"; +import useExamStore from "@/stores/examStore"; export default function FillBlanksSolutions({ id, @@ -12,13 +13,20 @@ export default function FillBlanksSolutions({ solutions, words, text, - userSolutions, onNext, onBack, }: FillBlanksExercise & CommonProps) { + + // next and back was all messed up and still don't know why, anyways + const storeUserSolutions = useExamStore((state) => state.userSolutions); + + const correctUserSolutions = storeUserSolutions.find( + (solution) => solution.exercise === id + )?.solutions; + const calculateScore = () => { const total = text.match(/({{\d+}})/g)?.length || 0; - const correct = userSolutions.filter((x) => { + const correct = correctUserSolutions!.filter((x) => { const solution = solutions.find((y) => x.id.toString() === y.id.toString())?.solution; if (!solution) return false; @@ -42,9 +50,7 @@ export default function FillBlanksSolutions({ } return false; }).length; - - const missing = total - userSolutions.filter((x) => solutions.find((y) => x.id.toString() === y.id.toString())).length; - + const missing = total - correctUserSolutions!.filter((x) => solutions.find((y) => x.id.toString() === y.id.toString())).length; return { total, correct, missing }; }; @@ -60,16 +66,27 @@ export default function FillBlanksSolutions({ {reactStringReplace(line, /({{\d+}})/g, (match) => { const id = match.replaceAll(/[\{\}]/g, ""); - const userSolution = userSolutions.find((x) => x.id === id); - const solution = solutions.find((x) => x.id === id)!; + const userSolution = correctUserSolutions!.find((x) => x.id.toString() === id.toString()); + const answerSolution = solutions.find(sol => sol.id.toString() === id.toString())!.solution; if (!userSolution) { + let answerText; + if (typeCheckWordsMC(words)) { + const options = words.find((x) => x.id.toString() === id.toString()); + const correctKey = Object.keys(options!.options).find(key => + key.toLowerCase() === answerSolution.toLowerCase() + ); + answerText = options!.options[correctKey as keyof typeof options]; + } else { + answerText = answerSolution; + } + return ( ); } @@ -96,21 +113,21 @@ export default function FillBlanksSolutions({ let correct; let solutionText; if (typeCheckWordsMC(words)) { - const options = words.find((x) => x.id === id); + const options = words.find((x) => x.id.toString() === id.toString()); if (options) { const correctKey = Object.keys(options.options).find(key => - key.toLowerCase() === solution.solution.toLowerCase() + key.toLowerCase() === answerSolution.toLowerCase() ); correct = userSolution.solution == options.options[correctKey as keyof typeof options.options]; - solutionText = options.options[correctKey as keyof typeof options.options] || solution.solution; + solutionText = options.options[correctKey as keyof typeof options.options] || answerSolution; } else { correct = false; - solutionText = solution?.solution; + solutionText = answerSolution; } } else { - correct = userSolutionText === solution.solution; - solutionText = solution.solution; + correct = userSolutionText === answerSolution; + solutionText = answerSolution; } if (correct) { @@ -152,16 +169,8 @@ export default function FillBlanksSolutions({ return ( <>
- - {prompt.split("\\n").map((line, index) => ( - - {line} -
-
- ))} -
- {userSolutions && + {correctUserSolutions && text.split("\\n").map((line, index) => (

{renderLines(line)} @@ -189,14 +198,14 @@ export default function FillBlanksSolutions({ diff --git a/src/components/Solutions/MultipleChoice.tsx b/src/components/Solutions/MultipleChoice.tsx index 095e3a5e..320163e4 100644 --- a/src/components/Solutions/MultipleChoice.tsx +++ b/src/components/Solutions/MultipleChoice.tsx @@ -17,8 +17,7 @@ function Question({ }: MultipleChoiceQuestion & { userSolution: string | undefined; onSelectOption?: (option: string) => void; showSolution?: boolean }) { const { userSolutions } = useExamStore((state) => state); - /* - const getShuffledOptions = (options: {id: string, text: string}[], questionShuffleMap: ShuffleMap) => { + const getShuffledOptions = (options: { id: string, text: string }[], questionShuffleMap: ShuffleMap) => { const shuffledOptions = ['A', 'B', 'C', 'D'].map(newId => { const originalId = questionShuffleMap.map[newId]; const originalOption = options.find(option => option.id === originalId); @@ -43,10 +42,9 @@ function Question({ if (foundMap) return foundMap; return userSolution.shuffleMaps?.find(map => map.id === id) || null; }, null as ShuffleMap | null); - */ - - const questionOptions = options; // questionShuffleMap ? getShuffledOptions(options as {id: string, text: string}[], questionShuffleMap) : options; - const newSolution = solution; //questionShuffleMap ? getShuffledSolution(solution, questionShuffleMap) : solution; + + const questionOptions = questionShuffleMap ? getShuffledOptions(options as { id: string, text: string }[], questionShuffleMap) : options; + const newSolution = questionShuffleMap ? getShuffledSolution(solution, questionShuffleMap) : solution; const renderPrompt = (prompt: string) => { return reactStringReplace(prompt, /(.*?<\/u>)/g, (match) => { @@ -68,23 +66,23 @@ function Question({ }; return ( -

+
{isNaN(Number(id)) ? ( {renderPrompt(prompt).filter((x) => x?.toString() !== "")} ) : ( - + <> - {id} - {renderPrompt(prompt).filter((x) => x?.toString() !== "")} + {id} - {renderPrompt(prompt).filter((x) => x?.toString() !== "")} )} -
+
{variant === "image" && questionOptions.map((option) => (
{option?.id} @@ -95,7 +93,7 @@ function Question({ questionOptions.map((option) => (
+ className={clsx("flex border p-4 rounded-xl gap-2 cursor-pointer bg-white text-base select-none", optionColor(option!.id))}> {option?.id}. {option?.text}
@@ -106,7 +104,8 @@ function Question({ } export default function MultipleChoice({ id, type, prompt, questions, userSolutions, onNext, onBack }: MultipleChoiceExercise & CommonProps) { - const { questionIndex, setQuestionIndex } = useExamStore((state) => state); + const { questionIndex, setQuestionIndex, partIndex, exam } = useExamStore((state) => state); + const calculateScore = () => { const total = questions.length; @@ -138,7 +137,7 @@ export default function MultipleChoice({ id, type, prompt, questions, userSoluti <>
- {prompt} + {/*{prompt}*/} {userSolutions && questionIndex < questions.length && (
-
+
- diff --git a/src/exams/Level.tsx b/src/exams/Level.tsx deleted file mode 100644 index a5bdf478..00000000 --- a/src/exams/Level.tsx +++ /dev/null @@ -1,482 +0,0 @@ -import BlankQuestionsModal from "@/components/BlankQuestionsModal"; -import { renderExercise } from "@/components/Exercises"; -import HighlightContent from "@/components/HighlightContent"; -import Button from "@/components/Low/Button"; -import ModuleTitle from "@/components/Medium/ModuleTitle"; -import { renderSolution } from "@/components/Solutions"; -import { infoButtonStyle } from "@/constants/buttonStyles"; -import { Module } from "@/interfaces"; -import { Exercise, FillBlanksExercise, FillBlanksMCOption, LevelExam, LevelPart, MultipleChoiceExercise, ShuffleMap, UserSolution, WritingExam } from "@/interfaces/exam"; -import useExamStore from "@/stores/examStore"; -import { defaultUserSolutions } from "@/utils/exams"; -import { countExercises } from "@/utils/moduleUtils"; -import { mdiArrowRight } from "@mdi/js"; -import Icon from "@mdi/react"; -import clsx from "clsx"; -import { Dispatch, Fragment, SetStateAction, use, useEffect, useMemo, useRef, useState } from "react"; -import { BsChevronDown, BsChevronUp } from "react-icons/bs"; -import { toast } from "react-toastify"; -import { v4 } from "uuid"; - -interface Props { - exam: LevelExam; - showSolutions?: boolean; - onFinish: (userSolutions: UserSolution[]) => void; - editing?: boolean; -} - -function TextComponent({ - part, contextWord, setContextWordLine -}: { - part: LevelPart, contextWord: string | undefined, setContextWordLine: React.Dispatch> -}) { - const textRef = useRef(null); - const [lineNumbers, setLineNumbers] = useState([]); - const [lineHeight, setLineHeight] = useState(0); - part.showContextLines = true; - - const calculateLineNumbers = () => { - if (textRef.current) { - const computedStyle = window.getComputedStyle(textRef.current); - const lineHeightValue = parseFloat(computedStyle.lineHeight); - const containerWidth = textRef.current.clientWidth; - setLineHeight(lineHeightValue); - - const offscreenElement = document.createElement('div'); - offscreenElement.style.position = 'absolute'; - offscreenElement.style.top = '-9999px'; - offscreenElement.style.left = '-9999px'; - offscreenElement.style.whiteSpace = 'pre-wrap'; - offscreenElement.style.width = `${containerWidth}px`; - offscreenElement.style.font = computedStyle.font; - offscreenElement.style.lineHeight = computedStyle.lineHeight; - offscreenElement.style.textAlign = computedStyle.textAlign as CanvasTextAlign; - - const textContent = textRef.current.textContent || ''; - textContent.split(/(\s+)/).forEach((word: string) => { - const span = document.createElement('span'); - span.textContent = word; - span.style.display = 'inline-block'; - span.style.height = `calc(1em + 16px)`; - offscreenElement.appendChild(span); - }); - - document.body.appendChild(offscreenElement); - - const lines: string[][] = [[]]; - let currentLine = 1; - let currentLineTop: number | undefined; - let contextWordLine: number | null = null; - - const firstChild = offscreenElement.firstChild as HTMLElement; - if (firstChild) { - currentLineTop = firstChild.getBoundingClientRect().top; - } - - const spans = offscreenElement.querySelectorAll('span'); - - spans.forEach(span => { - const rect = span.getBoundingClientRect(); - const top = rect.top; - - if (currentLineTop !== undefined && top > currentLineTop) { - currentLine++; - currentLineTop = top; - lines.push([]); - } - - lines[lines.length - 1].push(span.textContent?.trim() || ''); - - - if (contextWord && contextWordLine === null && span.textContent?.includes(contextWord)) { - contextWordLine = currentLine; - } - }); - - setLineNumbers(lines.map((_, index) => index + 1)); - if (contextWordLine) { - setContextWordLine(contextWordLine); - } - - document.body.removeChild(offscreenElement); - } - }; - - useEffect(() => { - calculateLineNumbers(); - - const resizeObserver = new ResizeObserver(() => { - calculateLineNumbers(); - }); - - if (textRef.current) { - resizeObserver.observe(textRef.current); - } - - return () => { - if (textRef.current) { - resizeObserver.unobserve(textRef.current); - } - }; - }, [part.context, part.showContextLines, contextWord]); - - if (typeof part.showContextLines === "undefined") { - return ( -
-
- {!!part.context && - part.context - .split(/\n|(\\n)/g) - .filter((x) => x && x.length > 0 && x !== "\\n") - .map((line, index) => ( - -

{line}

-
- ))} -
- ); - } - - return ( -
-
-
-
- {part.context!.split('\n\n').map((line, index) => { - return

{index + 1}{line}

- })} -
-
-
- ); -} - - -const typeCheckWordsMC = (words: any[]): words is FillBlanksMCOption[] => { - return Array.isArray(words) && words.every( - word => word && typeof word === 'object' && 'id' in word && 'options' in word - ); -} - - -export default function Level({ exam, showSolutions = false, onFinish, editing = false }: Props) { - const [multipleChoicesDone, setMultipleChoicesDone] = useState<{ id: string; amount: number }[]>([]); - const [showBlankModal, setShowBlankModal] = useState(false); - - const { userSolutions, setUserSolutions } = useExamStore((state) => state); - const { hasExamEnded, setHasExamEnded } = useExamStore((state) => state); - const { partIndex, setPartIndex } = useExamStore((state) => state); - const { exerciseIndex, setExerciseIndex } = useExamStore((state) => state); - const [storeQuestionIndex, setStoreQuestionIndex] = useExamStore((state) => [state.questionIndex, state.setQuestionIndex]); - //const [shuffleMaps, setShuffleMaps] = useExamStore((state) => [state.shuffleMaps, state.setShuffleMaps]) - const [currentExercise, setCurrentExercise] = useState(); - - const scrollToTop = () => Array.from(document.getElementsByTagName("body")).forEach((body) => body.scrollTo(0, 0)); - - const [contextWord, setContextWord] = useState(undefined); - const [contextWordLine, setContextWordLine] = useState(undefined); - - /*useEffect(() => { - if (showSolutions && userSolutions[exerciseIndex].shuffleMaps) { - setShuffleMaps(userSolutions[exerciseIndex].shuffleMaps as ShuffleMap[]) - } - }, [showSolutions])*/ - - useEffect(() => { - if (hasExamEnded && exerciseIndex === -1) { - setExerciseIndex(exerciseIndex + 1); - } - }, [hasExamEnded, exerciseIndex, setExerciseIndex]); - - const confirmFinishModule = (keepGoing?: boolean) => { - if (!keepGoing) { - setShowBlankModal(false); - return; - } - - onFinish(userSolutions); - }; - - - const getExercise = () => { - if (exerciseIndex === -1) { - return undefined; - } - let exercise = exam.parts[partIndex]?.exercises[exerciseIndex]; - if (!exercise) return undefined; - - exercise = { - ...exercise, - userSolutions: userSolutions.find((x) => x.exercise === exercise.id)?.solutions || [], - }; - - /*if (exam.shuffle && exercise.type === "multipleChoice") { - if (shuffleMaps.length == 0 && !showSolutions) { - console.log("Shuffling answers"); - const newShuffleMaps: ShuffleMap[] = []; - - exercise.questions = exercise.questions.map(question => { - const options = [...question.options]; - let shuffledOptions = [...options].sort(() => Math.random() - 0.5); - - const newOptions = options.map((option, index) => ({ - id: option.id, - text: shuffledOptions[index].text - })); - - const optionMapping = options.reduce<{ [key: string]: string }>((acc, originalOption) => { - const shuffledPosition = newOptions.find(newOpt => newOpt.text === originalOption.text)?.id; - if (shuffledPosition) { - acc[shuffledPosition] = originalOption.id; - } - return acc; - }, {}); - - newShuffleMaps.push({ id: question.id, map: optionMapping }); - - return { ...question, options: newOptions }; - }); - - setShuffleMaps(newShuffleMaps); - } else { - console.log("Retrieving shuffles"); - exercise.questions = exercise.questions.map(question => { - const questionShuffleMap = shuffleMaps.find(map => map.id === question.id); - if (questionShuffleMap) { - const newOptions = question.options.map(option => ({ - id: option.id, - text: question.options.find(o => questionShuffleMap.map[o.id] === option.id)?.text || option.text - })); - return { ...question, options: newOptions }; - } - return question; - }); - } - } else if (exam.shuffle && exercise.type === "fillBlanks" && typeCheckWordsMC(exercise.words)) { - if (shuffleMaps.length === 0 && !showSolutions) { - const newShuffleMaps: ShuffleMap[] = []; - - exercise.words = exercise.words.map(word => { - if ('options' in word) { - const options = { ...word.options }; - const originalKeys = Object.keys(options); - const shuffledKeys = [...originalKeys].sort(() => Math.random() - 0.5); - - const newOptions = shuffledKeys.reduce((acc, key, index) => { - acc[key as keyof typeof options] = options[originalKeys[index] as keyof typeof options]; - return acc; - }, {} as { [key in keyof typeof options]: string }); - - const optionMapping = originalKeys.reduce((acc, key, index) => { - acc[key as keyof typeof options] = shuffledKeys[index]; - return acc; - }, {} as { [key in keyof typeof options]: string }); - - newShuffleMaps.push({ id: word.id, map: optionMapping }); - - return { ...word, options: newOptions }; - } - return word; - }); - - setShuffleMaps(newShuffleMaps); - } - } - */ - return exercise; - }; - - useEffect(() => { - //console.log("Getting another exercise"); - //setShuffleMaps([]); - setCurrentExercise(getExercise()); - }, [partIndex, exerciseIndex]); - - - useEffect(() => { - const regex = /.*?['"](.*?)['"] in line (\d+)\?$/; - if (currentExercise && currentExercise.type === "multipleChoice") { - const match = currentExercise.questions[storeQuestionIndex].prompt.match(regex); - if (match) { - const word = match[1]; - const originalLineNumber = match[2]; - - if (word !== contextWord) { - setContextWord(word); - } - - const updatedPrompt = currentExercise.questions[storeQuestionIndex].prompt.replace( - `in line ${originalLineNumber}`, - `in line ${contextWordLine || originalLineNumber}` - ); - - currentExercise.questions[storeQuestionIndex].prompt = updatedPrompt; - } else { - setContextWord(undefined); - } - } - }, [storeQuestionIndex, contextWordLine, exerciseIndex, partIndex]); //, shuffleMaps]); - - const nextExercise = (solution?: UserSolution) => { - scrollToTop(); - if (solution) { - setUserSolutions([...userSolutions.filter((x) => x.exercise !== solution.exercise), { ...solution, module: "level", exam: exam.id }]); - } - - if (storeQuestionIndex > 0 || currentExercise?.type == "fillBlanks") { - setMultipleChoicesDone((prev) => [...prev.filter((x) => x.id !== currentExercise!.id), { id: currentExercise!.id, amount: currentExercise?.type == "fillBlanks" ? currentExercise.words.length - 1 : storeQuestionIndex }]); - } - setStoreQuestionIndex(0); - - if (exerciseIndex + 1 < exam.parts[partIndex].exercises.length && !hasExamEnded) { - setExerciseIndex(exerciseIndex + 1); - return; - } - - if (partIndex + 1 < exam.parts.length && !hasExamEnded) { - setPartIndex(partIndex + 1); - setExerciseIndex(!!exam.parts[partIndex + 1].context ? -1 : 0); - return; - } - - if ( - solution && - ![...userSolutions.filter((x) => x.exercise !== solution?.exercise).map((x) => x.score.missing), solution?.score.missing].every( - (x) => x === 0, - ) && - !showSolutions && - !editing && - !hasExamEnded - ) { - setShowBlankModal(true); - return; - } - - setHasExamEnded(false); - - if (solution) { - let stat = { ...solution, module: "level" as Module, exam: exam.id } - /*if (exam.shuffle) { - stat.shuffleMaps = shuffleMaps - }*/ - onFinish([...userSolutions.filter((x) => x.exercise !== solution.exercise), { ...stat }]); - } else { - onFinish(userSolutions); - } - }; - - const previousExercise = (solution?: UserSolution) => { - scrollToTop(); - if (solution) { - setUserSolutions([...userSolutions.filter((x) => x.exercise !== solution.exercise), { ...solution, module: "level", exam: exam.id }]); - } - - if (storeQuestionIndex > 0 || currentExercise?.type == "fillBlanks") { - setMultipleChoicesDone((prev) => [...prev.filter((x) => x.id !== currentExercise!.id), { id: currentExercise!.id, amount: currentExercise?.type == "fillBlanks" ? currentExercise.words.length - 1 : storeQuestionIndex }]); - } - setStoreQuestionIndex(0); - - setExerciseIndex(exerciseIndex - 1); - }; - - const calculateExerciseIndex = () => { - 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) => { return acc + curr.amount}, 0) - ); - }; - - const renderText = () => ( -
- <> -
-

- Please read the following excerpt attentively, you will then be asked questions about the text you've read. -

- You will be allowed to read the text while doing the exercises -
- - -
- ); - - const partLabel = () => { - if (currentExercise?.type === "fillBlanks" && typeCheckWordsMC(currentExercise.words)) - return `Part ${partIndex + 1} (Questions ${currentExercise.words[0].id} - ${currentExercise.words[currentExercise.words.length - 1].id})\n\n${currentExercise.prompt}` - if (currentExercise?.type === "multipleChoice") - return `Part ${partIndex + 1} (Questions ${currentExercise.questions[0].id} - ${currentExercise.questions[currentExercise.questions.length - 1].id})\n\n${currentExercise.prompt}` - } - - return ( - <> -
- - x.exercises))} - disableTimer={showSolutions || editing} - /> -
-1 && exerciseIndex > -1 && !!exam.parts[partIndex].context && "grid grid-cols-2 gap-4", - )}> - {partIndex > -1 && !!exam.parts[partIndex].context && renderText()} - - {exerciseIndex > -1 && - partIndex > -1 && - exerciseIndex < exam.parts[partIndex].exercises.length && - !showSolutions && - !editing && - currentExercise && - renderExercise(currentExercise, exam.id, nextExercise, previousExercise)} - - {exerciseIndex > -1 && - partIndex > -1 && - exerciseIndex < exam.parts[partIndex].exercises.length && - (showSolutions || editing) && - renderSolution(exam.parts[partIndex].exercises[exerciseIndex], nextExercise, previousExercise)} -
- {exerciseIndex === -1 && partIndex > 0 && ( -
- - - -
- )} - {exerciseIndex === -1 && partIndex === 0 && ( - - )} -
- - ); -} diff --git a/src/exams/Level/PartDivider.tsx b/src/exams/Level/PartDivider.tsx new file mode 100644 index 00000000..480eeaad --- /dev/null +++ b/src/exams/Level/PartDivider.tsx @@ -0,0 +1,37 @@ +import Button from "@/components/Low/Button"; +import { Module } from "@/interfaces"; +import { LevelPart, UserSolution } from "@/interfaces/exam"; +import { ReactNode } from "react"; +import { BsBook, BsClipboard, BsHeadphones, BsMegaphone, BsPen } from "react-icons/bs"; + +interface Props { + partIndex: number; + part: LevelPart // for now + onNext: () => void; +} + +const PartDivider: React.FC = ({ partIndex, part, onNext }) => { + + const moduleIcon: { [key in Module]: ReactNode } = { + reading: , + listening: , + writing: , + speaking: , + level: , + }; + + return ( +
+ {/** only level for now */} +
{moduleIcon["level"]}

{`Part ${partIndex + 1}`}

+ {part.intro!.split('\\n\\n').map((x, index) =>

{x}

)} +
+ +
+
+ ) +} + +export default PartDivider; \ No newline at end of file diff --git a/src/exams/Level/TextComponent.tsx b/src/exams/Level/TextComponent.tsx new file mode 100644 index 00000000..fa2b0f66 --- /dev/null +++ b/src/exams/Level/TextComponent.tsx @@ -0,0 +1,143 @@ +import { LevelPart } from "@/interfaces/exam"; +import { useEffect, useRef } from "react"; + +interface Props { + part: LevelPart, + contextWord: string | undefined, + setContextWordLine: React.Dispatch> +} + +const TextComponent: React.FC = ({part, contextWord, setContextWordLine}) => { + const textRef = useRef(null); + + const calculateLineNumbers = () => { + if (textRef.current) { + const computedStyle = window.getComputedStyle(textRef.current); + const containerWidth = textRef.current.clientWidth; + + const offscreenElement = document.createElement('div'); + offscreenElement.style.position = 'absolute'; + offscreenElement.style.top = '-9999px'; + offscreenElement.style.left = '-9999px'; + offscreenElement.style.width = `${containerWidth}px`; + offscreenElement.style.font = computedStyle.font; + offscreenElement.style.lineHeight = computedStyle.lineHeight; + offscreenElement.style.whiteSpace = 'pre-wrap'; + offscreenElement.style.wordWrap = 'break-word'; + offscreenElement.style.textAlign = computedStyle.textAlign as CanvasTextAlign; + + const paragraphs = part.context!.split('\n\n'); + let currentLine = 1; + let contextWordLine: number | null = null; + const paragraphLineStarts: number[] = []; + + paragraphs.forEach((paragraph, pIndex) => { + const p = document.createElement('p'); + p.style.margin = '0'; + p.style.padding = '0'; + + paragraph.split(/(\s+)/).forEach((word: string) => { + const span = document.createElement('span'); + span.textContent = word; + p.appendChild(span); + }); + + offscreenElement.appendChild(p); + + if (pIndex < paragraphs.length - 1) { + const gap = document.createElement('div'); + gap.style.height = '16px'; // gap-4 + offscreenElement.appendChild(gap); + } + }); + + document.body.appendChild(offscreenElement); + + let currentLineTop: number | undefined; + const elements = offscreenElement.querySelectorAll('p, div'); + + elements.forEach((element) => { + if (element.tagName === 'P') { + const spans = element.querySelectorAll('span'); + paragraphLineStarts.push(currentLine); + + spans.forEach(span => { + const rect = span.getBoundingClientRect(); + const top = rect.top; + + if (currentLineTop === undefined || top > currentLineTop) { + if (currentLineTop !== undefined) { + currentLine++; + } + currentLineTop = top; + } + + if (contextWord && contextWordLine === null && span.textContent?.includes(contextWord)) { + contextWordLine = currentLine; + } + }); + } else if (element.tagName === 'DIV') { // Gap + currentLine++; + currentLineTop = undefined; + } + }); + if (contextWordLine) { + setContextWordLine(contextWordLine); + } + + document.body.removeChild(offscreenElement); + } + }; + + + + useEffect(() => { + calculateLineNumbers(); + + const resizeObserver = new ResizeObserver(() => { + calculateLineNumbers(); + }); + + if (textRef.current) { + resizeObserver.observe(textRef.current); + } + + return () => { + if (textRef.current) { + resizeObserver.unobserve(textRef.current); + } + }; + }, [part.context, contextWord]); + + /*if (typeof part.showContextLines === "undefined") { + return ( +
+
+ {!!part.context && + part.context + .split(/\n|(\\n)/g) + .filter((x) => x && x.length > 0 && x !== "\\n") + .map((line, index) => ( + +

{line}

+
+ ))} +
+ ); + }*/ + + return ( +
+
+
+
+ {part.context!.split('\n\n').map((line, index) => { + return

{index + 1}{line}

+ })} +
+
+
+ ); +} + +export default TextComponent; diff --git a/src/exams/Level/index.tsx b/src/exams/Level/index.tsx new file mode 100644 index 00000000..491a1b47 --- /dev/null +++ b/src/exams/Level/index.tsx @@ -0,0 +1,416 @@ +import QuestionsModal from "@/components/QuestionsModal"; +import { renderExercise } from "@/components/Exercises"; +import Button from "@/components/Low/Button"; +import ModuleTitle from "@/components/Medium/ModuleTitle"; +import { renderSolution } from "@/components/Solutions"; +import { Module } from "@/interfaces"; +import { Exercise, FillBlanksMCOption, LevelExam, MultipleChoiceExercise, MultipleChoiceQuestion, ShuffleMap, UserSolution } from "@/interfaces/exam"; +import useExamStore from "@/stores/examStore"; +import { countExercises } from "@/utils/moduleUtils"; +import clsx from "clsx"; +import { use, useEffect, useState } from "react"; +import TextComponent from "./TextComponent"; +import PartDivider from "./PartDivider"; +import Timer from "@/components/Medium/Timer"; + +interface Props { + exam: LevelExam; + showSolutions?: boolean; + onFinish: (userSolutions: UserSolution[]) => void; + editing?: boolean; + partDividers?: boolean; +} + +const typeCheckWordsMC = (words: any[]): words is FillBlanksMCOption[] => { + return Array.isArray(words) && words.every( + word => word && typeof word === 'object' && 'id' in word && 'options' in word + ); +} + + +export default function Level({ exam, showSolutions = false, onFinish, editing = false }: Props) { + const levelBgColor = "bg-ielts-level-light"; + const [multipleChoicesDone, setMultipleChoicesDone] = useState<{ id: string; amount: number }[]>([]); + const [showQuestionsModal, setShowQuestionsModal] = useState(false); + + const { setBgColor } = useExamStore((state) => state); + const { userSolutions, setUserSolutions } = useExamStore((state) => state); + const { hasExamEnded, setHasExamEnded } = useExamStore((state) => state); + const { partIndex, setPartIndex } = useExamStore((state) => state); + const { exerciseIndex, setExerciseIndex } = useExamStore((state) => state); + const [storeQuestionIndex, setStoreQuestionIndex] = useExamStore((state) => [state.questionIndex, state.setQuestionIndex]); + const [shuffleMaps, setShuffleMaps] = useExamStore((state) => [state.shuffleMaps, state.setShuffleMaps]) + const [currentExercise, setCurrentExercise] = useState(); + const [showPartDivider, setShowPartDivider] = useState(typeof exam.parts[0].intro === "string" && !showSolutions); + + const scrollToTop = () => Array.from(document.getElementsByTagName("body")).forEach((body) => body.scrollTo(0, 0)); + + const [contextWord, setContextWord] = useState(undefined); + const [contextWordLine, setContextWordLine] = useState(undefined); + + useEffect(() => { + if (showSolutions && exerciseIndex && userSolutions[exerciseIndex].shuffleMaps) { + setShuffleMaps(userSolutions[exerciseIndex].shuffleMaps as ShuffleMap[]) + } + }, [showSolutions]) + + useEffect(() => { + if (hasExamEnded && exerciseIndex === -1) { + setExerciseIndex(exerciseIndex + 1); + } + }, [hasExamEnded, exerciseIndex, setExerciseIndex]); + + const getExercise = () => { + let exercise = exam.parts[partIndex]?.exercises[exerciseIndex]; + if (!exercise) return undefined; + + exercise = { + ...exercise, + userSolutions: userSolutions.find((x) => x.exercise === exercise.id)?.solutions || [], + }; + + if (exam.shuffle && exercise.type === "multipleChoice") { + const exerciseShuffles = userSolutions[exerciseIndex].shuffleMaps; + if (exerciseShuffles && exerciseShuffles.length == 0) { + const newShuffleMaps: ShuffleMap[] = []; + + exercise.questions = exercise.questions.map(question => { + const options = [...question.options]; + let shuffledOptions = [...options].sort(() => Math.random() - 0.5); + + const newOptions = options.map((option, index) => ({ + id: option.id, + text: shuffledOptions[index].text + })); + + const optionMapping = options.reduce<{ [key: string]: string }>((acc, originalOption) => { + const shuffledPosition = newOptions.find(newOpt => newOpt.text === originalOption.text)?.id; + if (shuffledPosition) { + acc[shuffledPosition] = originalOption.id; + } + return acc; + }, {}); + + newShuffleMaps.push({ id: question.id, map: optionMapping }); + + return { ...question, options: newOptions }; + }); + + setShuffleMaps(newShuffleMaps); + } else { + exercise.questions = exercise.questions.map(question => { + const questionShuffleMap = shuffleMaps.find(map => map.id === question.id); + if (questionShuffleMap) { + const newOptions = question.options.map(option => ({ + id: option.id, + text: question.options.find(o => questionShuffleMap.map[o.id] === option.id)?.text || option.text + })); + return { ...question, options: newOptions }; + } + return question; + }); + } + } else if (exam.shuffle && exercise.type === "fillBlanks" && typeCheckWordsMC(exercise.words)) { + if (shuffleMaps.length === 0 && !showSolutions) { + const newShuffleMaps: ShuffleMap[] = []; + + exercise.words = exercise.words.map(word => { + if ('options' in word) { + const options = { ...word.options }; + const originalKeys = Object.keys(options); + const shuffledKeys = [...originalKeys].sort(() => Math.random() - 0.5); + + const newOptions = shuffledKeys.reduce((acc, key, index) => { + acc[key as keyof typeof options] = options[originalKeys[index] as keyof typeof options]; + return acc; + }, {} as { [key in keyof typeof options]: string }); + + const optionMapping = originalKeys.reduce((acc, key, index) => { + acc[key as keyof typeof options] = shuffledKeys[index]; + return acc; + }, {} as { [key in keyof typeof options]: string }); + + newShuffleMaps.push({ id: word.id, map: optionMapping }); + + return { ...word, options: newOptions }; + } + return word; + }); + + setShuffleMaps(newShuffleMaps); + } + } + return exercise; + }; + + useEffect(() => { + if (exerciseIndex !== -1) { + setCurrentExercise(getExercise()); + } + }, [partIndex, exerciseIndex, shuffleMaps, exam.parts[partIndex].context]); + + + useEffect(() => { + const regex = /.*?['"](.*?)['"] in line (\d+)\?$/; + if (exerciseIndex !== -1 && currentExercise && currentExercise.type === "multipleChoice" && currentExercise.questions[storeQuestionIndex].prompt) { + const match = currentExercise.questions[storeQuestionIndex].prompt.match(regex); + if (match) { + const word = match[1]; + const originalLineNumber = match[2]; + + if (word !== contextWord) { + setContextWord(word); + } + + const updatedPrompt = currentExercise.questions[storeQuestionIndex].prompt.replace( + `in line ${originalLineNumber}`, + `in line ${contextWordLine || originalLineNumber}` + ); + + currentExercise.questions[storeQuestionIndex].prompt = updatedPrompt; + } else { + setContextWord(undefined); + } + } + }, [currentExercise, storeQuestionIndex]); + + const nextExercise = (solution?: UserSolution) => { + scrollToTop(); + if (solution) { + setUserSolutions([...userSolutions.filter((x) => x.exercise !== solution.exercise), { ...solution, module: "level", exam: exam.id }]); + } + + /*if (storeQuestionIndex > 0 || currentExercise?.type == "fillBlanks") { + setMultipleChoicesDone((prev) => [...prev.filter((x) => x.id !== currentExercise!.id), { id: currentExercise!.id, amount: currentExercise?.type == "fillBlanks" ? currentExercise.words.length - 1 : storeQuestionIndex }]); + }*/ + + if (exerciseIndex + 1 < exam.parts[partIndex].exercises.length && !hasExamEnded) { + setExerciseIndex(exerciseIndex + 1); + return; + } + + if (partIndex + 1 < exam.parts.length && !hasExamEnded && (showQuestionsModal || showSolutions)) { + if (!showSolutions) { + setShowPartDivider(true); + setBgColor(levelBgColor); + } + setPartIndex(partIndex + 1); + setExerciseIndex(!!exam.parts[partIndex + 1].context ? -1 : 0); + setStoreQuestionIndex(0); + setMultipleChoicesDone((prev) => [...prev.filter((x) => x.id !== currentExercise!.id), { id: currentExercise!.id, amount: currentExercise?.type == "fillBlanks" ? currentExercise.words.length - 1 : storeQuestionIndex }]); + return; + } + + if (partIndex + 1 < exam.parts.length && !hasExamEnded && !showQuestionsModal && !showSolutions) { + setShowQuestionsModal(true); + return; + } + + if ( + solution && + ![...userSolutions.filter((x) => x.exercise !== solution?.exercise).map((x) => x.score.missing), solution?.score.missing].every( + (x) => x === 0, + ) && + !showSolutions && + !editing && + !hasExamEnded + ) { + setShowQuestionsModal(true); + return; + } + + setHasExamEnded(false); + + if (solution) { + let stat = { ...solution, module: "level" as Module, exam: exam.id } + if (exam.shuffle) { + stat.shuffleMaps = shuffleMaps + } + onFinish([...userSolutions.filter((x) => x.exercise !== solution.exercise), { ...stat }]); + } else { + onFinish(userSolutions); + } + }; + + const previousExercise = (solution?: UserSolution) => { + scrollToTop(); + if (solution) { + setUserSolutions([...userSolutions.filter((x) => x.exercise !== solution.exercise), { ...solution, module: "level", exam: exam.id }]); + } + + setExerciseIndex(exerciseIndex - 1); + if (exerciseIndex - 1 === -1) { + setPartIndex(partIndex - 1); + const lastPartExerciseIndex = exam.parts[partIndex - 1].exercises.length - 1; + const previousExercise = exam.parts[partIndex - 1].exercises[lastPartExerciseIndex]; + if (previousExercise.type === "multipleChoice") { + setStoreQuestionIndex(previousExercise.questions.length - 1) + } + const multipleChoiceQuestionsDone = []; + for (let i = 0; i < exam.parts.length; i++) { + if (i == (partIndex - 1)) break; + for (let j = 0; j < exam.parts[i].exercises.length; j++) { + const exercise = exam.parts[i].exercises[j]; + if (exercise.type === "multipleChoice") { + multipleChoiceQuestionsDone.push({ id: exercise.id, amount: exercise.questions.length - 1 }) + } + if (exercise.type === "fillBlanks") { + multipleChoiceQuestionsDone.push({ id: exercise.id, amount: exercise.words.length - 1 }) + } + } + } + setMultipleChoicesDone(multipleChoiceQuestionsDone); + } + + }; + + useEffect(() => { + if (exerciseIndex === -1) { + nextExercise() + } + }, [exerciseIndex]) + + const calculateExerciseIndex = () => { + 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) => { return acc + curr.amount }, 0) + ); + }; + + const renderText = () => ( +
+ <> +
+

+ Please read the following excerpt attentively, you will then be asked questions about the text you've read. +

+ You will be allowed to read the text while doing the exercises +
+ + +
+ ); + + const partLabel = () => { + if (currentExercise?.type === "fillBlanks" && typeCheckWordsMC(currentExercise.words)) + return `Part ${partIndex + 1} (Questions ${currentExercise.words[0].id} - ${currentExercise.words[currentExercise.words.length - 1].id})\n\n${currentExercise.prompt}` + + if (currentExercise?.type === "multipleChoice") { + return `Part ${partIndex + 1} (Questions ${currentExercise.questions[0].id} - ${currentExercise.questions[currentExercise.questions.length - 1].id})\n\n${currentExercise.prompt}` + } + + if (typeof exam.parts[partIndex].context === "string") { + const nextExercise = exam.parts[partIndex].exercises[0] as MultipleChoiceExercise; + return `Part ${partIndex + 1} (Questions ${nextExercise.questions[0].id} - ${nextExercise.questions[nextExercise.questions.length - 1].id})\n\n${nextExercise.prompt}` + } + } + + const modalKwargs = () => { + const allSolutionsCorrectLength = exam.parts[partIndex].exercises.every((exercise) => { + const userSolution = userSolutions.find(x => x.exercise === exercise.id); + if (exercise.type === "multipleChoice") { + return userSolution?.solutions.length === exercise.questions.length; + } + if (exercise.type === "fillBlanks") { + return userSolution?.solutions.length === exercise.words.length; + } + return false; + }); + + return { + blankQuestions: !allSolutionsCorrectLength, + finishingWhat: "part", + onClose: partIndex !== exam.parts.length - 1 ? ( + function (x: boolean | undefined) { if (x) { setShowQuestionsModal(false); nextExercise(); } else { setShowQuestionsModal(false) } } + ) : function (x: boolean | undefined) { if (x) { setShowQuestionsModal(false); onFinish(userSolutions); } else { setShowQuestionsModal(false) } } + } + + } + + return ( + <> +
+ + { + !(partIndex === 0 && storeQuestionIndex === 0 && showPartDivider) && + + } + {showPartDivider ? { setShowPartDivider(false); setBgColor("bg-white") }} /> : ( + <> + x.exercises))} + disableTimer={showSolutions || editing} + showTimer={typeof exam.parts[0].intro === "undefined"} + /> +
-1 && exerciseIndex > -1 && !!exam.parts[partIndex].context && "grid grid-cols-2 gap-4", + )}> + {partIndex > -1 && !!exam.parts[partIndex].context && renderText()} + + {exerciseIndex > -1 && + partIndex > -1 && + exerciseIndex < exam.parts[partIndex].exercises.length && + !showSolutions && + !editing && + currentExercise && + renderExercise(currentExercise, exam.id, nextExercise, previousExercise)} + + {exerciseIndex > -1 && + partIndex > -1 && + exerciseIndex < exam.parts[partIndex].exercises.length && + (showSolutions || editing) && + currentExercise && + renderSolution(currentExercise, nextExercise, previousExercise)} +
+ {/*exerciseIndex === -1 && partIndex > 0 && ( +
+ + + +
+ )*/} + {exerciseIndex === -1 && partIndex === 0 && ( + + )} + + )} +
+ + ); +} diff --git a/src/exams/Listening.tsx b/src/exams/Listening.tsx index 24cb4859..0afb0c09 100644 --- a/src/exams/Listening.tsx +++ b/src/exams/Listening.tsx @@ -5,7 +5,7 @@ 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/BlankQuestionsModal"; +import BlankQuestionsModal from "@/components/QuestionsModal"; import useExamStore from "@/stores/examStore"; import {countExercises} from "@/utils/moduleUtils"; diff --git a/src/exams/Reading.tsx b/src/exams/Reading.tsx index f2049069..163eb7ee 100644 --- a/src/exams/Reading.tsx +++ b/src/exams/Reading.tsx @@ -15,7 +15,7 @@ import ProgressBar from "@/components/Low/ProgressBar"; import ModuleTitle from "@/components/Medium/ModuleTitle"; import {Divider} from "primereact/divider"; import Button from "@/components/Low/Button"; -import BlankQuestionsModal from "@/components/BlankQuestionsModal"; +import BlankQuestionsModal from "@/components/QuestionsModal"; import useExamStore from "@/stores/examStore"; import {defaultUserSolutions} from "@/utils/exams"; import {countExercises} from "@/utils/moduleUtils"; diff --git a/src/interfaces/exam.ts b/src/interfaces/exam.ts index 85a1bc2c..5c2cf7e2 100644 --- a/src/interfaces/exam.ts +++ b/src/interfaces/exam.ts @@ -37,7 +37,7 @@ export interface LevelExam extends ExamBase { export interface LevelPart { context?: string; - showContextLines?: boolean; + intro?: string; exercises: Exercise[]; } diff --git a/src/pages/(exam)/ExamPage.tsx b/src/pages/(exam)/ExamPage.tsx index 57b38e4b..20fad5c6 100644 --- a/src/pages/(exam)/ExamPage.tsx +++ b/src/pages/(exam)/ExamPage.tsx @@ -23,6 +23,7 @@ import {toast, ToastContainer} from "react-toastify"; import {v4 as uuidv4} from "uuid"; import useSessions from "@/hooks/useSessions"; import ShortUniqueId from "short-unique-id"; +import clsx from "clsx"; interface Props { page: "exams" | "exercises"; @@ -54,6 +55,7 @@ export default function ExamPage({page}: Props) { const {showSolutions, setShowSolutions} = useExamStore((state) => state); const {selectedModules, setSelectedModules} = useExamStore((state) => state); const {inactivity, setInactivity} = useExamStore((state) => state); + const {bgColor, setBgColor} = useExamStore((state) => state); const {user} = useUser({redirectTo: "/login"}); const router = useRouter(); @@ -280,6 +282,13 @@ export default function ExamPage({page}: Props) { // eslint-disable-next-line react-hooks/exhaustive-deps }, [statsAwaitingEvaluation]); + useEffect(()=> { + + if(exam && exam.module === "level" && exam.parts[0].intro && !showSolutions) { + setBgColor("bg-ielts-level-light"); + } + }, [exam]) + const checkIfStatsHaveBeenEvaluated = (ids: string[]) => { setTimeout(async () => { try { @@ -513,6 +522,7 @@ export default function ExamPage({page}: Props) { {user && ( setShowAbandonPopup(true)}> diff --git a/src/stores/examStore.ts b/src/stores/examStore.ts index 60bc8fcb..13e69f50 100644 --- a/src/stores/examStore.ts +++ b/src/stores/examStore.ts @@ -19,6 +19,7 @@ export interface ExamState { questionIndex: number; inactivity: number; shuffleMaps: ShuffleMap[]; + bgColor: string; } export interface ExamFunctions { @@ -37,6 +38,7 @@ export interface ExamFunctions { setQuestionIndex: (questionIndex: number) => void; setInactivity: (inactivity: number) => void; setShuffleMaps: (shuffleMaps: ShuffleMap[]) => void; + setBgColor: (bgColor: string) => void; reset: () => void; } @@ -55,7 +57,8 @@ export const initialState: ExamState = { exerciseIndex: -1, questionIndex: 0, inactivity: 0, - shuffleMaps: [] + shuffleMaps: [], + bgColor: "bg-white" }; const useExamStore = create((set) => ({ @@ -76,6 +79,7 @@ const useExamStore = create((set) => ({ setQuestionIndex: (questionIndex: number) => set(() => ({questionIndex})), setInactivity: (inactivity: number) => set(() => ({inactivity})), setShuffleMaps: (shuffleMaps) => set(() => ({shuffleMaps})), + setBgColor: (bgColor) => set(()=> ({bgColor})), reset: () => set(() => initialState), })); diff --git a/tailwind.config.js b/tailwind.config.js index 5c2e2e9d..29211d46 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -14,6 +14,7 @@ module.exports = { boxShadow: { 'training-inset': 'inset 0px 2px 18px 0px #00000029', }, + safelist: ["bg-ielts-level"], colors: { mti: { orange: {DEFAULT: "#FF6000", dark: "#cc4402", light: "#ff790a", ultralight: "#ffdaa5"},