Merged develop into ENCOA-83_MasterStatistical
This commit is contained in:
2943
package-lock.json
generated
2943
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,56 +0,0 @@
|
|||||||
import {Dialog, Transition} from "@headlessui/react";
|
|
||||||
import {Fragment} from "react";
|
|
||||||
import Button from "./Low/Button";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
isOpen: boolean;
|
|
||||||
onClose: (next?: boolean) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function BlankQuestionsModal({isOpen, onClose}: Props) {
|
|
||||||
return (
|
|
||||||
<Transition show={isOpen} as={Fragment}>
|
|
||||||
<Dialog onClose={() => onClose(false)} className="relative z-50">
|
|
||||||
<Transition.Child
|
|
||||||
as={Fragment}
|
|
||||||
enter="ease-out duration-300"
|
|
||||||
enterFrom="opacity-0"
|
|
||||||
enterTo="opacity-100"
|
|
||||||
leave="ease-in duration-200"
|
|
||||||
leaveFrom="opacity-100"
|
|
||||||
leaveTo="opacity-0">
|
|
||||||
<div className="fixed inset-0 bg-black/30" />
|
|
||||||
</Transition.Child>
|
|
||||||
|
|
||||||
<Transition.Child
|
|
||||||
as={Fragment}
|
|
||||||
enter="ease-out duration-300"
|
|
||||||
enterFrom="opacity-0 scale-95"
|
|
||||||
enterTo="opacity-100 scale-100"
|
|
||||||
leave="ease-in duration-200"
|
|
||||||
leaveFrom="opacity-100 scale-100"
|
|
||||||
leaveTo="opacity-0 scale-95">
|
|
||||||
<div className="fixed inset-0 flex items-center justify-center p-4">
|
|
||||||
<Dialog.Panel className="w-full max-w-2xl h-fit p-8 rounded-xl bg-white flex flex-col gap-4">
|
|
||||||
<Dialog.Title className="font-bold text-xl">Questions Unanswered</Dialog.Title>
|
|
||||||
<span>
|
|
||||||
Please note that you are finishing the current module and once you proceed to the next module, you will no longer be
|
|
||||||
able to change the answers in the current one, including your unanswered questions. <br />
|
|
||||||
<br />
|
|
||||||
Are you sure you want to continue without completing those questions?
|
|
||||||
</span>
|
|
||||||
<div className="w-full flex justify-between mt-8">
|
|
||||||
<Button color="purple" onClick={() => onClose(false)} variant="outline" className="max-w-[200px] self-end w-full">
|
|
||||||
Go Back
|
|
||||||
</Button>
|
|
||||||
<Button color="purple" onClick={() => onClose(true)} className="max-w-[200px] self-end w-full">
|
|
||||||
Continue
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</Dialog.Panel>
|
|
||||||
</div>
|
|
||||||
</Transition.Child>
|
|
||||||
</Dialog>
|
|
||||||
</Transition>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -20,7 +20,7 @@ const FillBlanks: React.FC<FillBlanksExercise & CommonProps> = ({
|
|||||||
onNext,
|
onNext,
|
||||||
onBack,
|
onBack,
|
||||||
}) => {
|
}) => {
|
||||||
//const { shuffleMaps } = useExamStore((state) => state);
|
const { shuffleMaps, exam, partIndex, questionIndex, exerciseIndex } = useExamStore((state) => state);
|
||||||
const [answers, setAnswers] = useState<{ id: string; solution: string }[]>(userSolutions);
|
const [answers, setAnswers] = useState<{ id: string; solution: string }[]>(userSolutions);
|
||||||
const hasExamEnded = useExamStore((state) => state.hasExamEnded);
|
const hasExamEnded = useExamStore((state) => state.hasExamEnded);
|
||||||
|
|
||||||
@@ -41,19 +41,24 @@ const FillBlanks: React.FC<FillBlanksExercise & CommonProps> = ({
|
|||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [hasExamEnded]);
|
}, [hasExamEnded]);
|
||||||
|
|
||||||
|
|
||||||
|
let correctWords: any;
|
||||||
|
if (exam && exam.module === "level" && exam.parts[partIndex].exercises[exerciseIndex].type === "fillBlanks") {
|
||||||
|
correctWords = (exam.parts[partIndex].exercises[exerciseIndex] as FillBlanksExercise).words;
|
||||||
|
}
|
||||||
|
|
||||||
const calculateScore = () => {
|
const calculateScore = () => {
|
||||||
const total = text.match(/({{\d+}})/g)?.length || 0;
|
const total = text.match(/({{\d+}})/g)?.length || 0;
|
||||||
const correct = userSolutions.filter((x) => {
|
const correct = answers!.filter((x) => {
|
||||||
const solution = solutions.find((y) => x.id.toString() === y.id.toString())?.solution;
|
const solution = solutions.find((y) => x.id.toString() === y.id.toString())?.solution;
|
||||||
if (!solution) return false;
|
if (!solution) return false;
|
||||||
|
const option = correctWords!.find((w: any) => {
|
||||||
const option = words.find((w) => {
|
|
||||||
if (typeof w === "string") {
|
if (typeof w === "string") {
|
||||||
return w.toLowerCase() === x.solution.toLowerCase();
|
return w.toLowerCase() === x.solution.toLowerCase();
|
||||||
} else if ('letter' in w) {
|
} else if ('letter' in w) {
|
||||||
return w.word.toLowerCase() === x.solution.toLowerCase();
|
return w.word.toLowerCase() === x.solution.toLowerCase();
|
||||||
} else {
|
} else {
|
||||||
return w.id === x.id;
|
return w.id.toString() === x.id.toString();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
if (!option) return false;
|
if (!option) return false;
|
||||||
@@ -63,25 +68,13 @@ const FillBlanks: React.FC<FillBlanksExercise & CommonProps> = ({
|
|||||||
} else if ('letter' in option) {
|
} else if ('letter' in option) {
|
||||||
return solution.toLowerCase() === option.word.toLowerCase();
|
return solution.toLowerCase() === option.word.toLowerCase();
|
||||||
} else if ('options' in option) {
|
} else if ('options' in option) {
|
||||||
/*
|
return option.options[solution as keyof typeof option.options] == x.solution;
|
||||||
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;
|
return false;
|
||||||
}).length;
|
}).length;
|
||||||
|
const missing = total - answers!.filter((x) => solutions.find((y) => x.id.toString() === y.id.toString())).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 renderLines = (line: string) => {
|
const renderLines = (line: string) => {
|
||||||
return (
|
return (
|
||||||
<div className="text-base leading-5">
|
<div className="text-base leading-5">
|
||||||
@@ -132,7 +125,7 @@ const FillBlanks: React.FC<FillBlanksExercise & CommonProps> = ({
|
|||||||
setAnswers((prev) => [...prev.filter((x) => x.id !== id), { id: id, solution: value }]);
|
setAnswers((prev) => [...prev.filter((x) => x.id !== id), { id: id, solution: value }]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/*const getShuffles = () => {
|
const getShuffles = () => {
|
||||||
let shuffle = {};
|
let shuffle = {};
|
||||||
if (shuffleMaps.length !== 0) {
|
if (shuffleMaps.length !== 0) {
|
||||||
shuffle = {
|
shuffle = {
|
||||||
@@ -142,7 +135,7 @@ const FillBlanks: React.FC<FillBlanksExercise & CommonProps> = ({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
return shuffle;
|
return shuffle;
|
||||||
}*/
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -227,14 +220,18 @@ const FillBlanks: React.FC<FillBlanksExercise & CommonProps> = ({
|
|||||||
<Button
|
<Button
|
||||||
color="purple"
|
color="purple"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() => onBack({ exercise: id, solutions: answers, score: calculateScore(), type, })}//...getShuffles() })}
|
onClick={() => onBack({ exercise: id, solutions: answers, score: calculateScore(), type, ...getShuffles() })}
|
||||||
className="max-w-[200px] w-full">
|
className="max-w-[200px] w-full"
|
||||||
|
disabled={
|
||||||
|
exam && typeof partIndex !== "undefined" && exam.module === "level" &&
|
||||||
|
typeof exam.parts[0].intro === "string" && questionIndex === 0}
|
||||||
|
>
|
||||||
Back
|
Back
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
color="purple"
|
color="purple"
|
||||||
onClick={() => onNext({ exercise: id, solutions: answers, score: calculateScore(), type, })}//...getShuffles() })}
|
onClick={() => onNext({ exercise: id, solutions: answers, score: calculateScore(), type, ...getShuffles() })}
|
||||||
className="max-w-[200px] self-end w-full">
|
className="max-w-[200px] self-end w-full">
|
||||||
Next
|
Next
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { useEffect, useState } from "react";
|
|||||||
import reactStringReplace from "react-string-replace";
|
import reactStringReplace from "react-string-replace";
|
||||||
import { CommonProps } from ".";
|
import { CommonProps } from ".";
|
||||||
import Button from "../Low/Button";
|
import Button from "../Low/Button";
|
||||||
|
import { v4 } from "uuid";
|
||||||
|
|
||||||
function Question({
|
function Question({
|
||||||
id,
|
id,
|
||||||
@@ -30,9 +31,9 @@ function Question({
|
|||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-8">
|
<div className="flex flex-col gap-8">
|
||||||
{isNaN(Number(id)) ? (
|
{isNaN(Number(id)) ? (
|
||||||
<span className={clsx(true ? "text-lg" : "text-base")}>{renderPrompt(prompt).filter((x) => x?.toString() !== "<u>")}</span>
|
<span className="text-lg">{renderPrompt(prompt).filter((x) => x?.toString() !== "<u>")}</span>
|
||||||
) : (
|
) : (
|
||||||
<span className={clsx(true ? "text-lg" : "text-base")}>
|
<span className="text-lg">
|
||||||
<>
|
<>
|
||||||
{id} - <span>{renderPrompt(prompt).filter((x) => x?.toString() !== "<u>")}</span>
|
{id} - <span>{renderPrompt(prompt).filter((x) => x?.toString() !== "<u>")}</span>
|
||||||
</>
|
</>
|
||||||
@@ -42,10 +43,10 @@ function Question({
|
|||||||
{variant === "image" &&
|
{variant === "image" &&
|
||||||
options.map((option) => (
|
options.map((option) => (
|
||||||
<div
|
<div
|
||||||
key={option.id.toString()}
|
key={v4()}
|
||||||
onClick={() => (onSelectOption ? onSelectOption(option.id.toString()) : null)}
|
onClick={() => (onSelectOption ? onSelectOption(option.id.toString()) : null)}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"flex flex-col items-center border border-mti-gray-platinum p-4 px-8 rounded-xl gap-4 cursor-pointer bg-white relative",
|
"flex flex-col items-center border border-mti-gray-platinum p-4 px-8 rounded-xl gap-4 cursor-pointer bg-white relative select-none",
|
||||||
userSolution === option.id.toString() && "border-mti-purple-light",
|
userSolution === option.id.toString() && "border-mti-purple-light",
|
||||||
)}>
|
)}>
|
||||||
<span className={clsx("text-sm", userSolution !== option.id.toString() && "opacity-50")}>{option.id.toString()}</span>
|
<span className={clsx("text-sm", userSolution !== option.id.toString() && "opacity-50")}>{option.id.toString()}</span>
|
||||||
@@ -55,10 +56,10 @@ function Question({
|
|||||||
{variant === "text" &&
|
{variant === "text" &&
|
||||||
options.map((option) => (
|
options.map((option) => (
|
||||||
<div
|
<div
|
||||||
key={option.id.toString()}
|
key={v4()}
|
||||||
onClick={() => (onSelectOption ? onSelectOption(option.id.toString()) : null)}
|
onClick={() => (onSelectOption ? onSelectOption(option.id.toString()) : null)}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"flex border p-4 rounded-xl gap-2 cursor-pointer bg-white text-base",
|
"flex border p-4 rounded-xl gap-2 cursor-pointer bg-white text-base select-none",
|
||||||
userSolution === option.id.toString() && "border-mti-purple-light",
|
userSolution === option.id.toString() && "border-mti-purple-light",
|
||||||
)}>
|
)}>
|
||||||
<span className="font-semibold">{option.id.toString()}.</span>
|
<span className="font-semibold">{option.id.toString()}.</span>
|
||||||
@@ -73,10 +74,15 @@ function Question({
|
|||||||
export default function MultipleChoice({ id, prompt, type, questions, userSolutions, onNext, onBack }: MultipleChoiceExercise & CommonProps) {
|
export default function MultipleChoice({ id, prompt, type, questions, userSolutions, onNext, onBack }: MultipleChoiceExercise & CommonProps) {
|
||||||
const [answers, setAnswers] = useState<{ question: string; option: string }[]>(userSolutions);
|
const [answers, setAnswers] = useState<{ question: string; option: string }[]>(userSolutions);
|
||||||
|
|
||||||
//const { shuffleMaps } = useExamStore((state) => state);
|
const {
|
||||||
const { questionIndex, setQuestionIndex } = useExamStore((state) => state);
|
questionIndex,
|
||||||
const { userSolutions: storeUserSolutions, setUserSolutions } = useExamStore((state) => state);
|
exam,
|
||||||
const hasExamEnded = useExamStore((state) => state.hasExamEnded);
|
shuffleMaps,
|
||||||
|
hasExamEnded,
|
||||||
|
userSolutions: storeUserSolutions,
|
||||||
|
setQuestionIndex,
|
||||||
|
setUserSolutions
|
||||||
|
} = useExamStore((state) => state);
|
||||||
|
|
||||||
const scrollToTop = () => Array.from(document.getElementsByTagName("body")).forEach((body) => body.scrollTo(0, 0));
|
const scrollToTop = () => Array.from(document.getElementsByTagName("body")).forEach((body) => body.scrollTo(0, 0));
|
||||||
|
|
||||||
@@ -106,12 +112,12 @@ export default function MultipleChoice({ id, prompt, type, questions, userSoluti
|
|||||||
});
|
});
|
||||||
|
|
||||||
let isSolutionCorrect;
|
let isSolutionCorrect;
|
||||||
//if (shuffleMaps.length == 0) {
|
if (shuffleMaps.length == 0) {
|
||||||
isSolutionCorrect = matchingQuestion?.solution === x.option;
|
isSolutionCorrect = matchingQuestion?.solution === x.option;
|
||||||
//} else {
|
} else {
|
||||||
// const shuffleMap = shuffleMaps.find((map) => map.id == x.question)
|
const shuffleMap = shuffleMaps.find((map) => map.id == x.question)
|
||||||
// isSolutionCorrect = shuffleMap?.map[x.option] == matchingQuestion?.solution;
|
isSolutionCorrect = shuffleMap?.map[x.option] == matchingQuestion?.solution;
|
||||||
//}
|
}
|
||||||
return isSolutionCorrect || false;
|
return isSolutionCorrect || false;
|
||||||
}).length;
|
}).length;
|
||||||
const missing = total - correct;
|
const missing = total - correct;
|
||||||
@@ -119,7 +125,7 @@ export default function MultipleChoice({ id, prompt, type, questions, userSoluti
|
|||||||
return { total, correct, missing };
|
return { total, correct, missing };
|
||||||
};
|
};
|
||||||
|
|
||||||
/*const getShuffles = () => {
|
const getShuffles = () => {
|
||||||
let shuffle = {};
|
let shuffle = {};
|
||||||
if (shuffleMaps.length !== 0) {
|
if (shuffleMaps.length !== 0) {
|
||||||
shuffle = {
|
shuffle = {
|
||||||
@@ -129,11 +135,11 @@ export default function MultipleChoice({ id, prompt, type, questions, userSoluti
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
return shuffle;
|
return shuffle;
|
||||||
}*/
|
}
|
||||||
|
|
||||||
const next = () => {
|
const next = () => {
|
||||||
if (questionIndex === questions.length - 1) {
|
if (questionIndex === questions.length - 1) {
|
||||||
onNext({ exercise: id, solutions: answers, score: calculateScore(), type, });//...getShuffles() });
|
onNext({ exercise: id, solutions: answers, score: calculateScore(), type, ...getShuffles() });
|
||||||
} else {
|
} else {
|
||||||
setQuestionIndex(questionIndex + 1);
|
setQuestionIndex(questionIndex + 1);
|
||||||
}
|
}
|
||||||
@@ -142,7 +148,7 @@ export default function MultipleChoice({ id, prompt, type, questions, userSoluti
|
|||||||
|
|
||||||
const back = () => {
|
const back = () => {
|
||||||
if (questionIndex === 0) {
|
if (questionIndex === 0) {
|
||||||
onBack({ exercise: id, solutions: answers, score: calculateScore(), type, });// ...getShuffles() });
|
onBack({ exercise: id, solutions: answers, score: calculateScore(), type, ...getShuffles() });
|
||||||
} else {
|
} else {
|
||||||
setQuestionIndex(questionIndex - 1);
|
setQuestionIndex(questionIndex - 1);
|
||||||
}
|
}
|
||||||
@@ -164,7 +170,10 @@ export default function MultipleChoice({ id, prompt, type, questions, userSoluti
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="self-end flex justify-between w-full gap-8 absolute bottom-8 left-0 px-8">
|
<div className="self-end flex justify-between w-full gap-8 absolute bottom-8 left-0 px-8">
|
||||||
<Button color="purple" variant="outline" onClick={back} className="max-w-[200px] w-full">
|
<Button color="purple" variant="outline" onClick={back} className="max-w-[200px] w-full"
|
||||||
|
disabled={
|
||||||
|
exam && exam.module === "level" && typeof exam.parts[0].intro === "string" && questionIndex === 0}
|
||||||
|
>
|
||||||
Back
|
Back
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
|
|||||||
@@ -11,14 +11,15 @@ interface Props {
|
|||||||
className?: string;
|
className?: string;
|
||||||
navDisabled?: boolean;
|
navDisabled?: boolean;
|
||||||
focusMode?: boolean;
|
focusMode?: boolean;
|
||||||
|
bgColor?: string;
|
||||||
onFocusLayerMouseEnter?: () => void;
|
onFocusLayerMouseEnter?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Layout({user, children, className, navDisabled = false, focusMode = false, onFocusLayerMouseEnter}: Props) {
|
export default function Layout({user, children, className, bgColor="bg-white", navDisabled = false, focusMode = false, onFocusLayerMouseEnter}: Props) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="w-full min-h-full h-screen flex flex-col bg-mti-gray-smoke relative">
|
<main className={clsx("w-full min-h-full h-screen flex flex-col bg-mti-gray-smoke relative")}>
|
||||||
<Navbar
|
<Navbar
|
||||||
path={router.pathname}
|
path={router.pathname}
|
||||||
user={user}
|
user={user}
|
||||||
@@ -37,7 +38,8 @@ export default function Layout({user, children, className, navDisabled = false,
|
|||||||
/>
|
/>
|
||||||
<div
|
<div
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"w-full min-h-full h-fit md:mr-8 bg-white shadow-md rounded-2xl p-4 xl:p-10 pb-8 flex flex-col gap-8 relative overflow-hidden mt-2",
|
`w-full min-h-full md:mr-8 ${bgColor} shadow-md rounded-2xl p-4 xl:p-10 pb-8 flex flex-col gap-8 relative overflow-hidden mt-2`,
|
||||||
|
bgColor !== "bg-white" ? "justify-center" : "h-fit",
|
||||||
className,
|
className,
|
||||||
)}>
|
)}>
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {ReactNode, useEffect, useState} from "react";
|
|||||||
import {BsBook, BsClipboard, BsHeadphones, BsMegaphone, BsPen, BsStopwatch} from "react-icons/bs";
|
import {BsBook, BsClipboard, BsHeadphones, BsMegaphone, BsPen, BsStopwatch} from "react-icons/bs";
|
||||||
import ProgressBar from "../Low/ProgressBar";
|
import ProgressBar from "../Low/ProgressBar";
|
||||||
import TimerEndedModal from "../TimerEndedModal";
|
import TimerEndedModal from "../TimerEndedModal";
|
||||||
|
import Timer from "./Timer";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
minTimer: number;
|
minTimer: number;
|
||||||
@@ -16,35 +17,12 @@ interface Props {
|
|||||||
totalExercises: number;
|
totalExercises: number;
|
||||||
disableTimer?: boolean;
|
disableTimer?: boolean;
|
||||||
partLabel?: string;
|
partLabel?: string;
|
||||||
|
showTimer?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ModuleTitle({minTimer, module, label, exerciseIndex, totalExercises, disableTimer = false, partLabel}: Props) {
|
export default function ModuleTitle({
|
||||||
const [timer, setTimer] = useState(minTimer * 60);
|
minTimer, module, label, exerciseIndex, totalExercises, disableTimer = false, partLabel, showTimer = true
|
||||||
const [showModal, setShowModal] = useState(false);
|
}: Props) {
|
||||||
const [warningMode, setWarningMode] = useState(false);
|
|
||||||
|
|
||||||
const setHasExamEnded = useExamStore((state) => state.setHasExamEnded);
|
|
||||||
const {timeSpent} = useExamStore((state) => state);
|
|
||||||
|
|
||||||
useEffect(() => setTimer((prev) => prev - timeSpent), [timeSpent]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!disableTimer) {
|
|
||||||
const timerInterval = setInterval(() => setTimer((prev) => prev - 1), 1000);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
clearInterval(timerInterval);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}, [disableTimer, minTimer]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (timer <= 0) setShowModal(true);
|
|
||||||
}, [timer]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (timer < 300 && !warningMode) setWarningMode(true);
|
|
||||||
}, [timer, warningMode]);
|
|
||||||
|
|
||||||
const moduleIcon: {[key in Module]: ReactNode} = {
|
const moduleIcon: {[key in Module]: ReactNode} = {
|
||||||
reading: <BsBook className="text-ielts-reading w-6 h-6" />,
|
reading: <BsBook className="text-ielts-reading w-6 h-6" />,
|
||||||
@@ -56,37 +34,7 @@ export default function ModuleTitle({minTimer, module, label, exerciseIndex, tot
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<TimerEndedModal
|
{showTimer && <Timer minTimer={minTimer} disableTimer={disableTimer} />}
|
||||||
isOpen={showModal}
|
|
||||||
onClose={() => {
|
|
||||||
setHasExamEnded(true);
|
|
||||||
setShowModal(false);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<motion.div
|
|
||||||
className={clsx(
|
|
||||||
"absolute top-4 right-6 bg-mti-gray-seasalt px-4 py-3 flex items-center gap-2 rounded-full text-mti-gray-davy",
|
|
||||||
warningMode && !disableTimer && "bg-mti-red-light text-mti-gray-seasalt",
|
|
||||||
)}
|
|
||||||
initial={{scale: warningMode && !disableTimer ? 0.8 : 1}}
|
|
||||||
animate={{scale: warningMode && !disableTimer ? 1.1 : 1}}
|
|
||||||
transition={{repeat: Infinity, repeatType: "reverse", duration: 0.5, ease: "easeInOut"}}>
|
|
||||||
<BsStopwatch className="w-6 h-6" />
|
|
||||||
<span className="text-base font-semibold w-12">
|
|
||||||
{timer > 0 && (
|
|
||||||
<>
|
|
||||||
{Math.floor(timer / 60)
|
|
||||||
.toString(10)
|
|
||||||
.padStart(2, "0")}
|
|
||||||
:
|
|
||||||
{Math.floor(timer % 60)
|
|
||||||
.toString(10)
|
|
||||||
.padStart(2, "0")}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{timer <= 0 && <>00:00</>}
|
|
||||||
</span>
|
|
||||||
</motion.div>
|
|
||||||
<div className="w-full">
|
<div className="w-full">
|
||||||
{partLabel && (
|
{partLabel && (
|
||||||
<div className="text-3xl space-y-4">
|
<div className="text-3xl space-y-4">
|
||||||
|
|||||||
80
src/components/Medium/Timer.tsx
Normal file
80
src/components/Medium/Timer.tsx
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
import useExamStore from "@/stores/examStore";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { motion } from "framer-motion";
|
||||||
|
import TimerEndedModal from "../TimerEndedModal";
|
||||||
|
import clsx from "clsx";
|
||||||
|
import { BsStopwatch } from "react-icons/bs";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
minTimer: number;
|
||||||
|
disableTimer?: boolean;
|
||||||
|
standalone?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Timer: React.FC<Props> = ({minTimer, disableTimer, standalone = false}) => {
|
||||||
|
const [timer, setTimer] = useState(minTimer * 60);
|
||||||
|
const [showModal, setShowModal] = useState(false);
|
||||||
|
const [warningMode, setWarningMode] = useState(false);
|
||||||
|
|
||||||
|
const setHasExamEnded = useExamStore((state) => state.setHasExamEnded);
|
||||||
|
const { timeSpent } = useExamStore((state) => state);
|
||||||
|
|
||||||
|
useEffect(() => setTimer((prev) => prev - timeSpent), [timeSpent]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!disableTimer) {
|
||||||
|
const timerInterval = setInterval(() => setTimer((prev) => prev - 1), 1000);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
clearInterval(timerInterval);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}, [disableTimer, minTimer]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (timer <= 0) setShowModal(true);
|
||||||
|
}, [timer]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (timer < 300 && !warningMode) setWarningMode(true);
|
||||||
|
}, [timer, warningMode]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<TimerEndedModal
|
||||||
|
isOpen={showModal}
|
||||||
|
onClose={() => {
|
||||||
|
setHasExamEnded(true);
|
||||||
|
setShowModal(false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<motion.div
|
||||||
|
className={clsx(
|
||||||
|
"absolute right-6 bg-mti-gray-seasalt px-4 py-3 flex items-center gap-2 rounded-full text-mti-gray-davy",
|
||||||
|
standalone ? "top-6" : "top-4",
|
||||||
|
warningMode && !disableTimer && "bg-mti-red-light text-mti-gray-seasalt",
|
||||||
|
)}
|
||||||
|
initial={{ scale: warningMode && !disableTimer ? 0.8 : 1 }}
|
||||||
|
animate={{ scale: warningMode && !disableTimer ? 1.1 : 1 }}
|
||||||
|
transition={{ repeat: Infinity, repeatType: "reverse", duration: 0.5, ease: "easeInOut" }}>
|
||||||
|
<BsStopwatch className="w-6 h-6" />
|
||||||
|
<span className="text-base font-semibold w-12">
|
||||||
|
{timer > 0 && (
|
||||||
|
<>
|
||||||
|
{Math.floor(timer / 60)
|
||||||
|
.toString(10)
|
||||||
|
.padStart(2, "0")}
|
||||||
|
:
|
||||||
|
{Math.floor(timer % 60)
|
||||||
|
.toString(10)
|
||||||
|
.padStart(2, "0")}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{timer <= 0 && <>00:00</>}
|
||||||
|
</span>
|
||||||
|
</motion.div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Timer;
|
||||||
80
src/components/QuestionsModal.tsx
Normal file
80
src/components/QuestionsModal.tsx
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
import { Dialog, Transition } from "@headlessui/react";
|
||||||
|
import { Fragment } from "react";
|
||||||
|
import Button from "./Low/Button";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
isOpen: boolean;
|
||||||
|
blankQuestions?: boolean;
|
||||||
|
finishingWhat? : string;
|
||||||
|
onClose: (next?: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function QuestionsModal({ isOpen, onClose, blankQuestions = true, finishingWhat = "module" }: Props) {
|
||||||
|
return (
|
||||||
|
<Transition show={isOpen} as={Fragment}>
|
||||||
|
<Dialog onClose={() => onClose(false)} className="relative z-50">
|
||||||
|
<Transition.Child
|
||||||
|
as={Fragment}
|
||||||
|
enter="ease-out duration-300"
|
||||||
|
enterFrom="opacity-0"
|
||||||
|
enterTo="opacity-100"
|
||||||
|
leave="ease-in duration-200"
|
||||||
|
leaveFrom="opacity-100"
|
||||||
|
leaveTo="opacity-0">
|
||||||
|
<div className="fixed inset-0 bg-black/30" />
|
||||||
|
</Transition.Child>
|
||||||
|
|
||||||
|
<Transition.Child
|
||||||
|
as={Fragment}
|
||||||
|
enter="ease-out duration-300"
|
||||||
|
enterFrom="opacity-0 scale-95"
|
||||||
|
enterTo="opacity-100 scale-100"
|
||||||
|
leave="ease-in duration-200"
|
||||||
|
leaveFrom="opacity-100 scale-100"
|
||||||
|
leaveTo="opacity-0 scale-95">
|
||||||
|
<div className="fixed inset-0 flex items-center justify-center p-4">
|
||||||
|
<Dialog.Panel className="w-full max-w-2xl h-fit p-8 rounded-xl bg-white flex flex-col gap-4">
|
||||||
|
{blankQuestions ? (
|
||||||
|
<>
|
||||||
|
<Dialog.Title className="font-bold text-xl">Questions Unanswered</Dialog.Title>
|
||||||
|
<span>
|
||||||
|
Please note that you are finishing the current {finishingWhat} and once you proceed to the next {finishingWhat}, you will no longer be
|
||||||
|
able to change the answers of the current one, including your unanswered questions. <br />
|
||||||
|
<br />
|
||||||
|
Are you sure you want to continue without completing those questions?
|
||||||
|
</span>
|
||||||
|
<div className="w-full flex justify-between mt-8">
|
||||||
|
<Button color="purple" onClick={() => onClose(false)} variant="outline" className="max-w-[200px] self-end w-full">
|
||||||
|
Go Back
|
||||||
|
</Button>
|
||||||
|
<Button color="purple" onClick={() => onClose(true)} className="max-w-[200px] self-end w-full">
|
||||||
|
Continue
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
): (
|
||||||
|
<>
|
||||||
|
<Dialog.Title className="font-bold text-xl">Confirm Submission</Dialog.Title>
|
||||||
|
<span>
|
||||||
|
Please note that you are finishing the current {finishingWhat} and once you proceed to the next {finishingWhat}, you will no longer be
|
||||||
|
able to review the answers of the current one. <br />
|
||||||
|
<br />
|
||||||
|
Are you sure you want to continue?
|
||||||
|
</span>
|
||||||
|
<div className="w-full flex justify-between mt-8">
|
||||||
|
<Button color="purple" onClick={() => onClose(false)} variant="outline" className="max-w-[200px] self-end w-full">
|
||||||
|
Go Back
|
||||||
|
</Button>
|
||||||
|
<Button color="purple" onClick={() => onClose(true)} className="max-w-[200px] self-end w-full">
|
||||||
|
Continue
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Dialog.Panel>
|
||||||
|
</div>
|
||||||
|
</Transition.Child>
|
||||||
|
</Dialog>
|
||||||
|
</Transition>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import reactStringReplace from "react-string-replace";
|
|||||||
import { CommonProps } from ".";
|
import { CommonProps } from ".";
|
||||||
import { Fragment } from "react";
|
import { Fragment } from "react";
|
||||||
import Button from "../Low/Button";
|
import Button from "../Low/Button";
|
||||||
|
import useExamStore from "@/stores/examStore";
|
||||||
|
|
||||||
export default function FillBlanksSolutions({
|
export default function FillBlanksSolutions({
|
||||||
id,
|
id,
|
||||||
@@ -12,14 +13,22 @@ export default function FillBlanksSolutions({
|
|||||||
solutions,
|
solutions,
|
||||||
words,
|
words,
|
||||||
text,
|
text,
|
||||||
userSolutions,
|
|
||||||
onNext,
|
onNext,
|
||||||
onBack,
|
onBack,
|
||||||
}: FillBlanksExercise & CommonProps) {
|
}: FillBlanksExercise & CommonProps) {
|
||||||
|
|
||||||
|
// next and back was all messed up and still don't know why, anyways
|
||||||
|
const storeUserSolutions = useExamStore((state) => state.userSolutions);
|
||||||
|
|
||||||
|
const correctUserSolutions = storeUserSolutions.find(
|
||||||
|
(solution) => solution.exercise === id
|
||||||
|
)?.solutions;
|
||||||
|
|
||||||
const calculateScore = () => {
|
const calculateScore = () => {
|
||||||
const total = text.match(/({{\d+}})/g)?.length || 0;
|
const total = text.match(/({{\d+}})/g)?.length || 0;
|
||||||
const correct = userSolutions.filter((x) => {
|
const correct = correctUserSolutions!.filter((x) => {
|
||||||
const solution = solutions.find((y) => x.id.toString() === y.id.toString())?.solution;
|
const solution = solutions.find((y) => x.id.toString() === y.id.toString())?.solution;
|
||||||
|
console.log(solution);
|
||||||
if (!solution) return false;
|
if (!solution) return false;
|
||||||
|
|
||||||
const option = words.find((w) => {
|
const option = words.find((w) => {
|
||||||
@@ -28,7 +37,7 @@ export default function FillBlanksSolutions({
|
|||||||
} else if ('letter' in w) {
|
} else if ('letter' in w) {
|
||||||
return w.word.toLowerCase() === x.solution.toLowerCase();
|
return w.word.toLowerCase() === x.solution.toLowerCase();
|
||||||
} else {
|
} else {
|
||||||
return w.id === x.id;
|
return w.id.toString() === x.id.toString();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
if (!option) return false;
|
if (!option) return false;
|
||||||
@@ -42,9 +51,7 @@ export default function FillBlanksSolutions({
|
|||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
}).length;
|
}).length;
|
||||||
|
const missing = total - correctUserSolutions!.filter((x) => solutions.find((y) => x.id.toString() === y.id.toString())).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 };
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -60,16 +67,27 @@ export default function FillBlanksSolutions({
|
|||||||
<span>
|
<span>
|
||||||
{reactStringReplace(line, /({{\d+}})/g, (match) => {
|
{reactStringReplace(line, /({{\d+}})/g, (match) => {
|
||||||
const id = match.replaceAll(/[\{\}]/g, "");
|
const id = match.replaceAll(/[\{\}]/g, "");
|
||||||
const userSolution = userSolutions.find((x) => x.id === id);
|
const userSolution = correctUserSolutions!.find((x) => x.id.toString() === id.toString());
|
||||||
const solution = solutions.find((x) => x.id === id)!;
|
const answerSolution = solutions.find(sol => sol.id.toString() === id.toString())!.solution;
|
||||||
|
|
||||||
if (!userSolution) {
|
if (!userSolution) {
|
||||||
|
let answerText;
|
||||||
|
if (typeCheckWordsMC(words)) {
|
||||||
|
const options = words.find((x) => x.id.toString() === id.toString());
|
||||||
|
const correctKey = Object.keys(options!.options).find(key =>
|
||||||
|
key.toLowerCase() === answerSolution.toLowerCase()
|
||||||
|
);
|
||||||
|
answerText = options!.options[correctKey as keyof typeof options];
|
||||||
|
} else {
|
||||||
|
answerText = answerSolution;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"rounded-full hover:text-white hover:bg-mti-gray-davy transition duration-300 ease-in-out my-1 px-5 py-2 text-center text-white bg-mti-gray-davy",
|
"rounded-full hover:text-white hover:bg-mti-gray-davy transition duration-300 ease-in-out my-1 px-5 py-2 text-center text-white bg-mti-gray-davy",
|
||||||
)}>
|
)}>
|
||||||
{solution?.solution}
|
{answerText}
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -96,21 +114,21 @@ export default function FillBlanksSolutions({
|
|||||||
let correct;
|
let correct;
|
||||||
let solutionText;
|
let solutionText;
|
||||||
if (typeCheckWordsMC(words)) {
|
if (typeCheckWordsMC(words)) {
|
||||||
const options = words.find((x) => x.id === id);
|
const options = words.find((x) => x.id.toString() === id.toString());
|
||||||
if (options) {
|
if (options) {
|
||||||
const correctKey = Object.keys(options.options).find(key =>
|
const correctKey = Object.keys(options.options).find(key =>
|
||||||
key.toLowerCase() === solution.solution.toLowerCase()
|
key.toLowerCase() === answerSolution.toLowerCase()
|
||||||
);
|
);
|
||||||
correct = userSolution.solution == options.options[correctKey as keyof typeof options.options];
|
correct = userSolution.solution == options.options[correctKey as keyof typeof options.options];
|
||||||
solutionText = options.options[correctKey as keyof typeof options.options] || solution.solution;
|
solutionText = options.options[correctKey as keyof typeof options.options] || answerSolution;
|
||||||
} else {
|
} else {
|
||||||
correct = false;
|
correct = false;
|
||||||
solutionText = solution?.solution;
|
solutionText = answerSolution;
|
||||||
}
|
}
|
||||||
|
|
||||||
} else {
|
} else {
|
||||||
correct = userSolutionText === solution.solution;
|
correct = userSolutionText === answerSolution;
|
||||||
solutionText = solution.solution;
|
solutionText = answerSolution;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (correct) {
|
if (correct) {
|
||||||
@@ -152,16 +170,8 @@ export default function FillBlanksSolutions({
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="flex flex-col gap-4 mt-4 h-full w-full mb-20">
|
<div className="flex flex-col gap-4 mt-4 h-full w-full mb-20">
|
||||||
<span className="text-sm w-full leading-6">
|
|
||||||
{prompt.split("\\n").map((line, index) => (
|
|
||||||
<Fragment key={index}>
|
|
||||||
{line}
|
|
||||||
<br />
|
|
||||||
</Fragment>
|
|
||||||
))}
|
|
||||||
</span>
|
|
||||||
<span className="bg-mti-gray-smoke rounded-xl px-5 py-6">
|
<span className="bg-mti-gray-smoke rounded-xl px-5 py-6">
|
||||||
{userSolutions &&
|
{correctUserSolutions &&
|
||||||
text.split("\\n").map((line, index) => (
|
text.split("\\n").map((line, index) => (
|
||||||
<p key={index}>
|
<p key={index}>
|
||||||
{renderLines(line)}
|
{renderLines(line)}
|
||||||
@@ -189,14 +199,14 @@ export default function FillBlanksSolutions({
|
|||||||
<Button
|
<Button
|
||||||
color="purple"
|
color="purple"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() => onBack({ exercise: id, solutions: userSolutions, score: calculateScore(), type })}
|
onClick={() => onBack({ exercise: id, solutions: correctUserSolutions!, score: calculateScore(), type })}
|
||||||
className="max-w-[200px] w-full">
|
className="max-w-[200px] w-full">
|
||||||
Back
|
Back
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
color="purple"
|
color="purple"
|
||||||
onClick={() => onNext({ exercise: id, solutions: userSolutions, score: calculateScore(), type })}
|
onClick={() => onNext({ exercise: id, solutions: correctUserSolutions!, score: calculateScore(), type })}
|
||||||
className="max-w-[200px] self-end w-full">
|
className="max-w-[200px] self-end w-full">
|
||||||
Next
|
Next
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ function Question({
|
|||||||
}: 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 { userSolutions } = useExamStore((state) => state);
|
||||||
|
|
||||||
/*
|
|
||||||
const getShuffledOptions = (options: { id: string, text: string }[], questionShuffleMap: ShuffleMap) => {
|
const getShuffledOptions = (options: { id: string, text: string }[], questionShuffleMap: ShuffleMap) => {
|
||||||
const shuffledOptions = ['A', 'B', 'C', 'D'].map(newId => {
|
const shuffledOptions = ['A', 'B', 'C', 'D'].map(newId => {
|
||||||
const originalId = questionShuffleMap.map[newId];
|
const originalId = questionShuffleMap.map[newId];
|
||||||
@@ -43,10 +42,9 @@ function Question({
|
|||||||
if (foundMap) return foundMap;
|
if (foundMap) return foundMap;
|
||||||
return userSolution.shuffleMaps?.find(map => map.id === id) || null;
|
return userSolution.shuffleMaps?.find(map => map.id === id) || null;
|
||||||
}, null as ShuffleMap | null);
|
}, null as ShuffleMap | null);
|
||||||
*/
|
|
||||||
|
|
||||||
const questionOptions = options; // questionShuffleMap ? getShuffledOptions(options as {id: string, text: string}[], questionShuffleMap) : options;
|
const questionOptions = questionShuffleMap ? getShuffledOptions(options as { id: string, text: string }[], questionShuffleMap) : options;
|
||||||
const newSolution = solution; //questionShuffleMap ? getShuffledSolution(solution, questionShuffleMap) : solution;
|
const newSolution = questionShuffleMap ? getShuffledSolution(solution, questionShuffleMap) : solution;
|
||||||
|
|
||||||
const renderPrompt = (prompt: string) => {
|
const renderPrompt = (prompt: string) => {
|
||||||
return reactStringReplace(prompt, /(<u>.*?<\/u>)/g, (match) => {
|
return reactStringReplace(prompt, /(<u>.*?<\/u>)/g, (match) => {
|
||||||
@@ -68,23 +66,23 @@ function Question({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
{isNaN(Number(id)) ? (
|
{isNaN(Number(id)) ? (
|
||||||
<span>{renderPrompt(prompt).filter((x) => x?.toString() !== "<u>")} </span>
|
<span>{renderPrompt(prompt).filter((x) => x?.toString() !== "<u>")} </span>
|
||||||
) : (
|
) : (
|
||||||
<span className="">
|
<span className="text-lg">
|
||||||
<>
|
<>
|
||||||
{id} - <span>{renderPrompt(prompt).filter((x) => x?.toString() !== "<u>")} </span>
|
{id} - <span className="text-lg">{renderPrompt(prompt).filter((x) => x?.toString() !== "<u>")} </span>
|
||||||
</>
|
</>
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<div className="grid grid-cols-4 gap-4 place-items-center">
|
<div className="flex flex-wrap gap-4 justify-between">
|
||||||
{variant === "image" &&
|
{variant === "image" &&
|
||||||
questionOptions.map((option) => (
|
questionOptions.map((option) => (
|
||||||
<div
|
<div
|
||||||
key={option?.id}
|
key={option?.id}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"flex flex-col items-center border border-mti-gray-platinum p-4 px-8 rounded-xl gap-4 cursor-pointer bg-white relative",
|
"flex flex-col items-center border border-mti-gray-platinum p-4 px-8 rounded-xl gap-4 cursor-pointer bg-white relative select-none",
|
||||||
optionColor(option!.id),
|
optionColor(option!.id),
|
||||||
)}>
|
)}>
|
||||||
<span className={clsx("text-sm", newSolution !== option?.id && userSolution !== option?.id && "opacity-50")}>{option?.id}</span>
|
<span className={clsx("text-sm", newSolution !== option?.id && userSolution !== option?.id && "opacity-50")}>{option?.id}</span>
|
||||||
@@ -95,7 +93,7 @@ function Question({
|
|||||||
questionOptions.map((option) => (
|
questionOptions.map((option) => (
|
||||||
<div
|
<div
|
||||||
key={option?.id}
|
key={option?.id}
|
||||||
className={clsx("flex border p-4 rounded-xl gap-2 cursor-pointer bg-white text-sm", optionColor(option!.id))}>
|
className={clsx("flex border p-4 rounded-xl gap-2 cursor-pointer bg-white text-base select-none", optionColor(option!.id))}>
|
||||||
<span className="font-semibold">{option?.id}.</span>
|
<span className="font-semibold">{option?.id}.</span>
|
||||||
<span>{option?.text}</span>
|
<span>{option?.text}</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -106,7 +104,8 @@ function Question({
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function MultipleChoice({ id, type, prompt, questions, userSolutions, onNext, onBack }: MultipleChoiceExercise & CommonProps) {
|
export default function MultipleChoice({ id, type, prompt, questions, userSolutions, onNext, onBack }: MultipleChoiceExercise & CommonProps) {
|
||||||
const { questionIndex, setQuestionIndex } = useExamStore((state) => state);
|
const { questionIndex, setQuestionIndex, partIndex, exam } = useExamStore((state) => state);
|
||||||
|
|
||||||
|
|
||||||
const calculateScore = () => {
|
const calculateScore = () => {
|
||||||
const total = questions.length;
|
const total = questions.length;
|
||||||
@@ -138,7 +137,7 @@ export default function MultipleChoice({ id, type, prompt, questions, userSoluti
|
|||||||
<>
|
<>
|
||||||
<div className="flex flex-col gap-4 w-full h-full mb-20">
|
<div className="flex flex-col gap-4 w-full h-full mb-20">
|
||||||
<div className="flex flex-col gap-2 mt-4 h-full bg-mti-gray-smoke rounded-xl px-16 py-8">
|
<div className="flex flex-col gap-2 mt-4 h-full bg-mti-gray-smoke rounded-xl px-16 py-8">
|
||||||
<span className="text-xl font-semibold">{prompt}</span>
|
{/*<span className="text-xl font-semibold">{prompt}</span>*/}
|
||||||
{userSolutions && questionIndex < questions.length && (
|
{userSolutions && questionIndex < questions.length && (
|
||||||
<Question
|
<Question
|
||||||
{...questions[questionIndex]}
|
{...questions[questionIndex]}
|
||||||
@@ -163,7 +162,11 @@ export default function MultipleChoice({ id, type, prompt, questions, userSoluti
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="self-end flex justify-between w-full gap-8 absolute bottom-8 left-0 px-8">
|
<div className="self-end flex justify-between w-full gap-8 absolute bottom-8 left-0 px-8">
|
||||||
<Button color="purple" variant="outline" onClick={back} className="max-w-[200px] w-full">
|
<Button color="purple" variant="outline" onClick={back} className="max-w-[200px] w-full"
|
||||||
|
disabled={
|
||||||
|
exam && typeof partIndex !== "undefined" && exam.module === "level" &&
|
||||||
|
typeof exam.parts[0].intro === "string" && questionIndex === 0 && partIndex === 0}
|
||||||
|
>
|
||||||
Back
|
Back
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ import {
|
|||||||
BsPlus,
|
BsPlus,
|
||||||
BsPersonFillGear,
|
BsPersonFillGear,
|
||||||
BsFilter,
|
BsFilter,
|
||||||
BsDatabase
|
BsDatabase,
|
||||||
} from "react-icons/bs";
|
} from "react-icons/bs";
|
||||||
import UserCard from "@/components/UserCard";
|
import UserCard from "@/components/UserCard";
|
||||||
import useGroups from "@/hooks/useGroups";
|
import useGroups from "@/hooks/useGroups";
|
||||||
@@ -309,21 +309,11 @@ export default function MasterCorporateDashboard({user}: Props) {
|
|||||||
const {codes} = useCodes(user.id);
|
const {codes} = useCodes(user.id);
|
||||||
const {groups} = useGroups({admin: user.id, userType: user.type});
|
const {groups} = useGroups({admin: user.id, userType: user.type});
|
||||||
|
|
||||||
const masterCorporateUserGroups = [
|
const masterCorporateUserGroups = [...new Set(groups.filter((u) => u.admin === user.id).flatMap((g) => g.participants))];
|
||||||
...new Set(
|
|
||||||
groups.filter((u) => u.admin === user.id).flatMap((g) => g.participants)
|
|
||||||
),
|
|
||||||
];
|
|
||||||
|
|
||||||
const corporateUserGroups = [
|
const corporateUserGroups = [...new Set(groups.flatMap((g) => g.participants))];
|
||||||
...new Set(groups.flatMap((g) => g.participants)),
|
|
||||||
];
|
|
||||||
|
|
||||||
const {
|
const {assignments, isLoading: isAssignmentsLoading, reload: reloadAssignments} = useAssignments({corporate: user.id});
|
||||||
assignments,
|
|
||||||
isLoading: isAssignmentsLoading,
|
|
||||||
reload: reloadAssignments,
|
|
||||||
} = useAssignments({ corporate: user.id });
|
|
||||||
|
|
||||||
const appendUserFilters = useFilterStore((state) => state.appendUserFilter);
|
const appendUserFilters = useFilterStore((state) => state.appendUserFilter);
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -350,13 +340,8 @@ export default function MasterCorporateDashboard({user}: Props) {
|
|||||||
const UserDisplay = (displayUser: User) => (
|
const UserDisplay = (displayUser: User) => (
|
||||||
<div
|
<div
|
||||||
onClick={() => setSelectedUser(displayUser)}
|
onClick={() => setSelectedUser(displayUser)}
|
||||||
className="flex w-full p-4 gap-4 items-center hover:bg-mti-purple-ultralight cursor-pointer transition ease-in-out duration-300"
|
className="flex w-full p-4 gap-4 items-center hover:bg-mti-purple-ultralight cursor-pointer transition ease-in-out duration-300">
|
||||||
>
|
<img src={displayUser.profilePicture} alt={displayUser.name} className="rounded-full w-10 h-10" />
|
||||||
<img
|
|
||||||
src={displayUser.profilePicture}
|
|
||||||
alt={displayUser.name}
|
|
||||||
className="rounded-full w-10 h-10"
|
|
||||||
/>
|
|
||||||
<div className="flex flex-col gap-1 items-start">
|
<div className="flex flex-col gap-1 items-start">
|
||||||
<span>{displayUser.name}</span>
|
<span>{displayUser.name}</span>
|
||||||
<span className="text-sm opacity-75">{displayUser.email}</span>
|
<span className="text-sm opacity-75">{displayUser.email}</span>
|
||||||
@@ -366,10 +351,7 @@ export default function MasterCorporateDashboard({user}: Props) {
|
|||||||
|
|
||||||
const StudentsList = () => {
|
const StudentsList = () => {
|
||||||
const filter = (x: User) =>
|
const filter = (x: User) =>
|
||||||
x.type === "student" &&
|
x.type === "student" && (!!selectedUser ? corporateUserGroups.includes(x.id) || false : corporateUserGroups.includes(x.id));
|
||||||
(!!selectedUser
|
|
||||||
? corporateUserGroups.includes(x.id) || false
|
|
||||||
: corporateUserGroups.includes(x.id));
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<UserList
|
<UserList
|
||||||
@@ -379,8 +361,7 @@ export default function MasterCorporateDashboard({user}: Props) {
|
|||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
<div
|
<div
|
||||||
onClick={() => setPage("")}
|
onClick={() => setPage("")}
|
||||||
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300"
|
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300">
|
||||||
>
|
|
||||||
<BsArrowLeft className="text-xl" />
|
<BsArrowLeft className="text-xl" />
|
||||||
<span>Back</span>
|
<span>Back</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -393,10 +374,7 @@ export default function MasterCorporateDashboard({user}: Props) {
|
|||||||
|
|
||||||
const TeachersList = () => {
|
const TeachersList = () => {
|
||||||
const filter = (x: User) =>
|
const filter = (x: User) =>
|
||||||
x.type === "teacher" &&
|
x.type === "teacher" && (!!selectedUser ? corporateUserGroups.includes(x.id) || false : corporateUserGroups.includes(x.id));
|
||||||
(!!selectedUser
|
|
||||||
? corporateUserGroups.includes(x.id) || false
|
|
||||||
: corporateUserGroups.includes(x.id));
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<UserList
|
<UserList
|
||||||
@@ -406,8 +384,7 @@ export default function MasterCorporateDashboard({user}: Props) {
|
|||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
<div
|
<div
|
||||||
onClick={() => setPage("")}
|
onClick={() => setPage("")}
|
||||||
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300"
|
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300">
|
||||||
>
|
|
||||||
<BsArrowLeft className="text-xl" />
|
<BsArrowLeft className="text-xl" />
|
||||||
<span>Back</span>
|
<span>Back</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -419,10 +396,7 @@ export default function MasterCorporateDashboard({user}: Props) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const corporateUserFilter = (x: User) =>
|
const corporateUserFilter = (x: User) =>
|
||||||
x.type === "corporate" &&
|
x.type === "corporate" && (!!selectedUser ? masterCorporateUserGroups.includes(x.id) || false : masterCorporateUserGroups.includes(x.id));
|
||||||
(!!selectedUser
|
|
||||||
? masterCorporateUserGroups.includes(x.id) || false
|
|
||||||
: masterCorporateUserGroups.includes(x.id));
|
|
||||||
|
|
||||||
const CorporateList = () => {
|
const CorporateList = () => {
|
||||||
return (
|
return (
|
||||||
@@ -433,8 +407,7 @@ export default function MasterCorporateDashboard({user}: Props) {
|
|||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
<div
|
<div
|
||||||
onClick={() => setPage("")}
|
onClick={() => setPage("")}
|
||||||
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300"
|
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300">
|
||||||
>
|
|
||||||
<BsArrowLeft className="text-xl" />
|
<BsArrowLeft className="text-xl" />
|
||||||
<span>Back</span>
|
<span>Back</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -451,8 +424,7 @@ export default function MasterCorporateDashboard({user}: Props) {
|
|||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
<div
|
<div
|
||||||
onClick={() => setPage("")}
|
onClick={() => setPage("")}
|
||||||
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300"
|
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300">
|
||||||
>
|
|
||||||
<BsArrowLeft className="text-xl" />
|
<BsArrowLeft className="text-xl" />
|
||||||
<span>Back</span>
|
<span>Back</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -646,22 +618,18 @@ export default function MasterCorporateDashboard({user}: Props) {
|
|||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
<div
|
<div
|
||||||
onClick={() => setPage("")}
|
onClick={() => setPage("")}
|
||||||
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300"
|
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300">
|
||||||
>
|
|
||||||
<BsArrowLeft className="text-xl" />
|
<BsArrowLeft className="text-xl" />
|
||||||
<span>Back</span>
|
<span>Back</span>
|
||||||
</div>
|
</div>
|
||||||
<h2 className="text-2xl font-semibold">Master Statistical</h2>
|
<h2 className="text-2xl font-semibold">Master Statistical</h2>
|
||||||
</div>
|
</div>
|
||||||
<MasterStatistical
|
<MasterStatistical
|
||||||
users={masterCorporateUserGroups.reduce(
|
users={masterCorporateUserGroups.reduce((accm: CorporateUser[], id) => {
|
||||||
(accm: CorporateUser[], id) => {
|
|
||||||
const user = users.find((u) => u.id === id) as CorporateUser;
|
const user = users.find((u) => u.id === id) as CorporateUser;
|
||||||
if (user) return [...accm, user];
|
if (user) return [...accm, user];
|
||||||
return accm;
|
return accm;
|
||||||
},
|
}, [])}
|
||||||
[]
|
|
||||||
)}
|
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
@@ -726,13 +694,13 @@ export default function MasterCorporateDashboard({user}: Props) {
|
|||||||
color="purple"
|
color="purple"
|
||||||
onClick={() => setPage("studentsPerformance")}
|
onClick={() => setPage("studentsPerformance")}
|
||||||
/>
|
/>
|
||||||
<IconCard
|
{/* <IconCard
|
||||||
Icon={BsDatabase}
|
Icon={BsDatabase}
|
||||||
label="Master Statistical"
|
label="Master Statistical"
|
||||||
// value={masterCorporateUserGroups.length}
|
// value={masterCorporateUserGroups.length}
|
||||||
color="purple"
|
color="purple"
|
||||||
onClick={() => setPage("statistical")}
|
onClick={() => setPage("statistical")}
|
||||||
/>
|
/> */}
|
||||||
<button
|
<button
|
||||||
disabled={isAssignmentsLoading}
|
disabled={isAssignmentsLoading}
|
||||||
onClick={() => setPage("assignments")}
|
onClick={() => setPage("assignments")}
|
||||||
@@ -775,11 +743,7 @@ export default function MasterCorporateDashboard({user}: Props) {
|
|||||||
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
|
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
|
||||||
{users
|
{users
|
||||||
.filter(studentFilter)
|
.filter(studentFilter)
|
||||||
.sort(
|
.sort((a, b) => calculateAverageLevel(b.levels) - calculateAverageLevel(a.levels))
|
||||||
(a, b) =>
|
|
||||||
calculateAverageLevel(b.levels) -
|
|
||||||
calculateAverageLevel(a.levels)
|
|
||||||
)
|
|
||||||
.map((x) => (
|
.map((x) => (
|
||||||
<UserDisplay key={x.id} {...x} />
|
<UserDisplay key={x.id} {...x} />
|
||||||
))}
|
))}
|
||||||
@@ -792,8 +756,7 @@ export default function MasterCorporateDashboard({user}: Props) {
|
|||||||
.filter(studentFilter)
|
.filter(studentFilter)
|
||||||
.sort(
|
.sort(
|
||||||
(a, b) =>
|
(a, b) =>
|
||||||
Object.keys(groupByExam(getStatsByStudent(b))).length -
|
Object.keys(groupByExam(getStatsByStudent(b))).length - Object.keys(groupByExam(getStatsByStudent(a))).length,
|
||||||
Object.keys(groupByExam(getStatsByStudent(a))).length
|
|
||||||
)
|
)
|
||||||
.map((x) => (
|
.map((x) => (
|
||||||
<UserDisplay key={x.id} {...x} />
|
<UserDisplay key={x.id} {...x} />
|
||||||
@@ -817,8 +780,7 @@ export default function MasterCorporateDashboard({user}: Props) {
|
|||||||
if (shouldReload) reload();
|
if (shouldReload) reload();
|
||||||
}}
|
}}
|
||||||
onViewStudents={
|
onViewStudents={
|
||||||
selectedUser.type === "corporate" ||
|
selectedUser.type === "corporate" || selectedUser.type === "teacher"
|
||||||
selectedUser.type === "teacher"
|
|
||||||
? () => {
|
? () => {
|
||||||
appendUserFilters({
|
appendUserFilters({
|
||||||
id: "view-students",
|
id: "view-students",
|
||||||
@@ -828,11 +790,7 @@ export default function MasterCorporateDashboard({user}: Props) {
|
|||||||
id: "belongs-to-admin",
|
id: "belongs-to-admin",
|
||||||
filter: (x: User) =>
|
filter: (x: User) =>
|
||||||
groups
|
groups
|
||||||
.filter(
|
.filter((g) => g.admin === selectedUser.id || g.participants.includes(selectedUser.id))
|
||||||
(g) =>
|
|
||||||
g.admin === selectedUser.id ||
|
|
||||||
g.participants.includes(selectedUser.id)
|
|
||||||
)
|
|
||||||
.flatMap((g) => g.participants)
|
.flatMap((g) => g.participants)
|
||||||
.includes(x.id),
|
.includes(x.id),
|
||||||
});
|
});
|
||||||
@@ -842,8 +800,7 @@ export default function MasterCorporateDashboard({user}: Props) {
|
|||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
onViewTeachers={
|
onViewTeachers={
|
||||||
selectedUser.type === "corporate" ||
|
selectedUser.type === "corporate" || selectedUser.type === "student"
|
||||||
selectedUser.type === "student"
|
|
||||||
? () => {
|
? () => {
|
||||||
appendUserFilters({
|
appendUserFilters({
|
||||||
id: "view-teachers",
|
id: "view-teachers",
|
||||||
@@ -853,11 +810,7 @@ export default function MasterCorporateDashboard({user}: Props) {
|
|||||||
id: "belongs-to-admin",
|
id: "belongs-to-admin",
|
||||||
filter: (x: User) =>
|
filter: (x: User) =>
|
||||||
groups
|
groups
|
||||||
.filter(
|
.filter((g) => g.admin === selectedUser.id || g.participants.includes(selectedUser.id))
|
||||||
(g) =>
|
|
||||||
g.admin === selectedUser.id ||
|
|
||||||
g.participants.includes(selectedUser.id)
|
|
||||||
)
|
|
||||||
.flatMap((g) => g.participants)
|
.flatMap((g) => g.participants)
|
||||||
.includes(x.id),
|
.includes(x.id),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import React from "react";
|
|||||||
import {CorporateUser} from "@/interfaces/user";
|
import {CorporateUser} from "@/interfaces/user";
|
||||||
import {BsBank, BsPersonFill} from "react-icons/bs";
|
import {BsBank, BsPersonFill} from "react-icons/bs";
|
||||||
import IconCard from "./IconCard";
|
import IconCard from "./IconCard";
|
||||||
import useAssignmentsCorporates from '@/hooks/useAssignmentCorporates';
|
import useAssignmentsCorporates from "@/hooks/useAssignmentCorporates";
|
||||||
interface Props {
|
interface Props {
|
||||||
users: CorporateUser[];
|
users: CorporateUser[];
|
||||||
}
|
}
|
||||||
@@ -13,16 +13,9 @@ const MasterStatistical = (props: Props) => {
|
|||||||
|
|
||||||
const {assignments} = useAssignmentsCorporates({corporates: usersList});
|
const {assignments} = useAssignmentsCorporates({corporates: usersList});
|
||||||
|
|
||||||
console.log('Assignments', assignments);
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-wrap gap-2 items-center text-center">
|
<div className="flex flex-wrap gap-2 items-center text-center">
|
||||||
<IconCard
|
<IconCard Icon={BsBank} label="Consolidate" value={0} color="purple" onClick={() => console.log("clicked")} />
|
||||||
Icon={BsBank}
|
|
||||||
label="Consolidate"
|
|
||||||
value={0}
|
|
||||||
color="purple"
|
|
||||||
onClick={() => console.log("clicked")}
|
|
||||||
/>
|
|
||||||
{users.map((group) => (
|
{users.map((group) => (
|
||||||
<IconCard
|
<IconCard
|
||||||
key={group.id}
|
key={group.id}
|
||||||
@@ -33,12 +26,7 @@ const MasterStatistical = (props: Props) => {
|
|||||||
onClick={() => console.log("clicked", group)}
|
onClick={() => console.log("clicked", group)}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
<IconCard
|
<IconCard onClick={() => console.log("clicked")} Icon={BsPersonFill} label="Consolidate Highest Student" color="purple" />
|
||||||
onClick={() => console.log("clicked")}
|
|
||||||
Icon={BsPersonFill}
|
|
||||||
label="Consolidate Highest Student"
|
|
||||||
color="purple"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,504 +0,0 @@
|
|||||||
import BlankQuestionsModal from "@/components/BlankQuestionsModal";
|
|
||||||
import {renderExercise} from "@/components/Exercises";
|
|
||||||
import HighlightContent from "@/components/HighlightContent";
|
|
||||||
import Button from "@/components/Low/Button";
|
|
||||||
import ModuleTitle from "@/components/Medium/ModuleTitle";
|
|
||||||
import {renderSolution} from "@/components/Solutions";
|
|
||||||
import {infoButtonStyle} from "@/constants/buttonStyles";
|
|
||||||
import {Module} from "@/interfaces";
|
|
||||||
import {
|
|
||||||
Exercise,
|
|
||||||
FillBlanksExercise,
|
|
||||||
FillBlanksMCOption,
|
|
||||||
LevelExam,
|
|
||||||
LevelPart,
|
|
||||||
MultipleChoiceExercise,
|
|
||||||
ShuffleMap,
|
|
||||||
UserSolution,
|
|
||||||
WritingExam,
|
|
||||||
} from "@/interfaces/exam";
|
|
||||||
import useExamStore from "@/stores/examStore";
|
|
||||||
import {defaultUserSolutions} from "@/utils/exams";
|
|
||||||
import {countExercises} from "@/utils/moduleUtils";
|
|
||||||
import {mdiArrowRight} from "@mdi/js";
|
|
||||||
import Icon from "@mdi/react";
|
|
||||||
import clsx from "clsx";
|
|
||||||
import {Dispatch, Fragment, SetStateAction, use, useEffect, useMemo, useRef, useState} from "react";
|
|
||||||
import {BsChevronDown, BsChevronUp} from "react-icons/bs";
|
|
||||||
import {toast} from "react-toastify";
|
|
||||||
import {v4} from "uuid";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
exam: LevelExam;
|
|
||||||
showSolutions?: boolean;
|
|
||||||
onFinish: (userSolutions: UserSolution[]) => void;
|
|
||||||
editing?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
function TextComponent({
|
|
||||||
part,
|
|
||||||
contextWord,
|
|
||||||
setContextWordLine,
|
|
||||||
}: {
|
|
||||||
part: LevelPart;
|
|
||||||
contextWord: string | undefined;
|
|
||||||
setContextWordLine: React.Dispatch<React.SetStateAction<number | undefined>>;
|
|
||||||
}) {
|
|
||||||
const textRef = useRef<HTMLDivElement>(null);
|
|
||||||
const [lineNumbers, setLineNumbers] = useState<number[]>([]);
|
|
||||||
const [lineHeight, setLineHeight] = useState<number>(0);
|
|
||||||
part.showContextLines = true;
|
|
||||||
|
|
||||||
const calculateLineNumbers = () => {
|
|
||||||
if (textRef.current) {
|
|
||||||
const computedStyle = window.getComputedStyle(textRef.current);
|
|
||||||
const lineHeightValue = parseFloat(computedStyle.lineHeight);
|
|
||||||
const containerWidth = textRef.current.clientWidth;
|
|
||||||
setLineHeight(lineHeightValue);
|
|
||||||
|
|
||||||
const offscreenElement = document.createElement("div");
|
|
||||||
offscreenElement.style.position = "absolute";
|
|
||||||
offscreenElement.style.top = "-9999px";
|
|
||||||
offscreenElement.style.left = "-9999px";
|
|
||||||
offscreenElement.style.whiteSpace = "pre-wrap";
|
|
||||||
offscreenElement.style.width = `${containerWidth}px`;
|
|
||||||
offscreenElement.style.font = computedStyle.font;
|
|
||||||
offscreenElement.style.lineHeight = computedStyle.lineHeight;
|
|
||||||
offscreenElement.style.textAlign = computedStyle.textAlign as CanvasTextAlign;
|
|
||||||
|
|
||||||
const textContent = textRef.current.textContent || "";
|
|
||||||
textContent.split(/(\s+)/).forEach((word: string) => {
|
|
||||||
const span = document.createElement("span");
|
|
||||||
span.textContent = word;
|
|
||||||
span.style.display = "inline-block";
|
|
||||||
span.style.height = `calc(1em + 16px)`;
|
|
||||||
offscreenElement.appendChild(span);
|
|
||||||
});
|
|
||||||
|
|
||||||
document.body.appendChild(offscreenElement);
|
|
||||||
|
|
||||||
const lines: string[][] = [[]];
|
|
||||||
let currentLine = 1;
|
|
||||||
let currentLineTop: number | undefined;
|
|
||||||
let contextWordLine: number | null = null;
|
|
||||||
|
|
||||||
const firstChild = offscreenElement.firstChild as HTMLElement;
|
|
||||||
if (firstChild) {
|
|
||||||
currentLineTop = firstChild.getBoundingClientRect().top;
|
|
||||||
}
|
|
||||||
|
|
||||||
const spans = offscreenElement.querySelectorAll<HTMLSpanElement>("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) {
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
resizeObserver.unobserve(textRef.current);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [part.context, part.showContextLines, contextWord]);
|
|
||||||
|
|
||||||
if (typeof part.showContextLines === "undefined") {
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col gap-2 w-full">
|
|
||||||
<div className="border border-mti-gray-dim w-full rounded-full opacity-10" />
|
|
||||||
{!!part.context &&
|
|
||||||
part.context
|
|
||||||
.split(/\n|(\\n)/g)
|
|
||||||
.filter((x) => x && x.length > 0 && x !== "\\n")
|
|
||||||
.map((line, index) => (
|
|
||||||
<Fragment key={index}>
|
|
||||||
<p key={index}>{line}</p>
|
|
||||||
</Fragment>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col gap-2 w-full">
|
|
||||||
<div className="border border-mti-gray-dim w-full rounded-full opacity-10" />
|
|
||||||
<div className="flex mt-2">
|
|
||||||
<div ref={textRef} className="h-fit ml-2 flex flex-col gap-4">
|
|
||||||
{part.context!.split("\n\n").map((line, index) => {
|
|
||||||
return (
|
|
||||||
<p key={`line-${index}`}>
|
|
||||||
<span className="mr-6">{index + 1}</span>
|
|
||||||
{line}
|
|
||||||
</p>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const typeCheckWordsMC = (words: any[]): words is FillBlanksMCOption[] => {
|
|
||||||
return Array.isArray(words) && words.every((word) => word && typeof word === "object" && "id" in word && "options" in word);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function Level({exam, showSolutions = false, onFinish, editing = false}: Props) {
|
|
||||||
const [multipleChoicesDone, setMultipleChoicesDone] = useState<{id: string; amount: number}[]>([]);
|
|
||||||
const [showBlankModal, setShowBlankModal] = useState(false);
|
|
||||||
|
|
||||||
const {userSolutions, setUserSolutions} = useExamStore((state) => state);
|
|
||||||
const {hasExamEnded, setHasExamEnded} = useExamStore((state) => state);
|
|
||||||
const {partIndex, setPartIndex} = useExamStore((state) => state);
|
|
||||||
const {exerciseIndex, setExerciseIndex} = useExamStore((state) => state);
|
|
||||||
const [storeQuestionIndex, setStoreQuestionIndex] = useExamStore((state) => [state.questionIndex, state.setQuestionIndex]);
|
|
||||||
//const [shuffleMaps, setShuffleMaps] = useExamStore((state) => [state.shuffleMaps, state.setShuffleMaps])
|
|
||||||
const [currentExercise, setCurrentExercise] = useState<Exercise>();
|
|
||||||
|
|
||||||
const scrollToTop = () => Array.from(document.getElementsByTagName("body")).forEach((body) => body.scrollTo(0, 0));
|
|
||||||
|
|
||||||
const [contextWord, setContextWord] = useState<string | undefined>(undefined);
|
|
||||||
const [contextWordLine, setContextWordLine] = useState<number | undefined>(undefined);
|
|
||||||
|
|
||||||
/*useEffect(() => {
|
|
||||||
if (showSolutions && userSolutions[exerciseIndex].shuffleMaps) {
|
|
||||||
setShuffleMaps(userSolutions[exerciseIndex].shuffleMaps as ShuffleMap[])
|
|
||||||
}
|
|
||||||
}, [showSolutions])*/
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (hasExamEnded && exerciseIndex === -1) {
|
|
||||||
setExerciseIndex(exerciseIndex + 1);
|
|
||||||
}
|
|
||||||
}, [hasExamEnded, exerciseIndex, setExerciseIndex]);
|
|
||||||
|
|
||||||
const confirmFinishModule = (keepGoing?: boolean) => {
|
|
||||||
if (!keepGoing) {
|
|
||||||
setShowBlankModal(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
onFinish(userSolutions);
|
|
||||||
};
|
|
||||||
|
|
||||||
const getExercise = () => {
|
|
||||||
if (exerciseIndex === -1) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
let exercise = exam.parts[partIndex]?.exercises[exerciseIndex];
|
|
||||||
if (!exercise) return undefined;
|
|
||||||
|
|
||||||
exercise = {
|
|
||||||
...exercise,
|
|
||||||
userSolutions: userSolutions.find((x) => x.exercise === exercise.id)?.solutions || [],
|
|
||||||
};
|
|
||||||
|
|
||||||
/*if (exam.shuffle && exercise.type === "multipleChoice") {
|
|
||||||
if (shuffleMaps.length == 0 && !showSolutions) {
|
|
||||||
console.log("Shuffling answers");
|
|
||||||
const newShuffleMaps: ShuffleMap[] = [];
|
|
||||||
|
|
||||||
exercise.questions = exercise.questions.map(question => {
|
|
||||||
const options = [...question.options];
|
|
||||||
let shuffledOptions = [...options].sort(() => Math.random() - 0.5);
|
|
||||||
|
|
||||||
const newOptions = options.map((option, index) => ({
|
|
||||||
id: option.id,
|
|
||||||
text: shuffledOptions[index].text
|
|
||||||
}));
|
|
||||||
|
|
||||||
const optionMapping = options.reduce<{ [key: string]: string }>((acc, originalOption) => {
|
|
||||||
const shuffledPosition = newOptions.find(newOpt => newOpt.text === originalOption.text)?.id;
|
|
||||||
if (shuffledPosition) {
|
|
||||||
acc[shuffledPosition] = originalOption.id;
|
|
||||||
}
|
|
||||||
return acc;
|
|
||||||
}, {});
|
|
||||||
|
|
||||||
newShuffleMaps.push({ id: question.id, map: optionMapping });
|
|
||||||
|
|
||||||
return { ...question, options: newOptions };
|
|
||||||
});
|
|
||||||
|
|
||||||
setShuffleMaps(newShuffleMaps);
|
|
||||||
} else {
|
|
||||||
console.log("Retrieving shuffles");
|
|
||||||
exercise.questions = exercise.questions.map(question => {
|
|
||||||
const questionShuffleMap = shuffleMaps.find(map => map.id === question.id);
|
|
||||||
if (questionShuffleMap) {
|
|
||||||
const newOptions = question.options.map(option => ({
|
|
||||||
id: option.id,
|
|
||||||
text: question.options.find(o => questionShuffleMap.map[o.id] === option.id)?.text || option.text
|
|
||||||
}));
|
|
||||||
return { ...question, options: newOptions };
|
|
||||||
}
|
|
||||||
return question;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} else if (exam.shuffle && exercise.type === "fillBlanks" && typeCheckWordsMC(exercise.words)) {
|
|
||||||
if (shuffleMaps.length === 0 && !showSolutions) {
|
|
||||||
const newShuffleMaps: ShuffleMap[] = [];
|
|
||||||
|
|
||||||
exercise.words = exercise.words.map(word => {
|
|
||||||
if ('options' in word) {
|
|
||||||
const options = { ...word.options };
|
|
||||||
const originalKeys = Object.keys(options);
|
|
||||||
const shuffledKeys = [...originalKeys].sort(() => Math.random() - 0.5);
|
|
||||||
|
|
||||||
const newOptions = shuffledKeys.reduce((acc, key, index) => {
|
|
||||||
acc[key as keyof typeof options] = options[originalKeys[index] as keyof typeof options];
|
|
||||||
return acc;
|
|
||||||
}, {} as { [key in keyof typeof options]: string });
|
|
||||||
|
|
||||||
const optionMapping = originalKeys.reduce((acc, key, index) => {
|
|
||||||
acc[key as keyof typeof options] = shuffledKeys[index];
|
|
||||||
return acc;
|
|
||||||
}, {} as { [key in keyof typeof options]: string });
|
|
||||||
|
|
||||||
newShuffleMaps.push({ id: word.id, map: optionMapping });
|
|
||||||
|
|
||||||
return { ...word, options: newOptions };
|
|
||||||
}
|
|
||||||
return word;
|
|
||||||
});
|
|
||||||
|
|
||||||
setShuffleMaps(newShuffleMaps);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
*/
|
|
||||||
return exercise;
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setCurrentExercise(getExercise());
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [partIndex, exerciseIndex]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const regex = /.*?['"](.*?)['"] in line (\d+)\?$/;
|
|
||||||
if (currentExercise && currentExercise.type === "multipleChoice") {
|
|
||||||
const match = currentExercise.questions[storeQuestionIndex].prompt.match(regex);
|
|
||||||
if (match) {
|
|
||||||
const word = match[1];
|
|
||||||
const originalLineNumber = match[2];
|
|
||||||
|
|
||||||
if (word !== contextWord) {
|
|
||||||
setContextWord(word);
|
|
||||||
}
|
|
||||||
|
|
||||||
const updatedPrompt = currentExercise.questions[storeQuestionIndex].prompt.replace(
|
|
||||||
`in line ${originalLineNumber}`,
|
|
||||||
`in line ${contextWordLine || originalLineNumber}`,
|
|
||||||
);
|
|
||||||
|
|
||||||
currentExercise.questions[storeQuestionIndex].prompt = updatedPrompt;
|
|
||||||
} else {
|
|
||||||
setContextWord(undefined);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [storeQuestionIndex, contextWordLine, exerciseIndex, partIndex]); //, shuffleMaps]);
|
|
||||||
|
|
||||||
const nextExercise = (solution?: UserSolution) => {
|
|
||||||
scrollToTop();
|
|
||||||
if (solution) {
|
|
||||||
setUserSolutions([...userSolutions.filter((x) => x.exercise !== solution.exercise), {...solution, module: "level", exam: exam.id}]);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (storeQuestionIndex > 0 || currentExercise?.type == "fillBlanks") {
|
|
||||||
setMultipleChoicesDone((prev) => [
|
|
||||||
...prev.filter((x) => x.id !== currentExercise!.id),
|
|
||||||
{id: currentExercise!.id, amount: currentExercise?.type == "fillBlanks" ? currentExercise.words.length - 1 : storeQuestionIndex},
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
setStoreQuestionIndex(0);
|
|
||||||
|
|
||||||
if (exerciseIndex + 1 < exam.parts[partIndex].exercises.length && !hasExamEnded) {
|
|
||||||
setExerciseIndex(exerciseIndex + 1);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (partIndex + 1 < exam.parts.length && !hasExamEnded) {
|
|
||||||
setPartIndex(partIndex + 1);
|
|
||||||
setExerciseIndex(!!exam.parts[partIndex + 1].context ? -1 : 0);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
solution &&
|
|
||||||
![...userSolutions.filter((x) => x.exercise !== solution?.exercise).map((x) => x.score.missing), solution?.score.missing].every(
|
|
||||||
(x) => x === 0,
|
|
||||||
) &&
|
|
||||||
!showSolutions &&
|
|
||||||
!editing &&
|
|
||||||
!hasExamEnded
|
|
||||||
) {
|
|
||||||
setShowBlankModal(true);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setHasExamEnded(false);
|
|
||||||
|
|
||||||
if (solution) {
|
|
||||||
let stat = {...solution, module: "level" as Module, exam: exam.id};
|
|
||||||
/*if (exam.shuffle) {
|
|
||||||
stat.shuffleMaps = shuffleMaps
|
|
||||||
}*/
|
|
||||||
onFinish([...userSolutions.filter((x) => x.exercise !== solution.exercise), {...stat}]);
|
|
||||||
} else {
|
|
||||||
onFinish(userSolutions);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const previousExercise = (solution?: UserSolution) => {
|
|
||||||
scrollToTop();
|
|
||||||
if (solution) {
|
|
||||||
setUserSolutions([...userSolutions.filter((x) => x.exercise !== solution.exercise), {...solution, module: "level", exam: exam.id}]);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (storeQuestionIndex > 0 || currentExercise?.type == "fillBlanks") {
|
|
||||||
setMultipleChoicesDone((prev) => [
|
|
||||||
...prev.filter((x) => x.id !== currentExercise!.id),
|
|
||||||
{id: currentExercise!.id, amount: currentExercise?.type == "fillBlanks" ? currentExercise.words.length - 1 : storeQuestionIndex},
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
setStoreQuestionIndex(0);
|
|
||||||
|
|
||||||
setExerciseIndex(exerciseIndex - 1);
|
|
||||||
};
|
|
||||||
|
|
||||||
const calculateExerciseIndex = () => {
|
|
||||||
if (partIndex === 0)
|
|
||||||
return (
|
|
||||||
(exerciseIndex === -1 ? 0 : exerciseIndex + 1) + storeQuestionIndex + multipleChoicesDone.reduce((acc, curr) => acc + curr.amount, 0)
|
|
||||||
);
|
|
||||||
|
|
||||||
const exercisesPerPart = exam.parts.map((x) => x.exercises.length);
|
|
||||||
const exercisesDone = exercisesPerPart.filter((_, index) => index < partIndex).reduce((acc, curr) => curr + acc, 0);
|
|
||||||
return (
|
|
||||||
exercisesDone +
|
|
||||||
(exerciseIndex === -1 ? 0 : exerciseIndex + 1) +
|
|
||||||
storeQuestionIndex +
|
|
||||||
multipleChoicesDone.reduce((acc, curr) => {
|
|
||||||
return acc + curr.amount;
|
|
||||||
}, 0)
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const renderText = () => (
|
|
||||||
<div className={clsx("flex flex-col gap-6 w-full bg-mti-gray-seasalt rounded-xl mt-4 relative py-8 px-16")}>
|
|
||||||
<>
|
|
||||||
<div className="flex flex-col w-full gap-2">
|
|
||||||
<h4 className="text-xl font-semibold">
|
|
||||||
Please read the following excerpt attentively, you will then be asked questions about the text you've read.
|
|
||||||
</h4>
|
|
||||||
<span className="text-base">You will be allowed to read the text while doing the exercises</span>
|
|
||||||
</div>
|
|
||||||
<TextComponent part={exam.parts[partIndex]} contextWord={contextWord} setContextWordLine={setContextWordLine} />
|
|
||||||
</>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
const partLabel = () => {
|
|
||||||
if (currentExercise?.type === "fillBlanks" && typeCheckWordsMC(currentExercise.words))
|
|
||||||
return `Part ${partIndex + 1} (Questions ${currentExercise.words[0].id} - ${
|
|
||||||
currentExercise.words[currentExercise.words.length - 1].id
|
|
||||||
})\n\n${currentExercise.prompt}`;
|
|
||||||
if (currentExercise?.type === "multipleChoice")
|
|
||||||
return `Part ${partIndex + 1} (Questions ${currentExercise.questions[0].id} - ${
|
|
||||||
currentExercise.questions[currentExercise.questions.length - 1].id
|
|
||||||
})\n\n${currentExercise.prompt}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div className="flex flex-col h-full w-full gap-8 items-center">
|
|
||||||
<BlankQuestionsModal isOpen={showBlankModal} onClose={confirmFinishModule} />
|
|
||||||
<ModuleTitle
|
|
||||||
partLabel={partLabel()}
|
|
||||||
minTimer={exam.minTimer}
|
|
||||||
exerciseIndex={calculateExerciseIndex()}
|
|
||||||
module="level"
|
|
||||||
totalExercises={countExercises(exam.parts.flatMap((x) => x.exercises))}
|
|
||||||
disableTimer={showSolutions || editing}
|
|
||||||
/>
|
|
||||||
<div
|
|
||||||
className={clsx(
|
|
||||||
"mb-20 w-full",
|
|
||||||
partIndex > -1 && exerciseIndex > -1 && !!exam.parts[partIndex].context && "grid grid-cols-2 gap-4",
|
|
||||||
)}>
|
|
||||||
{partIndex > -1 && !!exam.parts[partIndex].context && renderText()}
|
|
||||||
|
|
||||||
{exerciseIndex > -1 &&
|
|
||||||
partIndex > -1 &&
|
|
||||||
exerciseIndex < exam.parts[partIndex].exercises.length &&
|
|
||||||
!showSolutions &&
|
|
||||||
!editing &&
|
|
||||||
currentExercise &&
|
|
||||||
renderExercise(currentExercise, exam.id, nextExercise, previousExercise)}
|
|
||||||
|
|
||||||
{exerciseIndex > -1 &&
|
|
||||||
partIndex > -1 &&
|
|
||||||
exerciseIndex < exam.parts[partIndex].exercises.length &&
|
|
||||||
(showSolutions || editing) &&
|
|
||||||
renderSolution(exam.parts[partIndex].exercises[exerciseIndex], nextExercise, previousExercise)}
|
|
||||||
</div>
|
|
||||||
{exerciseIndex === -1 && partIndex > 0 && (
|
|
||||||
<div className="self-end flex justify-between w-full gap-8 absolute bottom-8 left-0 px-8">
|
|
||||||
<Button
|
|
||||||
color="purple"
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => {
|
|
||||||
setExerciseIndex(exam.parts[partIndex - 1].exercises.length - 1);
|
|
||||||
setPartIndex(partIndex - 1);
|
|
||||||
}}
|
|
||||||
className="max-w-[200px] w-full">
|
|
||||||
Back
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button color="purple" onClick={() => nextExercise()} className="max-w-[200px] self-end w-full">
|
|
||||||
Next
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{exerciseIndex === -1 && partIndex === 0 && (
|
|
||||||
<Button color="purple" onClick={() => nextExercise()} className="max-w-[200px] self-end w-full">
|
|
||||||
Start now
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
37
src/exams/Level/PartDivider.tsx
Normal file
37
src/exams/Level/PartDivider.tsx
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import Button from "@/components/Low/Button";
|
||||||
|
import { Module } from "@/interfaces";
|
||||||
|
import { LevelPart, UserSolution } from "@/interfaces/exam";
|
||||||
|
import { ReactNode } from "react";
|
||||||
|
import { BsBook, BsClipboard, BsHeadphones, BsMegaphone, BsPen } from "react-icons/bs";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
partIndex: number;
|
||||||
|
part: LevelPart // for now
|
||||||
|
onNext: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PartDivider: React.FC<Props> = ({ partIndex, part, onNext }) => {
|
||||||
|
|
||||||
|
const moduleIcon: { [key in Module]: ReactNode } = {
|
||||||
|
reading: <BsBook className="text-ielts-reading w-6 h-6" />,
|
||||||
|
listening: <BsHeadphones className="text-ielts-listening w-6 h-6" />,
|
||||||
|
writing: <BsPen className="text-ielts-writing w-6 h-6" />,
|
||||||
|
speaking: <BsMegaphone className="text-ielts-speaking w-6 h-6" />,
|
||||||
|
level: <BsClipboard className="text-white w-6 h-6" />,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col w-3/6 h-fit border bg-white rounded-3xl p-12 gap-8">
|
||||||
|
{/** only level for now */}
|
||||||
|
<div className="flex flex-row gap-4 items-center"><div className="w-12 h-12 bg-ielts-level flex items-center justify-center rounded-lg">{moduleIcon["level"]}</div><p className="text-3xl">{`Part ${partIndex + 1}`}</p></div>
|
||||||
|
{part.intro!.split('\\n\\n').map((x, index) => <p key={`line-${index}`} className="text-2xl text-clip">{x}</p>)}
|
||||||
|
<div className="flex items-center justify-center mt-4">
|
||||||
|
<Button color="purple" onClick={() => onNext()} className="max-w-[200px] self-end w-full text-2xl">
|
||||||
|
{partIndex === 0 ? `Start now`: `Start Part ${partIndex + 1}`}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default PartDivider;
|
||||||
146
src/exams/Level/TextComponent.tsx
Normal file
146
src/exams/Level/TextComponent.tsx
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
import { LevelPart } from "@/interfaces/exam";
|
||||||
|
import { useEffect, useRef } from "react";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
part: LevelPart,
|
||||||
|
contextWord: string | undefined,
|
||||||
|
setContextWordLine: React.Dispatch<React.SetStateAction<number | undefined>>
|
||||||
|
}
|
||||||
|
|
||||||
|
const TextComponent: React.FC<Props> = ({part, contextWord, setContextWordLine}) => {
|
||||||
|
const textRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const calculateLineNumbers = () => {
|
||||||
|
if (textRef.current) {
|
||||||
|
const computedStyle = window.getComputedStyle(textRef.current);
|
||||||
|
const containerWidth = textRef.current.clientWidth;
|
||||||
|
|
||||||
|
const offscreenElement = document.createElement('div');
|
||||||
|
offscreenElement.style.position = 'absolute';
|
||||||
|
offscreenElement.style.top = '-9999px';
|
||||||
|
offscreenElement.style.left = '-9999px';
|
||||||
|
offscreenElement.style.width = `${containerWidth}px`;
|
||||||
|
offscreenElement.style.font = computedStyle.font;
|
||||||
|
offscreenElement.style.lineHeight = computedStyle.lineHeight;
|
||||||
|
offscreenElement.style.whiteSpace = 'pre-wrap';
|
||||||
|
offscreenElement.style.wordWrap = 'break-word';
|
||||||
|
offscreenElement.style.textAlign = computedStyle.textAlign as CanvasTextAlign;
|
||||||
|
|
||||||
|
const paragraphs = part.context!.split('\n\n');
|
||||||
|
let currentLine = 1;
|
||||||
|
let contextWordLine: number | null = null;
|
||||||
|
const paragraphLineStarts: number[] = [];
|
||||||
|
|
||||||
|
paragraphs.forEach((paragraph, pIndex) => {
|
||||||
|
const p = document.createElement('p');
|
||||||
|
p.style.margin = '0';
|
||||||
|
p.style.padding = '0';
|
||||||
|
|
||||||
|
paragraph.split(/(\s+)/).forEach((word: string) => {
|
||||||
|
const span = document.createElement('span');
|
||||||
|
span.textContent = word;
|
||||||
|
p.appendChild(span);
|
||||||
|
});
|
||||||
|
|
||||||
|
offscreenElement.appendChild(p);
|
||||||
|
|
||||||
|
if (pIndex < paragraphs.length - 1) {
|
||||||
|
const gap = document.createElement('div');
|
||||||
|
gap.style.height = '16px'; // gap-4
|
||||||
|
offscreenElement.appendChild(gap);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.body.appendChild(offscreenElement);
|
||||||
|
|
||||||
|
let currentLineTop: number | undefined;
|
||||||
|
const elements = offscreenElement.querySelectorAll('p, div');
|
||||||
|
|
||||||
|
elements.forEach((element) => {
|
||||||
|
if (element.tagName === 'P') {
|
||||||
|
const spans = element.querySelectorAll<HTMLSpanElement>('span');
|
||||||
|
paragraphLineStarts.push(currentLine);
|
||||||
|
|
||||||
|
spans.forEach(span => {
|
||||||
|
const rect = span.getBoundingClientRect();
|
||||||
|
const top = rect.top;
|
||||||
|
|
||||||
|
if (currentLineTop === undefined || top > currentLineTop) {
|
||||||
|
if (currentLineTop !== undefined) {
|
||||||
|
currentLine++;
|
||||||
|
}
|
||||||
|
currentLineTop = top;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (contextWord && contextWordLine === null && span.textContent?.includes(contextWord)) {
|
||||||
|
contextWordLine = currentLine;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else if (element.tagName === 'DIV') { // Gap
|
||||||
|
currentLine++;
|
||||||
|
currentLineTop = undefined;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (contextWordLine) {
|
||||||
|
setContextWordLine(contextWordLine);
|
||||||
|
}
|
||||||
|
|
||||||
|
document.body.removeChild(offscreenElement);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
calculateLineNumbers();
|
||||||
|
|
||||||
|
const resizeObserver = new ResizeObserver(() => {
|
||||||
|
calculateLineNumbers();
|
||||||
|
});
|
||||||
|
|
||||||
|
if (textRef.current) {
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
resizeObserver.observe(textRef.current);
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (textRef.current) {
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
resizeObserver.unobserve(textRef.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [part.context, contextWord]);
|
||||||
|
|
||||||
|
/*if (typeof part.showContextLines === "undefined") {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-2 w-full">
|
||||||
|
<div className="border border-mti-gray-dim w-full rounded-full opacity-10" />
|
||||||
|
{!!part.context &&
|
||||||
|
part.context
|
||||||
|
.split(/\n|(\\n)/g)
|
||||||
|
.filter((x) => x && x.length > 0 && x !== "\\n")
|
||||||
|
.map((line, index) => (
|
||||||
|
<Fragment key={index}>
|
||||||
|
<p key={index}>{line}</p>
|
||||||
|
</Fragment>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}*/
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-2 w-full">
|
||||||
|
<div className="border border-mti-gray-dim w-full rounded-full opacity-10" />
|
||||||
|
<div className="flex mt-2">
|
||||||
|
<div ref={textRef} className="h-fit ml-2 flex flex-col gap-4">
|
||||||
|
{part.context!.split('\n\n').map((line, index) => {
|
||||||
|
return <p key={`line-${index}`}><span className="mr-6">{index + 1}</span>{line}</p>
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default TextComponent;
|
||||||
422
src/exams/Level/index.tsx
Normal file
422
src/exams/Level/index.tsx
Normal file
@@ -0,0 +1,422 @@
|
|||||||
|
import QuestionsModal from "@/components/QuestionsModal";
|
||||||
|
import { renderExercise } from "@/components/Exercises";
|
||||||
|
import Button from "@/components/Low/Button";
|
||||||
|
import ModuleTitle from "@/components/Medium/ModuleTitle";
|
||||||
|
import { renderSolution } from "@/components/Solutions";
|
||||||
|
import { Module } from "@/interfaces";
|
||||||
|
import { Exercise, FillBlanksMCOption, LevelExam, MultipleChoiceExercise, MultipleChoiceQuestion, ShuffleMap, UserSolution } from "@/interfaces/exam";
|
||||||
|
import useExamStore from "@/stores/examStore";
|
||||||
|
import { countExercises } from "@/utils/moduleUtils";
|
||||||
|
import clsx from "clsx";
|
||||||
|
import { use, useEffect, useState } from "react";
|
||||||
|
import TextComponent from "./TextComponent";
|
||||||
|
import PartDivider from "./PartDivider";
|
||||||
|
import Timer from "@/components/Medium/Timer";
|
||||||
|
import { Stat } from "@/interfaces/user";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
exam: LevelExam;
|
||||||
|
showSolutions?: boolean;
|
||||||
|
onFinish: (userSolutions: UserSolution[]) => void;
|
||||||
|
editing?: boolean;
|
||||||
|
partDividers?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const typeCheckWordsMC = (words: any[]): words is FillBlanksMCOption[] => {
|
||||||
|
return Array.isArray(words) && words.every(
|
||||||
|
word => word && typeof word === 'object' && 'id' in word && 'options' in word
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export default function Level({ exam, showSolutions = false, onFinish, editing = false }: Props) {
|
||||||
|
const levelBgColor = "bg-ielts-level-light";
|
||||||
|
const [multipleChoicesDone, setMultipleChoicesDone] = useState<{ id: string; amount: number }[]>([]);
|
||||||
|
const [showQuestionsModal, setShowQuestionsModal] = useState(false);
|
||||||
|
|
||||||
|
const { setBgColor } = useExamStore((state) => state);
|
||||||
|
const { userSolutions, setUserSolutions } = useExamStore((state) => state);
|
||||||
|
const { hasExamEnded, setHasExamEnded } = useExamStore((state) => state);
|
||||||
|
const { partIndex, setPartIndex } = useExamStore((state) => state);
|
||||||
|
const { exerciseIndex, setExerciseIndex } = useExamStore((state) => state);
|
||||||
|
const [storeQuestionIndex, setStoreQuestionIndex] = useExamStore((state) => [state.questionIndex, state.setQuestionIndex]);
|
||||||
|
const [shuffleMaps, setShuffleMaps] = useExamStore((state) => [state.shuffleMaps, state.setShuffleMaps])
|
||||||
|
const [currentExercise, setCurrentExercise] = useState<Exercise>();
|
||||||
|
const [showPartDivider, setShowPartDivider] = useState<boolean>(typeof exam.parts[0].intro === "string" && !showSolutions);
|
||||||
|
|
||||||
|
const scrollToTop = () => Array.from(document.getElementsByTagName("body")).forEach((body) => body.scrollTo(0, 0));
|
||||||
|
|
||||||
|
const [contextWord, setContextWord] = useState<string | undefined>(undefined);
|
||||||
|
const [contextWordLine, setContextWordLine] = useState<number | undefined>(undefined);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (showSolutions && exerciseIndex && userSolutions[exerciseIndex].shuffleMaps) {
|
||||||
|
setShuffleMaps(userSolutions[exerciseIndex].shuffleMaps as ShuffleMap[])
|
||||||
|
}
|
||||||
|
}, [showSolutions, exerciseIndex, setShuffleMaps, userSolutions])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (hasExamEnded && exerciseIndex === -1) {
|
||||||
|
setExerciseIndex(exerciseIndex + 1);
|
||||||
|
}
|
||||||
|
}, [hasExamEnded, exerciseIndex, setExerciseIndex]);
|
||||||
|
|
||||||
|
const getExercise = () => {
|
||||||
|
let exercise = exam.parts[partIndex]?.exercises[exerciseIndex];
|
||||||
|
if (!exercise) return undefined;
|
||||||
|
|
||||||
|
exercise = {
|
||||||
|
...exercise,
|
||||||
|
userSolutions: userSolutions.find((x) => x.exercise === exercise.id)?.solutions || [],
|
||||||
|
};
|
||||||
|
|
||||||
|
if (exam.shuffle && exercise.type === "multipleChoice" && !showSolutions) {
|
||||||
|
console.log("Shuffling");
|
||||||
|
const exerciseShuffles = userSolutions[exerciseIndex].shuffleMaps;
|
||||||
|
if (exerciseShuffles && exerciseShuffles.length == 0) {
|
||||||
|
const newShuffleMaps: ShuffleMap[] = [];
|
||||||
|
|
||||||
|
exercise.questions = exercise.questions.map(question => {
|
||||||
|
const options = [...question.options];
|
||||||
|
let shuffledOptions = [...options].sort(() => Math.random() - 0.5);
|
||||||
|
|
||||||
|
const newOptions = options.map((option, index) => ({
|
||||||
|
id: option.id,
|
||||||
|
text: shuffledOptions[index].text
|
||||||
|
}));
|
||||||
|
|
||||||
|
const optionMapping = options.reduce<{ [key: string]: string }>((acc, originalOption) => {
|
||||||
|
const shuffledPosition = newOptions.find(newOpt => newOpt.text === originalOption.text)?.id;
|
||||||
|
if (shuffledPosition) {
|
||||||
|
acc[shuffledPosition] = originalOption.id;
|
||||||
|
}
|
||||||
|
return acc;
|
||||||
|
}, {});
|
||||||
|
|
||||||
|
newShuffleMaps.push({ id: question.id, map: optionMapping });
|
||||||
|
|
||||||
|
return { ...question, options: newOptions };
|
||||||
|
});
|
||||||
|
|
||||||
|
setShuffleMaps(newShuffleMaps);
|
||||||
|
} else {
|
||||||
|
console.log("retrieving shuffles");
|
||||||
|
exercise.questions = exercise.questions.map(question => {
|
||||||
|
const questionShuffleMap = shuffleMaps.find(map => map.id === question.id);
|
||||||
|
if (questionShuffleMap) {
|
||||||
|
const newOptions = question.options.map(option => ({
|
||||||
|
id: option.id,
|
||||||
|
text: question.options.find(o => questionShuffleMap.map[o.id] === option.id)?.text || option.text
|
||||||
|
}));
|
||||||
|
return { ...question, options: newOptions };
|
||||||
|
}
|
||||||
|
return question;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else if (exam.shuffle && exercise.type === "fillBlanks" && typeCheckWordsMC(exercise.words) && !showSolutions) {
|
||||||
|
if (shuffleMaps.length === 0 && !showSolutions) {
|
||||||
|
const newShuffleMaps: ShuffleMap[] = [];
|
||||||
|
|
||||||
|
exercise.words = exercise.words.map(word => {
|
||||||
|
if ('options' in word) {
|
||||||
|
const options = { ...word.options };
|
||||||
|
const originalKeys = Object.keys(options);
|
||||||
|
const shuffledKeys = [...originalKeys].sort(() => Math.random() - 0.5);
|
||||||
|
|
||||||
|
const newOptions = shuffledKeys.reduce((acc, key, index) => {
|
||||||
|
acc[key as keyof typeof options] = options[originalKeys[index] as keyof typeof options];
|
||||||
|
return acc;
|
||||||
|
}, {} as { [key in keyof typeof options]: string });
|
||||||
|
|
||||||
|
const optionMapping = originalKeys.reduce((acc, key, index) => {
|
||||||
|
acc[key as keyof typeof options] = shuffledKeys[index];
|
||||||
|
return acc;
|
||||||
|
}, {} as { [key in keyof typeof options]: string });
|
||||||
|
|
||||||
|
newShuffleMaps.push({ id: word.id, map: optionMapping });
|
||||||
|
|
||||||
|
return { ...word, options: newOptions };
|
||||||
|
}
|
||||||
|
return word;
|
||||||
|
});
|
||||||
|
|
||||||
|
setShuffleMaps(newShuffleMaps);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return exercise;
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (exerciseIndex !== -1) {
|
||||||
|
setCurrentExercise(getExercise());
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [partIndex, exerciseIndex, shuffleMaps, exam.parts[partIndex].context]);
|
||||||
|
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const regex = /.*?['"](.*?)['"] in line (\d+)\?$/;
|
||||||
|
if (exerciseIndex !== -1 && currentExercise && currentExercise.type === "multipleChoice" && currentExercise.questions[storeQuestionIndex].prompt) {
|
||||||
|
const match = currentExercise.questions[storeQuestionIndex].prompt.match(regex);
|
||||||
|
if (match) {
|
||||||
|
const word = match[1];
|
||||||
|
const originalLineNumber = match[2];
|
||||||
|
|
||||||
|
if (word !== contextWord) {
|
||||||
|
setContextWord(word);
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatedPrompt = currentExercise.questions[storeQuestionIndex].prompt.replace(
|
||||||
|
`in line ${originalLineNumber}`,
|
||||||
|
`in line ${contextWordLine || originalLineNumber}`
|
||||||
|
);
|
||||||
|
|
||||||
|
currentExercise.questions[storeQuestionIndex].prompt = updatedPrompt;
|
||||||
|
} else {
|
||||||
|
setContextWord(undefined);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [currentExercise, storeQuestionIndex]);
|
||||||
|
|
||||||
|
const nextExercise = (solution?: UserSolution) => {
|
||||||
|
scrollToTop();
|
||||||
|
if (solution) {
|
||||||
|
setUserSolutions([...userSolutions.filter((x) => x.exercise !== solution.exercise), { ...solution, module: "level", exam: exam.id }]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/*if (storeQuestionIndex > 0 || currentExercise?.type == "fillBlanks") {
|
||||||
|
setMultipleChoicesDone((prev) => [...prev.filter((x) => x.id !== currentExercise!.id), { id: currentExercise!.id, amount: currentExercise?.type == "fillBlanks" ? currentExercise.words.length - 1 : storeQuestionIndex }]);
|
||||||
|
}*/
|
||||||
|
|
||||||
|
if (exerciseIndex + 1 < exam.parts[partIndex].exercises.length && !hasExamEnded) {
|
||||||
|
setExerciseIndex(exerciseIndex + 1);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (partIndex + 1 < exam.parts.length && !hasExamEnded && (showQuestionsModal || showSolutions)) {
|
||||||
|
if (!showSolutions && exam.parts[0].intro) {
|
||||||
|
setShowPartDivider(true);
|
||||||
|
setBgColor(levelBgColor);
|
||||||
|
}
|
||||||
|
setPartIndex(partIndex + 1);
|
||||||
|
setExerciseIndex(!!exam.parts[partIndex + 1].context ? -1 : 0);
|
||||||
|
setStoreQuestionIndex(0);
|
||||||
|
setMultipleChoicesDone((prev) => [...prev.filter((x) => x.id !== currentExercise!.id), { id: currentExercise!.id, amount: currentExercise?.type == "fillBlanks" ? currentExercise.words.length - 1 : storeQuestionIndex }]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (partIndex + 1 < exam.parts.length && !hasExamEnded && !showQuestionsModal && !showSolutions) {
|
||||||
|
setShowQuestionsModal(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
solution &&
|
||||||
|
![...userSolutions.filter((x) => x.exercise !== solution?.exercise).map((x) => x.score.missing), solution?.score.missing].every(
|
||||||
|
(x) => x === 0,
|
||||||
|
) &&
|
||||||
|
!showSolutions &&
|
||||||
|
!editing &&
|
||||||
|
!hasExamEnded
|
||||||
|
) {
|
||||||
|
setShowQuestionsModal(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setHasExamEnded(false);
|
||||||
|
|
||||||
|
if (solution) {
|
||||||
|
let stat = { ...solution, module: "level" as Module, exam: exam.id }
|
||||||
|
if (exam.shuffle) {
|
||||||
|
stat.shuffleMaps = shuffleMaps
|
||||||
|
}
|
||||||
|
onFinish([...userSolutions.filter((x) => x.exercise !== solution.exercise), { ...stat }]);
|
||||||
|
} else {
|
||||||
|
onFinish(userSolutions);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const previousExercise = (solution?: UserSolution) => {
|
||||||
|
scrollToTop();
|
||||||
|
if (solution) {
|
||||||
|
setUserSolutions([...userSolutions.filter((x) => x.exercise !== solution.exercise), { ...solution, module: "level", exam: exam.id }]);
|
||||||
|
}
|
||||||
|
|
||||||
|
setExerciseIndex(exerciseIndex - 1);
|
||||||
|
if (exerciseIndex - 1 === -1) {
|
||||||
|
setPartIndex(partIndex - 1);
|
||||||
|
const lastPartExerciseIndex = exam.parts[partIndex - 1].exercises.length - 1;
|
||||||
|
const previousExercise = exam.parts[partIndex - 1].exercises[lastPartExerciseIndex];
|
||||||
|
if (previousExercise.type === "multipleChoice") {
|
||||||
|
setStoreQuestionIndex(previousExercise.questions.length - 1)
|
||||||
|
}
|
||||||
|
const multipleChoiceQuestionsDone = [];
|
||||||
|
for (let i = 0; i < exam.parts.length; i++) {
|
||||||
|
if (i == (partIndex - 1)) break;
|
||||||
|
for (let j = 0; j < exam.parts[i].exercises.length; j++) {
|
||||||
|
const exercise = exam.parts[i].exercises[j];
|
||||||
|
if (exercise.type === "multipleChoice") {
|
||||||
|
multipleChoiceQuestionsDone.push({ id: exercise.id, amount: exercise.questions.length - 1 })
|
||||||
|
}
|
||||||
|
if (exercise.type === "fillBlanks") {
|
||||||
|
multipleChoiceQuestionsDone.push({ id: exercise.id, amount: exercise.words.length - 1 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setMultipleChoicesDone(multipleChoiceQuestionsDone);
|
||||||
|
}
|
||||||
|
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (exerciseIndex === -1) {
|
||||||
|
nextExercise()
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [exerciseIndex])
|
||||||
|
|
||||||
|
const calculateExerciseIndex = () => {
|
||||||
|
if (partIndex === 0) {
|
||||||
|
return (
|
||||||
|
(exerciseIndex === -1 ? 0 : exerciseIndex + 1) + storeQuestionIndex //+ multipleChoicesDone.reduce((acc, curr) => acc + curr.amount, 0)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const exercisesPerPart = exam.parts.map((x) => x.exercises.length);
|
||||||
|
const exercisesDone = exercisesPerPart.filter((_, index) => index < partIndex).reduce((acc, curr) => curr + acc, 0);
|
||||||
|
return (
|
||||||
|
exercisesDone +
|
||||||
|
(exerciseIndex === -1 ? 0 : exerciseIndex + 1) +
|
||||||
|
storeQuestionIndex
|
||||||
|
+ multipleChoicesDone.reduce((acc, curr) => { return acc + curr.amount }, 0)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderText = () => (
|
||||||
|
<div className={clsx("flex flex-col gap-6 w-full bg-mti-gray-seasalt rounded-xl mt-4 relative py-8 px-16")}>
|
||||||
|
<>
|
||||||
|
<div className="flex flex-col w-full gap-2">
|
||||||
|
<h4 className="text-xl font-semibold">
|
||||||
|
Please read the following excerpt attentively, you will then be asked questions about the text you've read.
|
||||||
|
</h4>
|
||||||
|
<span className="text-base">You will be allowed to read the text while doing the exercises</span>
|
||||||
|
</div>
|
||||||
|
<TextComponent
|
||||||
|
part={exam.parts[partIndex]}
|
||||||
|
contextWord={contextWord}
|
||||||
|
setContextWordLine={setContextWordLine}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const partLabel = () => {
|
||||||
|
if (currentExercise?.type === "fillBlanks" && typeCheckWordsMC(currentExercise.words))
|
||||||
|
return `Part ${partIndex + 1} (Questions ${currentExercise.words[0].id} - ${currentExercise.words[currentExercise.words.length - 1].id})\n\n${currentExercise.prompt}`
|
||||||
|
|
||||||
|
if (currentExercise?.type === "multipleChoice") {
|
||||||
|
return `Part ${partIndex + 1} (Questions ${currentExercise.questions[0].id} - ${currentExercise.questions[currentExercise.questions.length - 1].id})\n\n${currentExercise.prompt}`
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof exam.parts[partIndex].context === "string") {
|
||||||
|
const nextExercise = exam.parts[partIndex].exercises[0] as MultipleChoiceExercise;
|
||||||
|
return `Part ${partIndex + 1} (Questions ${nextExercise.questions[0].id} - ${nextExercise.questions[nextExercise.questions.length - 1].id})\n\n${nextExercise.prompt}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const modalKwargs = () => {
|
||||||
|
const allSolutionsCorrectLength = exam.parts[partIndex].exercises.every((exercise) => {
|
||||||
|
const userSolution = userSolutions.find(x => x.exercise === exercise.id);
|
||||||
|
if (exercise.type === "multipleChoice") {
|
||||||
|
return userSolution?.solutions.length === exercise.questions.length;
|
||||||
|
}
|
||||||
|
if (exercise.type === "fillBlanks") {
|
||||||
|
return userSolution?.solutions.length === exercise.words.length;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
blankQuestions: !allSolutionsCorrectLength,
|
||||||
|
finishingWhat: "part",
|
||||||
|
onClose: partIndex !== exam.parts.length - 1 ? (
|
||||||
|
function (x: boolean | undefined) { if (x) { setShowQuestionsModal(false); nextExercise(); } else { setShowQuestionsModal(false) } }
|
||||||
|
) : function (x: boolean | undefined) { if (x) { setShowQuestionsModal(false); onFinish(userSolutions); } else { setShowQuestionsModal(false) } }
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className={clsx("flex flex-col h-full w-full gap-8 items-center", showPartDivider && "justify-center")}>
|
||||||
|
<QuestionsModal isOpen={showQuestionsModal} {...modalKwargs()} />
|
||||||
|
{
|
||||||
|
!(partIndex === 0 && storeQuestionIndex === 0 && showPartDivider) &&
|
||||||
|
<Timer minTimer={exam.minTimer} disableTimer={showSolutions} standalone={true} />
|
||||||
|
}
|
||||||
|
{exam.parts[0].intro && showPartDivider ? <PartDivider part={exam.parts[partIndex]} partIndex={partIndex} onNext={() => { setShowPartDivider(false); setBgColor("bg-white") }} /> : (
|
||||||
|
<>
|
||||||
|
<ModuleTitle
|
||||||
|
partLabel={partLabel()}
|
||||||
|
minTimer={exam.minTimer}
|
||||||
|
exerciseIndex={calculateExerciseIndex()}
|
||||||
|
module="level"
|
||||||
|
totalExercises={countExercises(exam.parts.flatMap((x) => x.exercises))}
|
||||||
|
disableTimer={showSolutions || editing}
|
||||||
|
showTimer={typeof exam.parts[0].intro === "undefined"}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className={clsx(
|
||||||
|
"mb-20 w-full",
|
||||||
|
partIndex > -1 && exerciseIndex > -1 && !!exam.parts[partIndex].context && "grid grid-cols-2 gap-4",
|
||||||
|
)}>
|
||||||
|
{partIndex > -1 && !!exam.parts[partIndex].context && renderText()}
|
||||||
|
|
||||||
|
{exerciseIndex > -1 &&
|
||||||
|
partIndex > -1 &&
|
||||||
|
exerciseIndex < exam.parts[partIndex].exercises.length &&
|
||||||
|
!showSolutions &&
|
||||||
|
!editing &&
|
||||||
|
currentExercise &&
|
||||||
|
renderExercise(currentExercise, exam.id, nextExercise, previousExercise)}
|
||||||
|
|
||||||
|
{exerciseIndex > -1 &&
|
||||||
|
partIndex > -1 &&
|
||||||
|
exerciseIndex < exam.parts[partIndex].exercises.length &&
|
||||||
|
(showSolutions || editing) &&
|
||||||
|
currentExercise &&
|
||||||
|
renderSolution(currentExercise, nextExercise, previousExercise)}
|
||||||
|
</div>
|
||||||
|
{/*exerciseIndex === -1 && partIndex > 0 && (
|
||||||
|
<div className="self-end flex justify-between w-full gap-8 absolute bottom-8 left-0 px-8">
|
||||||
|
<Button
|
||||||
|
color="purple"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => {
|
||||||
|
setExerciseIndex(exam.parts[partIndex - 1].exercises.length - 1);
|
||||||
|
setPartIndex(partIndex - 1);
|
||||||
|
}}
|
||||||
|
className="max-w-[200px] w-full"
|
||||||
|
disabled={
|
||||||
|
exam && typeof partIndex !== "undefined" && exam.module === "level" &&
|
||||||
|
typeof exam.parts[0].intro === "string" && storeQuestionIndex === 0}
|
||||||
|
>
|
||||||
|
Back
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button color="purple" onClick={() => nextExercise()} className="max-w-[200px] self-end w-full">
|
||||||
|
Next
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)*/}
|
||||||
|
{exerciseIndex === -1 && partIndex === 0 && (
|
||||||
|
<Button color="purple" onClick={() => nextExercise()} className="max-w-[200px] self-end w-full">
|
||||||
|
Start now
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -5,7 +5,7 @@ import {renderSolution} from "@/components/Solutions";
|
|||||||
import ModuleTitle from "@/components/Medium/ModuleTitle";
|
import ModuleTitle from "@/components/Medium/ModuleTitle";
|
||||||
import AudioPlayer from "@/components/Low/AudioPlayer";
|
import AudioPlayer from "@/components/Low/AudioPlayer";
|
||||||
import Button from "@/components/Low/Button";
|
import Button from "@/components/Low/Button";
|
||||||
import BlankQuestionsModal from "@/components/BlankQuestionsModal";
|
import BlankQuestionsModal from "@/components/QuestionsModal";
|
||||||
import useExamStore from "@/stores/examStore";
|
import useExamStore from "@/stores/examStore";
|
||||||
import {countExercises} from "@/utils/moduleUtils";
|
import {countExercises} from "@/utils/moduleUtils";
|
||||||
|
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import ProgressBar from "@/components/Low/ProgressBar";
|
|||||||
import ModuleTitle from "@/components/Medium/ModuleTitle";
|
import ModuleTitle from "@/components/Medium/ModuleTitle";
|
||||||
import {Divider} from "primereact/divider";
|
import {Divider} from "primereact/divider";
|
||||||
import Button from "@/components/Low/Button";
|
import Button from "@/components/Low/Button";
|
||||||
import BlankQuestionsModal from "@/components/BlankQuestionsModal";
|
import BlankQuestionsModal from "@/components/QuestionsModal";
|
||||||
import useExamStore from "@/stores/examStore";
|
import useExamStore from "@/stores/examStore";
|
||||||
import {defaultUserSolutions} from "@/utils/exams";
|
import {defaultUserSolutions} from "@/utils/exams";
|
||||||
import {countExercises} from "@/utils/moduleUtils";
|
import {countExercises} from "@/utils/moduleUtils";
|
||||||
|
|||||||
@@ -133,7 +133,7 @@ export default function Selection({user, page, onStart, disableSelection = false
|
|||||||
<BsArrowRepeat className={clsx("text-xl", isLoading && "animate-spin")} />
|
<BsArrowRepeat className={clsx("text-xl", isLoading && "animate-spin")} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<span className="text-mti-gray-taupe scrollbar-hide flex gap-8 overflow-x-scroll">
|
<span className="text-mti-gray-taupe flex gap-8 overflow-x-auto pb-2">
|
||||||
{sessions
|
{sessions
|
||||||
.sort((a, b) => moment(b.date).diff(moment(a.date)))
|
.sort((a, b) => moment(b.date).diff(moment(a.date)))
|
||||||
.map((session) => (
|
.map((session) => (
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ export interface LevelExam extends ExamBase {
|
|||||||
|
|
||||||
export interface LevelPart {
|
export interface LevelPart {
|
||||||
context?: string;
|
context?: string;
|
||||||
showContextLines?: boolean;
|
intro?: string;
|
||||||
exercises: Exercise[];
|
exercises: Exercise[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import {toast} from "react-toastify";
|
|||||||
import readXlsxFile from "read-excel-file";
|
import readXlsxFile from "read-excel-file";
|
||||||
import {useFilePicker} from "use-file-picker";
|
import {useFilePicker} from "use-file-picker";
|
||||||
import {getUserCorporate} from "@/utils/groups";
|
import {getUserCorporate} from "@/utils/groups";
|
||||||
import {isAgentUser, isCorporateUser} from "@/resources/user";
|
import {isAgentUser, isCorporateUser, USER_TYPE_LABELS} from "@/resources/user";
|
||||||
import {checkAccess} from "@/utils/permissions";
|
import {checkAccess} from "@/utils/permissions";
|
||||||
import usePermissions from "@/hooks/usePermissions";
|
import usePermissions from "@/hooks/usePermissions";
|
||||||
|
|
||||||
@@ -109,7 +109,7 @@ const CreatePanel = ({user, users, group, onClose}: CreateDialogProps) => {
|
|||||||
const submit = () => {
|
const submit = () => {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
|
|
||||||
if (name !== group?.name && (name === "Students" || name === "Teachers")) {
|
if (name !== group?.name && (name?.trim() === "Students" || name?.trim() === "Teachers" || name?.trim() === "Corporate")) {
|
||||||
toast.error("That group name is reserved and cannot be used, please enter another one.");
|
toast.error("That group name is reserved and cannot be used, please enter another one.");
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
return;
|
return;
|
||||||
@@ -202,7 +202,6 @@ const filterTypes = ["corporate", "teacher", "mastercorporate"];
|
|||||||
export default function GroupList({user}: {user: User}) {
|
export default function GroupList({user}: {user: User}) {
|
||||||
const [isCreating, setIsCreating] = useState(false);
|
const [isCreating, setIsCreating] = useState(false);
|
||||||
const [editingGroup, setEditingGroup] = useState<Group>();
|
const [editingGroup, setEditingGroup] = useState<Group>();
|
||||||
const [filterByUser, setFilterByUser] = useState(false);
|
|
||||||
|
|
||||||
const {permissions} = usePermissions(user?.id || "");
|
const {permissions} = usePermissions(user?.id || "");
|
||||||
|
|
||||||
@@ -218,12 +217,6 @@ export default function GroupList({user}: {user: User}) {
|
|||||||
adminAdmins: user?.id,
|
adminAdmins: user?.id,
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (user && ["corporate", "teacher", "mastercorporate"].includes(user.type)) {
|
|
||||||
setFilterByUser(true);
|
|
||||||
}
|
|
||||||
}, [user]);
|
|
||||||
|
|
||||||
const deleteGroup = (group: Group) => {
|
const deleteGroup = (group: Group) => {
|
||||||
if (!confirm(`Are you sure you want to delete "${group.name}"?`)) return;
|
if (!confirm(`Are you sure you want to delete "${group.name}"?`)) return;
|
||||||
|
|
||||||
@@ -246,7 +239,7 @@ export default function GroupList({user}: {user: User}) {
|
|||||||
columnHelper.accessor("admin", {
|
columnHelper.accessor("admin", {
|
||||||
header: "Admin",
|
header: "Admin",
|
||||||
cell: (info) => (
|
cell: (info) => (
|
||||||
<div className="tooltip" data-tip={capitalize(users.find((x) => x.id === info.getValue())?.type)}>
|
<div className="tooltip" data-tip={USER_TYPE_LABELS[users.find((x) => x.id === info.getValue())?.type || "student"]}>
|
||||||
{users.find((x) => x.id === info.getValue())?.name}
|
{users.find((x) => x.id === info.getValue())?.name}
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
@@ -309,7 +302,7 @@ export default function GroupList({user}: {user: User}) {
|
|||||||
user={user}
|
user={user}
|
||||||
onClose={closeModal}
|
onClose={closeModal}
|
||||||
users={
|
users={
|
||||||
user?.type === "corporate" || user?.type === "teacher"
|
checkAccess(user, ["corporate", "teacher", "mastercorporate"])
|
||||||
? users.filter(
|
? users.filter(
|
||||||
(u) =>
|
(u) =>
|
||||||
groups
|
groups
|
||||||
|
|||||||
@@ -58,7 +58,10 @@ export default function UserList({
|
|||||||
|
|
||||||
const {users, reload} = useUsers();
|
const {users, reload} = useUsers();
|
||||||
const {permissions} = usePermissions(user?.id || "");
|
const {permissions} = usePermissions(user?.id || "");
|
||||||
const {groups} = useGroups({admin: user && ["corporate", "teacher", "mastercorporate"].includes(user?.type) ? user.id : undefined});
|
const {groups} = useGroups({
|
||||||
|
admin: user && ["corporate", "teacher", "mastercorporate"].includes(user?.type) ? user.id : undefined,
|
||||||
|
userType: user?.type,
|
||||||
|
});
|
||||||
|
|
||||||
const appendUserFilters = useFilterStore((state) => state.appendUserFilter);
|
const appendUserFilters = useFilterStore((state) => state.appendUserFilter);
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -76,8 +79,7 @@ export default function UserList({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
(async () => {
|
(async () => {
|
||||||
if (user && users) {
|
if (user && users) {
|
||||||
const filterUsers =
|
const filterUsers = ["corporate", "teacher", "mastercorporate"].includes(user.type)
|
||||||
user.type === "corporate" || user.type === "teacher"
|
|
||||||
? users.filter((u) => groups.flatMap((g) => g.participants).includes(u.id))
|
? users.filter((u) => groups.flatMap((g) => g.participants).includes(u.id))
|
||||||
: users;
|
: users;
|
||||||
|
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import {toast, ToastContainer} from "react-toastify";
|
|||||||
import {v4 as uuidv4} from "uuid";
|
import {v4 as uuidv4} from "uuid";
|
||||||
import useSessions from "@/hooks/useSessions";
|
import useSessions from "@/hooks/useSessions";
|
||||||
import ShortUniqueId from "short-unique-id";
|
import ShortUniqueId from "short-unique-id";
|
||||||
|
import clsx from "clsx";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
page: "exams" | "exercises";
|
page: "exams" | "exercises";
|
||||||
@@ -54,6 +55,7 @@ export default function ExamPage({page}: Props) {
|
|||||||
const {showSolutions, setShowSolutions} = useExamStore((state) => state);
|
const {showSolutions, setShowSolutions} = useExamStore((state) => state);
|
||||||
const {selectedModules, setSelectedModules} = useExamStore((state) => state);
|
const {selectedModules, setSelectedModules} = useExamStore((state) => state);
|
||||||
const {inactivity, setInactivity} = useExamStore((state) => state);
|
const {inactivity, setInactivity} = useExamStore((state) => state);
|
||||||
|
const {bgColor, setBgColor} = useExamStore((state) => state);
|
||||||
|
|
||||||
const {user} = useUser({redirectTo: "/login"});
|
const {user} = useUser({redirectTo: "/login"});
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -280,6 +282,13 @@ export default function ExamPage({page}: Props) {
|
|||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [statsAwaitingEvaluation]);
|
}, [statsAwaitingEvaluation]);
|
||||||
|
|
||||||
|
useEffect(()=> {
|
||||||
|
|
||||||
|
if(exam && exam.module === "level" && exam.parts[0].intro && !showSolutions) {
|
||||||
|
setBgColor("bg-ielts-level-light");
|
||||||
|
}
|
||||||
|
}, [exam, showSolutions, setBgColor])
|
||||||
|
|
||||||
const checkIfStatsHaveBeenEvaluated = (ids: string[]) => {
|
const checkIfStatsHaveBeenEvaluated = (ids: string[]) => {
|
||||||
setTimeout(async () => {
|
setTimeout(async () => {
|
||||||
try {
|
try {
|
||||||
@@ -513,6 +522,7 @@ export default function ExamPage({page}: Props) {
|
|||||||
{user && (
|
{user && (
|
||||||
<Layout
|
<Layout
|
||||||
user={user}
|
user={user}
|
||||||
|
bgColor={bgColor}
|
||||||
className="justify-between"
|
className="justify-between"
|
||||||
focusMode={selectedModules.length !== 0 && !showSolutions && moduleIndex < selectedModules.length}
|
focusMode={selectedModules.length !== 0 && !showSolutions && moduleIndex < selectedModules.length}
|
||||||
onFocusLayerMouseEnter={() => setShowAbandonPopup(true)}>
|
onFocusLayerMouseEnter={() => setShowAbandonPopup(true)}>
|
||||||
|
|||||||
@@ -1,20 +1,13 @@
|
|||||||
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
|
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
|
||||||
import type {NextApiRequest, NextApiResponse} from "next";
|
import type {NextApiRequest, NextApiResponse} from "next";
|
||||||
import {app} from "@/firebase";
|
import {app} from "@/firebase";
|
||||||
import {
|
import {getFirestore, collection, getDocs, setDoc, doc, query, where} from "firebase/firestore";
|
||||||
getFirestore,
|
|
||||||
collection,
|
|
||||||
getDocs,
|
|
||||||
setDoc,
|
|
||||||
doc,
|
|
||||||
query,
|
|
||||||
where,
|
|
||||||
} from "firebase/firestore";
|
|
||||||
import {withIronSessionApiRoute} from "iron-session/next";
|
import {withIronSessionApiRoute} from "iron-session/next";
|
||||||
import {sessionOptions} from "@/lib/session";
|
import {sessionOptions} from "@/lib/session";
|
||||||
import {Group} from "@/interfaces/user";
|
import {Group} from "@/interfaces/user";
|
||||||
import {v4} from "uuid";
|
import {v4} from "uuid";
|
||||||
import {updateExpiryDateOnGroup, getGroupsForUser} from "@/utils/groups.be";
|
import {updateExpiryDateOnGroup, getGroupsForUser} from "@/utils/groups.be";
|
||||||
|
import {uniqBy} from "lodash";
|
||||||
|
|
||||||
const db = getFirestore(app);
|
const db = getFirestore(app);
|
||||||
|
|
||||||
@@ -39,32 +32,17 @@ async function get(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
if (req.session?.user?.type === "mastercorporate") {
|
if (req.session?.user?.type === "mastercorporate") {
|
||||||
try {
|
try {
|
||||||
const masterCorporateGroups = await getGroupsForUser(admin, participant);
|
const masterCorporateGroups = await getGroupsForUser(admin, participant);
|
||||||
const corporatesFromMaster = masterCorporateGroups
|
const corporatesFromMaster = masterCorporateGroups.filter((g) => g.name.trim() === "Corporate").flatMap((g) => g.participants);
|
||||||
.filter((g) => g.name === "Corporate")
|
|
||||||
.flatMap((g) => g.participants);
|
|
||||||
|
|
||||||
if (corporatesFromMaster.length === 0) {
|
if (corporatesFromMaster.length === 0) return res.status(200).json(masterCorporateGroups);
|
||||||
res.status(200).json([]);
|
|
||||||
return;
|
const groups = await Promise.all(corporatesFromMaster.map((c) => getGroupsForUser(c, participant)));
|
||||||
}
|
return res.status(200).json([...masterCorporateGroups, ...uniqBy(groups.flat(), "id")]);
|
||||||
Promise.all(
|
|
||||||
corporatesFromMaster.map((c) => getGroupsForUser(c, participant))
|
|
||||||
)
|
|
||||||
.then((groups) => {
|
|
||||||
res.status(200).json([...masterCorporateGroups, ...groups.flat()]);
|
|
||||||
return;
|
|
||||||
})
|
|
||||||
.catch((e) => {
|
|
||||||
console.error(e);
|
|
||||||
res.status(500).json({ ok: false });
|
|
||||||
return;
|
|
||||||
});
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e);
|
console.error(e);
|
||||||
res.status(500).json({ok: false});
|
res.status(500).json({ok: false});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -79,11 +57,7 @@ async function get(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
async function post(req: NextApiRequest, res: NextApiResponse) {
|
async function post(req: NextApiRequest, res: NextApiResponse) {
|
||||||
const body = req.body as Group;
|
const body = req.body as Group;
|
||||||
|
|
||||||
await Promise.all(
|
await Promise.all(body.participants.map(async (p) => await updateExpiryDateOnGroup(p, body.admin)));
|
||||||
body.participants.map(
|
|
||||||
async (p) => await updateExpiryDateOnGroup(p, body.admin)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
await setDoc(doc(db, "groups", v4()), {
|
await setDoc(doc(db, "groups", v4()), {
|
||||||
name: body.name,
|
name: body.name,
|
||||||
|
|||||||
@@ -61,13 +61,14 @@ export default function History({user}: {user: User}) {
|
|||||||
state.training,
|
state.training,
|
||||||
state.setTraining,
|
state.setTraining,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// const [statsUserId, setStatsUserId] = useState<string | undefined>(user.id);
|
// const [statsUserId, setStatsUserId] = useState<string | undefined>(user.id);
|
||||||
const [groupedStats, setGroupedStats] = useState<{[key: string]: Stat[]}>();
|
const [groupedStats, setGroupedStats] = useState<{[key: string]: Stat[]}>();
|
||||||
const [filter, setFilter] = useState<"months" | "weeks" | "days" | "assignments">();
|
const [filter, setFilter] = useState<"months" | "weeks" | "days" | "assignments">();
|
||||||
const {assignments} = useAssignments({});
|
const {assignments} = useAssignments({});
|
||||||
|
|
||||||
const {users} = useUsers();
|
const {users} = useUsers();
|
||||||
const {stats, isLoading: isStatsLoading} = useStats(statsUserId);
|
const {stats, isLoading: isStatsLoading} = useStats(user?.type === "student" ? user?.id : statsUserId);
|
||||||
const {groups: allGroups} = useGroups({});
|
const {groups: allGroups} = useGroups({});
|
||||||
|
|
||||||
const groups = allGroups.filter((x) => x.admin === user.id);
|
const groups = allGroups.filter((x) => x.admin === user.id);
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ export interface ExamState {
|
|||||||
questionIndex: number;
|
questionIndex: number;
|
||||||
inactivity: number;
|
inactivity: number;
|
||||||
shuffleMaps: ShuffleMap[];
|
shuffleMaps: ShuffleMap[];
|
||||||
|
bgColor: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ExamFunctions {
|
export interface ExamFunctions {
|
||||||
@@ -37,6 +38,7 @@ export interface ExamFunctions {
|
|||||||
setQuestionIndex: (questionIndex: number) => void;
|
setQuestionIndex: (questionIndex: number) => void;
|
||||||
setInactivity: (inactivity: number) => void;
|
setInactivity: (inactivity: number) => void;
|
||||||
setShuffleMaps: (shuffleMaps: ShuffleMap[]) => void;
|
setShuffleMaps: (shuffleMaps: ShuffleMap[]) => void;
|
||||||
|
setBgColor: (bgColor: string) => void;
|
||||||
reset: () => void;
|
reset: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -55,7 +57,8 @@ export const initialState: ExamState = {
|
|||||||
exerciseIndex: -1,
|
exerciseIndex: -1,
|
||||||
questionIndex: 0,
|
questionIndex: 0,
|
||||||
inactivity: 0,
|
inactivity: 0,
|
||||||
shuffleMaps: []
|
shuffleMaps: [],
|
||||||
|
bgColor: "bg-white"
|
||||||
};
|
};
|
||||||
|
|
||||||
const useExamStore = create<ExamState & ExamFunctions>((set) => ({
|
const useExamStore = create<ExamState & ExamFunctions>((set) => ({
|
||||||
@@ -76,6 +79,7 @@ const useExamStore = create<ExamState & ExamFunctions>((set) => ({
|
|||||||
setQuestionIndex: (questionIndex: number) => set(() => ({questionIndex})),
|
setQuestionIndex: (questionIndex: number) => set(() => ({questionIndex})),
|
||||||
setInactivity: (inactivity: number) => set(() => ({inactivity})),
|
setInactivity: (inactivity: number) => set(() => ({inactivity})),
|
||||||
setShuffleMaps: (shuffleMaps) => set(() => ({shuffleMaps})),
|
setShuffleMaps: (shuffleMaps) => set(() => ({shuffleMaps})),
|
||||||
|
setBgColor: (bgColor) => set(()=> ({bgColor})),
|
||||||
|
|
||||||
reset: () => set(() => initialState),
|
reset: () => set(() => initialState),
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ module.exports = {
|
|||||||
boxShadow: {
|
boxShadow: {
|
||||||
"training-inset": "inset 0px 2px 18px 0px #00000029",
|
"training-inset": "inset 0px 2px 18px 0px #00000029",
|
||||||
},
|
},
|
||||||
|
safelist: ["bg-ielts-level"],
|
||||||
colors: {
|
colors: {
|
||||||
mti: {
|
mti: {
|
||||||
orange: {DEFAULT: "#FF6000", dark: "#cc4402", light: "#ff790a", ultralight: "#ffdaa5"},
|
orange: {DEFAULT: "#FF6000", dark: "#cc4402", light: "#ff790a", ultralight: "#ffdaa5"},
|
||||||
|
|||||||
Reference in New Issue
Block a user