From edc9d4de2a68fc83ef14584a017ee33a244ad168 Mon Sep 17 00:00:00 2001 From: Carlos Mesquita Date: Sun, 18 Aug 2024 08:07:16 +0100 Subject: [PATCH 1/3] Fill Blanks changes --- package-lock.json | 23 + package.json | 1 + src/components/Exercises/FillBlanks.tsx | 187 -------- .../Exercises/FillBlanks/WordsDrawer.tsx | 64 +++ src/components/Exercises/FillBlanks/index.tsx | 209 ++++++++ src/components/Exercises/MultipleChoice.tsx | 16 +- .../Generation/fill.blanks.edit.tsx | 2 +- src/components/HighlightContent.tsx | 39 ++ src/components/Solutions/FillBlanks.tsx | 89 +++- .../TrainingContent/AnimatedHighlight.tsx | 23 - .../TrainingContent/ExerciseWalkthrough.tsx | 4 +- src/exams/Level.tsx | 230 +++++++-- src/interfaces/exam.ts | 21 +- tailwind.config.js | 3 + yarn.lock | 445 ++++++++++-------- 15 files changed, 875 insertions(+), 481 deletions(-) delete mode 100644 src/components/Exercises/FillBlanks.tsx create mode 100644 src/components/Exercises/FillBlanks/WordsDrawer.tsx create mode 100644 src/components/Exercises/FillBlanks/index.tsx create mode 100644 src/components/HighlightContent.tsx delete mode 100644 src/components/TrainingContent/AnimatedHighlight.tsx diff --git a/package-lock.json b/package-lock.json index b7c22f81..920b7381 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "dependencies": { "@beam-australia/react-env": "^3.1.1", "@dnd-kit/core": "^6.1.0", + "@dnd-kit/sortable": "^8.0.0", "@firebase/util": "^1.9.7", "@headlessui/react": "^1.7.13", "@mdi/js": "^7.1.96", @@ -453,6 +454,19 @@ "react-dom": ">=16.8.0" } }, + "node_modules/@dnd-kit/sortable": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-8.0.0.tgz", + "integrity": "sha512-U3jk5ebVXe1Lr7c2wU7SBZjcWdQP+j7peHJfCspnA81enlu88Mgd7CC8Q+pub9ubP7eKVETzJW+IBAhsqbSu/g==", + "dependencies": { + "@dnd-kit/utilities": "^3.2.2", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "@dnd-kit/core": "^6.1.0", + "react": ">=16.8.0" + } + }, "node_modules/@dnd-kit/utilities": { "version": "3.2.2", "resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz", @@ -10430,6 +10444,15 @@ "tslib": "^2.0.0" } }, + "@dnd-kit/sortable": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-8.0.0.tgz", + "integrity": "sha512-U3jk5ebVXe1Lr7c2wU7SBZjcWdQP+j7peHJfCspnA81enlu88Mgd7CC8Q+pub9ubP7eKVETzJW+IBAhsqbSu/g==", + "requires": { + "@dnd-kit/utilities": "^3.2.2", + "tslib": "^2.0.0" + } + }, "@dnd-kit/utilities": { "version": "3.2.2", "resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz", diff --git a/package.json b/package.json index edebc0e7..29e2a35a 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ "dependencies": { "@beam-australia/react-env": "^3.1.1", "@dnd-kit/core": "^6.1.0", + "@dnd-kit/sortable": "^8.0.0", "@firebase/util": "^1.9.7", "@headlessui/react": "^1.7.13", "@mdi/js": "^7.1.96", diff --git a/src/components/Exercises/FillBlanks.tsx b/src/components/Exercises/FillBlanks.tsx deleted file mode 100644 index d208687b..00000000 --- a/src/components/Exercises/FillBlanks.tsx +++ /dev/null @@ -1,187 +0,0 @@ -import {FillBlanksExercise} from "@/interfaces/exam"; -import useExamStore from "@/stores/examStore"; -import clsx from "clsx"; -import {Fragment, useEffect, useState} from "react"; -import reactStringReplace from "react-string-replace"; -import {CommonProps} from "."; -import Button from "../Low/Button"; - -interface WordsDrawerProps { - words: {word: string; isDisabled: boolean}[]; - isOpen: boolean; - blankId?: string; - previouslySelectedWord?: string; - onCancel: () => void; - onAnswer: (answer: string) => void; -} - -function WordsDrawer({words, isOpen, blankId, previouslySelectedWord, onCancel, onAnswer}: WordsDrawerProps) { - const [selectedWord, setSelectedWord] = useState(previouslySelectedWord); - - return ( - <> -
-
-
-
{blankId}
- Choose the correct word: -
-
- {words.map(({word, isDisabled}) => ( - - ))} -
-
- - -
-
- - ); -} - -export default function FillBlanks({ - id, - allowRepetition, - type, - prompt, - solutions, - text, - words, - userSolutions, - onNext, - onBack, -}: FillBlanksExercise & CommonProps) { - const [answers, setAnswers] = useState<{id: string; solution: string}[]>(userSolutions); - - const hasExamEnded = useExamStore((state) => state.hasExamEnded); - - useEffect(() => { - if (hasExamEnded) onNext({exercise: id, solutions: answers, score: calculateScore(), type}); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [hasExamEnded]); - - 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.toLowerCase(); - if (!solution) return false; - - const option = words.find((w) => - typeof w === "string" ? w.toLowerCase() === x.solution.toLowerCase() : w.letter.toLowerCase() === x.solution.toLowerCase(), - ); - if (!option) return false; - - return solution === (typeof option === "string" ? option.toLowerCase() : option.word.toLowerCase()); - }).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 ( -
- {reactStringReplace(line, /({{\d+}})/g, (match) => { - const id = match.replaceAll(/[\{\}]/g, ""); - const userSolution = answers.find((x) => x.id === id); - - return ( - setAnswers((prev) => [...prev.filter((x) => x.id !== id), {id, solution: e.target.value}])} - value={userSolution?.solution}> - ); - })} -
- ); - }; - - return ( - <> -
- - {prompt.split("\\n").map((line, index) => ( - - {line} -
-
- ))} -
- - {text.split("\\n").map((line, index) => ( -

- {renderLines(line)} -
-

- ))} -
-
- Options -
- {words.map((v) => { - const text = typeof v === "string" ? v : `${v.letter} - ${v.word}`; - - return ( - x.solution.toLowerCase() === (typeof v === "string" ? v : v.letter).toLowerCase()) && - "bg-mti-purple-dark text-white", - )} - key={text}> - {text} - - ); - })} -
-
-
- -
- - - -
- - ); -} diff --git a/src/components/Exercises/FillBlanks/WordsDrawer.tsx b/src/components/Exercises/FillBlanks/WordsDrawer.tsx new file mode 100644 index 00000000..2553f0a2 --- /dev/null +++ b/src/components/Exercises/FillBlanks/WordsDrawer.tsx @@ -0,0 +1,64 @@ +import Button from "@/components/Low/Button"; +import clsx from "clsx"; +import { useState } from "react"; + +interface WordsDrawerProps { + words: {word: string; isDisabled: boolean}[]; + isOpen: boolean; + blankId?: string; + previouslySelectedWord?: string; + onCancel: () => void; + onAnswer: (answer: string) => void; +} + + +const WordsDrawer: React.FC = ({words, isOpen, blankId, previouslySelectedWord, onCancel, onAnswer}) => { + const [selectedWord, setSelectedWord] = useState(previouslySelectedWord); + + return ( + <> +
+
+
+
{blankId}
+ Choose the correct word: +
+
+ {words.map(({word, isDisabled}) => ( + + ))} +
+
+ + +
+
+ + ); +} + +export default WordsDrawer; diff --git a/src/components/Exercises/FillBlanks/index.tsx b/src/components/Exercises/FillBlanks/index.tsx new file mode 100644 index 00000000..f59096a2 --- /dev/null +++ b/src/components/Exercises/FillBlanks/index.tsx @@ -0,0 +1,209 @@ +import { FillBlanksExercise, FillBlanksMCOption } from "@/interfaces/exam"; +import useExamStore from "@/stores/examStore"; +import clsx from "clsx"; +import { Fragment, useEffect, useState } from "react"; +import reactStringReplace from "react-string-replace"; +import { CommonProps } from ".."; +import Button from "../../Low/Button"; +import { v4 } from "uuid"; + + +const FillBlanks: React.FC = ({ + id, + type, + prompt, + solutions, + text, + words, + userSolutions, + variant, + onNext, + onBack, +}) => { + const [answers, setAnswers] = useState<{ id: string; solution: string }[]>(userSolutions); + const hasExamEnded = useExamStore((state) => state.hasExamEnded); + + 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 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]); + + 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; + + 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 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; + + return { total, correct, missing }; + }; + + const renderLines = (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 focus:ring-0 focus:outline-none focus:!text-white focus:bg-mti-purple transition duration-300 ease-in-out my-1 px-5 py-2 text-center", + !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" ? ( + + ) : ( + setAnswers((prev) => [...prev.filter((x) => x.id !== id), { id, solution: e.target.value }])} + value={userSolution?.solution} /> + ) + ); + })} +
+ ); + }; + + const onSelection = (id: string, value: string) => { + setAnswers((prev) => [...prev.filter((x) => x.id !== id), { id: id, solution: value }]); + } + + return ( + <> +
+ + {prompt.split("\\n").map((line, index) => ( + + {line} +
+
+ ))} +
+ + {text.split("\\n").map((line, index) => ( +

+ {renderLines(line)} +
+

+ ))} +
+ {variant === "mc" && typeCheckWordsMC(words) ? ( + <> + {currentMCSelection && ( +
+ Options +
+ {currentMCSelection.selection?.options && Object.entries(currentMCSelection.selection.options).map(([key, value]) => { + return ; + })} +
+
+ )} + + ) : ( +
+ 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} + + ) + })} +
+
+ )} +
+
+ + + +
+ + ); +} + +export default FillBlanks; diff --git a/src/components/Exercises/MultipleChoice.tsx b/src/components/Exercises/MultipleChoice.tsx index aed907e2..b6e1e3e0 100644 --- a/src/components/Exercises/MultipleChoice.tsx +++ b/src/components/Exercises/MultipleChoice.tsx @@ -14,22 +14,32 @@ function Question({ options, userSolution, onSelectOption, -}: MultipleChoiceQuestion & {userSolution: string | undefined; onSelectOption?: (option: string) => void; showSolution?: boolean}) { + setContextHighlight +}: MultipleChoiceQuestion & { + userSolution: string | undefined; + onSelectOption?: (option: string) => void; + showSolution?: boolean, + setContextHighlight?: React.Dispatch> +}) { + + /* const renderPrompt = (prompt: string) => { return reactStringReplace(prompt, /(()[\w\s']+(<\/u>))/g, (match) => { const word = match.replaceAll("", "").replaceAll("", ""); return word.length > 0 ? {word} : null; }); }; + */ return ( + // {renderPrompt(prompt).filter((x) => x?.toString() !== "")}
{isNaN(Number(id)) ? ( - {renderPrompt(prompt).filter((x) => x?.toString() !== "")} + ) : ( <> - {id} - {renderPrompt(prompt).filter((x) => x?.toString() !== "")} + {id} - )} diff --git a/src/components/Generation/fill.blanks.edit.tsx b/src/components/Generation/fill.blanks.edit.tsx index 4c82c68f..507f3e5d 100644 --- a/src/components/Generation/fill.blanks.edit.tsx +++ b/src/components/Generation/fill.blanks.edit.tsx @@ -63,7 +63,7 @@ const FillBlanksEdit = (props: Props) => { label={`Word ${index + 1}`} name="word" required - value={typeof word === "string" ? word : word.word} + value={typeof word === "string" ? word : ("word" in word ? word.word : "")} onChange={(value) => updateExercise({ words: exercise.words.map((sol, idx) => diff --git a/src/components/HighlightContent.tsx b/src/components/HighlightContent.tsx new file mode 100644 index 00000000..32401523 --- /dev/null +++ b/src/components/HighlightContent.tsx @@ -0,0 +1,39 @@ +import { useCallback } from "react"; + +const HighlightContent: React.FC<{ + html: string; + highlightPhrases: string[], + firstOccurence?: boolean +}> = ({ + html, + highlightPhrases, + firstOccurence = false +}) => { + + const createHighlightedContent = useCallback(() => { + if (highlightPhrases.length === 0) { + return { __html: html }; + } + + const escapeRegExp = (string: string) => { + return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + }; + + const regex = new RegExp(`(${highlightPhrases.map(escapeRegExp).join('|')})`, 'i'); + const globalRegex = new RegExp(`(${highlightPhrases.map(escapeRegExp).join('|')})`, 'gi'); + + let highlightedHtml = html; + + if (firstOccurence) { + highlightedHtml = html.replace(regex, (match) => `${match}`); + } else { + highlightedHtml = html.replace(globalRegex, (match) => `${match}`); + } + + return { __html: highlightedHtml }; + }, [html, highlightPhrases, firstOccurence]); + + return
; +}; + +export default HighlightContent; diff --git a/src/components/Solutions/FillBlanks.tsx b/src/components/Solutions/FillBlanks.tsx index a08f5330..00f5ba47 100644 --- a/src/components/Solutions/FillBlanks.tsx +++ b/src/components/Solutions/FillBlanks.tsx @@ -1,8 +1,8 @@ -import {FillBlanksExercise} from "@/interfaces/exam"; +import { FillBlanksExercise, FillBlanksMCOption } from "@/interfaces/exam"; import clsx from "clsx"; import reactStringReplace from "react-string-replace"; -import {CommonProps} from "."; -import {Fragment} from "react"; +import { CommonProps } from "."; +import { Fragment } from "react"; import Button from "../Low/Button"; export default function FillBlanksSolutions({ @@ -19,21 +19,42 @@ export default function FillBlanksSolutions({ 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.toLowerCase(); + const solution = solutions.find((y) => x.id.toString() === y.id.toString())?.solution; if (!solution) return false; - const option = words.find((w) => - typeof w === "string" ? w.toLowerCase() === x.solution.toLowerCase() : w.letter.toLowerCase() === x.solution.toLowerCase(), - ); + 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; - return solution === (typeof option === "string" ? option.toLowerCase() : option.word.toLowerCase()); + 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 - userSolutions.filter((x) => solutions.find((y) => x.id.toString() === y.id.toString())).length; - return {total, correct, missing}; + return { total, correct, missing }; }; + + 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 renderLines = (line: string) => { return ( @@ -56,23 +77,53 @@ export default function FillBlanksSolutions({ const userSolutionWord = words.find((w) => typeof w === "string" ? w.toLowerCase() === userSolution.solution.toLowerCase() - : w.letter.toLowerCase() === userSolution.solution.toLowerCase(), + : 'letter' in w + ? w.letter.toLowerCase() === userSolution.solution.toLowerCase() + : 'options' in w + ? w.id === userSolution.id + : false ); - const userSolutionText = typeof userSolutionWord === "string" ? userSolutionWord : userSolutionWord?.word; - if (userSolutionText === solution.solution) { + const userSolutionText = + typeof userSolutionWord === "string" + ? userSolutionWord + : userSolutionWord && 'letter' in userSolutionWord + ? userSolutionWord.word + : userSolutionWord && 'options' in userSolutionWord + ? userSolution.solution + : userSolution.solution; + + let correct; + let solutionText; + if (typeCheckWordsMC(words)) { + const options = words.find((x) => x.id === id); + if (options) { + const correctKey = Object.keys(options.options).find(key => + key.toLowerCase() === solution.solution.toLowerCase() + ); + correct = userSolution.solution == options.options[correctKey as keyof typeof options.options]; + solutionText = options.options[correctKey as keyof typeof options.options] || solution.solution; + } else { + correct = false; + solutionText = solution.solution; + } + + } else { + correct = userSolutionText === solution.solution; + solutionText = solution.solution; + } + + if (correct) { return ( ); - } - - if (userSolutionText !== solution.solution) { + } else { return ( <> ); @@ -138,14 +189,14 @@ export default function FillBlanksSolutions({ diff --git a/src/components/TrainingContent/AnimatedHighlight.tsx b/src/components/TrainingContent/AnimatedHighlight.tsx deleted file mode 100644 index 493907c2..00000000 --- a/src/components/TrainingContent/AnimatedHighlight.tsx +++ /dev/null @@ -1,23 +0,0 @@ -import { useCallback } from "react"; - -const HighlightedContent: React.FC<{ html: string; highlightPhrases: string[] }> = ({ html, highlightPhrases }) => { - - const createHighlightedContent = useCallback(() => { - if (highlightPhrases.length === 0) { - return { __html: html }; - } - - const escapeRegExp = (string: string) => { - return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); - }; - - const regex = new RegExp(`(${highlightPhrases.map(escapeRegExp).join('|')})`, 'gi'); - const highlightedHtml = html.replace(regex, (match) => `${match}`); - - return { __html: highlightedHtml }; - }, [html, highlightPhrases]); - - return
; -}; - -export default HighlightedContent; diff --git a/src/components/TrainingContent/ExerciseWalkthrough.tsx b/src/components/TrainingContent/ExerciseWalkthrough.tsx index 07ea16ff..74c7219f 100644 --- a/src/components/TrainingContent/ExerciseWalkthrough.tsx +++ b/src/components/TrainingContent/ExerciseWalkthrough.tsx @@ -1,7 +1,7 @@ import React, { useState, useEffect, useRef, useCallback } from 'react'; import { animated } from '@react-spring/web'; import { FaRegCirclePlay, FaRegCircleStop } from "react-icons/fa6"; -import HighlightedContent from './AnimatedHighlight'; +import HighlightContent from '../HighlightContent'; import { ITrainingTip, SegmentRef, TimelineEvent } from './TrainingInterfaces'; @@ -267,7 +267,7 @@ const ExerciseWalkthrough: React.FC = (tip: ITrainingTip) => {
{/*

Question

*/}
- +
diff --git a/src/exams/Level.tsx b/src/exams/Level.tsx index 8f1043e2..76b30b8d 100644 --- a/src/exams/Level.tsx +++ b/src/exams/Level.tsx @@ -1,55 +1,176 @@ import BlankQuestionsModal from "@/components/BlankQuestionsModal"; -import {renderExercise} from "@/components/Exercises"; +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 {LevelExam, LevelPart, UserSolution, WritingExam} from "@/interfaces/exam"; +import { renderSolution } from "@/components/Solutions"; +import { infoButtonStyle } from "@/constants/buttonStyles"; +import { LevelExam, LevelPart, 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 { defaultUserSolutions } from "@/utils/exams"; +import { countExercises } from "@/utils/moduleUtils"; +import { mdiArrowRight } from "@mdi/js"; import Icon from "@mdi/react"; import clsx from "clsx"; -import {Fragment, useEffect, useState} from "react"; -import {BsChevronDown, BsChevronUp} from "react-icons/bs"; -import {toast} from "react-toastify"; +import { Fragment, use, useEffect, useRef, useState } from "react"; +import { BsChevronDown, BsChevronUp } from "react-icons/bs"; +import { toast } from "react-toastify"; interface Props { exam: LevelExam; showSolutions?: boolean; onFinish: (userSolutions: UserSolution[]) => void; + editing?: boolean; } -function TextComponent({part}: {part: LevelPart}) { +function TextComponent({ + part, highlightPhrases, contextWord, setContextWordLine +}: { + part: LevelPart, highlightPhrases: string[], 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.wordWrap = 'break-word'; + 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; + 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 && - part.context - .split(/\n|(\\n)/g) - .filter((x) => x && x.length > 0 && x !== "\\n") - .map((line, index) => ( - -

{line}

-
+
+
+ {lineNumbers.map(num => ( +
+ {num} +
))} +
+
+ +
+
); } -export default function Level({exam, showSolutions = false, onFinish}: Props) { - const [multipleChoicesDone, setMultipleChoicesDone] = useState<{id: string; amount: number}[]>([]); + + +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 { 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 scrollToTop = () => Array.from(document.getElementsByTagName("body")).forEach((body) => body.scrollTo(0, 0)); + const [highlightPhrases, setContextHighlight] = useState([]); + const [contextWord, setContextWord] = useState(undefined); + const [contextWordLine, setContextWordLine] = useState(undefined); + useEffect(() => { if (hasExamEnded && exerciseIndex === -1) { setExerciseIndex(exerciseIndex + 1); @@ -68,12 +189,12 @@ export default function Level({exam, showSolutions = false, onFinish}: Props) { const nextExercise = (solution?: UserSolution) => { scrollToTop(); if (solution) { - setUserSolutions([...userSolutions.filter((x) => x.exercise !== solution.exercise), {...solution, module: "level", exam: exam.id}]); + setUserSolutions([...userSolutions.filter((x) => x.exercise !== solution.exercise), { ...solution, module: "level", exam: exam.id }]); } if (storeQuestionIndex > 0) { const exercise = getExercise(); - setMultipleChoicesDone((prev) => [...prev.filter((x) => x.id !== exercise.id), {id: exercise.id, amount: storeQuestionIndex}]); + setMultipleChoicesDone((prev) => [...prev.filter((x) => x.id !== exercise!.id), { id: exercise!.id, amount: storeQuestionIndex }]); } setStoreQuestionIndex(0); @@ -94,6 +215,7 @@ export default function Level({exam, showSolutions = false, onFinish}: Props) { (x) => x === 0, ) && !showSolutions && + !editing && !hasExamEnded ) { setShowBlankModal(true); @@ -103,7 +225,7 @@ export default function Level({exam, showSolutions = false, onFinish}: Props) { setHasExamEnded(false); if (solution) { - onFinish([...userSolutions.filter((x) => x.exercise !== solution.exercise), {...solution, module: "level", exam: exam.id}]); + onFinish([...userSolutions.filter((x) => x.exercise !== solution.exercise), { ...solution, module: "level", exam: exam.id }]); } else { onFinish(userSolutions); } @@ -112,12 +234,12 @@ export default function Level({exam, showSolutions = false, onFinish}: Props) { const previousExercise = (solution?: UserSolution) => { scrollToTop(); if (solution) { - setUserSolutions([...userSolutions.filter((x) => x.exercise !== solution.exercise), {...solution, module: "level", exam: exam.id}]); + setUserSolutions([...userSolutions.filter((x) => x.exercise !== solution.exercise), { ...solution, module: "level", exam: exam.id }]); } if (storeQuestionIndex > 0) { const exercise = getExercise(); - setMultipleChoicesDone((prev) => [...prev.filter((x) => x.id !== exercise.id), {id: exercise.id, amount: storeQuestionIndex}]); + setMultipleChoicesDone((prev) => [...prev.filter((x) => x.id !== exercise!.id), { id: exercise!.id, amount: storeQuestionIndex }]); } setStoreQuestionIndex(0); @@ -125,7 +247,10 @@ export default function Level({exam, showSolutions = false, onFinish}: Props) { }; const getExercise = () => { - const exercise = exam.parts[partIndex].exercises[exerciseIndex]; + if (exerciseIndex === -1) { + return undefined; + } + const exercise = exam.parts[partIndex]?.exercises[exerciseIndex]; return { ...exercise, userSolutions: userSolutions.find((x) => x.exercise === exercise.id)?.solutions || [], @@ -157,11 +282,45 @@ export default function Level({exam, showSolutions = false, onFinish}: Props) { You will be allowed to read the text while doing the exercises
- +
); + const exercise = getExercise(); + + + useEffect(() => { + const regex = /.*?['"](.*?)['"] in line (\d+)\?$/; + if (exercise && exercise.type === "multipleChoice") { + const match = exercise.questions[storeQuestionIndex].prompt.match(regex); + if (match) { + const word = match[1]; + const originalLineNumber = match[2]; + setContextHighlight([word]); + + if (word !== contextWord) { + setContextWord(word); + } + + const updatedPrompt = exercise.questions[storeQuestionIndex].prompt.replace( + `in line ${originalLineNumber}`, + `in line ${contextWordLine || originalLineNumber}` + ); + + exercise.questions[storeQuestionIndex].prompt = updatedPrompt; + } else { + setContextHighlight([]); + setContextWord(undefined); + } + } + }, [storeQuestionIndex, contextWordLine]); + return ( <>
@@ -171,7 +330,7 @@ export default function Level({exam, showSolutions = false, onFinish}: Props) { exerciseIndex={calculateExerciseIndex()} module="level" totalExercises={countExercises(exam.parts.flatMap((x) => x.exercises))} - disableTimer={showSolutions} + disableTimer={showSolutions || editing} />
-1 && exerciseIndex < exam.parts[partIndex].exercises.length && !showSolutions && - renderExercise(getExercise(), exam.id, nextExercise, previousExercise)} + !editing && + renderExercise(exercise!, exam.id, nextExercise, previousExercise)} {exerciseIndex > -1 && partIndex > -1 && exerciseIndex < exam.parts[partIndex].exercises.length && - showSolutions && + (showSolutions || editing) && renderSolution(exam.parts[partIndex].exercises[exerciseIndex], nextExercise, previousExercise)}
{exerciseIndex === -1 && partIndex > 0 && ( diff --git a/src/interfaces/exam.ts b/src/interfaces/exam.ts index 18c222b2..514ac6fa 100644 --- a/src/interfaces/exam.ts +++ b/src/interfaces/exam.ts @@ -36,6 +36,7 @@ export interface LevelExam extends ExamBase { export interface LevelPart { context?: string; + showContextLines?: boolean; exercises: Exercise[]; } @@ -187,10 +188,10 @@ export interface InteractiveSpeakingExercise { first_title?: string; second_title?: string; text: string; - prompts: {text: string; video_url: string}[]; + prompts: { text: string; video_url: string }[]; userSolutions: { id: string; - solution: {questionIndex: number; question: string; answer: string}[]; + solution: { questionIndex: number; question: string; answer: string }[]; evaluation?: InteractiveSpeakingEvaluation; }[]; topic?: string; @@ -199,13 +200,23 @@ export interface InteractiveSpeakingExercise { variant?: "initial" | "final"; } +export interface FillBlanksMCOption { + id: string; + options: { + A: string; + B: string; + C: string; + D: string; + } +} + export interface FillBlanksExercise { prompt: string; // *EXAMPLE: "Complete the summary below. Click a blank to select the corresponding word for it." type: "fillBlanks"; id: string; - words: (string | {letter: string; word: string})[]; // *EXAMPLE: ["preserve", "unaware"] + words: (string | { letter: string; word: string } | FillBlanksMCOption)[]; // *EXAMPLE: ["preserve", "unaware"] text: string; // *EXAMPLE: "They tried to {{1}} burning" - allowRepetition: boolean; + allowRepetition?: boolean; solutions: { id: string; // *EXAMPLE: "1" solution: string; // *EXAMPLE: "preserve" @@ -214,6 +225,7 @@ export interface FillBlanksExercise { id: string; // *EXAMPLE: "1" solution: string; // *EXAMPLE: "preserve" }[]; + variant?: string; } export interface TrueFalseExercise { @@ -274,6 +286,7 @@ export interface MultipleChoiceExercise { prompt: string; // *EXAMPLE: "Select the appropriate option." questions: MultipleChoiceQuestion[]; userSolutions: {question: string; option: string}[]; + setContextHighlight?: React.Dispatch> } export interface MultipleChoiceQuestion { diff --git a/tailwind.config.js b/tailwind.config.js index 4f71b300..5c2e2e9d 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -8,6 +8,9 @@ module.exports = { ], theme: { extend: { + scale: { + '102': '1.02', + }, boxShadow: { 'training-inset': 'inset 0px 2px 18px 0px #00000029', }, diff --git a/yarn.lock b/yarn.lock index 98c50bc2..01acb13e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -149,6 +149,14 @@ "@dnd-kit/utilities" "^3.2.2" tslib "^2.0.0" +"@dnd-kit/sortable@^8.0.0": + version "8.0.0" + resolved "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-8.0.0.tgz" + integrity sha512-U3jk5ebVXe1Lr7c2wU7SBZjcWdQP+j7peHJfCspnA81enlu88Mgd7CC8Q+pub9ubP7eKVETzJW+IBAhsqbSu/g== + dependencies: + "@dnd-kit/utilities" "^3.2.2" + tslib "^2.0.0" + "@dnd-kit/utilities@^3.2.2": version "3.2.2" resolved "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz" @@ -183,7 +191,7 @@ "@emotion/utils" "0.11.3" "@emotion/weak-memoize" "0.2.5" -"@emotion/cache@^11.13.0", "@emotion/cache@^11.4.0": +"@emotion/cache@^11.13.0": version "11.13.1" resolved "https://registry.npmjs.org/@emotion/cache/-/cache-11.13.1.tgz" integrity sha512-iqouYkuEblRcXmylXIwwOodiEK5Ifl7JcX7o6V4jI3iW4mLXX3dmt5xwBtIkJiQEXFAI+pC8X0i67yiPkH9Ucw== @@ -194,16 +202,27 @@ "@emotion/weak-memoize" "^0.4.0" stylis "4.2.0" -"@emotion/hash@0.8.0": - version "0.8.0" - resolved "https://registry.npmjs.org/@emotion/hash/-/hash-0.8.0.tgz" - integrity sha512-kBJtf7PH6aWwZ6fka3zQ0p6SBYzx4fl1LoZXE2RrnYST9Xljm7WfKJrU4g/Xr3Beg72MLrp1AWNUmuYJTL7Cow== +"@emotion/cache@^11.4.0": + version "11.13.1" + resolved "https://registry.npmjs.org/@emotion/cache/-/cache-11.13.1.tgz" + integrity sha512-iqouYkuEblRcXmylXIwwOodiEK5Ifl7JcX7o6V4jI3iW4mLXX3dmt5xwBtIkJiQEXFAI+pC8X0i67yiPkH9Ucw== + dependencies: + "@emotion/memoize" "^0.9.0" + "@emotion/sheet" "^1.4.0" + "@emotion/utils" "^1.4.0" + "@emotion/weak-memoize" "^0.4.0" + stylis "4.2.0" "@emotion/hash@^0.9.2": version "0.9.2" resolved "https://registry.npmjs.org/@emotion/hash/-/hash-0.9.2.tgz" integrity sha512-MyqliTZGuOm3+5ZRSaaBGP3USLw6+EGykkwZns2EPC5g8jJ4z9OrdZY9apkl3+UP9+sdz76YYkwCKP5gh8iY3g== +"@emotion/hash@0.8.0": + version "0.8.0" + resolved "https://registry.npmjs.org/@emotion/hash/-/hash-0.8.0.tgz" + integrity sha512-kBJtf7PH6aWwZ6fka3zQ0p6SBYzx4fl1LoZXE2RrnYST9Xljm7WfKJrU4g/Xr3Beg72MLrp1AWNUmuYJTL7Cow== + "@emotion/is-prop-valid@^0.8.2": version "0.8.8" resolved "https://registry.npmjs.org/@emotion/is-prop-valid/-/is-prop-valid-0.8.8.tgz" @@ -211,16 +230,16 @@ dependencies: "@emotion/memoize" "0.7.4" -"@emotion/memoize@0.7.4": - version "0.7.4" - resolved "https://registry.npmjs.org/@emotion/memoize/-/memoize-0.7.4.tgz" - integrity sha512-Ja/Vfqe3HpuzRsG1oBtWTHk2PGZ7GR+2Vz5iYGelAw8dx32K0y7PjVuxK6z1nMpZOqAFsRUPCkK1YjJ56qJlgw== - "@emotion/memoize@^0.9.0": version "0.9.0" resolved "https://registry.npmjs.org/@emotion/memoize/-/memoize-0.9.0.tgz" integrity sha512-30FAj7/EoJ5mwVPOWhAyCX+FPfMDrVecJAM+Iw9NRoSl4BBAQeqj4cApHHUXOVvIPgLVDsCFoz/hGD+5QQD1GQ== +"@emotion/memoize@0.7.4": + version "0.7.4" + resolved "https://registry.npmjs.org/@emotion/memoize/-/memoize-0.7.4.tgz" + integrity sha512-Ja/Vfqe3HpuzRsG1oBtWTHk2PGZ7GR+2Vz5iYGelAw8dx32K0y7PjVuxK6z1nMpZOqAFsRUPCkK1YjJ56qJlgw== + "@emotion/react@^11.8.1": version "11.13.0" resolved "https://registry.npmjs.org/@emotion/react/-/react-11.13.0.tgz" @@ -246,7 +265,7 @@ "@emotion/utils" "0.11.3" csstype "^2.5.7" -"@emotion/serialize@^1.2.0", "@emotion/serialize@^1.3.0": +"@emotion/serialize@^1.2.0": version "1.3.0" resolved "https://registry.npmjs.org/@emotion/serialize/-/serialize-1.3.0.tgz" integrity sha512-jACuBa9SlYajnpIVXB+XOXnfJHyckDfe6fOpORIM6yhBDlqGuExvDdZYHDQGoDf3bZXGv7tNr+LpLjJqiEQ6EA== @@ -257,56 +276,67 @@ "@emotion/utils" "^1.4.0" csstype "^3.0.2" -"@emotion/sheet@0.9.4": - version "0.9.4" - resolved "https://registry.npmjs.org/@emotion/sheet/-/sheet-0.9.4.tgz" - integrity sha512-zM9PFmgVSqBw4zL101Q0HrBVTGmpAxFZH/pYx/cjJT5advXguvcgjHFTCaIO3enL/xr89vK2bh0Mfyj9aa0ANA== +"@emotion/serialize@^1.3.0": + version "1.3.0" + resolved "https://registry.npmjs.org/@emotion/serialize/-/serialize-1.3.0.tgz" + integrity sha512-jACuBa9SlYajnpIVXB+XOXnfJHyckDfe6fOpORIM6yhBDlqGuExvDdZYHDQGoDf3bZXGv7tNr+LpLjJqiEQ6EA== + dependencies: + "@emotion/hash" "^0.9.2" + "@emotion/memoize" "^0.9.0" + "@emotion/unitless" "^0.9.0" + "@emotion/utils" "^1.4.0" + csstype "^3.0.2" "@emotion/sheet@^1.4.0": version "1.4.0" resolved "https://registry.npmjs.org/@emotion/sheet/-/sheet-1.4.0.tgz" integrity sha512-fTBW9/8r2w3dXWYM4HCB1Rdp8NLibOw2+XELH5m5+AkWiL/KqYX6dc0kKYlaYyKjrQ6ds33MCdMPEwgs2z1rqg== +"@emotion/sheet@0.9.4": + version "0.9.4" + resolved "https://registry.npmjs.org/@emotion/sheet/-/sheet-0.9.4.tgz" + integrity sha512-zM9PFmgVSqBw4zL101Q0HrBVTGmpAxFZH/pYx/cjJT5advXguvcgjHFTCaIO3enL/xr89vK2bh0Mfyj9aa0ANA== + "@emotion/stylis@0.8.5": version "0.8.5" resolved "https://registry.npmjs.org/@emotion/stylis/-/stylis-0.8.5.tgz" integrity sha512-h6KtPihKFn3T9fuIrwvXXUOwlx3rfUvfZIcP5a6rh8Y7zjE3O06hT5Ss4S/YI1AYhuZ1kjaE/5EaOOI2NqSylQ== -"@emotion/unitless@0.7.5": - version "0.7.5" - resolved "https://registry.npmjs.org/@emotion/unitless/-/unitless-0.7.5.tgz" - integrity sha512-OWORNpfjMsSSUBVrRBVGECkhWcULOAJz9ZW8uK9qgxD+87M7jHRcvh/A96XXNhXTLmKcoYSQtBEX7lHMO7YRwg== - "@emotion/unitless@^0.9.0": version "0.9.0" resolved "https://registry.npmjs.org/@emotion/unitless/-/unitless-0.9.0.tgz" integrity sha512-TP6GgNZtmtFaFcsOgExdnfxLLpRDla4Q66tnenA9CktvVSdNKDvMVuUah4QvWPIpNjrWsGg3qeGo9a43QooGZQ== +"@emotion/unitless@0.7.5": + version "0.7.5" + resolved "https://registry.npmjs.org/@emotion/unitless/-/unitless-0.7.5.tgz" + integrity sha512-OWORNpfjMsSSUBVrRBVGECkhWcULOAJz9ZW8uK9qgxD+87M7jHRcvh/A96XXNhXTLmKcoYSQtBEX7lHMO7YRwg== + "@emotion/use-insertion-effect-with-fallbacks@^1.1.0": version "1.1.0" resolved "https://registry.npmjs.org/@emotion/use-insertion-effect-with-fallbacks/-/use-insertion-effect-with-fallbacks-1.1.0.tgz" integrity sha512-+wBOcIV5snwGgI2ya3u99D7/FJquOIniQT1IKyDsBmEgwvpxMNeS65Oib7OnE2d2aY+3BU4OiH+0Wchf8yk3Hw== -"@emotion/utils@0.11.3": - version "0.11.3" - resolved "https://registry.npmjs.org/@emotion/utils/-/utils-0.11.3.tgz" - integrity sha512-0o4l6pZC+hI88+bzuaX/6BgOvQVhbt2PfmxauVaYOGgbsAw14wdKyvMCZXnsnsHys94iadcF+RG/wZyx6+ZZBw== - "@emotion/utils@^1.4.0": version "1.4.0" resolved "https://registry.npmjs.org/@emotion/utils/-/utils-1.4.0.tgz" integrity sha512-spEnrA1b6hDR/C68lC2M7m6ALPUHZC0lIY7jAS/B/9DuuO1ZP04eov8SMv/6fwRd8pzmsn2AuJEznRREWlQrlQ== -"@emotion/weak-memoize@0.2.5": - version "0.2.5" - resolved "https://registry.npmjs.org/@emotion/weak-memoize/-/weak-memoize-0.2.5.tgz" - integrity sha512-6U71C2Wp7r5XtFtQzYrW5iKFT67OixrSxjI4MptCHzdSVlgabczzqLe0ZSgnub/5Kp4hSbpDB1tMytZY9pwxxA== +"@emotion/utils@0.11.3": + version "0.11.3" + resolved "https://registry.npmjs.org/@emotion/utils/-/utils-0.11.3.tgz" + integrity sha512-0o4l6pZC+hI88+bzuaX/6BgOvQVhbt2PfmxauVaYOGgbsAw14wdKyvMCZXnsnsHys94iadcF+RG/wZyx6+ZZBw== "@emotion/weak-memoize@^0.4.0": version "0.4.0" resolved "https://registry.npmjs.org/@emotion/weak-memoize/-/weak-memoize-0.4.0.tgz" integrity sha512-snKqtPW01tN0ui7yu9rGv69aJXr/a/Ywvl11sUjNtEcRc+ng/mQriFL0wLXMef74iHa/EkftbDzU9F8iFbH+zg== +"@emotion/weak-memoize@0.2.5": + version "0.2.5" + resolved "https://registry.npmjs.org/@emotion/weak-memoize/-/weak-memoize-0.2.5.tgz" + integrity sha512-6U71C2Wp7r5XtFtQzYrW5iKFT67OixrSxjI4MptCHzdSVlgabczzqLe0ZSgnub/5Kp4hSbpDB1tMytZY9pwxxA== + "@eslint/eslintrc@^1.4.1": version "1.4.1" resolved "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-1.4.1.tgz" @@ -456,7 +486,7 @@ "@firebase/util" "1.9.3" tslib "^2.1.0" -"@firebase/database-compat@0.3.4", "@firebase/database-compat@^0.3.4": +"@firebase/database-compat@^0.3.4", "@firebase/database-compat@0.3.4": version "0.3.4" resolved "https://registry.npmjs.org/@firebase/database-compat/-/database-compat-0.3.4.tgz" integrity sha512-kuAW+l+sLMUKBThnvxvUZ+Q1ZrF/vFJ58iUY9kAcbX48U03nVzIF6Tmkf0p3WVQwMqiXguSgtOPIB6ZCeF+5Gg== @@ -468,7 +498,7 @@ "@firebase/util" "1.9.3" tslib "^2.1.0" -"@firebase/database-types@0.10.4", "@firebase/database-types@^0.10.4": +"@firebase/database-types@^0.10.4", "@firebase/database-types@0.10.4": version "0.10.4" resolved "https://registry.npmjs.org/@firebase/database-types/-/database-types-0.10.4.tgz" integrity sha512-dPySn0vJ/89ZeBac70T+2tWWPiJXWbmRygYv0smT5TfE3hDrQ09eKMF3Y+vMlTdrMWq7mUdYW5REWPSGH4kAZQ== @@ -689,13 +719,6 @@ node-fetch "2.6.7" tslib "^2.1.0" -"@firebase/util@1.9.3": - version "1.9.3" - resolved "https://registry.npmjs.org/@firebase/util/-/util-1.9.3.tgz" - integrity sha512-DY02CRhOZwpzO36fHpuVysz6JZrscPiBXD0fXp6qSrL9oNOx5KWICKdR95C0lSITzxp0TZosVyHqzatE8JbcjA== - dependencies: - tslib "^2.1.0" - "@firebase/util@^1.9.7": version "1.9.7" resolved "https://registry.npmjs.org/@firebase/util/-/util-1.9.7.tgz" @@ -703,6 +726,13 @@ dependencies: tslib "^2.1.0" +"@firebase/util@1.9.3": + version "1.9.3" + resolved "https://registry.npmjs.org/@firebase/util/-/util-1.9.3.tgz" + integrity sha512-DY02CRhOZwpzO36fHpuVysz6JZrscPiBXD0fXp6qSrL9oNOx5KWICKdR95C0lSITzxp0TZosVyHqzatE8JbcjA== + dependencies: + tslib "^2.1.0" + "@firebase/webchannel-wrapper@0.9.0": version "0.9.0" resolved "https://registry.npmjs.org/@firebase/webchannel-wrapper/-/webchannel-wrapper-0.9.0.tgz" @@ -944,66 +974,6 @@ resolved "https://registry.npmjs.org/@next/font/-/font-13.1.6.tgz" integrity sha512-AITjmeb1RgX1HKMCiA39ztx2mxeAyxl4ljv2UoSBUGAbFFMg8MO7YAvjHCgFhD39hL7YTbFjol04e/BPBH5RzQ== -"@next/swc-android-arm-eabi@13.1.6": - version "13.1.6" - resolved "https://registry.yarnpkg.com/@next/swc-android-arm-eabi/-/swc-android-arm-eabi-13.1.6.tgz#d766dfc10e27814d947b20f052067c239913dbcc" - integrity sha512-F3/6Z8LH/pGlPzR1AcjPFxx35mPqjE5xZcf+IL+KgbW9tMkp7CYi1y7qKrEWU7W4AumxX/8OINnDQWLiwLasLQ== - -"@next/swc-android-arm64@13.1.6": - version "13.1.6" - resolved "https://registry.yarnpkg.com/@next/swc-android-arm64/-/swc-android-arm64-13.1.6.tgz#f37a98d5f18927d8c9970d750d516ac779465176" - integrity sha512-cMwQjnB8vrYkWyK/H0Rf2c2pKIH4RGjpKUDvbjVAit6SbwPDpmaijLio0LWFV3/tOnY6kvzbL62lndVA0mkYpw== - -"@next/swc-darwin-arm64@13.1.6": - version "13.1.6" - resolved "https://registry.yarnpkg.com/@next/swc-darwin-arm64/-/swc-darwin-arm64-13.1.6.tgz#ec1b90fd9bf809d8b81004c5182e254dced4ad96" - integrity sha512-KKRQH4DDE4kONXCvFMNBZGDb499Hs+xcFAwvj+rfSUssIDrZOlyfJNy55rH5t2Qxed1e4K80KEJgsxKQN1/fyw== - -"@next/swc-darwin-x64@13.1.6": - version "13.1.6" - resolved "https://registry.yarnpkg.com/@next/swc-darwin-x64/-/swc-darwin-x64-13.1.6.tgz#e869ac75d16995eee733a7d1550322d9051c1eb4" - integrity sha512-/uOky5PaZDoaU99ohjtNcDTJ6ks/gZ5ykTQDvNZDjIoCxFe3+t06bxsTPY6tAO6uEAw5f6vVFX5H5KLwhrkZCA== - -"@next/swc-freebsd-x64@13.1.6": - version "13.1.6" - resolved "https://registry.yarnpkg.com/@next/swc-freebsd-x64/-/swc-freebsd-x64-13.1.6.tgz#84a7b2e423a2904afc2edca21c2f1ba6b53fa4c1" - integrity sha512-qaEALZeV7to6weSXk3Br80wtFQ7cFTpos/q+m9XVRFggu+8Ib895XhMWdJBzew6aaOcMvYR6KQ6JmHA2/eMzWw== - -"@next/swc-linux-arm-gnueabihf@13.1.6": - version "13.1.6" - resolved "https://registry.yarnpkg.com/@next/swc-linux-arm-gnueabihf/-/swc-linux-arm-gnueabihf-13.1.6.tgz#980eed1f655ff8a72187d8a6ef9e73ac39d20d23" - integrity sha512-OybkbC58A1wJ+JrJSOjGDvZzrVEQA4sprJejGqMwiZyLqhr9Eo8FXF0y6HL+m1CPCpPhXEHz/2xKoYsl16kNqw== - -"@next/swc-linux-arm64-gnu@13.1.6": - version "13.1.6" - resolved "https://registry.yarnpkg.com/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-13.1.6.tgz#87a71db21cded3f7c63d1d19079845c59813c53d" - integrity sha512-yCH+yDr7/4FDuWv6+GiYrPI9kcTAO3y48UmaIbrKy8ZJpi7RehJe3vIBRUmLrLaNDH3rY1rwoHi471NvR5J5NQ== - -"@next/swc-linux-arm64-musl@13.1.6": - version "13.1.6" - resolved "https://registry.yarnpkg.com/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-13.1.6.tgz#c5aac8619331b9fd030603bbe2b36052011e11de" - integrity sha512-ECagB8LGX25P9Mrmlc7Q/TQBb9rGScxHbv/kLqqIWs2fIXy6Y/EiBBiM72NTwuXUFCNrWR4sjUPSooVBJJ3ESQ== - -"@next/swc-linux-x64-gnu@13.1.6": - version "13.1.6" - resolved "https://registry.yarnpkg.com/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-13.1.6.tgz#9513d36d540bbfea575576746736054c31aacdea" - integrity sha512-GT5w2mruk90V/I5g6ScuueE7fqj/d8Bui2qxdw6lFxmuTgMeol5rnzAv4uAoVQgClOUO/MULilzlODg9Ib3Y4Q== - -"@next/swc-linux-x64-musl@13.1.6": - version "13.1.6" - resolved "https://registry.yarnpkg.com/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-13.1.6.tgz#d61fc6884899f5957251f4ce3f522e34a2c479b7" - integrity sha512-keFD6KvwOPzmat4TCnlnuxJCQepPN+8j3Nw876FtULxo8005Y9Ghcl7ACcR8GoiKoddAq8gxNBrpjoxjQRHeAQ== - -"@next/swc-win32-arm64-msvc@13.1.6": - version "13.1.6" - resolved "https://registry.yarnpkg.com/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-13.1.6.tgz#fac2077a8ae9768e31444c9ae90807e64117cda7" - integrity sha512-OwertslIiGQluFvHyRDzBCIB07qJjqabAmINlXUYt7/sY7Q7QPE8xVi5beBxX/rxTGPIbtyIe3faBE6Z2KywhQ== - -"@next/swc-win32-ia32-msvc@13.1.6": - version "13.1.6" - resolved "https://registry.yarnpkg.com/@next/swc-win32-ia32-msvc/-/swc-win32-ia32-msvc-13.1.6.tgz#498bc11c91b4c482a625bf4b978f98ae91111e46" - integrity sha512-g8zowiuP8FxUR9zslPmlju7qYbs2XBtTLVSxVikPtUDQedhcls39uKYLvOOd1JZg0ehyhopobRoH1q+MHlIN/w== - "@next/swc-win32-x64-msvc@13.1.6": version "13.1.6" resolved "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-13.1.6.tgz" @@ -1017,7 +987,7 @@ "@nodelib/fs.stat" "2.0.5" run-parallel "^1.1.9" -"@nodelib/fs.stat@2.0.5", "@nodelib/fs.stat@^2.0.2": +"@nodelib/fs.stat@^2.0.2", "@nodelib/fs.stat@2.0.5": version "2.0.5" resolved "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz" integrity sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A== @@ -1345,7 +1315,7 @@ resolved "https://registry.npmjs.org/@rushstack/eslint-patch/-/eslint-patch-1.2.0.tgz" integrity sha512-sXo/qW2/pAcmT43VoRKOJbDOfV3cYpq3szSVfIThQXNt+E4DfKj361vaAt3c88U5tPUxzEswam7GW48PJqtKAg== -"@swc/helpers@0.4.14", "@swc/helpers@^0.4.2": +"@swc/helpers@^0.4.2", "@swc/helpers@0.4.14": version "0.4.14" resolved "https://registry.npmjs.org/@swc/helpers/-/helpers-0.4.14.tgz" integrity sha512-4C7nX/dvpzB7za4Ql9K81xK3HPxCpHMgwTZVyf+9JQ6VUbn9jjZVN7/Nkdz/Ugzs2CSjqnL/UPXroiVBVHUWUw== @@ -1553,7 +1523,7 @@ resolved "https://registry.npmjs.org/@types/minimatch/-/minimatch-5.1.2.tgz" integrity sha512-K0VQKziLUWkVKiRVrx4a40iPaxTUefQmjtkQofBkYRcoaaL/8rhwDWww9qWbrgicNOgnpIsMxyNIUM4+n6dUIA== -"@types/node@*", "@types/node@18.13.0", "@types/node@>=12.12.47", "@types/node@>=13.7.0", "@types/node@>=8.1.0": +"@types/node@*", "@types/node@>=12.12.47", "@types/node@>=13.7.0", "@types/node@>=8.1.0", "@types/node@18.13.0": version "18.13.0" resolved "https://registry.npmjs.org/@types/node/-/node-18.13.0.tgz" integrity sha512-gC3TazRzGoOnoKAhUx+Q0t8S9Tzs74z7m0ipwGpSqQrleP14hKxP4/JUeEQcD3W1/aIpnWl8pHowI7WokuZpXg== @@ -2213,7 +2183,7 @@ classnames@^2.2.6, classnames@^2.3.0, classnames@^2.5.1: resolved "https://registry.npmjs.org/classnames/-/classnames-2.5.1.tgz" integrity sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow== -client-only@0.0.1, client-only@^0.0.1: +client-only@^0.0.1, client-only@0.0.1: version "0.0.1" resolved "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz" integrity sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA== @@ -2236,6 +2206,15 @@ cliui@^7.0.2: strip-ansi "^6.0.0" wrap-ansi "^7.0.0" +cliui@^8.0.1: + version "8.0.1" + resolved "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz" + integrity sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ== + dependencies: + string-width "^4.2.0" + strip-ansi "^6.0.1" + wrap-ansi "^7.0.0" + clone@^2.1.2: version "2.1.2" resolved "https://registry.npmjs.org/clone/-/clone-2.1.2.tgz" @@ -2260,16 +2239,16 @@ color-convert@^2.0.1: dependencies: color-name "~1.1.4" -color-name@1.1.3: - version "1.1.3" - resolved "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz" - integrity sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw== - color-name@^1.0.0, color-name@^1.1.4, color-name@~1.1.4: version "1.1.4" resolved "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz" integrity sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA== +color-name@1.1.3: + version "1.1.3" + resolved "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz" + integrity sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw== + color-string@^1.9.1: version "1.9.1" resolved "https://registry.npmjs.org/color-string/-/color-string-1.9.1.tgz" @@ -2462,13 +2441,6 @@ date-fns@^2.0.1, date-fns@^2.30.0: dependencies: "@babel/runtime" "^7.21.0" -debug@4, debug@^4.1.1, debug@^4.3.1, debug@^4.3.2, debug@^4.3.4: - version "4.3.4" - resolved "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz" - integrity sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ== - dependencies: - ms "2.1.2" - debug@^3.2.7: version "3.2.7" resolved "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz" @@ -2476,6 +2448,13 @@ debug@^3.2.7: dependencies: ms "^2.1.1" +debug@^4.1.1, debug@^4.3.1, debug@^4.3.2, debug@^4.3.4, debug@4: + version "4.3.4" + resolved "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz" + integrity sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ== + dependencies: + ms "2.1.2" + decamelize@^1.2.0: version "1.2.0" resolved "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz" @@ -2645,7 +2624,7 @@ eastasianwidth@^0.2.0: resolved "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz" integrity sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA== -ecdsa-sig-formatter@1.0.11, ecdsa-sig-formatter@^1.0.11: +ecdsa-sig-formatter@^1.0.11, ecdsa-sig-formatter@1.0.11: version "1.0.11" resolved "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz" integrity sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ== @@ -3358,11 +3337,6 @@ fs.realpath@^1.0.0: resolved "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz" integrity sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw== -fsevents@~2.3.2: - version "2.3.3" - resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.3.3.tgz#cac6407785d03675a2a5e1a5305c697b347d90d6" - integrity sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw== - function-bind@^1.1.1, function-bind@^1.1.2: version "1.1.2" resolved "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz" @@ -3450,7 +3424,7 @@ get-tsconfig@^4.2.0: resolved "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.4.0.tgz" integrity sha512-0Gdjo/9+FzsYhXCEFueo2aY1z1tpXrxWZzP7k8ul9qt1U5o8rYJwTJYmaeHdrVosYIVYkOy2iwCJ9FdpocJhPQ== -glob-parent@^5.1.2, glob-parent@~5.1.2: +glob-parent@^5.1.2: version "5.1.2" resolved "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz" integrity sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow== @@ -3464,29 +3438,12 @@ glob-parent@^6.0.2: dependencies: is-glob "^4.0.3" -glob@7.1.6: - version "7.1.6" - resolved "https://registry.npmjs.org/glob/-/glob-7.1.6.tgz" - integrity sha512-LwaxwyZ72Lk7vZINtNNrywX0ZuLyStrdDtabefZKAY5ZGJhVtgdznluResxNmPitE0SAO+O26sWTHeKSI2wMBA== +glob-parent@~5.1.2: + version "5.1.2" + resolved "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz" + integrity sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow== dependencies: - fs.realpath "^1.0.0" - inflight "^1.0.4" - inherits "2" - minimatch "^3.0.4" - once "^1.3.0" - path-is-absolute "^1.0.0" - -glob@7.1.7, glob@^7.1.3: - version "7.1.7" - resolved "https://registry.npmjs.org/glob/-/glob-7.1.7.tgz" - integrity sha512-OvD9ENzPLbegENnYP5UUfJIirTg4+XwMWGaQfQTY0JenxNvvIKP3U3/tAQSPIu/lHxXYSZmpXlUHeqAIdKzBLQ== - dependencies: - fs.realpath "^1.0.0" - inflight "^1.0.4" - inherits "2" - minimatch "^3.0.4" - once "^1.3.0" - path-is-absolute "^1.0.0" + is-glob "^4.0.1" glob@^10.4.2: version "10.4.5" @@ -3500,6 +3457,18 @@ glob@^10.4.2: package-json-from-dist "^1.0.0" path-scurry "^1.11.1" +glob@^7.1.3, glob@7.1.7: + version "7.1.7" + resolved "https://registry.npmjs.org/glob/-/glob-7.1.7.tgz" + integrity sha512-OvD9ENzPLbegENnYP5UUfJIirTg4+XwMWGaQfQTY0JenxNvvIKP3U3/tAQSPIu/lHxXYSZmpXlUHeqAIdKzBLQ== + dependencies: + fs.realpath "^1.0.0" + inflight "^1.0.4" + inherits "2" + minimatch "^3.0.4" + once "^1.3.0" + path-is-absolute "^1.0.0" + glob@^8.0.0: version "8.1.0" resolved "https://registry.npmjs.org/glob/-/glob-8.1.0.tgz" @@ -3511,6 +3480,18 @@ glob@^8.0.0: minimatch "^5.0.1" once "^1.3.0" +glob@7.1.6: + version "7.1.6" + resolved "https://registry.npmjs.org/glob/-/glob-7.1.6.tgz" + integrity sha512-LwaxwyZ72Lk7vZINtNNrywX0ZuLyStrdDtabefZKAY5ZGJhVtgdznluResxNmPitE0SAO+O26sWTHeKSI2wMBA== + dependencies: + fs.realpath "^1.0.0" + inflight "^1.0.4" + inherits "2" + minimatch "^3.0.4" + once "^1.3.0" + path-is-absolute "^1.0.0" + globals@^11.1.0: version "11.12.0" resolved "https://registry.npmjs.org/globals/-/globals-11.12.0.tgz" @@ -3804,7 +3785,7 @@ inflight@^1.0.4: once "^1.3.0" wrappy "1" -inherits@2, inherits@^2.0.3, inherits@~2.0.3: +inherits@^2.0.3, inherits@~2.0.3, inherits@2: version "2.0.4" resolved "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz" integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ== @@ -4401,18 +4382,18 @@ loose-envify@^1.0.0, loose-envify@^1.1.0, loose-envify@^1.4.0: dependencies: js-tokens "^3.0.0 || ^4.0.0" -lru-cache@6.0.0, lru-cache@^6.0.0: +lru-cache@^10.2.0: + version "10.4.3" + resolved "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz" + integrity sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ== + +lru-cache@^6.0.0, lru-cache@6.0.0: version "6.0.0" resolved "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz" integrity sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA== dependencies: yallist "^4.0.0" -lru-cache@^10.2.0: - version "10.4.3" - resolved "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz" - integrity sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ== - lru-memoizer@^2.2.0: version "2.3.0" resolved "https://registry.npmjs.org/lru-memoizer/-/lru-memoizer-2.3.0.tgz" @@ -4483,7 +4464,7 @@ micromatch@^4.0.4, micromatch@^4.0.5: braces "^3.0.2" picomatch "^2.3.1" -mime-db@1.52.0, "mime-db@>= 1.43.0 < 2": +"mime-db@>= 1.43.0 < 2", mime-db@1.52.0: version "1.52.0" resolved "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz" integrity sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg== @@ -4568,7 +4549,7 @@ moment@^2.29.4: resolved "https://registry.npmjs.org/moment/-/moment-2.29.4.tgz" integrity sha512-5LC9SOxjSc2HF6vO2CyuTDNivEdoz2IvyJJGj6X8DJ0eFyfszE0QiEd+iXmBvUP3WHxSjFH/vIsA0EN00cgr8w== -ms@2.1.2, ms@^2.1.1: +ms@^2.1.1, ms@2.1.2: version "2.1.2" resolved "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz" integrity sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w== @@ -4632,14 +4613,21 @@ node-addon-api@^5.0.0: resolved "https://registry.npmjs.org/node-addon-api/-/node-addon-api-5.1.0.tgz" integrity sha512-eh0GgfEkpnoWDq+VY8OyvYhFEzBk6jIYbRKdIlyTiAXIVJ8PyBaKb0rp7oDtoddbdoHWhq8wwr+XZ81F1rpNdA== -node-fetch@2.6.7, node-fetch@^2.6.1, node-fetch@^2.6.7: +node-fetch@^2.6.1, node-fetch@^2.6.7, node-fetch@2.6.7: version "2.6.7" resolved "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.7.tgz" integrity sha512-ZjMPFEfVx5j+y2yF35Kzx5sF7kDzxuDj6ziH4FFbOp87zKDZNx8yExJIb05OGF4Nlt9IHFIMBkRl41VdvcNdbQ== dependencies: whatwg-url "^5.0.0" -node-fetch@^2.6.12, node-fetch@^2.6.9: +node-fetch@^2.6.12: + version "2.7.0" + resolved "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz" + integrity sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A== + dependencies: + whatwg-url "^5.0.0" + +node-fetch@^2.6.9: version "2.7.0" resolved "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz" integrity sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A== @@ -4995,15 +4983,6 @@ postcss-value-parser@^4.0.0, postcss-value-parser@^4.1.0, postcss-value-parser@^ resolved "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz" integrity sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ== -postcss@8.4.14: - version "8.4.14" - resolved "https://registry.npmjs.org/postcss/-/postcss-8.4.14.tgz" - integrity sha512-E398TUmfAYFPBSdzgeieK2Y1+1cpdxJx8yXbK/m57nRhKSmk1GB2tO4lbLBtlkfPQTDKfe4Xqv1ASWPpayPEig== - dependencies: - nanoid "^3.3.4" - picocolors "^1.0.0" - source-map-js "^1.0.2" - postcss@^8, postcss@^8.0.9, postcss@^8.4.21: version "8.4.22" resolved "https://registry.npmjs.org/postcss/-/postcss-8.4.22.tgz" @@ -5013,6 +4992,15 @@ postcss@^8, postcss@^8.0.9, postcss@^8.4.21: picocolors "^1.0.0" source-map-js "^1.0.2" +postcss@8.4.14: + version "8.4.14" + resolved "https://registry.npmjs.org/postcss/-/postcss-8.4.14.tgz" + integrity sha512-E398TUmfAYFPBSdzgeieK2Y1+1cpdxJx8yXbK/m57nRhKSmk1GB2tO4lbLBtlkfPQTDKfe4Xqv1ASWPpayPEig== + dependencies: + nanoid "^3.3.4" + picocolors "^1.0.0" + source-map-js "^1.0.2" + prelude-ls@^1.2.1: version "1.2.1" resolved "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz" @@ -5046,15 +5034,6 @@ promise-polyfill@^8.3.0: resolved "https://registry.npmjs.org/promise-polyfill/-/promise-polyfill-8.3.0.tgz" integrity sha512-H5oELycFml5yto/atYqmjyigJoAo3+OXwolYiH7OfQuYlAqhxNvTfiNMbV9hsC6Yp83yE5r2KTVmtrG6R9i6Pg== -prop-types@15.7.2: - version "15.7.2" - resolved "https://registry.npmjs.org/prop-types/-/prop-types-15.7.2.tgz" - integrity sha512-8QQikdH7//R2vurIJSutZ1smHYTcLpRWEOlHnzcWHmBYrOGUysKwSsrC89BCiFj3CbrfJ/nXFdJepOVrY1GCHQ== - dependencies: - loose-envify "^1.4.0" - object-assign "^4.1.1" - react-is "^16.8.1" - prop-types@^15.6.0, prop-types@^15.6.2, prop-types@^15.7.2, prop-types@^15.8.1: version "15.8.1" resolved "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz" @@ -5064,6 +5043,15 @@ prop-types@^15.6.0, prop-types@^15.6.2, prop-types@^15.7.2, prop-types@^15.8.1: object-assign "^4.1.1" react-is "^16.13.1" +prop-types@15.7.2: + version "15.7.2" + resolved "https://registry.npmjs.org/prop-types/-/prop-types-15.7.2.tgz" + integrity sha512-8QQikdH7//R2vurIJSutZ1smHYTcLpRWEOlHnzcWHmBYrOGUysKwSsrC89BCiFj3CbrfJ/nXFdJepOVrY1GCHQ== + dependencies: + loose-envify "^1.4.0" + object-assign "^4.1.1" + react-is "^16.8.1" + proto3-json-serializer@^1.0.0: version "1.1.1" resolved "https://registry.npmjs.org/proto3-json-serializer/-/proto3-json-serializer-1.1.1.tgz" @@ -5087,24 +5075,6 @@ protobufjs-cli@1.1.1: tmp "^0.2.1" uglify-js "^3.7.7" -protobufjs@7.2.4: - version "7.2.4" - resolved "https://registry.npmjs.org/protobufjs/-/protobufjs-7.2.4.tgz" - integrity sha512-AT+RJgD2sH8phPmCf7OUZR8xGdcJRga4+1cOaXJ64hvcSkVhNcRHOwIxUatPH15+nj59WAGTDv3LSGZPEQbJaQ== - dependencies: - "@protobufjs/aspromise" "^1.1.2" - "@protobufjs/base64" "^1.1.2" - "@protobufjs/codegen" "^2.0.4" - "@protobufjs/eventemitter" "^1.1.0" - "@protobufjs/fetch" "^1.1.0" - "@protobufjs/float" "^1.0.2" - "@protobufjs/inquire" "^1.1.0" - "@protobufjs/path" "^1.1.2" - "@protobufjs/pool" "^1.1.0" - "@protobufjs/utf8" "^1.1.0" - "@types/node" ">=13.7.0" - long "^5.0.0" - protobufjs@^6.11.3: version "6.11.3" resolved "https://registry.npmjs.org/protobufjs/-/protobufjs-6.11.3.tgz" @@ -5160,6 +5130,24 @@ protobufjs@^7.2.5: "@types/node" ">=13.7.0" long "^5.0.0" +protobufjs@7.2.4: + version "7.2.4" + resolved "https://registry.npmjs.org/protobufjs/-/protobufjs-7.2.4.tgz" + integrity sha512-AT+RJgD2sH8phPmCf7OUZR8xGdcJRga4+1cOaXJ64hvcSkVhNcRHOwIxUatPH15+nj59WAGTDv3LSGZPEQbJaQ== + dependencies: + "@protobufjs/aspromise" "^1.1.2" + "@protobufjs/base64" "^1.1.2" + "@protobufjs/codegen" "^2.0.4" + "@protobufjs/eventemitter" "^1.1.0" + "@protobufjs/fetch" "^1.1.0" + "@protobufjs/float" "^1.0.2" + "@protobufjs/inquire" "^1.1.0" + "@protobufjs/path" "^1.1.2" + "@protobufjs/pool" "^1.1.0" + "@protobufjs/utf8" "^1.1.0" + "@types/node" ">=13.7.0" + long "^5.0.0" + proxy-from-env@^1.1.0: version "1.1.0" resolved "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz" @@ -5563,7 +5551,7 @@ run-parallel@^1.1.9: dependencies: queue-microtask "^1.2.2" -safe-buffer@>=5.1.0, safe-buffer@^5.0.1, safe-buffer@~5.2.0: +safe-buffer@^5.0.1, safe-buffer@>=5.1.0, safe-buffer@~5.2.0: version "5.2.1" resolved "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz" integrity sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ== @@ -5746,7 +5734,30 @@ stream-shift@^1.0.2: resolved "https://registry.npmjs.org/stream-shift/-/stream-shift-1.0.3.tgz" integrity sha512-76ORR0DO1o1hlKwTbi/DM3EXWGf3ZJYO8cXX5RJwnul2DEg2oyoZyjLNoQM8WsvZiFKCRfC1O0J7iCvie3RZmQ== -"string-width-cjs@npm:string-width@^4.2.0", "string-width@^1.0.2 || 2 || 3 || 4", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: +string_decoder@^1.1.1: + version "1.3.0" + resolved "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz" + integrity sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA== + dependencies: + safe-buffer "~5.2.0" + +string_decoder@~1.1.1: + version "1.1.1" + resolved "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz" + integrity sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg== + dependencies: + safe-buffer "~5.1.0" + +"string-width-cjs@npm:string-width@^4.2.0": + version "4.2.3" + resolved "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz" + integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== + dependencies: + emoji-regex "^8.0.0" + is-fullwidth-code-point "^3.0.0" + strip-ansi "^6.0.1" + +"string-width@^1.0.2 || 2 || 3 || 4", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: version "4.2.3" resolved "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz" integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== @@ -5796,21 +5807,14 @@ string.prototype.trimstart@^1.0.6: define-properties "^1.1.4" es-abstract "^1.20.4" -string_decoder@^1.1.1: - version "1.3.0" - resolved "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz" - integrity sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA== +"strip-ansi-cjs@npm:strip-ansi@^6.0.1": + version "6.0.1" + resolved "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz" + integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== dependencies: - safe-buffer "~5.2.0" + ansi-regex "^5.0.1" -string_decoder@~1.1.1: - version "1.1.1" - resolved "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz" - integrity sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg== - dependencies: - safe-buffer "~5.1.0" - -"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.0, strip-ansi@^6.0.1: +strip-ansi@^6.0.0, strip-ansi@^6.0.1: version "6.0.1" resolved "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz" integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== @@ -6187,7 +6191,7 @@ use-isomorphic-layout-effect@^1.1.2: resolved "https://registry.npmjs.org/use-isomorphic-layout-effect/-/use-isomorphic-layout-effect-1.1.2.tgz" integrity sha512-49L8yCO3iGT/ZF9QttjwLF/ZD9Iwto5LnH5LmEdk/6cFmXddqi2ulF0edxTwjj+7mqvpVVGQWvbXZdn32wRSHA== -use-sync-external-store@1.2.0, use-sync-external-store@^1.2.0: +use-sync-external-store@^1.2.0, use-sync-external-store@1.2.0: version "1.2.0" resolved "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.2.0.tgz" integrity sha512-eEgnFxGQ1Ife9bzYs6VLi8/4X6CObHMw9Qr9tPY43iKwsPw8xE8+EFsf/2cFZ5S3esXgpWgtSCtLNS41F+sKPA== @@ -6335,7 +6339,7 @@ wordwrap@^1.0.0: resolved "https://registry.npmjs.org/wordwrap/-/wordwrap-1.0.0.tgz" integrity sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q== -"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0", wrap-ansi@^7.0.0: +"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0": version "7.0.0" resolved "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz" integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== @@ -6353,6 +6357,15 @@ wrap-ansi@^6.2.0: string-width "^4.1.0" strip-ansi "^6.0.0" +wrap-ansi@^7.0.0: + version "7.0.0" + resolved "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz" + integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== + dependencies: + ansi-styles "^4.0.0" + string-width "^4.1.0" + strip-ansi "^6.0.0" + wrap-ansi@^8.1.0: version "8.1.0" resolved "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz" @@ -6405,6 +6418,11 @@ yargs-parser@^20.2.2: resolved "https://registry.npmjs.org/yargs-parser/-/yargs-parser-20.2.9.tgz" integrity sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w== +yargs-parser@^21.1.1: + version "21.1.1" + resolved "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz" + integrity sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw== + yargs@^15.3.1: version "15.4.1" resolved "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz" @@ -6435,6 +6453,19 @@ yargs@^16.2.0: y18n "^5.0.5" yargs-parser "^20.2.2" +yargs@^17.7.2: + version "17.7.2" + resolved "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz" + integrity sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w== + dependencies: + cliui "^8.0.1" + escalade "^3.1.1" + get-caller-file "^2.0.5" + require-directory "^2.1.1" + string-width "^4.2.3" + y18n "^5.0.5" + yargs-parser "^21.1.1" + yocto-queue@^0.1.0: version "0.1.0" resolved "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz" From bcb1a0f914787e1aa63a4cf91337b91b9fd1c30b Mon Sep 17 00:00:00 2001 From: Carlos Mesquita Date: Mon, 19 Aug 2024 01:24:55 +0100 Subject: [PATCH 2/3] If someone else wants to join in on the fun be my guest --- src/components/Exercises/FillBlanks/index.tsx | 26 ++- src/components/Exercises/MultipleChoice.tsx | 72 ++++-- src/components/Solutions/FillBlanks.tsx | 2 +- src/components/Solutions/MultipleChoice.tsx | 65 ++++-- src/exams/Level.tsx | 220 +++++++++++++----- src/interfaces/exam.ts | 30 ++- src/interfaces/user.ts | 3 +- src/pages/(exam)/ExamPage.tsx | 16 +- src/stores/examStore.ts | 6 +- src/utils/stats.ts | 1 + 10 files changed, 319 insertions(+), 122 deletions(-) diff --git a/src/components/Exercises/FillBlanks/index.tsx b/src/components/Exercises/FillBlanks/index.tsx index f59096a2..bd6a1c4c 100644 --- a/src/components/Exercises/FillBlanks/index.tsx +++ b/src/components/Exercises/FillBlanks/index.tsx @@ -20,6 +20,7 @@ const FillBlanks: React.FC = ({ onNext, onBack, }) => { + const { shuffleMaps } = useExamStore((state) => state); const [answers, setAnswers] = useState<{ id: string; solution: string }[]>(userSolutions); const hasExamEnded = useExamStore((state) => state.hasExamEnded); @@ -62,6 +63,15 @@ const FillBlanks: React.FC = ({ } 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; @@ -119,6 +129,18 @@ const FillBlanks: React.FC = ({ setAnswers((prev) => [...prev.filter((x) => x.id !== id), { id: id, solution: value }]); } + const getShuffles = () => { + let shuffle = {}; + if (shuffleMaps.length !== 0) { + shuffle = { + shuffleMaps: shuffleMaps.filter((map) => + answers.some(answer => answer.id === map.id) + ) + } + } + return shuffle; + } + return ( <>
@@ -190,14 +212,14 @@ const FillBlanks: React.FC = ({ diff --git a/src/components/Exercises/MultipleChoice.tsx b/src/components/Exercises/MultipleChoice.tsx index b6e1e3e0..1c07e9af 100644 --- a/src/components/Exercises/MultipleChoice.tsx +++ b/src/components/Exercises/MultipleChoice.tsx @@ -1,10 +1,10 @@ /* eslint-disable @next/next/no-img-element */ -import {MultipleChoiceExercise, MultipleChoiceQuestion} from "@/interfaces/exam"; +import { MultipleChoiceExercise, MultipleChoiceQuestion } from "@/interfaces/exam"; import useExamStore from "@/stores/examStore"; import clsx from "clsx"; -import {useEffect, useState} from "react"; +import { useEffect, useState } from "react"; import reactStringReplace from "react-string-replace"; -import {CommonProps} from "."; +import { CommonProps } from "."; import Button from "../Low/Button"; function Question({ @@ -14,12 +14,10 @@ function Question({ options, userSolution, onSelectOption, - setContextHighlight }: MultipleChoiceQuestion & { - userSolution: string | undefined; - onSelectOption?: (option: string) => void; + userSolution: string | undefined; + onSelectOption?: (option: string) => void; showSolution?: boolean, - setContextHighlight?: React.Dispatch> }) { /* @@ -35,11 +33,11 @@ function Question({ // {renderPrompt(prompt).filter((x) => x?.toString() !== "")}
{isNaN(Number(id)) ? ( - + ) : ( <> - {id} - + {id} - )} @@ -75,53 +73,79 @@ function Question({ ); } -export default function MultipleChoice({id, prompt, type, questions, userSolutions, onNext, onBack}: MultipleChoiceExercise & CommonProps) { - const [answers, setAnswers] = useState<{question: string; option: string}[]>(userSolutions); +export default function MultipleChoice({ id, prompt, type, questions, userSolutions, onNext, onBack }: MultipleChoiceExercise & CommonProps) { + const [answers, setAnswers] = useState<{ question: string; option: string }[]>(userSolutions); - const {questionIndex, setQuestionIndex} = useExamStore((state) => state); - const {userSolutions: storeUserSolutions, setUserSolutions} = useExamStore((state) => state); + 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 scrollToTop = () => Array.from(document.getElementsByTagName("body")).forEach((body) => body.scrollTo(0, 0)); useEffect(() => { - setUserSolutions([...storeUserSolutions.filter((x) => x.exercise !== id), {exercise: id, solutions: answers, score: calculateScore(), type}]); + setUserSolutions( + [...storeUserSolutions.filter((x) => x.exercise !== id), { + exercise: id, solutions: answers, score: calculateScore(), type + }]); // eslint-disable-next-line react-hooks/exhaustive-deps }, [answers]); useEffect(() => { - if (hasExamEnded) onNext({exercise: id, solutions: answers, score: calculateScore(), type}); + if (hasExamEnded) onNext({ exercise: id, solutions: answers, score: calculateScore(), type }); // eslint-disable-next-line react-hooks/exhaustive-deps }, [hasExamEnded]); const onSelectOption = (option: string) => { const question = questions[questionIndex]; - setAnswers((prev) => [...prev.filter((x) => x.question !== question.id), {option, question: question.id}]); + setAnswers((prev) => [...prev.filter((x) => x.question !== question.id), { option, question: question.id }]); }; const calculateScore = () => { const total = questions.length; - const correct = answers.filter( - (x) => questions.find((y) => y.id.toString() === x.question.toString())?.solution === x.option || false, - ).length; - const missing = total - answers.filter((x) => questions.find((y) => y.id.toString() === x.question.toString())).length; + const correct = answers.filter((x) => { + const matchingQuestion = questions.find((y) => { + return y.id.toString() === x.question.toString(); + }); - return {total, correct, missing}; + 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; + } + return isSolutionCorrect || false; + }).length; + const missing = total - correct; + + return { total, correct, missing }; }; + const getShuffles = () => { + let shuffle = {}; + if (shuffleMaps.length !== 0) { + shuffle = { + shuffleMaps: shuffleMaps.filter((map) => + answers.some(answer => answer.question === map.id) + ) + } + } + return shuffle; + } + const next = () => { if (questionIndex === questions.length - 1) { - onNext({exercise: id, solutions: answers, score: calculateScore(), type}); + onNext({ exercise: id, solutions: answers, score: calculateScore(), type, ...getShuffles() }); } else { setQuestionIndex(questionIndex + 1); } - scrollToTop(); }; const back = () => { if (questionIndex === 0) { - onBack({exercise: id, solutions: answers, score: calculateScore(), type}); + onBack({ exercise: id, solutions: answers, score: calculateScore(), type, ...getShuffles() }); } else { setQuestionIndex(questionIndex - 1); } diff --git a/src/components/Solutions/FillBlanks.tsx b/src/components/Solutions/FillBlanks.tsx index 00f5ba47..807536f9 100644 --- a/src/components/Solutions/FillBlanks.tsx +++ b/src/components/Solutions/FillBlanks.tsx @@ -105,7 +105,7 @@ export default function FillBlanksSolutions({ solutionText = options.options[correctKey as keyof typeof options.options] || solution.solution; } else { correct = false; - solutionText = solution.solution; + solutionText = solution?.solution; } } else { diff --git a/src/components/Solutions/MultipleChoice.tsx b/src/components/Solutions/MultipleChoice.tsx index 7f1ba0fd..2c3f35e2 100644 --- a/src/components/Solutions/MultipleChoice.tsx +++ b/src/components/Solutions/MultipleChoice.tsx @@ -1,10 +1,10 @@ /* eslint-disable @next/next/no-img-element */ -import {MultipleChoiceExercise, MultipleChoiceQuestion} from "@/interfaces/exam"; +import { MultipleChoiceExercise, MultipleChoiceQuestion, ShuffleMap } from "@/interfaces/exam"; import useExamStore from "@/stores/examStore"; import clsx from "clsx"; -import {useEffect, useState} from "react"; +import { useEffect, useState } from "react"; import reactStringReplace from "react-string-replace"; -import {CommonProps} from "."; +import { CommonProps } from "."; import Button from "../Low/Button"; function Question({ @@ -14,7 +14,30 @@ function Question({ solution, options, userSolution, -}: MultipleChoiceQuestion & {userSolution: string | undefined; onSelectOption?: (option: string) => void; showSolution?: boolean}) { +}: MultipleChoiceQuestion & { userSolution: string | undefined; onSelectOption?: (option: string) => void; showSolution?: boolean }) { + const { userSolutions } = useExamStore((state) => state); + + const questionShuffleMap = userSolutions.reduce((foundMap, userSolution) => { + if (foundMap) return foundMap; + return userSolution.shuffleMaps?.find(map => map.id === id) || null; + }, null as ShuffleMap | null); + + const shuffledOptions = new Array(options.length); + options.forEach(option => { + const newId = questionShuffleMap?.map[option.id]; + const newIndex = options.findIndex(opt => opt.id === newId); + shuffledOptions[newIndex] = option; + }); + + const lettersMap = ['A', 'B', 'C', 'D']; + const optionsWithLetters = shuffledOptions.map((option, index) => ({ + ...option, + id: lettersMap[index] + })); + + const questionOptions = questionShuffleMap ? optionsWithLetters : options; + const newQuestionSolution = questionShuffleMap ? questionShuffleMap.map[solution] : solution; + const renderPrompt = (prompt: string) => { return reactStringReplace(prompt, /(()[\w\s']+(<\/u>))/g, (match) => { const word = match.replaceAll("", "").replaceAll("", ""); @@ -23,11 +46,11 @@ function Question({ }; const optionColor = (option: string) => { - if (option === solution && !userSolution) { + if (option === newQuestionSolution && !userSolution) { return "!border-mti-gray-davy !text-mti-gray-davy"; } - if (option === solution) { + if (option === newQuestionSolution) { return "!border-mti-purple-light !text-mti-purple-light"; } @@ -47,24 +70,24 @@ function Question({ )}
{variant === "image" && - options.map((option) => ( + questionOptions.map((option) => (
- {option.id} - {`Option + {option?.id} + {`Option
))} {variant === "text" && - options.map((option) => ( + questionOptions.map((option) => (
- {option.id}. - {option.text} + key={option?.id} + className={clsx("flex border p-4 rounded-xl gap-2 cursor-pointer bg-white text-sm", optionColor(option!.id))}> + {option?.id}. + {option?.text}
))}
@@ -72,8 +95,8 @@ function Question({ ); } -export default function MultipleChoice({id, type, prompt, questions, userSolutions, onNext, onBack}: MultipleChoiceExercise & CommonProps) { - const {questionIndex, setQuestionIndex} = useExamStore((state) => state); +export default function MultipleChoice({ id, type, prompt, questions, userSolutions, onNext, onBack }: MultipleChoiceExercise & CommonProps) { + const { questionIndex, setQuestionIndex } = useExamStore((state) => state); const calculateScore = () => { const total = questions.length; @@ -82,12 +105,12 @@ export default function MultipleChoice({id, type, prompt, questions, userSolutio ).length; const missing = total - userSolutions.filter((x) => questions.find((y) => y.id.toString() === x.question.toString())).length; - return {total, correct, missing}; + return { total, correct, missing }; }; const next = () => { if (questionIndex === questions.length - 1) { - onNext({exercise: id, solutions: userSolutions, score: calculateScore(), type}); + onNext({ exercise: id, solutions: userSolutions, score: calculateScore(), type }); } else { setQuestionIndex(questionIndex + 1); } @@ -95,7 +118,7 @@ export default function MultipleChoice({id, type, prompt, questions, userSolutio const back = () => { if (questionIndex === 0) { - onBack({exercise: id, solutions: userSolutions, score: calculateScore(), type}); + onBack({ exercise: id, solutions: userSolutions, score: calculateScore(), type }); } else { setQuestionIndex(questionIndex - 1); } diff --git a/src/exams/Level.tsx b/src/exams/Level.tsx index 76b30b8d..2cd591f4 100644 --- a/src/exams/Level.tsx +++ b/src/exams/Level.tsx @@ -5,14 +5,15 @@ import Button from "@/components/Low/Button"; import ModuleTitle from "@/components/Medium/ModuleTitle"; import { renderSolution } from "@/components/Solutions"; import { infoButtonStyle } from "@/constants/buttonStyles"; -import { LevelExam, LevelPart, UserSolution, WritingExam } from "@/interfaces/exam"; +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 { Fragment, use, useEffect, useRef, useState } from "react"; +import { Dispatch, Fragment, SetStateAction, use, useEffect, useMemo, useRef, useState } from "react"; import { BsChevronDown, BsChevronUp } from "react-icons/bs"; import { toast } from "react-toastify"; @@ -39,7 +40,7 @@ function TextComponent({ 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'; @@ -50,51 +51,51 @@ function TextComponent({ offscreenElement.style.lineHeight = computedStyle.lineHeight; offscreenElement.style.wordWrap = 'break-word'; 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; 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); } }; @@ -154,6 +155,12 @@ function TextComponent({ } +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 }[]>([]); @@ -164,6 +171,8 @@ export default function Level({ exam, showSolutions = false, onFinish, editing = 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)); @@ -171,6 +180,12 @@ export default function Level({ exam, showSolutions = false, onFinish, editing = 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); @@ -186,6 +201,128 @@ export default function Level({ exam, showSolutions = false, onFinish, editing = 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) { + 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 shuffledKeys = Object.keys(options).sort(() => Math.random() - 0.5); + + const newOptions = shuffledKeys.reduce((acc, key, index) => { + acc[key as keyof typeof options] = options[shuffledKeys[index] as keyof typeof options]; + return acc; + }, {} as { [key in keyof typeof options]: string }); + + const optionMapping = shuffledKeys.reduce((acc, key, index) => { + acc[key as keyof typeof options] = Object.keys(options)[index] as keyof typeof options; + 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(() => { + const newExercise = getExercise(); + setCurrentExercise(newExercise); + }, [partIndex, exerciseIndex]); + + + //useShuffledMultipleChoiceOptions(currentExercise, exam.shuffle, storeQuestionIndex, shuffleMaps, setShuffleMaps, setCurrentExercise); + //useShuffledFillBlanksOptions(currentExercise, exam.shuffle, storeQuestionIndex, shuffleMaps, setShuffleMaps, setCurrentExercise); + + + 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]; + setContextHighlight([word]); + + 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 { + setContextHighlight([]); + setContextWord(undefined); + } + } + }, [storeQuestionIndex, contextWordLine, exerciseIndex, partIndex, shuffleMaps]); + const nextExercise = (solution?: UserSolution) => { scrollToTop(); if (solution) { @@ -193,8 +330,7 @@ export default function Level({ exam, showSolutions = false, onFinish, editing = } if (storeQuestionIndex > 0) { - const exercise = getExercise(); - setMultipleChoicesDone((prev) => [...prev.filter((x) => x.id !== exercise!.id), { id: exercise!.id, amount: storeQuestionIndex }]); + setMultipleChoicesDone((prev) => [...prev.filter((x) => x.id !== currentExercise!.id), { id: currentExercise!.id, amount: storeQuestionIndex }]); } setStoreQuestionIndex(0); @@ -225,7 +361,11 @@ export default function Level({ exam, showSolutions = false, onFinish, editing = setHasExamEnded(false); if (solution) { - onFinish([...userSolutions.filter((x) => x.exercise !== solution.exercise), { ...solution, module: "level", exam: exam.id }]); + 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); } @@ -238,25 +378,13 @@ export default function Level({ exam, showSolutions = false, onFinish, editing = } if (storeQuestionIndex > 0) { - const exercise = getExercise(); - setMultipleChoicesDone((prev) => [...prev.filter((x) => x.id !== exercise!.id), { id: exercise!.id, amount: storeQuestionIndex }]); + setMultipleChoicesDone((prev) => [...prev.filter((x) => x.id !== currentExercise!.id), { id: currentExercise!.id, amount: storeQuestionIndex }]); } setStoreQuestionIndex(0); setExerciseIndex(exerciseIndex - 1); }; - const getExercise = () => { - if (exerciseIndex === -1) { - return undefined; - } - const exercise = exam.parts[partIndex]?.exercises[exerciseIndex]; - return { - ...exercise, - userSolutions: userSolutions.find((x) => x.exercise === exercise.id)?.solutions || [], - }; - }; - const calculateExerciseIndex = () => { if (partIndex === 0) return ( @@ -292,35 +420,6 @@ export default function Level({ exam, showSolutions = false, onFinish, editing =
); - const exercise = getExercise(); - - - useEffect(() => { - const regex = /.*?['"](.*?)['"] in line (\d+)\?$/; - if (exercise && exercise.type === "multipleChoice") { - const match = exercise.questions[storeQuestionIndex].prompt.match(regex); - if (match) { - const word = match[1]; - const originalLineNumber = match[2]; - setContextHighlight([word]); - - if (word !== contextWord) { - setContextWord(word); - } - - const updatedPrompt = exercise.questions[storeQuestionIndex].prompt.replace( - `in line ${originalLineNumber}`, - `in line ${contextWordLine || originalLineNumber}` - ); - - exercise.questions[storeQuestionIndex].prompt = updatedPrompt; - } else { - setContextHighlight([]); - setContextWord(undefined); - } - } - }, [storeQuestionIndex, contextWordLine]); - return ( <>
@@ -344,7 +443,8 @@ export default function Level({ exam, showSolutions = false, onFinish, editing = exerciseIndex < exam.parts[partIndex].exercises.length && !showSolutions && !editing && - renderExercise(exercise!, exam.id, nextExercise, previousExercise)} + currentExercise && + renderExercise(currentExercise, exam.id, nextExercise, previousExercise)} {exerciseIndex > -1 && partIndex > -1 && diff --git a/src/interfaces/exam.ts b/src/interfaces/exam.ts index 514ac6fa..85a1bc2c 100644 --- a/src/interfaces/exam.ts +++ b/src/interfaces/exam.ts @@ -12,6 +12,7 @@ interface ExamBase { isDiagnostic: boolean; variant?: Variant; difficulty?: Difficulty; + shuffle?: boolean; createdBy?: string; // option as it has been added later createdAt?: string; // option as it has been added later } @@ -66,6 +67,7 @@ export interface UserSolution { }; exercise: string; isDisabled?: boolean; + shuffleMaps?: ShuffleMap[] } export interface WritingExam extends ExamBase { @@ -78,7 +80,7 @@ interface WordCounter { limit: number; } -export interface SpeakingExam extends ExamBase { +export interface SpeakingExam extends ExamBase { module: "speaking"; exercises: (SpeakingExercise | InteractiveSpeakingExercise)[]; instructorGender: InstructorGender; @@ -97,8 +99,8 @@ export type Exercise = export interface Evaluation { comment: string; overall: number; - task_response: {[key: string]: number | {grade: number; comment: string}}; - misspelled_pairs?: {correction: string | null; misspelled: string}[]; + task_response: { [key: string]: number | { grade: number; comment: string } }; + misspelled_pairs?: { correction: string | null; misspelled: string }[]; } @@ -111,10 +113,9 @@ type InteractiveTranscriptType = { [key in InteractiveTranscriptKey]?: string }; type InteractiveFixedTextType = { [key in InteractiveFixedTextKey]?: string }; interface InteractiveSpeakingEvaluation extends Evaluation, -InteractivePerfectAnswerType, -InteractiveTranscriptType, -InteractiveFixedTextType -{} + InteractivePerfectAnswerType, + InteractiveTranscriptType, + InteractiveFixedTextType { } interface SpeakingEvaluation extends CommonEvaluation { @@ -233,7 +234,7 @@ export interface TrueFalseExercise { id: string; prompt: string; // *EXAMPLE: "Select the appropriate option." questions: TrueFalseQuestion[]; - userSolutions: {id: string; solution: "true" | "false" | "not_given"}[]; + userSolutions: { id: string; solution: "true" | "false" | "not_given" }[]; } export interface TrueFalseQuestion { @@ -262,7 +263,7 @@ export interface MatchSentencesExercise { type: "matchSentences"; id: string; prompt: string; - userSolutions: {question: string; option: string}[]; + userSolutions: { question: string; option: string }[]; sentences: MatchSentenceExerciseSentence[]; allowRepetition: boolean; options: MatchSentenceExerciseOption[]; @@ -285,8 +286,7 @@ export interface MultipleChoiceExercise { id: string; prompt: string; // *EXAMPLE: "Select the appropriate option." questions: MultipleChoiceQuestion[]; - userSolutions: {question: string; option: string}[]; - setContextHighlight?: React.Dispatch> + userSolutions: { question: string; option: string }[]; } export interface MultipleChoiceQuestion { @@ -299,4 +299,12 @@ export interface MultipleChoiceQuestion { src?: string; // *EXAMPLE: "https://i.imgur.com/rEbrSqA.png" (only used if the variant is "image") text?: string; // *EXAMPLE: "wallet, pens and novel" (only used if the variant is "text") }[]; + shuffleMap?: Record; +} + +export interface ShuffleMap { + id: string; + map: { + [key: string]: string; + } } diff --git a/src/interfaces/user.ts b/src/interfaces/user.ts index ddda1ccb..96603ac1 100644 --- a/src/interfaces/user.ts +++ b/src/interfaces/user.ts @@ -1,5 +1,5 @@ import { Module } from "."; -import { InstructorGender } from "./exam"; +import { InstructorGender, ShuffleMap } from "./exam"; import { PermissionType } from "./permissions"; export type User = @@ -148,6 +148,7 @@ export interface Stat { missing: number; }; isDisabled?: boolean; + shuffleMaps?: ShuffleMap[]; } export interface Group { diff --git a/src/pages/(exam)/ExamPage.tsx b/src/pages/(exam)/ExamPage.tsx index 671712af..57b38e4b 100644 --- a/src/pages/(exam)/ExamPage.tsx +++ b/src/pages/(exam)/ExamPage.tsx @@ -12,7 +12,7 @@ import Selection from "@/exams/Selection"; import Speaking from "@/exams/Speaking"; import Writing from "@/exams/Writing"; import useUser from "@/hooks/useUser"; -import {Exam, UserSolution, Variant} from "@/interfaces/exam"; +import {Exam, LevelExam, UserSolution, Variant} from "@/interfaces/exam"; import {Stat} from "@/interfaces/user"; import useExamStore from "@/stores/examStore"; import {evaluateSpeakingAnswer, evaluateWritingAnswer} from "@/utils/evaluation"; @@ -257,6 +257,7 @@ export default function ExamPage({page}: Props) { user: user?.id || "", date: new Date().getTime(), isDisabled: solution.isDisabled, + shuffleMaps: solution.shuffleMaps, ...(assignment ? {assignment: assignment.id} : {}), })); @@ -459,6 +460,19 @@ export default function ExamPage({page}: Props) { inactivity: totalInactivity, }} onViewResults={(index?: number) => { + if (exams[0].module === "level") { + const levelExam = exams[0] as LevelExam; + const allExercises = levelExam.parts.flatMap(part => part.exercises); + const exerciseOrderMap = new Map(allExercises.map((ex, index) => [ex.id, index])); + const orderedSolutions = userSolutions.slice().sort((a, b) => { + const indexA = exerciseOrderMap.get(a.exercise) ?? Infinity; + const indexB = exerciseOrderMap.get(b.exercise) ?? Infinity; + return indexA - indexB; + }); + setUserSolutions(orderedSolutions); + } else { + setUserSolutions(userSolutions); + } setShowSolutions(true); setModuleIndex(index || 0); setExerciseIndex(["reading", "listening"].includes(exams[0].module) ? -1 : 0); diff --git a/src/stores/examStore.ts b/src/stores/examStore.ts index 4c0184e5..60bc8fcb 100644 --- a/src/stores/examStore.ts +++ b/src/stores/examStore.ts @@ -1,5 +1,5 @@ import {Module} from "@/interfaces"; -import {Exam, UserSolution} from "@/interfaces/exam"; +import {Exam, ShuffleMap, UserSolution} from "@/interfaces/exam"; import {Assignment} from "@/interfaces/results"; import {create} from "zustand"; @@ -18,6 +18,7 @@ export interface ExamState { exerciseIndex: number; questionIndex: number; inactivity: number; + shuffleMaps: ShuffleMap[]; } export interface ExamFunctions { @@ -35,6 +36,7 @@ export interface ExamFunctions { setExerciseIndex: (exerciseIndex: number) => void; setQuestionIndex: (questionIndex: number) => void; setInactivity: (inactivity: number) => void; + setShuffleMaps: (shuffleMaps: ShuffleMap[]) => void; reset: () => void; } @@ -53,6 +55,7 @@ export const initialState: ExamState = { exerciseIndex: -1, questionIndex: 0, inactivity: 0, + shuffleMaps: [] }; const useExamStore = create((set) => ({ @@ -72,6 +75,7 @@ const useExamStore = create((set) => ({ setExerciseIndex: (exerciseIndex: number) => set(() => ({exerciseIndex})), setQuestionIndex: (questionIndex: number) => set(() => ({questionIndex})), setInactivity: (inactivity: number) => set(() => ({inactivity})), + setShuffleMaps: (shuffleMaps) => set(() => ({shuffleMaps})), reset: () => set(() => initialState), })); diff --git a/src/utils/stats.ts b/src/utils/stats.ts index ebd2f074..e60fcec7 100644 --- a/src/utils/stats.ts +++ b/src/utils/stats.ts @@ -137,5 +137,6 @@ export const convertToUserSolutions = (stats: Stat[]): UserSolution[] => { solutions: stat.solutions, type: stat.type, module: stat.module, + shuffleMaps: stat.shuffleMaps })); }; From 8669ef462d2a7a8e19186e2b14ce01f86aa57edc Mon Sep 17 00:00:00 2001 From: Carlos Mesquita Date: Mon, 19 Aug 2024 16:42:14 +0100 Subject: [PATCH 3/3] Commented all related to shuffle --- src/components/Exercises/FillBlanks/index.tsx | 16 +++--- src/components/Exercises/MultipleChoice.tsx | 22 ++++---- src/components/Solutions/MultipleChoice.tsx | 50 +++++++++++-------- src/exams/Level.tsx | 44 ++++++++-------- 4 files changed, 71 insertions(+), 61 deletions(-) diff --git a/src/components/Exercises/FillBlanks/index.tsx b/src/components/Exercises/FillBlanks/index.tsx index bd6a1c4c..19439791 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 } = useExamStore((state) => state); const [answers, setAnswers] = useState<{ id: string; solution: string }[]>(userSolutions); const hasExamEnded = useExamStore((state) => state.hasExamEnded); @@ -62,7 +62,7 @@ const FillBlanks: React.FC = ({ return solution.toLowerCase() === option.toLowerCase(); } else if ('letter' in option) { return solution.toLowerCase() === option.word.toLowerCase(); - } else if ('options' in option) { + } /*else if ('options' in option) { if (shuffleMaps.length !== 0) { const shuffleMap = shuffleMaps.find((map) => map.id == x.id) if (!shuffleMap) { @@ -73,7 +73,7 @@ const FillBlanks: React.FC = ({ } return solution.toLowerCase() === (option.options[x.solution as keyof typeof option.options] || '').toLowerCase(); - } + }*/ return false; }).length; @@ -129,7 +129,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 = { @@ -139,7 +139,7 @@ const FillBlanks: React.FC = ({ } } return shuffle; - } + }*/ return ( <> @@ -166,7 +166,7 @@ const FillBlanks: React.FC = ({
Options
- {currentMCSelection.selection?.options && Object.entries(currentMCSelection.selection.options).map(([key, value]) => { + {currentMCSelection.selection?.options && Object.entries(currentMCSelection.selection.options).sort((a, b) => a[0].localeCompare(b[0])).map(([key, value]) => { return diff --git a/src/components/Exercises/MultipleChoice.tsx b/src/components/Exercises/MultipleChoice.tsx index 1c07e9af..70d8a3ba 100644 --- a/src/components/Exercises/MultipleChoice.tsx +++ b/src/components/Exercises/MultipleChoice.tsx @@ -76,7 +76,7 @@ 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 { shuffleMaps } = useExamStore((state) => state); const { questionIndex, setQuestionIndex } = useExamStore((state) => state); const { userSolutions: storeUserSolutions, setUserSolutions } = useExamStore((state) => state); const hasExamEnded = useExamStore((state) => state.hasExamEnded); @@ -109,12 +109,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; @@ -122,7 +122,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 = { @@ -132,11 +132,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); } @@ -145,7 +145,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); } diff --git a/src/components/Solutions/MultipleChoice.tsx b/src/components/Solutions/MultipleChoice.tsx index 2c3f35e2..d3ecfabc 100644 --- a/src/components/Solutions/MultipleChoice.tsx +++ b/src/components/Solutions/MultipleChoice.tsx @@ -17,26 +17,36 @@ 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 shuffledOptions = ['A', 'B', 'C', 'D'].map(newId => { + const originalId = questionShuffleMap.map[newId]; + const originalOption = options.find(option => option.id === originalId); + return { + id: newId, + text: originalOption!.text + }; + }); + return shuffledOptions; + } + + const getShuffledSolution = (originalSolution: string, questionShuffleMap: ShuffleMap) => { + for (const [newPosition, originalPosition] of Object.entries(questionShuffleMap.map)) { + if (originalPosition === originalSolution) { + return newPosition; + } + } + return originalSolution; + } + const questionShuffleMap = userSolutions.reduce((foundMap, userSolution) => { if (foundMap) return foundMap; return userSolution.shuffleMaps?.find(map => map.id === id) || null; }, null as ShuffleMap | null); - - const shuffledOptions = new Array(options.length); - options.forEach(option => { - const newId = questionShuffleMap?.map[option.id]; - const newIndex = options.findIndex(opt => opt.id === newId); - shuffledOptions[newIndex] = option; - }); - - const lettersMap = ['A', 'B', 'C', 'D']; - const optionsWithLetters = shuffledOptions.map((option, index) => ({ - ...option, - id: lettersMap[index] - })); - - const questionOptions = questionShuffleMap ? optionsWithLetters : options; - const newQuestionSolution = questionShuffleMap ? questionShuffleMap.map[solution] : solution; + */ + + const questionOptions = options; // questionShuffleMap ? getShuffledOptions(options as {id: string, text: string}[], questionShuffleMap) : options; + const newSolution = solution; //questionShuffleMap ? getShuffledSolution(solution, questionShuffleMap) : solution; const renderPrompt = (prompt: string) => { return reactStringReplace(prompt, /(()[\w\s']+(<\/u>))/g, (match) => { @@ -46,11 +56,11 @@ function Question({ }; const optionColor = (option: string) => { - if (option === newQuestionSolution && !userSolution) { + if (option === newSolution && !userSolution) { return "!border-mti-gray-davy !text-mti-gray-davy"; } - if (option === newQuestionSolution) { + if (option === newSolution) { return "!border-mti-purple-light !text-mti-purple-light"; } @@ -77,8 +87,8 @@ function Question({ "flex flex-col items-center border border-mti-gray-platinum p-4 px-8 rounded-xl gap-4 cursor-pointer bg-white relative", optionColor(option!.id), )}> - {option?.id} - {`Option + {option?.id} + {"src" in option && {`Option}
))} {variant === "text" && diff --git a/src/exams/Level.tsx b/src/exams/Level.tsx index 2cd591f4..ea360929 100644 --- a/src/exams/Level.tsx +++ b/src/exams/Level.tsx @@ -171,7 +171,7 @@ export default function Level({ exam, showSolutions = false, onFinish, editing = 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 [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)); @@ -180,11 +180,11 @@ export default function Level({ exam, showSolutions = false, onFinish, editing = const [contextWord, setContextWord] = useState(undefined); const [contextWordLine, setContextWordLine] = useState(undefined); - useEffect(() => { + /*useEffect(() => { if (showSolutions && userSolutions[exerciseIndex].shuffleMaps) { setShuffleMaps(userSolutions[exerciseIndex].shuffleMaps as ShuffleMap[]) } - }, [showSolutions]) + }, [showSolutions])*/ useEffect(() => { if (hasExamEnded && exerciseIndex === -1) { @@ -214,8 +214,9 @@ export default function Level({ exam, showSolutions = false, onFinish, editing = userSolutions: userSolutions.find((x) => x.exercise === exercise.id)?.solutions || [], }; - if (exam.shuffle && exercise.type === "multipleChoice") { + /*if (exam.shuffle && exercise.type === "multipleChoice") { if (shuffleMaps.length == 0 && !showSolutions) { + console.log("Shuffling answers"); const newShuffleMaps: ShuffleMap[] = []; exercise.questions = exercise.questions.map(question => { @@ -242,6 +243,7 @@ export default function Level({ exam, showSolutions = false, onFinish, editing = setShuffleMaps(newShuffleMaps); } else { + console.log("Retrieving shuffles"); exercise.questions = exercise.questions.map(question => { const questionShuffleMap = shuffleMaps.find(map => map.id === question.id); if (questionShuffleMap) { @@ -261,20 +263,21 @@ export default function Level({ exam, showSolutions = false, onFinish, editing = exercise.words = exercise.words.map(word => { if ('options' in word) { const options = { ...word.options }; - const shuffledKeys = Object.keys(options).sort(() => Math.random() - 0.5); - + 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[shuffledKeys[index] as keyof typeof options]; + acc[key as keyof typeof options] = options[originalKeys[index] as keyof typeof options]; return acc; }, {} as { [key in keyof typeof options]: string }); - - const optionMapping = shuffledKeys.reduce((acc, key, index) => { - acc[key as keyof typeof options] = Object.keys(options)[index] as keyof typeof options; + + 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; @@ -283,20 +286,17 @@ export default function Level({ exam, showSolutions = false, onFinish, editing = setShuffleMaps(newShuffleMaps); } } - + */ return exercise; }; useEffect(() => { - const newExercise = getExercise(); - setCurrentExercise(newExercise); + //console.log("Getting another exercise"); + //setShuffleMaps([]); + setCurrentExercise(getExercise()); }, [partIndex, exerciseIndex]); - //useShuffledMultipleChoiceOptions(currentExercise, exam.shuffle, storeQuestionIndex, shuffleMaps, setShuffleMaps, setCurrentExercise); - //useShuffledFillBlanksOptions(currentExercise, exam.shuffle, storeQuestionIndex, shuffleMaps, setShuffleMaps, setCurrentExercise); - - useEffect(() => { const regex = /.*?['"](.*?)['"] in line (\d+)\?$/; if (currentExercise && currentExercise.type === "multipleChoice") { @@ -321,7 +321,7 @@ export default function Level({ exam, showSolutions = false, onFinish, editing = setContextWord(undefined); } } - }, [storeQuestionIndex, contextWordLine, exerciseIndex, partIndex, shuffleMaps]); + }, [storeQuestionIndex, contextWordLine, exerciseIndex, partIndex]); //, shuffleMaps]); const nextExercise = (solution?: UserSolution) => { scrollToTop(); @@ -362,9 +362,9 @@ export default function Level({ exam, showSolutions = false, onFinish, editing = if (solution) { let stat = { ...solution, module: "level" as Module, exam: exam.id } - if (exam.shuffle) { + /*if (exam.shuffle) { stat.shuffleMaps = shuffleMaps - } + }*/ onFinish([...userSolutions.filter((x) => x.exercise !== solution.exercise), { ...stat }]); } else { onFinish(userSolutions);