Navigation rework, added prompt edit to components that were missing

This commit is contained in:
Carlos-Mesquita
2024-11-25 16:50:46 +00:00
parent e9b7bd14cc
commit 114da173be
105 changed files with 3761 additions and 3728 deletions

View File

@@ -39,7 +39,7 @@ const MCDropdown: React.FC<MCDropdownProps> = ({
});
return (
<div className={`${className} inline-block`} style={{ width: `${width}px` }}>
<div key={`dropdown-${id}`} className={`${className} inline-block`} style={{ width: `${width}px` }}>
<button
onClick={() => onToggle(id)}
className={

View File

@@ -1,12 +1,13 @@
//import "@/utils/wdyr";
import { FillBlanksExercise, FillBlanksMCOption } from "@/interfaces/exam";
import useExamStore, { usePersistentExamStore } from "@/stores/examStore";
import clsx from "clsx";
import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from "react";
import reactStringReplace from "react-string-replace";
import { CommonProps } from "..";
import Button from "../../Low/Button";
import { CommonProps } from "../types";
import { v4 } from "uuid";
import MCDropdown from "./MCDropdown";
import useExamStore, { usePersistentExamStore } from "@/stores/exam";
const FillBlanks: React.FC<FillBlanksExercise & CommonProps> = ({
id,
@@ -18,43 +19,29 @@ const FillBlanks: React.FC<FillBlanksExercise & CommonProps> = ({
words,
userSolutions,
variant,
registerSolution,
headerButtons,
footerButtons,
preview,
onNext,
onBack,
disableProgressButtons = false
}) => {
const examState = useExamStore((state) => state);
const persistentExamState = usePersistentExamStore((state) => state);
const {
hasExamEnded,
exerciseIndex,
partIndex,
questionIndex,
shuffles,
exam,
setCurrentSolution,
} = !preview ? examState : persistentExamState;
} = examState; !preview ? examState : persistentExamState;
const [answers, setAnswers] = useState<{ id: string; solution: string }[]>(userSolutions);
const shuffleMaps = shuffles.find((x) => x.exerciseID == id)?.shuffles;
const dropdownRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (disableProgressButtons) onNext({ exercise: id, solutions: answers, score: calculateScore(), type, isPractice });
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [answers, disableProgressButtons])
const excludeWordMCType = (x: any) => {
return typeof x === "string" ? x : (x as { letter: string; word: string });
};
useEffect(() => {
if (hasExamEnded) onNext({ exercise: id, solutions: answers, score: calculateScore(), type });
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [hasExamEnded]);
let correctWords: any;
if (exam && (exam.module === "level" || exam.module === "reading") && exam.parts[partIndex].exercises[exerciseIndex].type === "fillBlanks") {
correctWords = (exam.parts[partIndex].exercises[exerciseIndex] as FillBlanksExercise).words;
@@ -73,7 +60,7 @@ const FillBlanks: React.FC<FillBlanksExercise & CommonProps> = ({
};
}, []);
const calculateScore = () => {
const calculateScore = useCallback(() => {
const total = text.match(/({{\d+}})/g)?.length || 0;
const correct = answers!.filter((x) => {
const solution = solutions.find((y) => x.id.toString() === y.id.toString())?.solution;
@@ -100,7 +87,19 @@ const FillBlanks: React.FC<FillBlanksExercise & CommonProps> = ({
}).length;
const missing = total - answers!.filter((x) => solutions.find((y) => x.id.toString() === y.id.toString())).length;
return { total, correct, missing };
};
}, [answers, correctWords, solutions, text]);
useEffect(() => {
registerSolution(() => ({
exercise: id,
solutions: answers,
score: calculateScore(),
type,
shuffleMaps,
isPractice
}));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [id, answers, type, isPractice, shuffleMaps, calculateScore]);
const [openDropdownId, setOpenDropdownId] = useState<string | null>(null);
@@ -137,6 +136,7 @@ const FillBlanks: React.FC<FillBlanksExercise & CommonProps> = ({
/>
) : (
<input
key={`input-${id}`}
className={styles}
onChange={(e) => setAnswers((prev) => [...prev.filter((x) => x.id !== id), { id, solution: e.target.value }])}
value={userSolution?.solution}
@@ -151,10 +151,10 @@ const FillBlanks: React.FC<FillBlanksExercise & CommonProps> = ({
const memoizedLines = useMemo(() => {
return text.split("\\n").map((line, index) => (
<p key={index} className={clsx(variant === "mc" && "whitespace-pre-wrap")}>
<div key={index} className={clsx(variant === "mc" && "whitespace-pre-wrap")}>
{renderLines(line)}
<br />
</p>
</div>
));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [text, variant, renderLines]);
@@ -163,40 +163,10 @@ const FillBlanks: React.FC<FillBlanksExercise & CommonProps> = ({
setAnswers((prev) => [...prev.filter((x) => x.id !== questionID), { id: questionID, solution: value }]);
};
useEffect(() => {
if (variant === "mc") {
setCurrentSolution({ exercise: id, solutions: answers, score: calculateScore(), type, shuffleMaps: shuffleMaps });
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [answers]);
const progressButtons = () => (
<div className="flex justify-between w-full gap-8">
<Button
color="purple"
variant="outline"
onClick={() => onBack({ exercise: id, solutions: answers, score: calculateScore(), type, shuffleMaps: shuffleMaps, isPractice })}
className="max-w-[200px] w-full"
disabled={exam && exam.module === "level" && partIndex === 0 && questionIndex === 0}>
Previous Page
</Button>
<Button
color="purple"
onClick={() => {
onNext({ exercise: id, solutions: answers, score: calculateScore(), type, shuffleMaps: shuffleMaps, isPractice });
}}
className="max-w-[200px] self-end w-full">
Next Page
</Button>
</div>
)
return (
<div className="flex flex-col gap-4">
{!disableProgressButtons && progressButtons()}
<div className={clsx("flex flex-col gap-4 mt-4 h-full w-full", !disableProgressButtons && "mb-20")}>
{headerButtons}
<div className={clsx("flex flex-col gap-4 mt-4 h-full w-full", (!headerButtons && !footerButtons) && "mb-20")}>
{variant !== "mc" && (
<span className="text-sm w-full leading-6">
{prompt.split("\\n").map((line, index) => (
@@ -234,11 +204,12 @@ const FillBlanks: React.FC<FillBlanksExercise & CommonProps> = ({
</div>
</div>
)}
{footerButtons}
</div>
{!disableProgressButtons && progressButtons()}
</div>
);
};
//FillBlanks.whyDidYouRender = true
export default FillBlanks;

View File

@@ -0,0 +1,203 @@
import { InteractiveSpeakingExercise } from "@/interfaces/exam";
import { CommonProps } from "../types";
import { useCallback, useEffect, useRef, useState } from "react";
import { BsCheckCircleFill, BsMicFill, BsPauseCircle, BsPlayCircle, BsTrashFill } from "react-icons/bs";
import dynamic from "next/dynamic";
import useExamStore, { usePersistentExamStore } from "@/stores/exam";
import useAnswers, { Answer } from "./useAnswers";
const Waveform = dynamic(() => import("../../Waveform"), { ssr: false });
const ReactMediaRecorder = dynamic(() => import("react-media-recorder").then((mod) => mod.ReactMediaRecorder), {
ssr: false,
});
const InteractiveSpeaking: React.FC<InteractiveSpeakingExercise & CommonProps> = ({
id,
title,
first_title,
second_title,
prompts,
userSolutions,
isPractice = false,
registerSolution,
headerButtons,
footerButtons,
type,
preview,
}) => {
const [isRecording, setIsRecording] = useState(false);
const [recordingDuration, setRecordingDuration] = useState(0);
const timerRef = useRef<NodeJS.Timeout>();
const { answers, setAnswers, updateAnswers } = useAnswers(
id,
type,
isPractice,
prompts,
registerSolution,
preview
);
const examState = useExamStore((state) => state);
const persistentExamState = usePersistentExamStore((state) => state);
const { questionIndex } = !preview ? examState : persistentExamState;
useEffect(() => {
if (isRecording) {
timerRef.current = setInterval(() => {
setRecordingDuration(prev => prev + 1);
}, 1000);
}
return () => {
if (timerRef.current) {
clearInterval(timerRef.current);
}
};
}, [isRecording]);
useEffect(() => {
if (userSolutions.length > 0 && answers.length === 0) {
setAnswers(userSolutions.flatMap(solution =>
solution.solution.map(s => ({
questionIndex: s.questionIndex,
prompt: s.question,
blob: s.answer
}))
));
}
}, [userSolutions, answers.length, setAnswers]);
const resetRecording = useCallback(() => {
setRecordingDuration(0);
setIsRecording(false);
}, []);
const handleStartRecording = useCallback((startRecording: () => void) => {
resetRecording();
startRecording();
setIsRecording(true);
}, [resetRecording]);
return (
<div className="flex flex-col gap-4 mt-4 w-full">
{headerButtons}
<div className="flex flex-col h-full w-full gap-9">
<div className="flex flex-col w-full gap-8 bg-mti-gray-smoke rounded-xl py-8 pb-12 px-16">
<div className="flex flex-col gap-3">
<span className="font-semibold">
{!!first_title && !!second_title ? `${first_title} & ${second_title}` : title}
</span>
</div>
{prompts && prompts.length > 0 && (
<div className="flex flex-col gap-4 w-full items-center">
<video key={questionIndex} autoPlay controls className="max-w-3xl rounded-xl">
<source src={prompts[questionIndex].video_url} />
</video>
</div>
)}
</div>
<ReactMediaRecorder
audio
key={questionIndex}
onStop={updateAnswers}
render={({ status, startRecording, stopRecording, pauseRecording, resumeRecording, mediaBlobUrl }) => (
<div className="w-full p-4 px-8 bg-transparent border-2 border-mti-gray-platinum rounded-2xl flex-col gap-8 items-center">
<p className="text-base font-normal">Record your answer:</p>
<div className="flex gap-8 items-center justify-center py-8">
{status === "idle" && (
<>
<div className="w-full h-2 max-w-4xl bg-mti-gray-smoke rounded-full" />
<BsMicFill
onClick={() => handleStartRecording(startRecording)}
className="h-5 w-5 text-mti-gray-cool cursor-pointer"
/>
</>
)}
{status === "recording" && (
<>
<div className="flex gap-4 items-center">
<span className="text-xs w-9">
{`${Math.floor(recordingDuration / 60).toString().padStart(2, "0")}:${Math.floor(recordingDuration % 60).toString().padStart(2, "0")}`}
</span>
</div>
<div className="w-full h-2 max-w-4xl bg-mti-gray-smoke rounded-full" />
<div className="flex gap-4 items-center">
<BsPauseCircle
onClick={() => {
setIsRecording(false);
pauseRecording();
}}
className="text-red-500 w-8 h-8 cursor-pointer"
/>
<BsCheckCircleFill
onClick={() => {
setIsRecording(false);
stopRecording();
}}
className="text-mti-purple-light w-8 h-8 cursor-pointer"
/>
</div>
</>
)}
{status === "paused" && (
<>
<div className="flex gap-4 items-center">
<span className="text-xs w-9">
{`${Math.floor(recordingDuration / 60).toString().padStart(2, "0")}:${Math.floor(recordingDuration % 60).toString().padStart(2, "0")}`}
</span>
</div>
<div className="w-full h-2 max-w-4xl bg-mti-gray-smoke rounded-full" />
<div className="flex gap-4 items-center">
<BsPlayCircle
onClick={() => {
setIsRecording(true);
resumeRecording();
}}
className="text-mti-purple-light w-8 h-8 cursor-pointer"
/>
<BsCheckCircleFill
onClick={() => {
setIsRecording(false);
stopRecording();
}}
className="text-mti-purple-light w-8 h-8 cursor-pointer"
/>
</div>
</>
)}
{status === "stopped" && mediaBlobUrl && (
<>
<Waveform audio={mediaBlobUrl} waveColor="#FCDDEC" progressColor="#EF5DA8" />
<div className="flex gap-4 items-center">
<BsTrashFill
className="text-mti-gray-cool cursor-pointer w-5 h-5"
onClick={() => {
resetRecording();
setAnswers(prev => prev.filter(x => x.questionIndex !== questionIndex));
}}
/>
<BsMicFill
onClick={() => {
resetRecording();
startRecording();
setIsRecording(true);
}}
className="h-5 w-5 text-mti-gray-cool cursor-pointer"
/>
</div>
</>
)}
</div>
</div>
)}
/>
</div>
{footerButtons}
</div>
);
};
export default InteractiveSpeaking;

View File

@@ -1,19 +1,16 @@
import { InteractiveSpeakingExercise } from "@/interfaces/exam";
import { CommonProps } from ".";
import { CommonProps } from "../types";
import { useEffect, useState } from "react";
import { BsCheckCircleFill, BsMicFill, BsPauseCircle, BsPlayCircle, BsTrashFill } from "react-icons/bs";
import dynamic from "next/dynamic";
import Button from "../Low/Button";
import useExamStore from "@/stores/examStore";
import { downloadBlob } from "@/utils/evaluation";
import axios from "axios";
import useExamStore, { usePersistentExamStore } from "@/stores/exam";
const Waveform = dynamic(() => import("../Waveform"), { ssr: false });
const Waveform = dynamic(() => import("../../Waveform"), { ssr: false });
const ReactMediaRecorder = dynamic(() => import("react-media-recorder").then((mod) => mod.ReactMediaRecorder), {
ssr: false,
});
export default function InteractiveSpeaking({
const InteractiveSpeaking: React.FC<InteractiveSpeakingExercise & CommonProps> = ({
id,
title,
first_title,
@@ -22,65 +19,40 @@ export default function InteractiveSpeaking({
type,
prompts,
userSolutions,
onNext,
onBack,
isPractice = false,
preview = false
}: InteractiveSpeakingExercise & CommonProps) {
registerSolution,
headerButtons,
footerButtons,
preview,
}) => {
const [recordingDuration, setRecordingDuration] = useState(0);
const [isRecording, setIsRecording] = useState(false);
const [mediaBlob, setMediaBlob] = useState<string>();
const [answers, setAnswers] = useState<{ prompt: string; blob: string; questionIndex: number }[]>([]);
const [isLoading, setIsLoading] = useState(false);
const { questionIndex, setQuestionIndex } = useExamStore((state) => state);
const { userSolutions: storeUserSolutions, setUserSolutions } = useExamStore((state) => state);
const examState = useExamStore((state) => state);
const persistentExamState = usePersistentExamStore((state) => state);
const hasExamEnded = useExamStore((state) => state.hasExamEnded);
const { questionIndex } = !preview ? examState : persistentExamState;
const back = async () => {
setIsLoading(true);
useEffect(() => {
setAnswers((prev) => [...prev.filter(x => x.questionIndex !== questionIndex), {
questionIndex: questionIndex,
prompt: prompts[questionIndex].text,
blob: mediaBlob!,
}]);
setMediaBlob(undefined);
}, [answers, mediaBlob, prompts, questionIndex]);
const answer = await saveAnswer(questionIndex);
if (questionIndex - 1 >= 0) {
setQuestionIndex(questionIndex - 1);
setIsLoading(false);
return;
}
setIsLoading(false);
onBack({
useEffect(() => {
registerSolution(() => ({
exercise: id,
solutions: [...answers.filter((x) => x.questionIndex !== questionIndex), answer],
solutions: answers,
score: { correct: 100, total: 100, missing: 0 },
type,
isPractice
});
};
const next = async () => {
setIsLoading(true);
const answer = await saveAnswer(questionIndex);
if (questionIndex + 1 < prompts.length) {
setQuestionIndex(questionIndex + 1);
setIsLoading(false);
return;
}
setIsLoading(false);
setQuestionIndex(0);
onNext({
exercise: id,
solutions: [...answers.filter((x) => x.questionIndex !== questionIndex), answer],
score: { correct: 100, total: 100, missing: 0 },
type,
isPractice
});
};
}));
}, [id, answers, mediaBlob, type, isPractice, prompts, registerSolution]);
useEffect(() => {
if (userSolutions.length > 0 && answers.length === 0) {
@@ -92,24 +64,6 @@ export default function InteractiveSpeaking({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [userSolutions, mediaBlob, answers]);
useEffect(() => {
if (hasExamEnded) {
const answer = {
questionIndex,
prompt: prompts[questionIndex].text,
blob: mediaBlob!,
};
onNext({
exercise: id,
solutions: [...answers.filter((x) => x.questionIndex !== questionIndex), answer],
score: { correct: 100, total: 100, missing: 0 },
type,
});
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [hasExamEnded]);
useEffect(() => {
let recordingInterval: NodeJS.Timer | undefined = undefined;
if (isRecording) {
@@ -123,50 +77,9 @@ export default function InteractiveSpeaking({
};
}, [isRecording]);
useEffect(() => {
if (questionIndex <= answers.length - 1) {
const blob = answers.find((x) => x.questionIndex === questionIndex)?.blob;
setMediaBlob(blob);
}
}, [answers, questionIndex]);
const saveAnswer = async (index: number) => {
const answer = {
questionIndex,
prompt: prompts[questionIndex].text,
blob: mediaBlob!,
};
setAnswers((prev) => [...prev.filter((x) => x.questionIndex !== index), answer]);
setMediaBlob(undefined);
setUserSolutions([
...storeUserSolutions.filter((x) => x.exercise !== id),
{
exercise: id,
solutions: [...answers.filter((x) => x.questionIndex !== questionIndex), answer],
score: { correct: 100, total: 100, missing: 0 },
module: "speaking",
exam: examID,
type,
isPractice
},
]);
return answer;
};
return (
<div className="flex flex-col gap-4 mt-4 w-full">
<div className="flex justify-between w-full gap-8">
<Button color="purple" variant="outline" isLoading={isLoading} onClick={back} className="max-w-[200px] self-end w-full">
Back
</Button>
<Button color="purple" disabled={!mediaBlob} isLoading={isLoading} onClick={next} className="max-w-[200px] self-end w-full">
{questionIndex + 1 < prompts.length ? "Next Prompt" : "Submit"}
</Button>
</div>
{headerButtons}
<div className="flex flex-col h-full w-full gap-9">
<div className="flex flex-col w-full gap-8 bg-mti-gray-smoke rounded-xl py-8 pb-12 px-16">
<div className="flex flex-col gap-3">
@@ -298,22 +211,10 @@ export default function InteractiveSpeaking({
</div>
)}
/>
<div className="self-end flex justify-between w-full gap-8">
<Button color="purple" variant="outline" isLoading={isLoading} onClick={back} className="max-w-[200px] self-end w-full">
Back
</Button>
{preview ? (
<Button color="purple" isLoading={isLoading} onClick={next} className="max-w-[200px] self-end w-full">
{questionIndex + 1 < prompts.length ? "Next Prompt" : "Submit"}
</Button>
) : (
<Button color="purple" disabled={!mediaBlob} isLoading={isLoading} onClick={next} className="max-w-[200px] self-end w-full">
{questionIndex + 1 < prompts.length ? "Next Prompt" : "Submit"}
</Button>
)}
</div>
</div>
{footerButtons}
</div>
);
}
export default InteractiveSpeaking;

View File

@@ -0,0 +1,68 @@
import useExamStore, { usePersistentExamStore } from "@/stores/exam";
import { useCallback, useEffect, useRef, useState } from "react";
export interface Answer {
prompt: string;
blob: string;
questionIndex: number;
}
const useAnswers = (id: string, type: string, isPractice: boolean, prompts: any[], registerSolution: Function, preview: boolean) => {
const [answers, setAnswers] = useState<Answer[]>([]);
const examState = useExamStore((state) => state);
const persistentExamState = usePersistentExamStore((state) => state);
const { questionIndex } = !preview ? examState : persistentExamState;
const currentBlobUrlRef = useRef<string | null>(null);
const cleanupBlobUrl = useCallback((url: string) => {
if (url.startsWith('blob:')) {
URL.revokeObjectURL(url);
}
}, []);
// Update answers with new recording
const updateAnswers = useCallback((blobUrl: string) => {
if (!blobUrl) return;
setAnswers(prev => {
// Cleanup old blob URL for this question if it exists
const existingAnswer = prev.find(x => x.questionIndex === questionIndex);
if (existingAnswer?.blob) {
cleanupBlobUrl(existingAnswer.blob);
}
const filteredAnswers = prev.filter(x => x.questionIndex !== questionIndex);
return [...filteredAnswers, {
questionIndex,
prompt: prompts[questionIndex].text,
blob: blobUrl,
}];
});
// Store current blob URL for cleanup
currentBlobUrlRef.current = blobUrl;
}, [questionIndex, prompts, cleanupBlobUrl]);
// Register solutions
useEffect(() => {
registerSolution(() => ({
exercise: id,
solutions: answers,
score: { correct: 100, total: 100, missing: 0 },
type,
isPractice
}));
}, [answers, id, type, isPractice, registerSolution]);
return {
answers,
setAnswers,
updateAnswers,
getCurrentBlob: () => currentBlobUrlRef.current
};
};
export default useAnswers;

View File

@@ -1,177 +0,0 @@
import { errorButtonStyle, infoButtonStyle } from "@/constants/buttonStyles";
import { MatchSentenceExerciseOption, MatchSentenceExerciseSentence, MatchSentencesExercise } from "@/interfaces/exam";
import { mdiArrowLeft, mdiArrowRight } from "@mdi/js";
import Icon from "@mdi/react";
import clsx from "clsx";
import { Fragment, useEffect, useState } from "react";
import LineTo from "react-lineto";
import { CommonProps } from ".";
import Button from "../Low/Button";
import Xarrow from "react-xarrows";
import useExamStore from "@/stores/examStore";
import { DndContext, DragEndEvent, useDraggable, useDroppable } from "@dnd-kit/core";
function DroppableQuestionArea({ question, answer }: { question: MatchSentenceExerciseSentence; answer?: string }) {
const { isOver, setNodeRef } = useDroppable({ id: `droppable_sentence_${question.id}` });
return (
<div className="grid grid-cols-3 gap-4" ref={setNodeRef}>
<div className="flex items-center gap-3 cursor-pointer col-span-2">
<button
className={clsx(
"bg-mti-purple-ultralight text-mti-purple hover:text-white hover:bg-mti-purple w-8 h-8 rounded-full z-10",
"transition duration-300 ease-in-out",
)}>
{question.id}
</button>
<span>{question.sentence}</span>
</div>
<div
key={`answer_${question.id}_${answer}`}
className={clsx("w-48 h-10 border rounded-xl flex items-center justify-center", isOver && "border-mti-purple-light")}>
{answer && `Paragraph ${answer}`}
</div>
</div>
);
}
function DraggableOptionArea({ option }: { option: MatchSentenceExerciseOption }) {
const { attributes, listeners, setNodeRef, transform } = useDraggable({
id: `draggable_option_${option.id}`,
});
const style = transform
? {
transform: `translate3d(${transform.x}px, ${transform.y}px, 0)`,
zIndex: 99,
}
: undefined;
return (
<div className={clsx("flex items-center justify-start gap-6 cursor-pointer")} ref={setNodeRef} style={style} {...listeners} {...attributes}>
<button
id={`option_${option.id}`}
// onClick={() => selectOption(id)}
className={clsx(
"bg-mti-purple-ultralight text-mti-purple hover:text-white hover:bg-mti-purple px-3 py-2 rounded-full z-10",
"transition duration-300 ease-in-out",
option.id,
)}>
Paragraph {option.id}
</button>
</div>
);
}
export default function MatchSentences({
id,
options,
type,
prompt,
sentences,
userSolutions,
onNext,
onBack,
isPractice = false,
disableProgressButtons = false
}: MatchSentencesExercise & CommonProps) {
const [answers, setAnswers] = useState<{ question: string; option: string }[]>(userSolutions);
const hasExamEnded = useExamStore((state) => state.hasExamEnded);
const setCurrentSolution = useExamStore((state) => state.setCurrentSolution);
useEffect(() => {
setCurrentSolution({ exercise: id, solutions: answers, score: calculateScore(), type, isPractice });
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [answers, setAnswers]);
const handleDragEnd = (event: DragEndEvent) => {
if (event.over && event.over.id.toString().startsWith("droppable")) {
const optionID = event.active.id.toString().replace("draggable_option_", "");
const sentenceID = event.over.id.toString().replace("droppable_sentence_", "");
setAnswers((prev) => [...prev.filter((x) => x.question.toString() !== sentenceID), { question: sentenceID, option: optionID }]);
}
};
const calculateScore = () => {
const total = sentences.length;
const correct = answers.filter(
(x) => sentences.find((y) => y.id.toString() === x.question.toString())?.solution === x.option || false,
).length;
const missing = total - answers.filter((x) => sentences.find((y) => y.id.toString() === x.question.toString())).length;
return { total, correct, missing };
};
useEffect(() => {
if (disableProgressButtons) onNext({ exercise: id, solutions: answers, score: calculateScore(), type, isPractice });
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [answers, disableProgressButtons])
useEffect(() => {
if (hasExamEnded) onNext({ exercise: id, solutions: answers, score: calculateScore(), type, isPractice });
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [hasExamEnded]);
const progressButtons = () => (
<div className="flex justify-between w-full gap-8">
<Button
color="purple"
variant="outline"
onClick={() => onBack({ exercise: id, solutions: answers, score: calculateScore(), type, isPractice })}
className="max-w-[200px] w-full">
Back
</Button>
<Button
color="purple"
onClick={() => onNext({ exercise: id, solutions: answers, score: calculateScore(), type, isPractice })}
className="max-w-[200px] self-end w-full">
Next
</Button>
</div>
)
return (
<div className="flex flex-col gap-4 mt-4">
{!disableProgressButtons && progressButtons()}
<div className={clsx("flex flex-col gap-4 mt-4", !disableProgressButtons && "mb-20")}>
<span className="text-sm w-full leading-6">
{prompt.split("\\n").map((line, index) => (
<Fragment key={index}>
{line}
<br />
</Fragment>
))}
</span>
<DndContext onDragEnd={handleDragEnd}>
<div className="flex flex-col gap-8 w-full items-center justify-between bg-mti-gray-smoke rounded-xl px-24 py-6">
<div className="flex flex-col gap-4">
{sentences.map((question) => (
<DroppableQuestionArea
key={`question_${question.id}`}
question={question}
answer={answers.find((x) => x.question.toString() === question.id.toString())?.option}
/>
))}
</div>
<div className="flex flex-col gap-4">
<span>Drag one of these paragraphs into the slots above:</span>
<div className="flex gap-4 flex-wrap justify-center items-center max-w-lg">
{options.map((option) => (
<DraggableOptionArea key={`answer_${option.id}`} option={option} />
))}
</div>
</div>
</div>
</DndContext>
</div>
{!disableProgressButtons && progressButtons()}
</div>
);
}

View File

@@ -0,0 +1,65 @@
import { MatchSentenceExerciseOption, MatchSentenceExerciseSentence } from "@/interfaces/exam";
import { useDraggable, useDroppable } from "@dnd-kit/core";
import clsx from "clsx";
interface DroppableQuestionAreaProps {
question: MatchSentenceExerciseSentence;
answer?: string
}
const DroppableQuestionArea: React.FC<DroppableQuestionAreaProps> = ({ question, answer }) => {
const { isOver, setNodeRef } = useDroppable({ id: `droppable_sentence_${question.id}` });
return (
<div className="grid grid-cols-3 gap-4" ref={setNodeRef}>
<div className="flex items-center gap-3 cursor-pointer col-span-2">
<button
className={clsx(
"bg-mti-purple-ultralight text-mti-purple hover:text-white hover:bg-mti-purple p-2 rounded-full z-10",
"transition duration-300 ease-in-out",
)}>
{question.id}
</button>
<span>{question.sentence}</span>
</div>
<div
key={`answer_${question.id}_${answer}`}
className={clsx("w-48 h-10 border rounded-xl flex items-center justify-center", isOver && "border-mti-purple-light")}>
{answer && `Paragraph ${answer}`}
</div>
</div>
);
}
const DraggableOptionArea: React.FC<{ option: MatchSentenceExerciseOption }> = ({ option }) => {
const { attributes, listeners, setNodeRef, transform } = useDraggable({
id: `draggable_option_${option.id}`,
});
const style = transform
? {
transform: `translate3d(${transform.x}px, ${transform.y}px, 0)`,
zIndex: 99,
}
: undefined;
return (
<div className={clsx("flex items-center justify-start gap-6 cursor-pointer")} ref={setNodeRef} style={style} {...listeners} {...attributes}>
<button
id={`option_${option.id}`}
// onClick={() => selectOption(id)}
className={clsx(
"bg-mti-purple-ultralight text-mti-purple hover:text-white hover:bg-mti-purple px-3 py-2 rounded-full z-10",
"transition duration-300 ease-in-out",
option.id,
)}>
Paragraph {option.id}
</button>
</div>
);
}
export {
DroppableQuestionArea,
DraggableOptionArea,
}

View File

@@ -0,0 +1,92 @@
import { MatchSentencesExercise } from "@/interfaces/exam";
import clsx from "clsx";
import { Fragment, useCallback, useEffect, useState } from "react";
import { CommonProps } from "../types";
import { DndContext, DragEndEvent } from "@dnd-kit/core";
import { DraggableOptionArea, DroppableQuestionArea } from "./DragNDrop";
const MatchSentences: React.FC<MatchSentencesExercise & CommonProps> = ({
id,
options,
type,
prompt,
sentences,
userSolutions,
isPractice = false,
registerSolution,
headerButtons,
footerButtons,
}) => {
const [answers, setAnswers] = useState<{ question: string; option: string }[]>(userSolutions);
const handleDragEnd = (event: DragEndEvent) => {
if (event.over && event.over.id.toString().startsWith("droppable")) {
const optionID = event.active.id.toString().replace("draggable_option_", "");
const sentenceID = event.over.id.toString().replace("droppable_sentence_", "");
setAnswers((prev) => [...prev.filter((x) => x.question.toString() !== sentenceID), { question: sentenceID, option: optionID }]);
}
};
const calculateScore = useCallback(() => {
const total = sentences.length;
const correct = answers.filter(
(x) => sentences.find((y) => y.id.toString() === x.question.toString())?.solution === x.option || false,
).length;
const missing = total - answers.filter((x) => sentences.find((y) => y.id.toString() === x.question.toString())).length;
return { total, correct, missing };
}, [answers, sentences]);
useEffect(() => {
registerSolution(() => ({
exercise: id,
solutions: answers,
score: calculateScore(),
type,
isPractice
}));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [id, answers, type, isPractice, calculateScore]);
return (
<div className="flex flex-col gap-4 mt-4">
{headerButtons}
<div className={clsx("flex flex-col gap-4 mt-4", (!headerButtons && !footerButtons) && "mb-20")}>
<span className="text-sm w-full leading-6">
{prompt.split("\\n").map((line, index) => (
<Fragment key={index}>
{line}
<br />
</Fragment>
))}
</span>
<DndContext onDragEnd={handleDragEnd}>
<div className="flex flex-col gap-8 w-full items-center justify-between bg-mti-gray-smoke rounded-xl px-24 py-6">
<div className="flex flex-col gap-4">
{sentences.map((question) => (
<DroppableQuestionArea
key={`question_${question.id}`}
question={question}
answer={answers.find((x) => x.question.toString() === question.id.toString())?.option}
/>
))}
</div>
<div className="flex flex-col gap-4">
<span>Drag one of these paragraphs into the slots above:</span>
<div className="flex gap-4 flex-wrap justify-center items-center max-w-lg">
{options.map((option) => (
<DraggableOptionArea key={`answer_${option.id}`} option={option} />
))}
</div>
</div>
</div>
</DndContext>
</div>
{footerButtons}
</div>
);
}
export default MatchSentences;

View File

@@ -1,238 +0,0 @@
/* eslint-disable @next/next/no-img-element */
import { MultipleChoiceExercise, MultipleChoiceQuestion, ShuffleMap } from "@/interfaces/exam";
import useExamStore from "@/stores/examStore";
import clsx from "clsx";
import { useEffect, useState } from "react";
import reactStringReplace from "react-string-replace";
import { CommonProps } from ".";
import Button from "../Low/Button";
import { v4 } from "uuid";
function Question({
id,
variant,
prompt,
options,
userSolution,
onSelectOption,
}: MultipleChoiceQuestion & {
userSolution: string | undefined;
onSelectOption?: (option: string) => void;
showSolution?: boolean;
}) {
const renderPrompt = (prompt: string) => {
return reactStringReplace(prompt, /(<u>.*?<\/u>)/g, (match) => {
const word = match.replaceAll("<u>", "").replaceAll("</u>", "");
return word.length > 0 ? <u key={v4()}>{word}</u> : null;
});
};
return (
<div className="flex flex-col gap-8">
{isNaN(Number(id)) ? (
<span className="text-lg">{renderPrompt(prompt).filter((x) => x?.toString() !== "<u>")}</span>
) : (
<span className="text-lg">
<>
{id} - <span>{renderPrompt(prompt).filter((x) => x?.toString() !== "<u>")}</span>
</>
</span>
)}
<div className="flex flex-wrap gap-4 justify-between">
{variant === "image" &&
options.map((option) => (
<div
key={v4()}
onClick={() => (onSelectOption ? onSelectOption(option.id.toString()) : null)}
className={clsx(
"flex flex-col items-center border border-mti-gray-platinum p-4 px-8 rounded-xl gap-4 cursor-pointer bg-white relative select-none",
userSolution === option.id.toString() && "border-mti-purple-light",
)}>
<span key={v4()} className={clsx("text-sm", userSolution !== option.id.toString() && "opacity-50")}>
{option.id.toString()}
</span>
<img src={option.src!} alt={`Option ${option.id.toString()}`} />
</div>
))}
{variant === "text" &&
options.map((option) => (
<div
key={v4()}
onClick={() => (onSelectOption ? onSelectOption(option.id.toString()) : null)}
className={clsx(
"flex border p-4 rounded-xl gap-2 cursor-pointer bg-white text-base select-none",
userSolution === option.id.toString() && "!bg-mti-purple-light !text-white",
)}>
<span className="font-semibold">{option.id.toString()}.</span>
<span>{option.text}</span>
</div>
))}
</div>
</div>
);
}
export default function MultipleChoice({
id,
prompt,
type,
questions,
userSolutions,
isPractice = false,
onNext,
onBack,
disableProgressButtons = false
}: MultipleChoiceExercise & CommonProps) {
const [answers, setAnswers] = useState<{ question: string; option: string }[]>(userSolutions || []);
const { questionIndex, exerciseIndex, exam, shuffles, hasExamEnded, partIndex, setQuestionIndex, setCurrentSolution } = useExamStore(
(state) => state,
);
const shuffleMaps = shuffles.find((x) => x.exerciseID == id)?.shuffles;
const scrollToTop = () => Array.from(document.getElementsByTagName("body")).forEach((body) => body.scrollTo(0, 0));
useEffect(() => {
if (hasExamEnded) onNext({ exercise: id, solutions: answers, score: calculateScore(), type, isPractice });
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [hasExamEnded]);
const onSelectOption = (option: string, question: MultipleChoiceQuestion) => {
setAnswers((prev) => [...prev.filter((x) => x.question !== question.id), { option, question: question.id }]);
};
useEffect(() => {
setCurrentSolution({ exercise: id, solutions: answers, score: calculateScore(), type, shuffleMaps: shuffleMaps, isPractice });
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [answers, setAnswers]);
const getShuffledSolution = (originalSolution: string, questionShuffleMap: ShuffleMap) => {
for (const [newPosition, originalPosition] of Object.entries(questionShuffleMap.map)) {
if (originalPosition === originalSolution) {
return newPosition;
}
}
return originalSolution;
};
const calculateScore = () => {
const total = questions.length;
const correct = answers.filter((x) => {
const matchingQuestion = questions.find((y) => {
return y.id.toString() === x.question.toString();
});
let isSolutionCorrect;
if (!shuffleMaps) {
isSolutionCorrect = matchingQuestion?.solution === x.option;
} else {
const shuffleMap = shuffleMaps.find((map) => map.questionID == x.question);
if (shuffleMap) {
isSolutionCorrect = getShuffledSolution(x.option, shuffleMap) == matchingQuestion?.solution;
} else {
isSolutionCorrect = matchingQuestion?.solution === x.option;
}
}
return isSolutionCorrect || false;
}).length;
const missing = total - answers!.filter((x) => questions.find((y) => x.question.toString() === y.id.toString())).length;
return { total, correct, missing };
};
useEffect(() => {
if (disableProgressButtons) onNext({ exercise: id, solutions: answers, score: calculateScore(), type, shuffleMaps: shuffleMaps, isPractice });
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [answers, disableProgressButtons])
const next = () => {
if (questionIndex + 1 >= questions.length - 1) {
onNext({ exercise: id, solutions: answers, score: calculateScore(), type, shuffleMaps: shuffleMaps, isPractice });
} else {
setQuestionIndex(questionIndex + 2);
}
scrollToTop();
};
const back = () => {
if (questionIndex === 0) {
onBack({ exercise: id, solutions: answers, score: calculateScore(), type, shuffleMaps: shuffleMaps, isPractice });
} else {
if (exam?.module === "level" && typeof exam.parts[0].intro !== "undefined" && questionIndex === 0) return;
setQuestionIndex(questionIndex - 2);
}
scrollToTop();
};
const progressButtons = () => (
<div className="flex justify-between w-full gap-8">
<Button
color="purple"
variant="outline"
onClick={back}
className="max-w-[200px] w-full"
disabled={exam && exam.module === "level" && partIndex === 0 && questionIndex === 0}>
Back
</Button>
<Button color="purple" onClick={next} className="max-w-[200px] self-end w-full">
{exam &&
exam.module === "level" &&
partIndex === exam.parts.length - 1 &&
exerciseIndex === exam.parts[partIndex].exercises.length - 1 &&
questionIndex + 1 >= questions.length - 1
? "Submit"
: "Next"}
</Button>
</div>
)
const renderAllQuestions = () =>
questions.map(question => (
<div
key={question.id} className="flex flex-col gap-8 h-fit w-full bg-mti-gray-smoke rounded-xl px-16 py-8">
<Question
{...question}
userSolution={answers.find((x) => question.id === x.question)?.option}
onSelectOption={(option) => onSelectOption(option, question)}
/>
</div>
))
const renderTwoQuestions = () => (
<>
<div className="flex flex-col gap-8 h-fit w-full bg-mti-gray-smoke rounded-xl px-16 py-8">
{questionIndex < questions.length && (
<Question
{...questions[questionIndex]}
userSolution={answers.find((x) => questions[questionIndex].id === x.question)?.option}
onSelectOption={(option) => onSelectOption(option, questions[questionIndex])}
/>
)}
</div>
{questionIndex + 1 < questions.length && (
<div className="flex flex-col gap-8 h-fit w-full bg-mti-gray-smoke rounded-xl px-16 py-8">
<Question
{...questions[questionIndex + 1]}
userSolution={answers.find((x) => questions[questionIndex + 1].id === x.question)?.option}
onSelectOption={(option) => onSelectOption(option, questions[questionIndex + 1])}
/>
</div>
)}
</>
)
return (
<div className="flex flex-col gap-4">
{!disableProgressButtons && progressButtons()}
<div className={clsx("flex flex-col gap-4 mt-4", !disableProgressButtons && "mb-20")}>
{disableProgressButtons ? renderAllQuestions() : renderTwoQuestions()}
</div>
{!disableProgressButtons && progressButtons()}
</div>
);
}

View File

@@ -0,0 +1,73 @@
/* eslint-disable @next/next/no-img-element */
import { MultipleChoiceQuestion } from "@/interfaces/exam";
import clsx from "clsx";
import reactStringReplace from "react-string-replace";
import { v4 } from "uuid";
interface Props {
userSolution: string | undefined;
onSelectOption?: (option: string) => void;
showSolution?: boolean;
}
const Question: React.FC<MultipleChoiceQuestion & Props> = ({
id,
variant,
prompt,
options,
userSolution,
onSelectOption,
}) => {
const renderPrompt = (prompt: string) => {
return reactStringReplace(prompt, /(<u>.*?<\/u>)/g, (match) => {
const word = match.replaceAll("<u>", "").replaceAll("</u>", "");
return word.length > 0 ? <u key={v4()}>{word}</u> : null;
});
};
return (
<div className="flex flex-col gap-8">
{isNaN(Number(id)) ? (
<span className="text-lg">{renderPrompt(prompt).filter((x) => x?.toString() !== "<u>")}</span>
) : (
<span className="text-lg">
<>
{id} - <span>{renderPrompt(prompt).filter((x) => x?.toString() !== "<u>")}</span>
</>
</span>
)}
<div className="flex flex-wrap gap-4 justify-between">
{variant === "image" &&
options.map((option) => (
<div
key={v4()}
onClick={() => (onSelectOption ? onSelectOption(option.id.toString()) : null)}
className={clsx(
"flex flex-col items-center border border-mti-gray-platinum p-4 px-8 rounded-xl gap-4 cursor-pointer bg-white relative select-none",
userSolution === option.id.toString() && "border-mti-purple-light",
)}>
<span key={v4()} className={clsx("text-sm", userSolution !== option.id.toString() && "opacity-50")}>
{option.id.toString()}
</span>
<img src={option.src!} alt={`Option ${option.id.toString()}`} />
</div>
))}
{variant === "text" &&
options.map((option) => (
<div
key={v4()}
onClick={() => (onSelectOption ? onSelectOption(option.id.toString()) : null)}
className={clsx(
"flex border p-4 rounded-xl gap-2 cursor-pointer bg-white text-base select-none",
userSolution === option.id.toString() && "!bg-mti-purple-light !text-white",
)}>
<span className="font-semibold">{option.id.toString()}.</span>
<span>{option.text}</span>
</div>
))}
</div>
</div>
);
}
export default Question;

View File

@@ -0,0 +1,125 @@
import { MultipleChoiceExercise, MultipleChoiceQuestion, ShuffleMap } from "@/interfaces/exam";
import useExamStore, { usePersistentExamStore } from "@/stores/exam";
import clsx from "clsx";
import { useCallback, useEffect, useState } from "react";
import { CommonProps } from "../types";
import Question from "./Question";
const MultipleChoice: React.FC<MultipleChoiceExercise & CommonProps> = ({
id,
type,
questions,
userSolutions,
isPractice = false,
registerSolution,
headerButtons,
footerButtons,
preview,
}) => {
const [answers, setAnswers] = useState<{ question: string; option: string }[]>(userSolutions || []);
const examState = useExamStore((state) => state);
const persistentExamState = usePersistentExamStore((state) => state);
const { questionIndex, shuffles } = !preview ? examState : persistentExamState;
const shuffleMaps = shuffles.find((x) => x.exerciseID == id)?.shuffles;
const onSelectOption = (option: string, question: MultipleChoiceQuestion) => {
setAnswers((prev) => [...prev.filter((x) => x.question !== question.id), { option, question: question.id }]);
};
const getShuffledSolution = (originalSolution: string, questionShuffleMap: ShuffleMap) => {
for (const [newPosition, originalPosition] of Object.entries(questionShuffleMap.map)) {
if (originalPosition === originalSolution) {
return newPosition;
}
}
return originalSolution;
};
const calculateScore = useCallback(() => {
const total = questions.length;
const correct = answers.filter((x) => {
const matchingQuestion = questions.find((y) => {
return y.id.toString() === x.question.toString();
});
let isSolutionCorrect;
if (!shuffleMaps) {
isSolutionCorrect = matchingQuestion?.solution === x.option;
} else {
const shuffleMap = shuffleMaps.find((map) => map.questionID == x.question);
if (shuffleMap) {
isSolutionCorrect = getShuffledSolution(x.option, shuffleMap) == matchingQuestion?.solution;
} else {
isSolutionCorrect = matchingQuestion?.solution === x.option;
}
}
return isSolutionCorrect || false;
}).length;
const missing = total - answers!.filter((x) => questions.find((y) => x.question.toString() === y.id.toString())).length;
return { total, correct, missing };
}, [answers, questions, shuffleMaps]);
useEffect(() => {
registerSolution(() => ({
exercise: id,
solutions: answers,
score: calculateScore(),
shuffleMaps,
type,
isPractice
}));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [id, answers, type, isPractice, shuffleMaps, calculateScore]);
const renderAllQuestions = () =>
questions.map(question => (
<div
key={question.id} className="flex flex-col gap-8 h-fit w-full bg-mti-gray-smoke rounded-xl px-16 py-8">
<Question
{...question}
userSolution={answers.find((x) => question.id === x.question)?.option}
onSelectOption={(option) => onSelectOption(option, question)}
/>
</div>
))
const renderTwoQuestions = () => (
<>
<div className="flex flex-col gap-8 h-fit w-full bg-mti-gray-smoke rounded-xl px-16 py-8">
{questionIndex < questions.length && (
<Question
{...questions[questionIndex]}
userSolution={answers.find((x) => questions[questionIndex].id === x.question)?.option}
onSelectOption={(option) => onSelectOption(option, questions[questionIndex])}
/>
)}
</div>
{questionIndex + 1 < questions.length && (
<div className="flex flex-col gap-8 h-fit w-full bg-mti-gray-smoke rounded-xl px-16 py-8">
<Question
{...questions[questionIndex + 1]}
userSolution={answers.find((x) => questions[questionIndex + 1].id === x.question)?.option}
onSelectOption={(option) => onSelectOption(option, questions[questionIndex + 1])}
/>
</div>
)}
</>
)
return (
<div className="flex flex-col gap-4">
{headerButtons}
<div className={clsx("flex flex-col gap-4 mt-4", (headerButtons && footerButtons) && "mb-20")}>
{(!headerButtons && !footerButtons) ? renderAllQuestions() : renderTwoQuestions()}
</div>
{footerButtons}
</div>
);
}
export default MultipleChoice;

View File

@@ -1,67 +1,41 @@
import { SpeakingExercise } from "@/interfaces/exam";
import { CommonProps } from ".";
import { CommonProps } from "./types";
import { Fragment, useEffect, useState } from "react";
import { BsCheckCircleFill, BsMicFill, BsPauseCircle, BsPlayCircle, BsTrashFill } from "react-icons/bs";
import dynamic from "next/dynamic";
import Button from "../Low/Button";
import useExamStore from "@/stores/examStore";
import { downloadBlob } from "@/utils/evaluation";
import axios from "axios";
import Modal from "../Modal";
import useExamStore, { usePersistentExamStore } from "@/stores/exam";
const Waveform = dynamic(() => import("../Waveform"), { ssr: false });
const ReactMediaRecorder = dynamic(() => import("react-media-recorder").then((mod) => mod.ReactMediaRecorder), {
ssr: false,
});
export default function Speaking({ id, title, text, video_url, type, prompts, suffix, userSolutions, isPractice = false, onNext, onBack, preview = false }: SpeakingExercise & CommonProps) {
const Speaking: React.FC<SpeakingExercise & CommonProps> = ({
id, title, text, video_url, type, prompts,
suffix, userSolutions, isPractice = false,
registerSolution, headerButtons, footerButtons, preview
}) => {
const [recordingDuration, setRecordingDuration] = useState(0);
const [isRecording, setIsRecording] = useState(false);
const [mediaBlob, setMediaBlob] = useState<string>();
const [audioURL, setAudioURL] = useState<string>();
const [isLoading, setIsLoading] = useState(false);
const [isPromptsModalOpen, setIsPromptsModalOpen] = useState(false);
const [inputText, setInputText] = useState("");
const hasExamEnded = useExamStore((state) => state.hasExamEnded);
const examState = useExamStore((state) => state);
const persistentExamState = usePersistentExamStore((state) => state);
const {setNavigation} = !preview ? examState : persistentExamState;
const saveToStorage = async () => {
if (mediaBlob && mediaBlob.startsWith("blob")) {
const blobBuffer = await downloadBlob(mediaBlob);
const audioFile = new File([blobBuffer], "audio.wav", { type: "audio/wav" });
useEffect(()=> { if (!preview) setNavigation({nextDisabled: true}) }, [setNavigation, preview])
const seed = Math.random().toString().replace("0.", "");
const formData = new FormData();
formData.append("audio", audioFile, `${seed}.wav`);
formData.append("root", "speaking_recordings");
const config = {
headers: {
"Content-Type": "audio/wav",
},
};
const response = await axios.post<{ path: string }>("/api/storage/insert", formData, config);
if (audioURL) await axios.post("/api/storage/delete", { path: audioURL });
return response.data.path;
}
return undefined;
};
useEffect(() => {
/*useEffect(() => {
if (userSolutions.length > 0) {
const { solution } = userSolutions[0] as { solution?: string };
if (solution && !mediaBlob) setMediaBlob(solution);
if (solution && !solution.startsWith("blob")) setAudioURL(solution);
}
}, [userSolutions, mediaBlob]);
useEffect(() => {
if (hasExamEnded) next();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [hasExamEnded]);
}, [userSolutions, mediaBlob]);*/
useEffect(() => {
let recordingInterval: NodeJS.Timer | undefined = undefined;
@@ -76,23 +50,14 @@ export default function Speaking({ id, title, text, video_url, type, prompts, su
};
}, [isRecording]);
const next = async () => {
onNext({
useEffect(() => {
registerSolution(() => ({
exercise: id,
solutions: mediaBlob ? [{ id, solution: mediaBlob }] : [],
score: { correct: 0, total: 100, missing: 0 },
type, isPractice
});
};
const back = async () => {
onBack({
exercise: id,
solutions: mediaBlob ? [{ id, solution: mediaBlob }] : [],
score: { correct: 0, total: 100, missing: 0 },
type, isPractice
});
};
}));
}, [id, isPractice, mediaBlob, registerSolution, type]);
const handleNoteWriting = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
const newText = e.target.value;
@@ -115,17 +80,13 @@ export default function Speaking({ id, title, text, video_url, type, prompts, su
}
};
useEffect(()=> {
if(mediaBlob) setNavigation({nextDisabled: false});
}, [mediaBlob, setNavigation])
return (
<div className="flex flex-col gap-4 mt-4 w-full">
<div className="flex justify-between w-full gap-8">
<Button color="purple" variant="outline" isLoading={isLoading} onClick={back} className="max-w-[200px] self-end w-full">
Back
</Button>
<Button color="purple" isLoading={isLoading} disabled={!mediaBlob} onClick={next} className="max-w-[200px] self-end w-full">
Next
</Button>
</div>
{headerButtons}
<div className="flex flex-col h-full w-full gap-9">
<Modal title="Prompts" className="!w-96 aspect-square" isOpen={isPromptsModalOpen} onClose={() => setIsPromptsModalOpen(false)}>
<div className="flex flex-col items-center justify-center gap-4 w-full h-full">
@@ -302,22 +263,10 @@ export default function Speaking({ id, title, text, video_url, type, prompts, su
</div>
)}
/>
<div className="self-end flex justify-between w-full gap-8">
<Button color="purple" variant="outline" isLoading={isLoading} onClick={back} className="max-w-[200px] self-end w-full">
Back
</Button>
{preview ? (
<Button color="purple" isLoading={isLoading} onClick={next} className="max-w-[200px] self-end w-full">
Next
</Button>
) : (
<Button color="purple" isLoading={isLoading} disabled={!mediaBlob} onClick={next} className="max-w-[200px] self-end w-full">
Next
</Button>
)}
</div>
{footerButtons}
</div>
</div>
);
}
export default Speaking;

View File

@@ -1,32 +1,23 @@
import { TrueFalseExercise } from "@/interfaces/exam";
import useExamStore from "@/stores/examStore";
import clsx from "clsx";
import { Fragment, useEffect, useState } from "react";
import { CommonProps } from ".";
import { Fragment, useCallback, useEffect, useState } from "react";
import { CommonProps } from "./types";
import Button from "../Low/Button";
export default function TrueFalse({
const TrueFalse: React.FC<TrueFalseExercise & CommonProps> = ({
id,
type,
prompt,
questions,
userSolutions,
isPractice = false,
onNext,
onBack,
disableProgressButtons = false
}: TrueFalseExercise & CommonProps) {
registerSolution,
headerButtons,
footerButtons,
}) => {
const [answers, setAnswers] = useState<{ id: string; solution: "true" | "false" | "not_given" }[]>(userSolutions);
const hasExamEnded = useExamStore((state) => state.hasExamEnded);
const setCurrentSolution = useExamStore((state) => state.setCurrentSolution);
useEffect(() => {
if (hasExamEnded) onNext({ exercise: id, solutions: answers, score: calculateScore(), type, isPractice });
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [hasExamEnded]);
const calculateScore = () => {
const calculateScore = useCallback(() => {
const total = questions.length || 0;
const correct = answers.filter(
(x) =>
@@ -38,12 +29,7 @@ export default function TrueFalse({
const missing = total - answers.filter((x) => questions.find((y) => x.id.toString() === y.id.toString())).length;
return { total, correct, missing };
};
useEffect(() => {
setCurrentSolution({ exercise: id, solutions: answers, score: calculateScore(), type, isPractice });
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [answers, setAnswers]);
}, [answers, questions]);
const toggleAnswer = (solution: "true" | "false" | "not_given", questionId: string) => {
const answer = answers.find((x) => x.id === questionId);
@@ -56,34 +42,20 @@ export default function TrueFalse({
};
useEffect(() => {
if (disableProgressButtons) onNext({ exercise: id, solutions: answers, score: calculateScore(), type, isPractice });
registerSolution(() => ({
exercise: id,
solutions: answers,
score: calculateScore(),
type,
isPractice
}));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [answers, disableProgressButtons])
const progressButtons = () => (
<div className="flex justify-between w-full gap-8">
<Button
color="purple"
variant="outline"
onClick={() => onBack({ exercise: id, solutions: answers, score: calculateScore(), type, isPractice })}
className="max-w-[200px] w-full">
Back
</Button>
<Button
color="purple"
onClick={() => onNext({ exercise: id, solutions: answers, score: calculateScore(), type, isPractice })}
className="max-w-[200px] self-end w-full">
Next
</Button>
</div>
)
}, [id, answers, type, isPractice, calculateScore]);
return (
<div className="flex flex-col gap-4 mt-4">
{!disableProgressButtons && progressButtons()}
<div className={clsx("flex flex-col gap-4 mt-4 h-full w-full", !disableProgressButtons && "mb-20")}>
{headerButtons}
<div className={clsx("flex flex-col gap-4 mt-4 h-full w-full", (headerButtons && footerButtons) && "mb-20")}>
<span className="text-sm w-full leading-6">
{prompt.split("\\n").map((line, index) => (
<Fragment key={index}>
@@ -141,9 +113,11 @@ export default function TrueFalse({
);
})}
</div>
{footerButtons}
</div>
{!disableProgressButtons && progressButtons()}
</div>
);
}
export default TrueFalse;

View File

@@ -1,156 +0,0 @@
import { errorButtonStyle, infoButtonStyle } from "@/constants/buttonStyles";
import { WriteBlanksExercise } from "@/interfaces/exam";
import { mdiArrowLeft, mdiArrowRight } from "@mdi/js";
import Icon from "@mdi/react";
import clsx from "clsx";
import { Fragment, useEffect, useState } from "react";
import reactStringReplace from "react-string-replace";
import { CommonProps } from ".";
import { toast } from "react-toastify";
import Button from "../Low/Button";
import useExamStore from "@/stores/examStore";
function Blank({
id,
maxWords,
userSolution,
showSolutions = false,
setUserSolution,
}: {
id: string;
solutions?: string[];
userSolution?: string;
maxWords: number;
showSolutions?: boolean;
setUserSolution: (solution: string) => void;
}) {
const [userInput, setUserInput] = useState(userSolution || "");
useEffect(() => {
const words = userInput.split(" ");
if (words.length > maxWords) {
toast.warning(`You have reached your word limit of ${maxWords} words!`, { toastId: "word-limit" });
setUserInput(words.join(" ").trim());
}
}, [maxWords, userInput]);
return (
<input
className="py-2 px-3 mx-2 rounded-2xl w-48 bg-white focus:outline-none my-2"
placeholder={id}
onChange={(e) => setUserInput(e.target.value)}
onBlur={() => setUserSolution(userInput)}
value={userInput}
contentEditable={showSolutions}
/>
);
}
export default function WriteBlanks({
id,
prompt,
type,
maxWords,
solutions,
userSolutions,
isPractice = false,
text,
onNext,
onBack,
disableProgressButtons = false
}: WriteBlanksExercise & CommonProps) {
const [answers, setAnswers] = useState<{ id: string; solution: string }[]>(userSolutions);
const { hasExamEnded, setCurrentSolution } = useExamStore((state) => state);
useEffect(() => {
if (hasExamEnded) onNext({ exercise: id, solutions: answers, score: calculateScore(), type, isPractice });
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [hasExamEnded]);
const calculateScore = () => {
const total = text.match(/({{\d+}})/g)?.length || 0;
const correct = answers.filter(
(x) =>
solutions
.find((y) => x.id.toString() === y.id.toString())
?.solution.map((y) => y.toLowerCase().trim())
.includes(x.solution.toLowerCase().trim()) || false,
).length;
const missing = total - answers.filter((x) => solutions.find((y) => x.id === y.id)).length;
return { total, correct, missing };
};
useEffect(() => {
setCurrentSolution({ exercise: id, solutions: answers, score: calculateScore(), type, isPractice });
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [answers, setAnswers]);
useEffect(() => {
if (disableProgressButtons) onNext({ exercise: id, solutions: answers, score: calculateScore(), type, isPractice });
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [answers, disableProgressButtons])
const renderLines = (line: string) => {
return (
<span className="text-base leading-5">
{reactStringReplace(line, /({{\d+}})/g, (match) => {
const id = match.replaceAll(/[\{\}]/g, "");
const userSolution = answers.find((x) => x.id === id);
const setUserSolution = (solution: string) => {
setAnswers((prev) => [...prev.filter((x) => x.id !== id), { id, solution }]);
};
return <Blank userSolution={userSolution?.solution} maxWords={maxWords} id={id} setUserSolution={setUserSolution} />;
})}
</span>
);
};
const progressButtons = () => (
<div className="flex justify-between w-full gap-8">
<Button
color="purple"
variant="outline"
onClick={() => onBack({ exercise: id, solutions: answers, score: calculateScore(), type, isPractice })}
className="max-w-[200px] w-full">
Back
</Button>
<Button
color="purple"
onClick={() => onNext({ exercise: id, solutions: answers, score: calculateScore(), type, isPractice })}
className="max-w-[200px] self-end w-full">
Next
</Button>
</div>
)
return (
<div className="flex flex-col gap-4">
{!disableProgressButtons && progressButtons()}
<div className={clsx("flex flex-col gap-4 mt-4 h-full w-full", !disableProgressButtons && "mb-20")}>
<span className="text-sm w-full leading-6">
{prompt.split("\\n").map((line, index) => (
<span key={index}>
{line}
<br />
</span>
))}
</span>
<span className="bg-mti-gray-smoke rounded-xl px-5 py-6">
{text.split("\\n").map((line, index) => (
<p key={index}>
{renderLines(line)}
<br />
</p>
))}
</span>
</div>
{!disableProgressButtons && progressButtons()}
</div>
);
}

View File

@@ -0,0 +1,41 @@
import { useEffect, useState } from "react";
import { toast } from "react-toastify";
interface Props {
id: string;
solutions?: string[];
userSolution?: string;
maxWords: number;
showSolutions?: boolean;
setUserSolution: (solution: string) => void;
}
const Blank: React.FC<Props> = ({ id,
maxWords,
userSolution,
showSolutions = false,
setUserSolution, }) => {
const [userInput, setUserInput] = useState(userSolution || "");
useEffect(() => {
const words = userInput.split(" ");
if (words.length > maxWords) {
toast.warning(`You have reached your word limit of ${maxWords} words!`, { toastId: "word-limit" });
setUserInput(words.join(" ").trim());
}
}, [maxWords, userInput]);
return (
<input
className="py-2 px-3 mx-2 rounded-2xl w-48 bg-white focus:outline-none my-2"
placeholder={id}
onChange={(e) => setUserInput(e.target.value)}
onBlur={() => setUserSolution(userInput)}
value={userInput}
contentEditable={showSolutions}
/>
);
}
export default Blank;

View File

@@ -0,0 +1,92 @@
import { WriteBlanksExercise } from "@/interfaces/exam";
import clsx from "clsx";
import { useCallback, useEffect, useState } from "react";
import reactStringReplace from "react-string-replace";
import { CommonProps } from "../types";
import Blank from "./Blank";
const WriteBlanks: React.FC<WriteBlanksExercise & CommonProps> = ({
id,
prompt,
type,
maxWords,
solutions,
userSolutions,
isPractice = false,
text,
registerSolution,
headerButtons,
footerButtons,
}) => {
const [answers, setAnswers] = useState<{ id: string; solution: string }[]>(userSolutions);
const calculateScore = useCallback(() => {
const total = text.match(/({{\d+}})/g)?.length || 0;
const correct = answers.filter(
(x) =>
solutions
.find((y) => x.id.toString() === y.id.toString())
?.solution.map((y) => y.toLowerCase().trim())
.includes(x.solution.toLowerCase().trim()) || false,
).length;
const missing = total - answers.filter((x) => solutions.find((y) => x.id === y.id)).length;
return { total, correct, missing };
}, [answers, solutions, text]);
useEffect(() => {
registerSolution(() => ({
exercise: id,
solutions: answers,
score: calculateScore(),
type,
isPractice
}));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [id, answers, type, isPractice, calculateScore]);
const renderLines = (line: string) => {
return (
<span className="text-base leading-5">
{reactStringReplace(line, /({{\d+}})/g, (match) => {
const id = match.replaceAll(/[\{\}]/g, "");
const userSolution = answers.find((x) => x.id === id);
const setUserSolution = (solution: string) => {
setAnswers((prev) => [...prev.filter((x) => x.id !== id), { id, solution }]);
};
return <Blank key={`blank-${id}`} userSolution={userSolution?.solution} maxWords={maxWords} id={id} setUserSolution={setUserSolution} />;
})}
</span>
);
};
return (
<div className="flex flex-col gap-4">
{headerButtons}
<div className={clsx("flex flex-col gap-4 mt-4 h-full w-full", (!headerButtons && !footerButtons) && "mb-20")}>
<span className="text-sm w-full leading-6">
{prompt.split("\\n").map((line, index) => (
<span key={index}>
{line}
<br />
</span>
))}
</span>
<span className="bg-mti-gray-smoke rounded-xl px-5 py-6">
{text.split("\\n").map((line, index) => (
<p key={index}>
{renderLines(line)}
<br />
</p>
))}
</span>
{footerButtons}
</div>
</div>
);
}
export default WriteBlanks;

View File

@@ -1,13 +1,12 @@
/* eslint-disable @next/next/no-img-element */
import { WritingExercise } from "@/interfaces/exam";
import { CommonProps } from ".";
import React, { Fragment, useEffect, useRef, useState } from "react";
import React, { Fragment, useEffect, useState } from "react";
import { Dialog, DialogPanel, Transition, TransitionChild } from "@headlessui/react";
import useExamStore, { usePersistentExamStore } from "@/stores/exam";
import { CommonProps } from "./types";
import { toast } from "react-toastify";
import Button from "../Low/Button";
import { Dialog, Transition } from "@headlessui/react";
import useExamStore from "@/stores/examStore";
export default function Writing({
const Writing: React.FC<WritingExercise & CommonProps> = ({
id,
prompt,
prefix,
@@ -17,17 +16,24 @@ export default function Writing({
attachment,
userSolutions,
isPractice = false,
onNext,
onBack,
enableNavigation = false
}: WritingExercise & CommonProps) {
registerSolution,
headerButtons,
footerButtons,
preview,
}) => {
const [isModalOpen, setIsModalOpen] = useState(false);
const [inputText, setInputText] = useState(userSolutions.length === 1 ? userSolutions[0].solution : "");
const [isSubmitEnabled, setIsSubmitEnabled] = useState(false);
const [saveTimer, setSaveTimer] = useState(0);
const { userSolutions: storeUserSolutions, setUserSolutions } = useExamStore((state) => state);
const hasExamEnded = useExamStore((state) => state.hasExamEnded);
const examState = useExamStore((state) => state);
const persistentExamState = usePersistentExamStore((state) => state);
const { userSolutions: storeUserSolutions, setUserSolutions, setNavigation } = !preview ? examState : persistentExamState;
useEffect(() => {
if (!preview) setNavigation({ nextDisabled: true });
}, [setNavigation, preview]);
useEffect(() => {
const saveTimerInterval = setInterval(() => {
@@ -43,7 +49,7 @@ export default function Writing({
if (inputText.length > 0 && saveTimer % 10 === 0) {
setUserSolutions([
...storeUserSolutions.filter((x) => x.exercise !== id),
{ exercise: id, solutions: [{ id, solution: inputText }], score: { correct: 100, total: 100, missing: 0 }, type, module: "writing" },
{ exercise: id, solutions: [{ id, solution: inputText.replaceAll(/\s{2,}/g, " ") }], score: { correct: 100, total: 100, missing: 0 }, type, module: "writing" },
]);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
@@ -65,59 +71,39 @@ export default function Writing({
};
}, []);
useEffect(() => {
if (hasExamEnded)
onNext({ exercise: id, solutions: [{ id, solution: inputText }], score: { correct: 100, total: 100, missing: 0 }, type, module: "writing", isPractice });
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [hasExamEnded]);
useEffect(() => {
const words = inputText.split(" ").filter((x) => x !== "");
if (wordCounter.type === "min") {
setIsSubmitEnabled(wordCounter.limit <= words.length || enableNavigation);
setNavigation({ nextDisabled: !(wordCounter.limit <= words.length) });
} else {
setIsSubmitEnabled(true);
setNavigation({ nextDisabled: false });
if (wordCounter.limit < words.length) {
toast.warning(`You have reached your word limit of ${wordCounter.limit} words!`, { toastId: "word-limit" });
setInputText(words.slice(0, words.length - 1).join(" "));
}
}
}, [enableNavigation, inputText, wordCounter]);
}, [inputText, setNavigation, wordCounter]);
useEffect(() => {
registerSolution(() => ({
exercise: id,
solutions: [{ id, solution: inputText.replaceAll(/\s{2,}/g, " ") }],
score: { correct: 100, total: 100, missing: 0 },
type,
isPractice
}));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [id, type, isPractice, inputText]);
return (
<div className="flex flex-col gap-4 mt-4">
<div className="flex justify-between w-full gap-8">
<Button
color="purple"
variant="outline"
onClick={() =>
onBack({ exercise: id, solutions: [{ id, solution: inputText }], score: { correct: 100, total: 100, missing: 0 }, type, isPractice })
}
className="max-w-[200px] self-end w-full">
Back
</Button>
<Button
color="purple"
disabled={!isSubmitEnabled}
onClick={() =>
onNext({
exercise: id,
solutions: [{ id, solution: inputText.replaceAll(/\s{2,}/g, " ") }],
score: { correct: 100, total: 100, missing: 0 },
type,
module: "writing", isPractice
})
}
className="max-w-[200px] self-end w-full">
Next
</Button>
</div>
{headerButtons}
{attachment && (
<Transition show={isModalOpen} as={Fragment}>
<Dialog onClose={() => setIsModalOpen(false)} className="relative z-50">
<Transition.Child
<TransitionChild
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0"
@@ -126,9 +112,9 @@ export default function Writing({
leaveFrom="opacity-100"
leaveTo="opacity-0">
<div className="fixed inset-0 bg-black/30" />
</Transition.Child>
</TransitionChild>
<Transition.Child
<TransitionChild
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0 scale-95"
@@ -137,11 +123,11 @@ export default function Writing({
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-fit h-fit rounded-xl bg-white">
<DialogPanel className="w-fit h-fit rounded-xl bg-white">
<img src={attachment.url} alt={attachment.description} className="max-w-4xl w-full self-center rounded-xl p-4" />
</Dialog.Panel>
</DialogPanel>
</div>
</Transition.Child>
</TransitionChild>
</Dialog>
</Transition>
)}
@@ -172,33 +158,9 @@ export default function Writing({
<span className="text-base self-end text-mti-gray-cool">Word Count: {inputText.split(" ").filter((x) => x !== "").length}</span>
</div>
</div>
<div className="self-end flex justify-between w-full gap-8 absolute bottom-8 left-0 px-8">
<Button
color="purple"
variant="outline"
onClick={() =>
onBack({ exercise: id, solutions: [{ id, solution: inputText }], score: { correct: 100, total: 100, missing: 0 }, type, isPractice })
}
className="max-w-[200px] self-end w-full">
Back
</Button>
<Button
color="purple"
disabled={!isSubmitEnabled}
onClick={() =>
onNext({
exercise: id,
solutions: [{ id, solution: inputText.replaceAll(/\s{2,}/g, " ") }],
score: { correct: 100, total: 100, missing: 0 },
type,
module: "writing", isPractice
})
}
className="max-w-[200px] self-end w-full">
Next
</Button>
</div>
{footerButtons}
</div>
);
}
export default Writing;

View File

@@ -21,49 +21,38 @@ import InteractiveSpeaking from "./InteractiveSpeaking";
const MatchSentences = dynamic(() => import("@/components/Exercises/MatchSentences"), { ssr: false });
export interface CommonProps {
examID?: string;
onNext: (userSolutions: UserSolution) => void;
onBack: (userSolutions: UserSolution) => void;
enableNavigation?: boolean;
disableProgressButtons?: boolean
preview?: boolean;
}
export const renderExercise = (
exercise: Exercise,
examID: string,
onNext: (userSolutions: UserSolution) => void,
onBack: (userSolutions: UserSolution) => void,
enableNavigation?: boolean,
disableProgressButtons?: boolean,
preview?: boolean,
registerSolution: (updateSolution: () => UserSolution) => void,
preview: boolean,
headerButtons?: React.ReactNode,
footerButtons?: React.ReactNode,
) => {
const sharedProps = {
key: exercise.id,
registerSolution,
headerButtons,
footerButtons,
examID,
preview
}
switch (exercise.type) {
case "fillBlanks":
return <FillBlanks disableProgressButtons={disableProgressButtons} key={exercise.id} {...(exercise as FillBlanksExercise)} onNext={onNext} onBack={onBack} examID={examID} preview={preview} />;
return <FillBlanks {...(exercise as FillBlanksExercise)} {...sharedProps}/>;
case "trueFalse":
return <TrueFalse disableProgressButtons={disableProgressButtons} key={exercise.id} {...(exercise as TrueFalseExercise)} onNext={onNext} onBack={onBack} examID={examID} preview={preview} />;
return <TrueFalse {...(exercise as TrueFalseExercise)} {...sharedProps}/>;
case "matchSentences":
return <MatchSentences disableProgressButtons={disableProgressButtons} key={exercise.id} {...(exercise as MatchSentencesExercise)} onNext={onNext} onBack={onBack} examID={examID} preview={preview} />;
return <MatchSentences {...(exercise as MatchSentencesExercise)} {...sharedProps}/>;
case "multipleChoice":
return <MultipleChoice disableProgressButtons={disableProgressButtons} key={exercise.id} {...(exercise as MultipleChoiceExercise)} onNext={onNext} onBack={onBack} examID={examID} preview={preview} />;
return <MultipleChoice {...(exercise as MultipleChoiceExercise)} {...sharedProps}/>;
case "writeBlanks":
return <WriteBlanks disableProgressButtons={disableProgressButtons} key={exercise.id} {...(exercise as WriteBlanksExercise)} onNext={onNext} onBack={onBack} examID={examID} preview={preview} />;
return <WriteBlanks {...(exercise as WriteBlanksExercise)} {...sharedProps}/>;
case "writing":
return <Writing key={exercise.id} {...(exercise as WritingExercise)} onNext={onNext} onBack={onBack} examID={examID} enableNavigation={enableNavigation} preview={preview} />;
return <Writing {...(exercise as WritingExercise)} {...sharedProps}/>;
case "speaking":
return <Speaking key={exercise.id} {...(exercise as SpeakingExercise)} onNext={onNext} onBack={onBack} examID={examID} preview={preview} />;
return <Speaking {...(exercise as SpeakingExercise)} {...sharedProps}/>;
case "interactiveSpeaking":
return (
<InteractiveSpeaking
key={exercise.id}
{...(exercise as InteractiveSpeakingExercise)}
examID={examID}
onNext={onNext}
onBack={onBack}
preview={preview}
/>
);
return <InteractiveSpeaking {...(exercise as InteractiveSpeakingExercise)} {...sharedProps}/>;
}
};

View File

@@ -0,0 +1,9 @@
import { UserSolution } from "@/interfaces/exam";
export interface CommonProps {
examID?: string;
registerSolution: (updateSolution: () => UserSolution) => void;
headerButtons?: React.ReactNode,
footerButtons?: React.ReactNode,
preview: boolean,
}