diff --git a/src/components/Exercises/FillBlanks/index.tsx b/src/components/Exercises/FillBlanks/index.tsx index aed260b0..a2c0920d 100644 --- a/src/components/Exercises/FillBlanks/index.tsx +++ b/src/components/Exercises/FillBlanks/index.tsx @@ -1,237 +1,262 @@ -import { FillBlanksExercise, FillBlanksMCOption } from "@/interfaces/exam"; +import {FillBlanksExercise, FillBlanksMCOption} from "@/interfaces/exam"; import useExamStore from "@/stores/examStore"; import clsx from "clsx"; -import { Fragment, useCallback, useEffect, useMemo, useState } from "react"; +import {Fragment, useCallback, useEffect, useMemo, useState} from "react"; import reactStringReplace from "react-string-replace"; -import { CommonProps } from ".."; +import {CommonProps} from ".."; import Button from "../../Low/Button"; -import { v4 } from "uuid"; - +import {v4} from "uuid"; const FillBlanks: React.FC = ({ - id, - type, - prompt, - solutions, - text, - words, - userSolutions, - variant, - onNext, - onBack, + id, + type, + prompt, + solutions, + text, + words, + userSolutions, + variant, + onNext, + onBack, }) => { - const { shuffles, exam, partIndex, questionIndex, exerciseIndex } = useExamStore((state) => state); - const [answers, setAnswers] = useState<{ id: string; solution: string }[]>(userSolutions); - const hasExamEnded = useExamStore((state) => state.hasExamEnded); - const setCurrentSolution = useExamStore((state) => state.setCurrentSolution); - const shuffleMaps = shuffles.find((x) => x.exerciseID == id)?.shuffles; + const {shuffles, exam, partIndex, questionIndex, exerciseIndex} = useExamStore((state) => state); + const [answers, setAnswers] = useState<{id: string; solution: string}[]>(userSolutions); + const hasExamEnded = useExamStore((state) => state.hasExamEnded); + const setCurrentSolution = useExamStore((state) => state.setCurrentSolution); + const shuffleMaps = shuffles.find((x) => x.exerciseID == id)?.shuffles; - const [currentMCSelection, setCurrentMCSelection] = useState<{ id: string, selection: FillBlanksMCOption }>(); + const [currentMCSelection, setCurrentMCSelection] = useState<{id: string; selection: FillBlanksMCOption}>(); - const typeCheckWordsMC = (words: any[]): words is FillBlanksMCOption[] => { - return Array.isArray(words) && words.every( - word => word && typeof word === 'object' && 'id' in word && 'options' in word - ); - } + const typeCheckWordsMC = (words: any[]): words is FillBlanksMCOption[] => { + return Array.isArray(words) && words.every((word) => word && typeof word === "object" && "id" in word && "options" in word); + }; - const excludeWordMCType = (x: any) => { - return typeof x === "string" ? x : x as { letter: string; word: string }; - } + const excludeWordMCType = (x: any) => { + return typeof x === "string" ? x : (x as {letter: string; word: string}); + }; - useEffect(() => { - if (hasExamEnded) onNext({ exercise: id, solutions: answers, score: calculateScore(), type }); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [hasExamEnded]); + useEffect(() => { + if (hasExamEnded) onNext({exercise: id, solutions: answers, score: calculateScore(), type}); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [hasExamEnded]); + let correctWords: any; + if (exam && (exam.module === "level" || exam.module === "reading") && exam.parts[partIndex].exercises[exerciseIndex].type === "fillBlanks") { + correctWords = (exam.parts[partIndex].exercises[exerciseIndex] as FillBlanksExercise).words; + } - let correctWords: any; - if (exam && (exam.module === "level" || exam.module === "reading") && exam.parts[partIndex].exercises[exerciseIndex].type === "fillBlanks") { - correctWords = (exam.parts[partIndex].exercises[exerciseIndex] as FillBlanksExercise).words; - } + 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.letter.toLowerCase() === x.solution.toLowerCase(); + } else { + return w.id.toString() === x.id.toString(); + } + }); + if (!option) return false; - 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.letter.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 = useCallback( + (line: string) => { + return ( +
+ {reactStringReplace(line, /({{\d+}})/g, (match) => { + const id = match.replaceAll(/[\{\}]/g, ""); + const userSolution = answers.find((x) => x.id === id); + const styles = clsx( + "rounded-full hover:text-white transition duration-300 ease-in-out my-1 px-5 py-2 text-center", + currentMCSelection?.id == id && "!bg-mti-purple !text-white !outline-none !ring-0", + !userSolution && "text-center text-mti-purple-light bg-mti-purple-ultralight", + userSolution && "text-center text-mti-purple-dark bg-mti-purple-ultralight", + ); + return variant === "mc" ? ( + <> + {/*{`(${id})`}*/} + + + ) : ( + setAnswers((prev) => [...prev.filter((x) => x.id !== id), {id, solution: e.target.value}])} + value={userSolution?.solution} + /> + ); + })} +
+ ); + }, + [variant, words, setCurrentMCSelection, answers, currentMCSelection], + ); - 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 = useCallback((line: string) => { - return ( -
- {reactStringReplace(line, /({{\d+}})/g, (match) => { - const id = match.replaceAll(/[\{\}]/g, ""); - const userSolution = answers.find((x) => x.id === id); - const styles = clsx( - "rounded-full hover:text-white transition duration-300 ease-in-out my-1 px-5 py-2 text-center", - currentMCSelection?.id == id && "!bg-mti-purple !text-white !outline-none !ring-0", - !userSolution && "text-center text-mti-purple-light bg-mti-purple-ultralight", - userSolution && "text-center text-mti-purple-dark bg-mti-purple-ultralight", - ) - return ( - variant === "mc" ? ( - <> - {/*{`(${id})`}*/} - - - ) : ( - setAnswers((prev) => [...prev.filter((x) => x.id !== id), { id, solution: e.target.value }])} - value={userSolution?.solution} /> - ) - ); - })} -
- ); - }, [variant, words, setCurrentMCSelection, answers, currentMCSelection]); + const memoizedLines = useMemo(() => { + return text.split("\\n").map((line, index) => ( +

+ {renderLines(line)} +
+

+ )); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [text, variant, renderLines, currentMCSelection]); - const memoizedLines = useMemo(() => { - return text.split("\\n").map((line, index) => ( -

- {renderLines(line)} -
-

- )); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [text, variant, renderLines, currentMCSelection]); + const onSelection = (questionID: string, value: string) => { + setAnswers((prev) => [...prev.filter((x) => x.id !== questionID), {id: questionID, solution: value}]); + }; + useEffect(() => { + if (variant === "mc") { + setCurrentSolution({exercise: id, solutions: answers, score: calculateScore(), type, shuffleMaps: shuffleMaps}); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [answers]); - const onSelection = (questionID: string, value: string) => { - setAnswers((prev) => [...prev.filter((x) => x.id !== questionID), { id: questionID, solution: value }]); - } + return ( +
+
+ - useEffect(() => { - if (variant === "mc") { - setCurrentSolution({ exercise: id, solutions: answers, score: calculateScore(), type, shuffleMaps: shuffleMaps }); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [answers]) + +
- return ( - <> -
- {variant !== "mc" && - {prompt.split("\\n").map((line, index) => ( - - {line} -
-
- ))} -
} - - {memoizedLines} - - {variant === "mc" && typeCheckWordsMC(words) ? ( - <> - {currentMCSelection && ( -
- {`${currentMCSelection.id} - Select the appropriate word.`} -
- {currentMCSelection.selection?.options && Object.entries(currentMCSelection.selection.options).sort((a, b) => a[0].localeCompare(b[0])).map(([key, value]) => { - return
onSelection(currentMCSelection.id, value)} - className={clsx( - "flex border p-4 rounded-xl gap-2 cursor-pointer bg-white text-base", - !!answers.find((x) => x.solution.toLocaleLowerCase() === value.toLocaleLowerCase() && x.id === currentMCSelection.id) && - "!bg-mti-purple-light !text-white", - )}> - {key}. - {value} -
- })} -
-
- )} - - ) : ( -
- Options -
- {words.map((v) => { - v = excludeWordMCType(v); - const text = typeof v === "string" ? v : `${v.letter} - ${v.word}`; +
+ {variant !== "mc" && ( + + {prompt.split("\\n").map((line, index) => ( + + {line} +
+
+ ))} +
+ )} + {memoizedLines} + {variant === "mc" && typeCheckWordsMC(words) ? ( + <> + {currentMCSelection && ( +
+ {`${currentMCSelection.id} - Select the appropriate word.`} +
+ {currentMCSelection.selection?.options && + Object.entries(currentMCSelection.selection.options) + .sort((a, b) => a[0].localeCompare(b[0])) + .map(([key, value]) => { + return ( +
onSelection(currentMCSelection.id, value)} + className={clsx( + "flex border p-4 rounded-xl gap-2 cursor-pointer bg-white text-base", + !!answers.find( + (x) => + x.solution.toLocaleLowerCase() === value.toLocaleLowerCase() && + x.id === currentMCSelection.id, + ) && "!bg-mti-purple-light !text-white", + )}> + {key}. + {value} +
+ ); + })} +
+
+ )} + + ) : ( +
+ Options +
+ {words.map((v) => { + v = excludeWordMCType(v); + const text = typeof v === "string" ? v : `${v.letter} - ${v.word}`; - return ( - x.solution.toLowerCase() === (typeof v === "string" ? v : ("letter" in v ? v.letter : "")).toLowerCase()) && - "bg-mti-purple-dark text-white", - )} - key={v4()} - > - {text} - - ) - })} -
-
- )} -
-
- + return ( + + x.solution.toLowerCase() === + (typeof v === "string" ? v : "letter" in v ? v.letter : "").toLowerCase(), + ) && "bg-mti-purple-dark text-white", + )} + key={v4()}> + {text} + + ); + })} +
+
+ )} +
+
+ - -
- - ); -} + +
+
+ ); +}; export default FillBlanks; diff --git a/src/components/Exercises/InteractiveSpeaking.tsx b/src/components/Exercises/InteractiveSpeaking.tsx index 7bad812c..08a1eef7 100644 --- a/src/components/Exercises/InteractiveSpeaking.tsx +++ b/src/components/Exercises/InteractiveSpeaking.tsx @@ -152,139 +152,8 @@ export default function InteractiveSpeaking({ }; return ( -
-
-
- {!!first_title && !!second_title ? `${first_title} & ${second_title}` : title} -
- {prompts && prompts.length > 0 && ( -
- -
- )} -
- - setMediaBlob(blob)} - render={({status, startRecording, stopRecording, pauseRecording, resumeRecording, clearBlobUrl, mediaBlobUrl}) => ( -
-

Record your answer:

-
- {status === "idle" && ( - <> -
- {status === "idle" && ( - { - setRecordingDuration(0); - startRecording(); - setIsRecording(true); - }} - className="h-5 w-5 text-mti-gray-cool cursor-pointer" - /> - )} - - )} - {status === "recording" && ( - <> -
- - {Math.floor(recordingDuration / 60) - .toString(10) - .padStart(2, "0")} - : - {Math.floor(recordingDuration % 60) - .toString(10) - .padStart(2, "0")} - -
-
-
- { - setIsRecording(false); - pauseRecording(); - }} - className="text-red-500 w-8 h-8 cursor-pointer" - /> - { - setIsRecording(false); - stopRecording(); - }} - className="text-mti-purple-light w-8 h-8 cursor-pointer" - /> -
- - )} - {status === "paused" && ( - <> -
- - {Math.floor(recordingDuration / 60) - .toString(10) - .padStart(2, "0")} - : - {Math.floor(recordingDuration % 60) - .toString(10) - .padStart(2, "0")} - -
-
-
- { - setIsRecording(true); - resumeRecording(); - }} - className="text-mti-purple-light w-8 h-8 cursor-pointer" - /> - { - setIsRecording(false); - stopRecording(); - }} - className="text-mti-purple-light w-8 h-8 cursor-pointer" - /> -
- - )} - {status === "stopped" && mediaBlobUrl && ( - <> - -
- { - setRecordingDuration(0); - clearBlobUrl(); - setMediaBlob(undefined); - }} - /> - - { - clearBlobUrl(); - setRecordingDuration(0); - startRecording(); - setIsRecording(true); - setMediaBlob(undefined); - }} - className="h-5 w-5 text-mti-gray-cool cursor-pointer" - /> -
- - )} -
-
- )} - /> - -
+
+
@@ -292,6 +161,148 @@ export default function InteractiveSpeaking({ {questionIndex + 1 < prompts.length ? "Next Prompt" : "Submit"}
+ +
+
+
+ {!!first_title && !!second_title ? `${first_title} & ${second_title}` : title} +
+ {prompts && prompts.length > 0 && ( +
+ +
+ )} +
+ + setMediaBlob(blob)} + render={({status, startRecording, stopRecording, pauseRecording, resumeRecording, clearBlobUrl, mediaBlobUrl}) => ( +
+

Record your answer:

+
+ {status === "idle" && ( + <> +
+ {status === "idle" && ( + { + setRecordingDuration(0); + startRecording(); + setIsRecording(true); + }} + className="h-5 w-5 text-mti-gray-cool cursor-pointer" + /> + )} + + )} + {status === "recording" && ( + <> +
+ + {Math.floor(recordingDuration / 60) + .toString(10) + .padStart(2, "0")} + : + {Math.floor(recordingDuration % 60) + .toString(10) + .padStart(2, "0")} + +
+
+
+ { + setIsRecording(false); + pauseRecording(); + }} + className="text-red-500 w-8 h-8 cursor-pointer" + /> + { + setIsRecording(false); + stopRecording(); + }} + className="text-mti-purple-light w-8 h-8 cursor-pointer" + /> +
+ + )} + {status === "paused" && ( + <> +
+ + {Math.floor(recordingDuration / 60) + .toString(10) + .padStart(2, "0")} + : + {Math.floor(recordingDuration % 60) + .toString(10) + .padStart(2, "0")} + +
+
+
+ { + setIsRecording(true); + resumeRecording(); + }} + className="text-mti-purple-light w-8 h-8 cursor-pointer" + /> + { + setIsRecording(false); + stopRecording(); + }} + className="text-mti-purple-light w-8 h-8 cursor-pointer" + /> +
+ + )} + {status === "stopped" && mediaBlobUrl && ( + <> + +
+ { + setRecordingDuration(0); + clearBlobUrl(); + setMediaBlob(undefined); + }} + /> + + { + clearBlobUrl(); + setRecordingDuration(0); + startRecording(); + setIsRecording(true); + setMediaBlob(undefined); + }} + className="h-5 w-5 text-mti-gray-cool cursor-pointer" + /> +
+ + )} +
+
+ )} + /> + +
+ + +
+
); } diff --git a/src/components/Exercises/MatchSentences.tsx b/src/components/Exercises/MatchSentences.tsx index 1b62cad6..755f0292 100644 --- a/src/components/Exercises/MatchSentences.tsx +++ b/src/components/Exercises/MatchSentences.tsx @@ -67,13 +67,13 @@ export default function MatchSentences({id, options, type, prompt, sentences, us const [answers, setAnswers] = useState<{question: string; option: string}[]>(userSolutions); const hasExamEnded = useExamStore((state) => state.hasExamEnded); - + const setCurrentSolution = useExamStore((state) => state.setCurrentSolution); useEffect(() => { - setCurrentSolution({ exercise: id, solutions: answers, score: calculateScore(), type}); + setCurrentSolution({exercise: id, solutions: answers, score: calculateScore(), type}); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [answers, setAnswers]) + }, [answers, setAnswers]); const handleDragEnd = (event: DragEndEvent) => { if (event.over && event.over.id.toString().startsWith("droppable")) { @@ -100,7 +100,24 @@ export default function MatchSentences({id, options, type, prompt, sentences, us }, [hasExamEnded]); return ( - <> +
+
+ + + +
+
{prompt.split("\\n").map((line, index) => ( @@ -150,6 +167,6 @@ export default function MatchSentences({id, options, type, prompt, sentences, us Next
- +
); } diff --git a/src/components/Exercises/MultipleChoice.tsx b/src/components/Exercises/MultipleChoice.tsx index d98d9301..1f7bf6e0 100644 --- a/src/components/Exercises/MultipleChoice.tsx +++ b/src/components/Exercises/MultipleChoice.tsx @@ -151,8 +151,29 @@ export default function MultipleChoice({id, prompt, type, questions, userSolutio }; return ( - <> -
+
+
+ + + +
+ +
{/*{"Select the appropriate option."}*/} {questionIndex < questions.length && ( @@ -195,6 +216,6 @@ export default function MultipleChoice({id, prompt, type, questions, userSolutio : "Next"}
- +
); } diff --git a/src/components/Exercises/Speaking.tsx b/src/components/Exercises/Speaking.tsx index 106b76d4..12e942d0 100644 --- a/src/components/Exercises/Speaking.tsx +++ b/src/components/Exercises/Speaking.tsx @@ -1,20 +1,20 @@ -import { SpeakingExercise } from "@/interfaces/exam"; -import { CommonProps } from "."; -import { Fragment, useEffect, useState } from "react"; -import { BsCheckCircleFill, BsMicFill, BsPauseCircle, BsPlayCircle, BsTrashFill } from "react-icons/bs"; +import {SpeakingExercise} from "@/interfaces/exam"; +import {CommonProps} from "."; +import {Fragment, useEffect, useState} from "react"; +import {BsCheckCircleFill, BsMicFill, BsPauseCircle, BsPlayCircle, BsTrashFill} from "react-icons/bs"; import dynamic from "next/dynamic"; import Button from "../Low/Button"; import useExamStore from "@/stores/examStore"; -import { downloadBlob } from "@/utils/evaluation"; +import {downloadBlob} from "@/utils/evaluation"; import axios from "axios"; import Modal from "../Modal"; -const Waveform = dynamic(() => import("../Waveform"), { ssr: false }); +const Waveform = dynamic(() => import("../Waveform"), {ssr: false}); const ReactMediaRecorder = dynamic(() => import("react-media-recorder").then((mod) => mod.ReactMediaRecorder), { ssr: false, }); -export default function Speaking({ id, title, text, video_url, type, prompts, suffix, userSolutions, onNext, onBack }: SpeakingExercise & CommonProps) { +export default function Speaking({id, title, text, video_url, type, prompts, suffix, userSolutions, onNext, onBack}: SpeakingExercise & CommonProps) { const [recordingDuration, setRecordingDuration] = useState(0); const [isRecording, setIsRecording] = useState(false); const [mediaBlob, setMediaBlob] = useState(); @@ -28,7 +28,7 @@ export default function Speaking({ id, title, text, video_url, type, prompts, su const saveToStorage = async () => { if (mediaBlob && mediaBlob.startsWith("blob")) { const blobBuffer = await downloadBlob(mediaBlob); - const audioFile = new File([blobBuffer], "audio.wav", { type: "audio/wav" }); + const audioFile = new File([blobBuffer], "audio.wav", {type: "audio/wav"}); const seed = Math.random().toString().replace("0.", ""); @@ -42,8 +42,8 @@ export default function Speaking({ id, title, text, video_url, type, prompts, su }, }; - const response = await axios.post<{ path: string }>("/api/storage/insert", formData, config); - if (audioURL) await axios.post("/api/storage/delete", { path: audioURL }); + const response = await axios.post<{path: string}>("/api/storage/insert", formData, config); + if (audioURL) await axios.post("/api/storage/delete", {path: audioURL}); return response.data.path; } @@ -52,7 +52,7 @@ export default function Speaking({ id, title, text, video_url, type, prompts, su useEffect(() => { if (userSolutions.length > 0) { - const { solution } = userSolutions[0] as { solution?: string }; + const {solution} = userSolutions[0] as {solution?: string}; if (solution && !mediaBlob) setMediaBlob(solution); if (solution && !solution.startsWith("blob")) setAudioURL(solution); } @@ -79,8 +79,8 @@ export default function Speaking({ id, title, text, video_url, type, prompts, su const next = async () => { onNext({ exercise: id, - solutions: mediaBlob ? [{ id, solution: mediaBlob }] : [], - score: { correct: 0, total: 100, missing: 0 }, + solutions: mediaBlob ? [{id, solution: mediaBlob}] : [], + score: {correct: 0, total: 100, missing: 0}, type, }); }; @@ -88,8 +88,8 @@ export default function Speaking({ id, title, text, video_url, type, prompts, su const back = async () => { onBack({ exercise: id, - solutions: mediaBlob ? [{ id, solution: mediaBlob }] : [], - score: { correct: 0, total: 100, missing: 0 }, + solutions: mediaBlob ? [{id, solution: mediaBlob}] : [], + score: {correct: 0, total: 100, missing: 0}, type, }); }; @@ -98,7 +98,7 @@ export default function Speaking({ id, title, text, video_url, type, prompts, su const newText = e.target.value; const words = newText.match(/\S+/g); const wordCount = words ? words.length : 0; - + if (wordCount <= 100) { setInputText(newText); } else { @@ -110,188 +110,14 @@ export default function Speaking({ id, title, text, video_url, type, prompts, su if (count > 100) break; lastIndex = match.index! + match[0].length; } - + setInputText(newText.slice(0, lastIndex)); } }; return ( -
- setIsPromptsModalOpen(false)}> -
-
- {prompts.map((x, index) => ( -
  • - {x} -
  • - ))} -
    - {!!suffix && {suffix}} -
    -
    -
    -
    -
    - {title} - {prompts.length > 0 && ( - You should talk for at least 1 minute and 30 seconds for your answer to be valid. - )} -
    - {!video_url && ( - - {text.split("\\n").map((line, index) => ( - - {line} -
    -
    - ))} -
    - )} -
    -
    - {video_url && ( -
    - -
    - )} - {prompts && prompts.length > 0 && } -
    -
    - - {prompts && prompts.length > 0 && ( -
    -