ENCOA-137: Top right side Next Button on the exams

This commit is contained in:
Tiago Ribeiro
2024-09-04 15:10:17 +01:00
parent 49aac93618
commit 2d95cbd3dc
16 changed files with 1115 additions and 768 deletions

View File

@@ -1,237 +1,262 @@
import { FillBlanksExercise, FillBlanksMCOption } from "@/interfaces/exam"; import {FillBlanksExercise, FillBlanksMCOption} from "@/interfaces/exam";
import useExamStore from "@/stores/examStore"; import useExamStore from "@/stores/examStore";
import clsx from "clsx"; import clsx from "clsx";
import { Fragment, useCallback, useEffect, useMemo, useState } from "react"; import {Fragment, useCallback, useEffect, useMemo, useState} from "react";
import reactStringReplace from "react-string-replace"; import 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"; import {v4} from "uuid";
const FillBlanks: React.FC<FillBlanksExercise & CommonProps> = ({ const FillBlanks: React.FC<FillBlanksExercise & CommonProps> = ({
id, id,
type, type,
prompt, prompt,
solutions, solutions,
text, text,
words, words,
userSolutions, userSolutions,
variant, variant,
onNext, onNext,
onBack, onBack,
}) => { }) => {
const { shuffles, exam, partIndex, questionIndex, exerciseIndex } = useExamStore((state) => state); const {shuffles, 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);
const setCurrentSolution = useExamStore((state) => state.setCurrentSolution); const setCurrentSolution = useExamStore((state) => state.setCurrentSolution);
const shuffleMaps = shuffles.find((x) => x.exerciseID == id)?.shuffles; const shuffleMaps = shuffles.find((x) => x.exerciseID == id)?.shuffles;
const [currentMCSelection, setCurrentMCSelection] = useState<{ id: string, selection: FillBlanksMCOption }>(); const [currentMCSelection, setCurrentMCSelection] = useState<{id: string; selection: FillBlanksMCOption}>();
const typeCheckWordsMC = (words: any[]): words is FillBlanksMCOption[] => { const typeCheckWordsMC = (words: any[]): words is FillBlanksMCOption[] => {
return Array.isArray(words) && words.every( return Array.isArray(words) && words.every((word) => word && typeof word === "object" && "id" in word && "options" in word);
word => word && typeof word === 'object' && 'id' in word && 'options' in word };
);
}
const excludeWordMCType = (x: any) => { const excludeWordMCType = (x: any) => {
return typeof x === "string" ? x : x as { letter: string; word: string }; return typeof x === "string" ? x : (x as {letter: string; word: string});
} };
useEffect(() => { useEffect(() => {
if (hasExamEnded) onNext({ exercise: id, solutions: answers, score: calculateScore(), type }); if (hasExamEnded) onNext({exercise: id, solutions: answers, score: calculateScore(), type});
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [hasExamEnded]); }, [hasExamEnded]);
let correctWords: any;
if (exam && (exam.module === "level" || exam.module === "reading") && exam.parts[partIndex].exercises[exerciseIndex].type === "fillBlanks") {
correctWords = (exam.parts[partIndex].exercises[exerciseIndex] as FillBlanksExercise).words;
}
let correctWords: any; const calculateScore = () => {
if (exam && (exam.module === "level" || exam.module === "reading") && exam.parts[partIndex].exercises[exerciseIndex].type === "fillBlanks") { const total = text.match(/({{\d+}})/g)?.length || 0;
correctWords = (exam.parts[partIndex].exercises[exerciseIndex] as FillBlanksExercise).words; const correct = answers!.filter((x) => {
} const solution = solutions.find((y) => x.id.toString() === y.id.toString())?.solution;
if (!solution) return false;
const option = correctWords!.find((w: any) => {
if (typeof w === "string") {
return w.toLowerCase() === x.solution.toLowerCase();
} else if ("letter" in w) {
return w.letter.toLowerCase() === x.solution.toLowerCase();
} else {
return w.id.toString() === x.id.toString();
}
});
if (!option) return false;
const calculateScore = () => { if (typeof option === "string") {
const total = text.match(/({{\d+}})/g)?.length || 0; return solution.toLowerCase() === option.toLowerCase();
const correct = answers!.filter((x) => { } else if ("letter" in option) {
const solution = solutions.find((y) => x.id.toString() === y.id.toString())?.solution; return solution.toLowerCase() === option.word.toLowerCase();
if (!solution) return false; } else if ("options" in option) {
const option = correctWords!.find((w: any) => { return option.options[solution as keyof typeof option.options] == x.solution;
if (typeof w === "string") { }
return w.toLowerCase() === x.solution.toLowerCase(); return false;
} else if ('letter' in w) { }).length;
return w.letter.toLowerCase() === x.solution.toLowerCase(); const missing = total - answers!.filter((x) => solutions.find((y) => x.id.toString() === y.id.toString())).length;
} else { return {total, correct, missing};
return w.id.toString() === x.id.toString(); };
} const renderLines = useCallback(
}); (line: string) => {
if (!option) return false; return (
<div className="text-base leading-5" key={v4()}>
{reactStringReplace(line, /({{\d+}})/g, (match) => {
const id = match.replaceAll(/[\{\}]/g, "");
const userSolution = answers.find((x) => x.id === id);
const styles = clsx(
"rounded-full hover:text-white transition duration-300 ease-in-out my-1 px-5 py-2 text-center",
currentMCSelection?.id == id && "!bg-mti-purple !text-white !outline-none !ring-0",
!userSolution && "text-center text-mti-purple-light bg-mti-purple-ultralight",
userSolution && "text-center text-mti-purple-dark bg-mti-purple-ultralight",
);
return variant === "mc" ? (
<>
{/*<span className="mr-2">{`(${id})`}</span>*/}
<button
className={styles}
onClick={() => {
setCurrentMCSelection({
id: id,
selection: words.find((x) => {
if (typeof x !== "string" && "id" in x) {
return (x as FillBlanksMCOption).id.toString() == id.toString();
}
return false;
}) as FillBlanksMCOption,
});
}}>
{userSolution?.solution === undefined ? (
<span className="text-transparent select-none">placeholder</span>
) : (
<span> {userSolution.solution} </span>
)}
</button>
</>
) : (
<input
className={styles}
onChange={(e) => setAnswers((prev) => [...prev.filter((x) => x.id !== id), {id, solution: e.target.value}])}
value={userSolution?.solution}
/>
);
})}
</div>
);
},
[variant, words, setCurrentMCSelection, answers, currentMCSelection],
);
if (typeof option === "string") { const memoizedLines = useMemo(() => {
return solution.toLowerCase() === option.toLowerCase(); return text.split("\\n").map((line, index) => (
} else if ('letter' in option) { <p key={index} className={clsx(variant === "mc" && "whitespace-pre-wrap")}>
return solution.toLowerCase() === option.word.toLowerCase(); {renderLines(line)}
} else if ('options' in option) { <br />
return option.options[solution as keyof typeof option.options] == x.solution; </p>
} ));
return false; // eslint-disable-next-line react-hooks/exhaustive-deps
}).length; }, [text, variant, renderLines, currentMCSelection]);
const missing = total - answers!.filter((x) => solutions.find((y) => x.id.toString() === y.id.toString())).length;
return { total, correct, missing };
};
const renderLines = useCallback((line: string) => {
return (
<div className="text-base leading-5" key={v4()}>
{reactStringReplace(line, /({{\d+}})/g, (match) => {
const id = match.replaceAll(/[\{\}]/g, "");
const userSolution = answers.find((x) => x.id === id);
const styles = clsx(
"rounded-full hover:text-white transition duration-300 ease-in-out my-1 px-5 py-2 text-center",
currentMCSelection?.id == id && "!bg-mti-purple !text-white !outline-none !ring-0",
!userSolution && "text-center text-mti-purple-light bg-mti-purple-ultralight",
userSolution && "text-center text-mti-purple-dark bg-mti-purple-ultralight",
)
return (
variant === "mc" ? (
<>
{/*<span className="mr-2">{`(${id})`}</span>*/}
<button
className={styles}
onClick={() => {
setCurrentMCSelection(
{
id: id,
selection: words.find((x) => {
if (typeof x !== "string" && 'id' in x) {
return (x as FillBlanksMCOption).id.toString() == id.toString();
}
return false;
}) as FillBlanksMCOption
}
);
}}
>
{userSolution?.solution === undefined ? <span className="text-transparent select-none">placeholder</span> : <span> {userSolution.solution} </span>}
</button>
</>
) : (
<input
className={styles}
onChange={(e) => setAnswers((prev) => [...prev.filter((x) => x.id !== id), { id, solution: e.target.value }])}
value={userSolution?.solution} />
)
);
})}
</div>
);
}, [variant, words, setCurrentMCSelection, answers, currentMCSelection]);
const memoizedLines = useMemo(() => { const onSelection = (questionID: string, value: string) => {
return text.split("\\n").map((line, index) => ( setAnswers((prev) => [...prev.filter((x) => x.id !== questionID), {id: questionID, solution: value}]);
<p key={index} className={clsx(variant === "mc" && "whitespace-pre-wrap")}> };
{renderLines(line)}
<br />
</p>
));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [text, variant, renderLines, currentMCSelection]);
useEffect(() => {
if (variant === "mc") {
setCurrentSolution({exercise: id, solutions: answers, score: calculateScore(), type, shuffleMaps: shuffleMaps});
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [answers]);
const onSelection = (questionID: string, value: string) => { return (
setAnswers((prev) => [...prev.filter((x) => x.id !== questionID), { id: questionID, solution: value }]); <div className="flex flex-col gap-4">
} <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})}
className="max-w-[200px] w-full"
disabled={exam && exam.module === "level" && partIndex === 0 && questionIndex === 0}>
Back
</Button>
useEffect(() => { <Button
if (variant === "mc") { color="purple"
setCurrentSolution({ exercise: id, solutions: answers, score: calculateScore(), type, shuffleMaps: shuffleMaps }); onClick={() => {
} onNext({exercise: id, solutions: answers, score: calculateScore(), type, shuffleMaps: shuffleMaps});
// eslint-disable-next-line react-hooks/exhaustive-deps }}
}, [answers]) className="max-w-[200px] self-end w-full">
Next
</Button>
</div>
return ( <div className="flex flex-col gap-4 mt-4 h-full w-full mb-20">
<> {variant !== "mc" && (
<div className="flex flex-col gap-4 mt-4 h-full w-full mb-20"> <span className="text-sm w-full leading-6">
{variant !== "mc" && <span className="text-sm w-full leading-6"> {prompt.split("\\n").map((line, index) => (
{prompt.split("\\n").map((line, index) => ( <Fragment key={index}>
<Fragment key={index}> {line}
{line} <br />
<br /> </Fragment>
</Fragment> ))}
))} </span>
</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">{memoizedLines}</span>
{memoizedLines} {variant === "mc" && typeCheckWordsMC(words) ? (
</span> <>
{variant === "mc" && typeCheckWordsMC(words) ? ( {currentMCSelection && (
<> <div className="bg-mti-gray-smoke rounded-xl flex flex-col gap-4 px-16 py-8">
{currentMCSelection && ( <span className="font-medium text-lg text-mti-purple-dark mb-4 px-2">{`${currentMCSelection.id} - Select the appropriate word.`}</span>
<div className="bg-mti-gray-smoke rounded-xl flex flex-col gap-4 px-16 py-8"> <div className="flex gap-4 flex-wrap justify-between">
<span className="font-medium text-lg text-mti-purple-dark mb-4 px-2">{`${currentMCSelection.id} - Select the appropriate word.`}</span> {currentMCSelection.selection?.options &&
<div className="flex gap-4 flex-wrap justify-between"> Object.entries(currentMCSelection.selection.options)
{currentMCSelection.selection?.options && Object.entries(currentMCSelection.selection.options).sort((a, b) => a[0].localeCompare(b[0])).map(([key, value]) => { .sort((a, b) => a[0].localeCompare(b[0]))
return <div .map(([key, value]) => {
key={v4()} return (
onClick={() => onSelection(currentMCSelection.id, value)} <div
className={clsx( key={v4()}
"flex border p-4 rounded-xl gap-2 cursor-pointer bg-white text-base", onClick={() => onSelection(currentMCSelection.id, value)}
!!answers.find((x) => x.solution.toLocaleLowerCase() === value.toLocaleLowerCase() && x.id === currentMCSelection.id) && className={clsx(
"!bg-mti-purple-light !text-white", "flex border p-4 rounded-xl gap-2 cursor-pointer bg-white text-base",
)}> !!answers.find(
<span className="font-semibold">{key}.</span> (x) =>
<span>{value}</span> x.solution.toLocaleLowerCase() === value.toLocaleLowerCase() &&
</div> x.id === currentMCSelection.id,
})} ) && "!bg-mti-purple-light !text-white",
</div> )}>
</div> <span className="font-semibold">{key}.</span>
)} <span>{value}</span>
</> </div>
) : ( );
<div className="bg-mti-gray-smoke rounded-xl px-5 py-6 flex flex-col gap-4"> })}
<span className="font-medium text-mti-purple-dark">Options</span> </div>
<div className="flex gap-4 flex-wrap"> </div>
{words.map((v) => { )}
v = excludeWordMCType(v); </>
const text = typeof v === "string" ? v : `${v.letter} - ${v.word}`; ) : (
<div className="bg-mti-gray-smoke rounded-xl px-5 py-6 flex flex-col gap-4">
<span className="font-medium text-mti-purple-dark">Options</span>
<div className="flex gap-4 flex-wrap">
{words.map((v) => {
v = excludeWordMCType(v);
const text = typeof v === "string" ? v : `${v.letter} - ${v.word}`;
return ( return (
<span <span
className={clsx( className={clsx(
"border border-mti-purple-light rounded-full px-3 py-0.5 transition ease-in-out duration-300", "border border-mti-purple-light rounded-full px-3 py-0.5 transition ease-in-out duration-300",
!!answers.find((x) => x.solution.toLowerCase() === (typeof v === "string" ? v : ("letter" in v ? v.letter : "")).toLowerCase()) && !!answers.find(
"bg-mti-purple-dark text-white", (x) =>
)} x.solution.toLowerCase() ===
key={v4()} (typeof v === "string" ? v : "letter" in v ? v.letter : "").toLowerCase(),
> ) && "bg-mti-purple-dark text-white",
{text} )}
</span> key={v4()}>
) {text}
})} </span>
</div> );
</div > })}
)} </div>
</div> </div>
<div className="self-end flex justify-between w-full gap-8 absolute bottom-8 left-0 px-8"> )}
<Button </div>
color="purple" <div className="self-end flex justify-between w-full gap-8 absolute bottom-8 left-0 px-8">
variant="outline" <Button
onClick={() => onBack({ exercise: id, solutions: answers, score: calculateScore(), type, shuffleMaps: shuffleMaps })} color="purple"
className="max-w-[200px] w-full" variant="outline"
disabled={ onClick={() => onBack({exercise: id, solutions: answers, score: calculateScore(), type, shuffleMaps: shuffleMaps})}
exam && exam.module === "level" && className="max-w-[200px] w-full"
partIndex === 0 && disabled={exam && exam.module === "level" && partIndex === 0 && questionIndex === 0}>
questionIndex === 0 Back
} </Button>
>
Back
</Button>
<Button <Button
color="purple" color="purple"
onClick={() => { onNext({ exercise: id, solutions: answers, score: calculateScore(), type, shuffleMaps: shuffleMaps }) }} onClick={() => {
className="max-w-[200px] self-end w-full"> onNext({exercise: id, solutions: answers, score: calculateScore(), type, shuffleMaps: shuffleMaps});
Next }}
</Button> className="max-w-[200px] self-end w-full">
</div> Next
</> </Button>
); </div>
} </div>
);
};
export default FillBlanks; export default FillBlanks;

View File

@@ -152,139 +152,8 @@ export default function InteractiveSpeaking({
}; };
return ( return (
<div className="flex flex-col h-full w-full gap-9"> <div className="flex flex-col gap-4 mt-4 w-full">
<div className="flex flex-col w-full gap-8 bg-mti-gray-smoke rounded-xl py-8 pb-12 px-16"> <div className="flex justify-between w-full gap-8">
<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={(blob) => setMediaBlob(blob)}
render={({status, startRecording, stopRecording, pauseRecording, resumeRecording, clearBlobUrl, 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" />
{status === "idle" && (
<BsMicFill
onClick={() => {
setRecordingDuration(0);
startRecording();
setIsRecording(true);
}}
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(10)
.padStart(2, "0")}
:
{Math.floor(recordingDuration % 60)
.toString(10)
.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(10)
.padStart(2, "0")}
:
{Math.floor(recordingDuration % 60)
.toString(10)
.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={() => {
setRecordingDuration(0);
clearBlobUrl();
setMediaBlob(undefined);
}}
/>
<BsMicFill
onClick={() => {
clearBlobUrl();
setRecordingDuration(0);
startRecording();
setIsRecording(true);
setMediaBlob(undefined);
}}
className="h-5 w-5 text-mti-gray-cool cursor-pointer"
/>
</div>
</>
)}
</div>
</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"> <Button color="purple" variant="outline" isLoading={isLoading} onClick={back} className="max-w-[200px] self-end w-full">
Back Back
</Button> </Button>
@@ -292,6 +161,148 @@ export default function InteractiveSpeaking({
{questionIndex + 1 < prompts.length ? "Next Prompt" : "Submit"} {questionIndex + 1 < prompts.length ? "Next Prompt" : "Submit"}
</Button> </Button>
</div> </div>
<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={(blob) => setMediaBlob(blob)}
render={({status, startRecording, stopRecording, pauseRecording, resumeRecording, clearBlobUrl, 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" />
{status === "idle" && (
<BsMicFill
onClick={() => {
setRecordingDuration(0);
startRecording();
setIsRecording(true);
}}
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(10)
.padStart(2, "0")}
:
{Math.floor(recordingDuration % 60)
.toString(10)
.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(10)
.padStart(2, "0")}
:
{Math.floor(recordingDuration % 60)
.toString(10)
.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={() => {
setRecordingDuration(0);
clearBlobUrl();
setMediaBlob(undefined);
}}
/>
<BsMicFill
onClick={() => {
clearBlobUrl();
setRecordingDuration(0);
startRecording();
setIsRecording(true);
setMediaBlob(undefined);
}}
className="h-5 w-5 text-mti-gray-cool cursor-pointer"
/>
</div>
</>
)}
</div>
</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>
<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>
</div> </div>
); );
} }

View File

@@ -67,13 +67,13 @@ export default function MatchSentences({id, options, type, prompt, sentences, us
const [answers, setAnswers] = useState<{question: string; option: string}[]>(userSolutions); const [answers, setAnswers] = useState<{question: string; option: string}[]>(userSolutions);
const hasExamEnded = useExamStore((state) => state.hasExamEnded); const hasExamEnded = useExamStore((state) => state.hasExamEnded);
const setCurrentSolution = useExamStore((state) => state.setCurrentSolution); const setCurrentSolution = useExamStore((state) => state.setCurrentSolution);
useEffect(() => { useEffect(() => {
setCurrentSolution({ exercise: id, solutions: answers, score: calculateScore(), type}); setCurrentSolution({exercise: id, solutions: answers, score: calculateScore(), type});
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [answers, setAnswers]) }, [answers, setAnswers]);
const handleDragEnd = (event: DragEndEvent) => { const handleDragEnd = (event: DragEndEvent) => {
if (event.over && event.over.id.toString().startsWith("droppable")) { if (event.over && event.over.id.toString().startsWith("droppable")) {
@@ -100,7 +100,24 @@ export default function MatchSentences({id, options, type, prompt, sentences, us
}, [hasExamEnded]); }, [hasExamEnded]);
return ( 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: answers, score: calculateScore(), type})}
className="max-w-[200px] w-full">
Back
</Button>
<Button
color="purple"
onClick={() => onNext({exercise: id, solutions: answers, score: calculateScore(), type})}
className="max-w-[200px] self-end w-full">
Next
</Button>
</div>
<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"> <span className="text-sm w-full leading-6">
{prompt.split("\\n").map((line, index) => ( {prompt.split("\\n").map((line, index) => (
@@ -150,6 +167,6 @@ export default function MatchSentences({id, options, type, prompt, sentences, us
Next Next
</Button> </Button>
</div> </div>
</> </div>
); );
} }

View File

@@ -151,8 +151,29 @@ export default function MultipleChoice({id, prompt, type, questions, userSolutio
}; };
return ( return (
<> <div className="flex flex-col gap-4">
<div className="flex flex-col gap-4 mt-2 mb-20"> <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>
<div className="flex flex-col gap-4 mt-4 mb-20">
<div className="flex flex-col gap-8 h-fit w-full bg-mti-gray-smoke rounded-xl px-16 py-8"> <div className="flex flex-col gap-8 h-fit w-full bg-mti-gray-smoke rounded-xl px-16 py-8">
{/*<span className="text-xl font-semibold mb-2">{"Select the appropriate option."}</span>*/} {/*<span className="text-xl font-semibold mb-2">{"Select the appropriate option."}</span>*/}
{questionIndex < questions.length && ( {questionIndex < questions.length && (
@@ -195,6 +216,6 @@ export default function MultipleChoice({id, prompt, type, questions, userSolutio
: "Next"} : "Next"}
</Button> </Button>
</div> </div>
</> </div>
); );
} }

View File

@@ -1,20 +1,20 @@
import { SpeakingExercise } from "@/interfaces/exam"; import {SpeakingExercise} from "@/interfaces/exam";
import { CommonProps } from "."; import {CommonProps} from ".";
import { Fragment, useEffect, useState } from "react"; import {Fragment, useEffect, useState} from "react";
import { BsCheckCircleFill, BsMicFill, BsPauseCircle, BsPlayCircle, BsTrashFill } from "react-icons/bs"; import {BsCheckCircleFill, BsMicFill, BsPauseCircle, BsPlayCircle, BsTrashFill} from "react-icons/bs";
import dynamic from "next/dynamic"; import dynamic from "next/dynamic";
import Button from "../Low/Button"; import Button from "../Low/Button";
import useExamStore from "@/stores/examStore"; import useExamStore from "@/stores/examStore";
import { downloadBlob } from "@/utils/evaluation"; import {downloadBlob} from "@/utils/evaluation";
import axios from "axios"; import axios from "axios";
import Modal from "../Modal"; import Modal from "../Modal";
const Waveform = dynamic(() => import("../Waveform"), { ssr: false }); const Waveform = dynamic(() => import("../Waveform"), {ssr: false});
const ReactMediaRecorder = dynamic(() => import("react-media-recorder").then((mod) => mod.ReactMediaRecorder), { const ReactMediaRecorder = dynamic(() => import("react-media-recorder").then((mod) => mod.ReactMediaRecorder), {
ssr: false, ssr: false,
}); });
export default function Speaking({ id, title, text, video_url, type, prompts, suffix, userSolutions, onNext, onBack }: SpeakingExercise & CommonProps) { export default function Speaking({id, title, text, video_url, type, prompts, suffix, userSolutions, onNext, onBack}: SpeakingExercise & CommonProps) {
const [recordingDuration, setRecordingDuration] = useState(0); const [recordingDuration, setRecordingDuration] = useState(0);
const [isRecording, setIsRecording] = useState(false); const [isRecording, setIsRecording] = useState(false);
const [mediaBlob, setMediaBlob] = useState<string>(); const [mediaBlob, setMediaBlob] = useState<string>();
@@ -28,7 +28,7 @@ export default function Speaking({ id, title, text, video_url, type, prompts, su
const saveToStorage = async () => { const saveToStorage = async () => {
if (mediaBlob && mediaBlob.startsWith("blob")) { if (mediaBlob && mediaBlob.startsWith("blob")) {
const blobBuffer = await downloadBlob(mediaBlob); const blobBuffer = await downloadBlob(mediaBlob);
const audioFile = new File([blobBuffer], "audio.wav", { type: "audio/wav" }); const audioFile = new File([blobBuffer], "audio.wav", {type: "audio/wav"});
const seed = Math.random().toString().replace("0.", ""); const seed = Math.random().toString().replace("0.", "");
@@ -42,8 +42,8 @@ export default function Speaking({ id, title, text, video_url, type, prompts, su
}, },
}; };
const response = await axios.post<{ path: string }>("/api/storage/insert", formData, config); const response = await axios.post<{path: string}>("/api/storage/insert", formData, config);
if (audioURL) await axios.post("/api/storage/delete", { path: audioURL }); if (audioURL) await axios.post("/api/storage/delete", {path: audioURL});
return response.data.path; return response.data.path;
} }
@@ -52,7 +52,7 @@ export default function Speaking({ id, title, text, video_url, type, prompts, su
useEffect(() => { useEffect(() => {
if (userSolutions.length > 0) { if (userSolutions.length > 0) {
const { solution } = userSolutions[0] as { solution?: string }; const {solution} = userSolutions[0] as {solution?: string};
if (solution && !mediaBlob) setMediaBlob(solution); if (solution && !mediaBlob) setMediaBlob(solution);
if (solution && !solution.startsWith("blob")) setAudioURL(solution); if (solution && !solution.startsWith("blob")) setAudioURL(solution);
} }
@@ -79,8 +79,8 @@ export default function Speaking({ id, title, text, video_url, type, prompts, su
const next = async () => { const next = async () => {
onNext({ onNext({
exercise: id, exercise: id,
solutions: mediaBlob ? [{ id, solution: mediaBlob }] : [], solutions: mediaBlob ? [{id, solution: mediaBlob}] : [],
score: { correct: 0, total: 100, missing: 0 }, score: {correct: 0, total: 100, missing: 0},
type, type,
}); });
}; };
@@ -88,8 +88,8 @@ export default function Speaking({ id, title, text, video_url, type, prompts, su
const back = async () => { const back = async () => {
onBack({ onBack({
exercise: id, exercise: id,
solutions: mediaBlob ? [{ id, solution: mediaBlob }] : [], solutions: mediaBlob ? [{id, solution: mediaBlob}] : [],
score: { correct: 0, total: 100, missing: 0 }, score: {correct: 0, total: 100, missing: 0},
type, type,
}); });
}; };
@@ -98,7 +98,7 @@ export default function Speaking({ id, title, text, video_url, type, prompts, su
const newText = e.target.value; const newText = e.target.value;
const words = newText.match(/\S+/g); const words = newText.match(/\S+/g);
const wordCount = words ? words.length : 0; const wordCount = words ? words.length : 0;
if (wordCount <= 100) { if (wordCount <= 100) {
setInputText(newText); setInputText(newText);
} else { } else {
@@ -110,188 +110,14 @@ export default function Speaking({ id, title, text, video_url, type, prompts, su
if (count > 100) break; if (count > 100) break;
lastIndex = match.index! + match[0].length; lastIndex = match.index! + match[0].length;
} }
setInputText(newText.slice(0, lastIndex)); setInputText(newText.slice(0, lastIndex));
} }
}; };
return ( return (
<div className="flex flex-col h-full w-full gap-9"> <div className="flex flex-col gap-4 mt-4 w-full">
<Modal title="Prompts" className="!w-96 aspect-square" isOpen={isPromptsModalOpen} onClose={() => setIsPromptsModalOpen(false)}> <div className="flex justify-between w-full gap-8">
<div className="flex flex-col items-center justify-center gap-4 w-full h-full">
<div className="flex flex-col gap-1 ml-4">
{prompts.map((x, index) => (
<li className="italic" key={index}>
{x}
</li>
))}
</div>
{!!suffix && <span className="font-bold">{suffix}</span>}
</div>
</Modal>
<div className="flex flex-col w-full gap-2 bg-mti-gray-smoke rounded-xl py-8 px-16">
<div className="flex flex-col gap-3">
<div className="flex flex-col gap-0">
<span className="font-semibold">{title}</span>
{prompts.length > 0 && (
<span className="font-semibold">You should talk for at least 1 minute and 30 seconds for your answer to be valid.</span>
)}
</div>
{!video_url && (
<span className="font-regular">
{text.split("\\n").map((line, index) => (
<Fragment key={index}>
<span>{line}</span>
<br />
</Fragment>
))}
</span>
)}
</div>
<div className="flex flex-col gap-6 items-center">
{video_url && (
<div className="flex flex-col gap-4 w-full items-center">
<video key={id} autoPlay controls className="max-w-3xl rounded-xl">
<source src={video_url} />
</video>
</div>
)}
{prompts && prompts.length > 0 && <Button onClick={() => setIsPromptsModalOpen(true)}>View Prompts</Button>}
</div>
</div>
{prompts && prompts.length > 0 && (
<div className="w-full h-full flex flex-col gap-4">
<textarea
onContextMenu={(e) => e.preventDefault()}
className="w-full h-full min-h-[200px] cursor-text px-7 py-8 input border-2 border-mti-gray-platinum bg-white rounded-3xl"
onChange={handleNoteWriting}
value={inputText}
placeholder="Write your notes here..."
spellCheck={false}
/>
<span className="text-base self-end text-mti-gray-cool">Word Count: {(inputText.match(/\S+/g) || []).length}/100</span>
</div>
)}
<ReactMediaRecorder
audio
onStop={(blob) => setMediaBlob(blob)}
render={({ status, startRecording, stopRecording, pauseRecording, resumeRecording, clearBlobUrl, 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" && !mediaBlob && (
<>
<div className="w-full h-2 max-w-4xl bg-mti-gray-smoke rounded-full" />
{status === "idle" && (
<BsMicFill
onClick={() => {
setRecordingDuration(0);
startRecording();
setIsRecording(true);
}}
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(10)
.padStart(2, "0")}
:
{Math.floor(recordingDuration % 60)
.toString(10)
.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(10)
.padStart(2, "0")}
:
{Math.floor(recordingDuration % 60)
.toString(10)
.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) || (status === "idle" && mediaBlob)) && (
<>
<Waveform audio={mediaBlobUrl ? mediaBlobUrl : mediaBlob!} waveColor="#FCDDEC" progressColor="#EF5DA8" />
<div className="flex gap-4 items-center">
<BsTrashFill
className="text-mti-gray-cool cursor-pointer w-5 h-5"
onClick={() => {
setRecordingDuration(0);
clearBlobUrl();
setMediaBlob(undefined);
}}
/>
<BsMicFill
onClick={() => {
clearBlobUrl();
setRecordingDuration(0);
startRecording();
setIsRecording(true);
setMediaBlob(undefined);
}}
className="h-5 w-5 text-mti-gray-cool cursor-pointer"
/>
</div>
</>
)}
</div>
</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"> <Button color="purple" variant="outline" isLoading={isLoading} onClick={back} className="max-w-[200px] self-end w-full">
Back Back
</Button> </Button>
@@ -299,6 +125,193 @@ export default function Speaking({ id, title, text, video_url, type, prompts, su
Next Next
</Button> </Button>
</div> </div>
<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">
<div className="flex flex-col gap-1 ml-4">
{prompts.map((x, index) => (
<li className="italic" key={index}>
{x}
</li>
))}
</div>
{!!suffix && <span className="font-bold">{suffix}</span>}
</div>
</Modal>
<div className="flex flex-col w-full gap-2 bg-mti-gray-smoke rounded-xl py-8 px-16">
<div className="flex flex-col gap-3">
<div className="flex flex-col gap-0">
<span className="font-semibold">{title}</span>
{prompts.length > 0 && (
<span className="font-semibold">
You should talk for at least 1 minute and 30 seconds for your answer to be valid.
</span>
)}
</div>
{!video_url && (
<span className="font-regular">
{text.split("\\n").map((line, index) => (
<Fragment key={index}>
<span>{line}</span>
<br />
</Fragment>
))}
</span>
)}
</div>
<div className="flex flex-col gap-6 items-center">
{video_url && (
<div className="flex flex-col gap-4 w-full items-center">
<video key={id} autoPlay controls className="max-w-3xl rounded-xl">
<source src={video_url} />
</video>
</div>
)}
{prompts && prompts.length > 0 && <Button onClick={() => setIsPromptsModalOpen(true)}>View Prompts</Button>}
</div>
</div>
{prompts && prompts.length > 0 && (
<div className="w-full h-full flex flex-col gap-4">
<textarea
onContextMenu={(e) => e.preventDefault()}
className="w-full h-full min-h-[200px] cursor-text px-7 py-8 input border-2 border-mti-gray-platinum bg-white rounded-3xl"
onChange={handleNoteWriting}
value={inputText}
placeholder="Write your notes here..."
spellCheck={false}
/>
<span className="text-base self-end text-mti-gray-cool">Word Count: {(inputText.match(/\S+/g) || []).length}/100</span>
</div>
)}
<ReactMediaRecorder
audio
onStop={(blob) => setMediaBlob(blob)}
render={({status, startRecording, stopRecording, pauseRecording, resumeRecording, clearBlobUrl, 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" && !mediaBlob && (
<>
<div className="w-full h-2 max-w-4xl bg-mti-gray-smoke rounded-full" />
{status === "idle" && (
<BsMicFill
onClick={() => {
setRecordingDuration(0);
startRecording();
setIsRecording(true);
}}
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(10)
.padStart(2, "0")}
:
{Math.floor(recordingDuration % 60)
.toString(10)
.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(10)
.padStart(2, "0")}
:
{Math.floor(recordingDuration % 60)
.toString(10)
.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) || (status === "idle" && mediaBlob)) && (
<>
<Waveform audio={mediaBlobUrl ? mediaBlobUrl : mediaBlob!} waveColor="#FCDDEC" progressColor="#EF5DA8" />
<div className="flex gap-4 items-center">
<BsTrashFill
className="text-mti-gray-cool cursor-pointer w-5 h-5"
onClick={() => {
setRecordingDuration(0);
clearBlobUrl();
setMediaBlob(undefined);
}}
/>
<BsMicFill
onClick={() => {
clearBlobUrl();
setRecordingDuration(0);
startRecording();
setIsRecording(true);
setMediaBlob(undefined);
}}
className="h-5 w-5 text-mti-gray-cool cursor-pointer"
/>
</div>
</>
)}
</div>
</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>
<Button color="purple" isLoading={isLoading} disabled={!mediaBlob} onClick={next} className="max-w-[200px] self-end w-full">
Next
</Button>
</div>
</div>
</div> </div>
); );
} }

View File

@@ -30,10 +30,9 @@ export default function TrueFalse({id, type, prompt, questions, userSolutions, o
}; };
useEffect(() => { useEffect(() => {
setCurrentSolution({ exercise: id, solutions: answers, score: calculateScore(), type}); setCurrentSolution({exercise: id, solutions: answers, score: calculateScore(), type});
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [answers, setAnswers]) }, [answers, setAnswers]);
const toggleAnswer = (solution: "true" | "false" | "not_given", questionId: string) => { const toggleAnswer = (solution: "true" | "false" | "not_given", questionId: string) => {
const answer = answers.find((x) => x.id === questionId); const answer = answers.find((x) => x.id === questionId);
@@ -46,7 +45,24 @@ export default function TrueFalse({id, type, prompt, questions, userSolutions, o
}; };
return ( 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: answers, score: calculateScore(), type})}
className="max-w-[200px] w-full">
Back
</Button>
<Button
color="purple"
onClick={() => onNext({exercise: id, solutions: answers, score: calculateScore(), type})}
className="max-w-[200px] self-end w-full">
Next
</Button>
</div>
<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"> <span className="text-sm w-full leading-6">
{prompt.split("\\n").map((line, index) => ( {prompt.split("\\n").map((line, index) => (
@@ -123,6 +139,6 @@ export default function TrueFalse({id, type, prompt, questions, userSolutions, o
Next Next
</Button> </Button>
</div> </div>
</> </div>
); );
} }

View File

@@ -71,9 +71,9 @@ export default function WriteBlanks({id, prompt, type, maxWords, solutions, user
}; };
useEffect(() => { useEffect(() => {
setCurrentSolution({ exercise: id, solutions: answers, score: calculateScore(), type }); setCurrentSolution({exercise: id, solutions: answers, score: calculateScore(), type});
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [answers, setAnswers]) }, [answers, setAnswers]);
const renderLines = (line: string) => { const renderLines = (line: string) => {
return ( return (
@@ -92,7 +92,24 @@ export default function WriteBlanks({id, prompt, type, maxWords, solutions, user
}; };
return ( return (
<> <div className="flex flex-col gap-4">
<div className="flex justify-between w-full gap-8">
<Button
color="purple"
variant="outline"
onClick={() => onBack({exercise: id, solutions: answers, score: calculateScore(), type})}
className="max-w-[200px] w-full">
Back
</Button>
<Button
color="purple"
onClick={() => onNext({exercise: id, solutions: answers, score: calculateScore(), type})}
className="max-w-[200px] self-end w-full">
Next
</Button>
</div>
<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"> <span className="text-sm w-full leading-6">
{prompt.split("\\n").map((line, index) => ( {prompt.split("\\n").map((line, index) => (
@@ -128,6 +145,6 @@ export default function WriteBlanks({id, prompt, type, maxWords, solutions, user
Next Next
</Button> </Button>
</div> </div>
</> </div>
); );
} }

View File

@@ -84,7 +84,34 @@ export default function Writing({
}, [inputText, wordCounter]); }, [inputText, wordCounter]);
return ( 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})
}
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",
})
}
className="max-w-[200px] self-end w-full">
Next
</Button>
</div>
{attachment && ( {attachment && (
<Transition show={isModalOpen} as={Fragment}> <Transition show={isModalOpen} as={Fragment}>
<Dialog onClose={() => setIsModalOpen(false)} className="relative z-50"> <Dialog onClose={() => setIsModalOpen(false)} className="relative z-50">
@@ -170,6 +197,6 @@ export default function Writing({
Next Next
</Button> </Button>
</div> </div>
</> </div>
); );
} }

View File

@@ -1,28 +1,17 @@
import { FillBlanksExercise, FillBlanksMCOption, ShuffleMap } from "@/interfaces/exam"; import {FillBlanksExercise, FillBlanksMCOption, ShuffleMap} from "@/interfaces/exam";
import clsx from "clsx"; import clsx from "clsx";
import reactStringReplace from "react-string-replace"; 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"; import useExamStore from "@/stores/examStore";
export default function FillBlanksSolutions({ export default function FillBlanksSolutions({id, type, prompt, solutions, words, text, onNext, onBack}: FillBlanksExercise & CommonProps) {
id,
type,
prompt,
solutions,
words,
text,
onNext,
onBack,
}: FillBlanksExercise & CommonProps) {
const storeUserSolutions = useExamStore((state) => state.userSolutions); const storeUserSolutions = useExamStore((state) => state.userSolutions);
const {questionIndex, setQuestionIndex, partIndex, exam} = useExamStore((state) => state); const {questionIndex, setQuestionIndex, partIndex, exam} = useExamStore((state) => state);
const correctUserSolutions = storeUserSolutions.find( const correctUserSolutions = storeUserSolutions.find((solution) => solution.exercise === id)?.solutions;
(solution) => solution.exercise === id
)?.solutions;
const shuffles = useExamStore((state) => state.shuffles); const shuffles = useExamStore((state) => state.shuffles);
const calculateScore = () => { const calculateScore = () => {
@@ -34,7 +23,7 @@ export default function FillBlanksSolutions({
const option = words.find((w) => { 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.letter.toLowerCase() === x.solution.toLowerCase(); return w.letter.toLowerCase() === x.solution.toLowerCase();
} else { } else {
return w.id.toString() === x.id.toString(); return w.id.toString() === x.id.toString();
@@ -44,23 +33,20 @@ export default function FillBlanksSolutions({
if (typeof option === "string") { if (typeof option === "string") {
return solution.toLowerCase() === option.toLowerCase(); return solution.toLowerCase() === option.toLowerCase();
} 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; return option.options[solution as keyof typeof option.options] == x.solution;
} }
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 - correctUserSolutions!.filter((x) => solutions.find((y) => x.id.toString() === y.id.toString())).length;
return { total, correct, missing }; return {total, correct, missing};
}; };
const typeCheckWordsMC = (words: any[]): words is FillBlanksMCOption[] => { const typeCheckWordsMC = (words: any[]): words is FillBlanksMCOption[] => {
return Array.isArray(words) && words.every( return Array.isArray(words) && words.every((word) => word && typeof word === "object" && "id" in word && "options" in word);
word => word && typeof word === 'object' && 'id' in word && 'options' in word };
);
}
const renderLines = (line: string) => { const renderLines = (line: string) => {
return ( return (
@@ -68,17 +54,17 @@ export default function FillBlanksSolutions({
{reactStringReplace(line, /({{\d+}})/g, (match) => { {reactStringReplace(line, /({{\d+}})/g, (match) => {
const questionId = match.replaceAll(/[\{\}]/g, ""); const questionId = match.replaceAll(/[\{\}]/g, "");
const userSolution = correctUserSolutions!.find((x) => x.id.toString() === questionId.toString()); const userSolution = correctUserSolutions!.find((x) => x.id.toString() === questionId.toString());
const answerSolution = solutions.find(sol => sol.id.toString() === questionId.toString())!.solution; const answerSolution = solutions.find((sol) => sol.id.toString() === questionId.toString())!.solution;
const questionShuffleMap = shuffles.find((x) => x.exerciseID == id)?.shuffles.find((y) => y.questionID == questionId); const questionShuffleMap = shuffles.find((x) => x.exerciseID == id)?.shuffles.find((y) => y.questionID == questionId);
const newAnswerSolution = questionShuffleMap ? questionShuffleMap.map[answerSolution].toLowerCase() : answerSolution.toLowerCase(); const newAnswerSolution = questionShuffleMap
? questionShuffleMap.map[answerSolution].toLowerCase()
: answerSolution.toLowerCase();
if (!userSolution) { if (!userSolution) {
let answerText; let answerText;
if (typeCheckWordsMC(words)) { if (typeCheckWordsMC(words)) {
const options = words.find((x) => x.id.toString() === questionId.toString()); const options = words.find((x) => x.id.toString() === questionId.toString());
const correctKey = Object.keys(options!.options).find(key => const correctKey = Object.keys(options!.options).find((key) => key.toLowerCase() === newAnswerSolution);
key.toLowerCase() === newAnswerSolution
);
answerText = options!.options[correctKey as keyof typeof options]; answerText = options!.options[correctKey as keyof typeof options];
} else { } else {
answerText = answerSolution; answerText = answerSolution;
@@ -97,37 +83,34 @@ export default function FillBlanksSolutions({
const userSolutionWord = words.find((w) => const userSolutionWord = words.find((w) =>
typeof w === "string" typeof w === "string"
? w.toLowerCase() === userSolution.solution.toLowerCase() ? w.toLowerCase() === userSolution.solution.toLowerCase()
: 'letter' in w : "letter" in w
? w.letter.toLowerCase() === userSolution.solution.toLowerCase() ? w.letter.toLowerCase() === userSolution.solution.toLowerCase()
: 'options' in w : "options" in w
? w.id === userSolution.questionId ? w.id === userSolution.questionId
: false : false,
); );
const userSolutionText = const userSolutionText =
typeof userSolutionWord === "string" typeof userSolutionWord === "string"
? userSolutionWord ? userSolutionWord
: userSolutionWord && 'letter' in userSolutionWord : userSolutionWord && "letter" in userSolutionWord
? userSolutionWord.word ? userSolutionWord.word
: userSolutionWord && 'options' in userSolutionWord : userSolutionWord && "options" in userSolutionWord
? userSolution.solution ? userSolution.solution
: userSolution.solution; : userSolution.solution;
let correct; let correct;
let solutionText; let solutionText;
if (typeCheckWordsMC(words)) { if (typeCheckWordsMC(words)) {
const options = words.find((x) => x.id.toString() === questionId.toString()); const options = words.find((x) => x.id.toString() === questionId.toString());
if (options) { if (options) {
const correctKey = Object.keys(options.options).find(key => const correctKey = Object.keys(options.options).find((key) => key.toLowerCase() === newAnswerSolution);
key.toLowerCase() === newAnswerSolution
);
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] || answerSolution; solutionText = options.options[correctKey as keyof typeof options.options] || answerSolution;
} else { } else {
correct = false; correct = false;
solutionText = answerSolution; solutionText = answerSolution;
} }
} else { } else {
correct = userSolutionText === answerSolution; correct = userSolutionText === answerSolution;
solutionText = answerSolution; solutionText = answerSolution;
@@ -170,7 +153,25 @@ export default function FillBlanksSolutions({
}; };
return ( return (
<> <div className="flex flex-col gap-4">
<div className="flex justify-between w-full gap-8">
<Button
color="purple"
variant="outline"
onClick={() => onBack({exercise: id, solutions: correctUserSolutions!, score: calculateScore(), type})}
className="max-w-[200px] w-full"
disabled={exam && typeof partIndex !== "undefined" && exam.module === "level" && questionIndex === 0 && partIndex === 0}>
Back
</Button>
<Button
color="purple"
onClick={() => onNext({exercise: id, solutions: correctUserSolutions!, score: calculateScore(), type})}
className="max-w-[200px] self-end w-full">
Next
</Button>
</div>
<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="bg-mti-gray-smoke rounded-xl px-5 py-6"> <span className="bg-mti-gray-smoke rounded-xl px-5 py-6">
{correctUserSolutions && {correctUserSolutions &&
@@ -201,25 +202,19 @@ export default function FillBlanksSolutions({
<Button <Button
color="purple" color="purple"
variant="outline" variant="outline"
onClick={() => onBack({ exercise: id, solutions: correctUserSolutions!, score: calculateScore(), type })} onClick={() => onBack({exercise: id, solutions: correctUserSolutions!, score: calculateScore(), type})}
className="max-w-[200px] w-full" className="max-w-[200px] w-full"
disabled={ disabled={exam && typeof partIndex !== "undefined" && exam.module === "level" && questionIndex === 0 && partIndex === 0}>
exam &&
typeof partIndex !== "undefined" &&
exam.module === "level" &&
questionIndex === 0 &&
partIndex === 0
}>
Back Back
</Button> </Button>
<Button <Button
color="purple" color="purple"
onClick={() => onNext({ exercise: id, solutions: correctUserSolutions!, 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>
</div> </div>
</> </div>
); );
} }

View File

@@ -1,17 +1,17 @@
/* eslint-disable @next/next/no-img-element */ /* eslint-disable @next/next/no-img-element */
import { InteractiveSpeakingExercise } from "@/interfaces/exam"; import {InteractiveSpeakingExercise} from "@/interfaces/exam";
import { CommonProps } from "."; import {CommonProps} from ".";
import { useEffect, useState } from "react"; import {useEffect, useState} from "react";
import Button from "../Low/Button"; import Button from "../Low/Button";
import dynamic from "next/dynamic"; import dynamic from "next/dynamic";
import axios from "axios"; import axios from "axios";
import { speakingReverseMarking } from "@/utils/score"; import {speakingReverseMarking} from "@/utils/score";
import { Tab } from "@headlessui/react"; import {Tab} from "@headlessui/react";
import clsx from "clsx"; import clsx from "clsx";
import Modal from "../Modal"; import Modal from "../Modal";
import ReactDiffViewer, { DiffMethod } from "react-diff-viewer"; import ReactDiffViewer, {DiffMethod} from "react-diff-viewer";
const Waveform = dynamic(() => import("../Waveform"), { ssr: false }); const Waveform = dynamic(() => import("../Waveform"), {ssr: false});
export default function InteractiveSpeaking({ export default function InteractiveSpeaking({
id, id,
@@ -26,20 +26,24 @@ export default function InteractiveSpeaking({
const [solutionsURL, setSolutionsURL] = useState<string[]>([]); const [solutionsURL, setSolutionsURL] = useState<string[]>([]);
const [diffNumber, setDiffNumber] = useState(0); const [diffNumber, setDiffNumber] = useState(0);
const tooltips: { [key: string]: string } = { const tooltips: {[key: string]: string} = {
"Grammatical Range and Accuracy": "Assesses the variety and correctness of grammatical structures used. A higher score indicates a wide range of complex and accurate grammar; a lower score suggests the need for more basic grammar practice.", "Grammatical Range and Accuracy":
"Fluency and Coherence": "Evaluates smoothness and logical flow of speech. A higher score means natural, effortless speech and clear idea progression; a lower score indicates frequent pauses and difficulty in maintaining coherence.", "Assesses the variety and correctness of grammatical structures used. A higher score indicates a wide range of complex and accurate grammar; a lower score suggests the need for more basic grammar practice.",
"Pronunciation": "Measures clarity and accuracy of spoken words. A higher score reflects clear, well-articulated speech with correct intonation; a lower score shows challenges in being understood.", "Fluency and Coherence":
"Lexical Resource": "Looks at the range and appropriateness of vocabulary. A higher score demonstrates a rich and precise vocabulary; a lower score suggests limited vocabulary usage and appropriateness.", "Evaluates smoothness and logical flow of speech. A higher score means natural, effortless speech and clear idea progression; a lower score indicates frequent pauses and difficulty in maintaining coherence.",
Pronunciation:
"Measures clarity and accuracy of spoken words. A higher score reflects clear, well-articulated speech with correct intonation; a lower score shows challenges in being understood.",
"Lexical Resource":
"Looks at the range and appropriateness of vocabulary. A higher score demonstrates a rich and precise vocabulary; a lower score suggests limited vocabulary usage and appropriateness.",
}; };
useEffect(() => { useEffect(() => {
if (userSolutions && userSolutions.length > 0 && userSolutions[0].solution) { if (userSolutions && userSolutions.length > 0 && userSolutions[0].solution) {
Promise.all(userSolutions[0].solution.map((x) => axios.post(`/api/speaking`, { path: x.answer }, { responseType: "arraybuffer" }))).then( Promise.all(userSolutions[0].solution.map((x) => axios.post(`/api/speaking`, {path: x.answer}, {responseType: "arraybuffer"}))).then(
(values) => { (values) => {
setSolutionsURL( setSolutionsURL(
values.map(({ data }) => { values.map(({data}) => {
const blob = new Blob([data], { type: "audio/wav" }); const blob = new Blob([data], {type: "audio/wav"});
const url = URL.createObjectURL(blob); const url = URL.createObjectURL(blob);
return url; return url;
@@ -51,7 +55,41 @@ export default function InteractiveSpeaking({
}, [userSolutions]); }, [userSolutions]);
return ( 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"
onClick={() =>
onBack({
exercise: id,
solutions: userSolutions,
score: {total: 100, missing: 0, correct: speakingReverseMarking[userSolutions[0]!.evaluation!.overall] || 0},
type,
})
}
className="max-w-[200px] self-end w-full">
Back
</Button>
<Button
color="purple"
onClick={() =>
onNext({
exercise: id,
solutions: userSolutions,
score: {
total: 100,
missing: 0,
correct: userSolutions[0]?.evaluation ? speakingReverseMarking[userSolutions[0]!.evaluation!.overall] || 0 : 0,
},
type,
})
}
className="max-w-[200px] self-end w-full">
Next
</Button>
</div>
<Modal title={`Correction (Prompt ${diffNumber})`} isOpen={diffNumber !== 0} onClose={() => setDiffNumber(0)}> <Modal title={`Correction (Prompt ${diffNumber})`} isOpen={diffNumber !== 0} onClose={() => setDiffNumber(0)}>
<> <>
{userSolutions && {userSolutions &&
@@ -71,13 +109,13 @@ export default function InteractiveSpeaking({
fontFamily: '"Open Sans", system-ui, -apple-system, "Helvetica Neue", sans-serif', fontFamily: '"Open Sans", system-ui, -apple-system, "Helvetica Neue", sans-serif',
padding: "32px 28px", padding: "32px 28px",
}, },
marker: { display: "none" }, marker: {display: "none"},
diffRemoved: { padding: "32px 28px" }, diffRemoved: {padding: "32px 28px"},
diffAdded: { padding: "32px 28px" }, diffAdded: {padding: "32px 28px"},
wordRemoved: { padding: "0px", display: "initial" }, wordRemoved: {padding: "0px", display: "initial"},
wordAdded: { padding: "0px", display: "initial" }, wordAdded: {padding: "0px", display: "initial"},
wordDiff: { padding: "0px", display: "initial" }, wordDiff: {padding: "0px", display: "initial"},
}} }}
oldValue={userSolutions[0].evaluation[`transcript_${diffNumber}`]?.replaceAll("\\n", "\n")} oldValue={userSolutions[0].evaluation[`transcript_${diffNumber}`]?.replaceAll("\\n", "\n")}
newValue={userSolutions[0].evaluation[`fixed_text_${diffNumber}`]?.replaceAll("\\n", "\n")} newValue={userSolutions[0].evaluation[`fixed_text_${diffNumber}`]?.replaceAll("\\n", "\n")}
@@ -122,13 +160,13 @@ export default function InteractiveSpeaking({
{userSolutions && {userSolutions &&
userSolutions.length > 0 && userSolutions.length > 0 &&
userSolutions[0].evaluation && userSolutions[0].evaluation &&
userSolutions[0].evaluation[`transcript_${(index + 1)}`] && userSolutions[0].evaluation[`transcript_${index + 1}`] &&
userSolutions[0].evaluation[`fixed_text_${(index + 1)}`] && ( userSolutions[0].evaluation[`fixed_text_${index + 1}`] && (
<Button <Button
className="w-full max-w-[180px] !py-2 self-center" className="w-full max-w-[180px] !py-2 self-center"
color="pink" color="pink"
variant="outline" variant="outline"
onClick={() => setDiffNumber((index + 1))}> onClick={() => setDiffNumber(index + 1)}>
View Correction View Correction
</Button> </Button>
)} )}
@@ -144,20 +182,24 @@ export default function InteractiveSpeaking({
const grade: number = typeof taskResponse === "number" ? taskResponse : taskResponse.grade; const grade: number = typeof taskResponse === "number" ? taskResponse : taskResponse.grade;
return ( return (
<div className={clsx("bg-ielts-speaking text-ielts-speaking-light rounded-xl px-4 py-2 tooltip tooltip-bottom", <div
index === 0 && "tooltip-right" className={clsx(
)} key={key} data-tip={tooltips[key] || "No additional information available"}> "bg-ielts-speaking text-ielts-speaking-light rounded-xl px-4 py-2 tooltip tooltip-bottom",
index === 0 && "tooltip-right",
)}
key={key}
data-tip={tooltips[key] || "No additional information available"}>
{key}: Level {grade} {key}: Level {grade}
</div> </div>
); );
})} })}
</div> </div>
{userSolutions[0].evaluation && {userSolutions[0].evaluation &&
Object.keys(userSolutions[0].evaluation).filter((x) => x.startsWith("perfect_answer")).length > 0 ? ( Object.keys(userSolutions[0].evaluation).filter((x) => x.startsWith("perfect_answer")).length > 0 ? (
<Tab.Group> <Tab.Group>
<Tab.List className="flex space-x-1 rounded-xl bg-ielts-speaking/20 p-1"> <Tab.List className="flex space-x-1 rounded-xl bg-ielts-speaking/20 p-1">
<Tab <Tab
className={({ selected }) => className={({selected}) =>
clsx( clsx(
"w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-ielts-speaking/80", "w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-ielts-speaking/80",
"ring-white ring-opacity-60 ring-offset-2 ring-offset-ielts-speaking focus:outline-none focus:ring-2", "ring-white ring-opacity-60 ring-offset-2 ring-offset-ielts-speaking focus:outline-none focus:ring-2",
@@ -168,7 +210,7 @@ export default function InteractiveSpeaking({
General Feedback General Feedback
</Tab> </Tab>
<Tab <Tab
className={({ selected }) => className={({selected}) =>
clsx( clsx(
"w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-ielts-speaking/80", "w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-ielts-speaking/80",
"ring-white ring-opacity-60 ring-offset-2 ring-offset-ielts-speaking focus:outline-none focus:ring-2", "ring-white ring-opacity-60 ring-offset-2 ring-offset-ielts-speaking focus:outline-none focus:ring-2",
@@ -178,20 +220,26 @@ export default function InteractiveSpeaking({
}> }>
Evaluation Evaluation
</Tab> </Tab>
{Object.keys(userSolutions[0].evaluation).filter((x) => x.startsWith("perfect_answer")).map((key, index) => ( {Object.keys(userSolutions[0].evaluation)
<Tab .filter((x) => x.startsWith("perfect_answer"))
key={key} .map((key, index) => (
className={({ selected }) => <Tab
clsx( key={key}
"w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-ielts-speaking/80", className={({selected}) =>
"ring-white ring-opacity-60 ring-offset-2 ring-offset-ielts-speaking focus:outline-none focus:ring-2", clsx(
"transition duration-300 ease-in-out", "w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-ielts-speaking/80",
selected ? "bg-white shadow" : "text-blue-100 hover:bg-white/[0.12] hover:text-ielts-speaking", "ring-white ring-opacity-60 ring-offset-2 ring-offset-ielts-speaking focus:outline-none focus:ring-2",
) "transition duration-300 ease-in-out",
}> selected
Recommended Answer<br />(Prompt {index + 1}) ? "bg-white shadow"
</Tab> : "text-blue-100 hover:bg-white/[0.12] hover:text-ielts-speaking",
))} )
}>
Recommended Answer
<br />
(Prompt {index + 1})
</Tab>
))}
</Tab.List> </Tab.List>
<Tab.Panels> <Tab.Panels>
<Tab.Panel className="w-full bg-ielts-speaking/10 h-fit rounded-xl p-6 flex flex-col gap-4"> <Tab.Panel className="w-full bg-ielts-speaking/10 h-fit rounded-xl p-6 flex flex-col gap-4">
@@ -202,10 +250,16 @@ export default function InteractiveSpeaking({
return ( return (
<div key={key} className="flex flex-col gap-2"> <div key={key} className="flex flex-col gap-2">
<div className={clsx("bg-ielts-speaking text-ielts-speaking-light rounded-xl px-4 py-2 w-fit")} key={key}> <div
className={clsx(
"bg-ielts-speaking text-ielts-speaking-light rounded-xl px-4 py-2 w-fit",
)}
key={key}>
{key}: Level {grade} {key}: Level {grade}
</div> </div>
{typeof taskResponse !== "number" && <span className="px-2 py-2">{taskResponse.comment}</span>} {typeof taskResponse !== "number" && (
<span className="px-2 py-2">{taskResponse.comment}</span>
)}
</div> </div>
); );
})} })}
@@ -214,13 +268,18 @@ export default function InteractiveSpeaking({
<Tab.Panel className="w-full bg-ielts-speaking/10 h-fit rounded-xl p-6 flex flex-col gap-4"> <Tab.Panel className="w-full bg-ielts-speaking/10 h-fit rounded-xl p-6 flex flex-col gap-4">
<span className="w-full h-full min-h-fit cursor-text">{userSolutions[0].evaluation!.comment}</span> <span className="w-full h-full min-h-fit cursor-text">{userSolutions[0].evaluation!.comment}</span>
</Tab.Panel> </Tab.Panel>
{Object.keys(userSolutions[0].evaluation).filter((x) => x.startsWith("perfect_answer")).map((key, index) => ( {Object.keys(userSolutions[0].evaluation)
<Tab.Panel key={key} className="w-full bg-ielts-speaking/10 h-fit rounded-xl p-6 flex flex-col gap-4"> .filter((x) => x.startsWith("perfect_answer"))
<span className="w-full h-full min-h-fit cursor-text whitespace-pre-wrap"> .map((key, index) => (
{userSolutions[0].evaluation![`perfect_answer_${(index + 1)}`].answer.replaceAll(/\s{2,}/g, "\n\n")} <Tab.Panel key={key} className="w-full bg-ielts-speaking/10 h-fit rounded-xl p-6 flex flex-col gap-4">
</span> <span className="w-full h-full min-h-fit cursor-text whitespace-pre-wrap">
</Tab.Panel> {userSolutions[0].evaluation![`perfect_answer_${index + 1}`].answer.replaceAll(
))} /\s{2,}/g,
"\n\n",
)}
</span>
</Tab.Panel>
))}
</Tab.Panels> </Tab.Panels>
</Tab.Group> </Tab.Group>
) : ( ) : (
@@ -241,7 +300,7 @@ export default function InteractiveSpeaking({
onBack({ onBack({
exercise: id, exercise: id,
solutions: userSolutions, solutions: userSolutions,
score: { total: 100, missing: 0, correct: speakingReverseMarking[userSolutions[0]!.evaluation!.overall] || 0 }, score: {total: 100, missing: 0, correct: speakingReverseMarking[userSolutions[0]!.evaluation!.overall] || 0},
type, type,
}) })
} }
@@ -266,6 +325,6 @@ export default function InteractiveSpeaking({
Next Next
</Button> </Button>
</div> </div>
</> </div>
); );
} }

View File

@@ -63,7 +63,7 @@ export default function MatchSentencesSolutions({
onBack, onBack,
}: MatchSentencesExercise & CommonProps) { }: MatchSentencesExercise & CommonProps) {
const {questionIndex, setQuestionIndex, partIndex, exam} = useExamStore((state) => state); const {questionIndex, setQuestionIndex, partIndex, exam} = useExamStore((state) => state);
const calculateScore = () => { const calculateScore = () => {
const total = sentences.length; const total = sentences.length;
const correct = userSolutions.filter( const correct = userSolutions.filter(
@@ -75,7 +75,25 @@ export default function MatchSentencesSolutions({
}; };
return ( 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: userSolutions, score: calculateScore(), type})}
className="max-w-[200px] w-full"
disabled={exam && typeof partIndex !== "undefined" && exam.module === "level" && questionIndex === 0 && partIndex === 0}>
Back
</Button>
<Button
color="purple"
onClick={() => onNext({exercise: id, solutions: userSolutions, score: calculateScore(), type})}
className="max-w-[200px] self-end w-full">
Next
</Button>
</div>
<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"> <span className="text-sm w-full leading-6">
{prompt.split("\\n").map((line, index) => ( {prompt.split("\\n").map((line, index) => (
@@ -116,13 +134,7 @@ export default function MatchSentencesSolutions({
variant="outline" variant="outline"
onClick={() => onBack({exercise: id, solutions: userSolutions, score: calculateScore(), type})} onClick={() => onBack({exercise: id, solutions: userSolutions, score: calculateScore(), type})}
className="max-w-[200px] w-full" className="max-w-[200px] w-full"
disabled={ disabled={exam && typeof partIndex !== "undefined" && exam.module === "level" && questionIndex === 0 && partIndex === 0}>
exam &&
typeof partIndex !== "undefined" &&
exam.module === "level" &&
questionIndex === 0 &&
partIndex === 0
}>
Back Back
</Button> </Button>
@@ -133,6 +145,6 @@ export default function MatchSentencesSolutions({
Next Next
</Button> </Button>
</div> </div>
</> </div>
); );
} }

View File

@@ -127,8 +127,23 @@ export default function MultipleChoice({id, type, prompt, questions, userSolutio
}; };
return ( return (
<> <div className="flex flex-col gap-4">
<div className="flex flex-col gap-4 w-full h-full mb-20"> <div className="flex justify-between w-full gap-8">
<Button
color="purple"
variant="outline"
onClick={back}
className="max-w-[200px] w-full"
disabled={exam && typeof partIndex !== "undefined" && exam.module === "level" && questionIndex === 0 && partIndex === 0}>
Back
</Button>
<Button color="purple" onClick={next} className="max-w-[200px] self-end w-full">
Next
</Button>
</div>
<div className="flex flex-col gap-4 w-full h-full mb-20 mt-4">
<div className="flex flex-col gap-4 mt-2"> <div className="flex flex-col gap-4 mt-2">
<div className="flex flex-col gap-8 h-fit w-full bg-mti-gray-smoke rounded-xl px-16 py-8"> <div className="flex flex-col gap-8 h-fit w-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>*/}
@@ -180,6 +195,6 @@ export default function MultipleChoice({id, type, prompt, questions, userSolutio
Next Next
</Button> </Button>
</div> </div>
</> </div>
); );
} }

View File

@@ -1,20 +1,20 @@
/* eslint-disable @next/next/no-img-element */ /* eslint-disable @next/next/no-img-element */
import { SpeakingExercise } from "@/interfaces/exam"; import {SpeakingExercise} from "@/interfaces/exam";
import { CommonProps } from "."; import {CommonProps} from ".";
import { Fragment, useEffect, useState } from "react"; import {Fragment, useEffect, useState} from "react";
import Button from "../Low/Button"; import Button from "../Low/Button";
import dynamic from "next/dynamic"; import dynamic from "next/dynamic";
import axios from "axios"; import axios from "axios";
import { speakingReverseMarking } from "@/utils/score"; import {speakingReverseMarking} from "@/utils/score";
import { Tab } from "@headlessui/react"; import {Tab} from "@headlessui/react";
import clsx from "clsx"; import clsx from "clsx";
import Modal from "../Modal"; import Modal from "../Modal";
import { BsQuestionCircleFill } from "react-icons/bs"; import {BsQuestionCircleFill} from "react-icons/bs";
import ReactDiffViewer, { DiffMethod } from "react-diff-viewer"; import ReactDiffViewer, {DiffMethod} from "react-diff-viewer";
const Waveform = dynamic(() => import("../Waveform"), { ssr: false }); const Waveform = dynamic(() => import("../Waveform"), {ssr: false});
export default function Speaking({ id, type, title, video_url, text, prompts, userSolutions, onNext, onBack }: SpeakingExercise & CommonProps) { export default function Speaking({id, type, title, video_url, text, prompts, userSolutions, onNext, onBack}: SpeakingExercise & CommonProps) {
const [solutionURL, setSolutionURL] = useState<string>(); const [solutionURL, setSolutionURL] = useState<string>();
const [showDiff, setShowDiff] = useState(false); const [showDiff, setShowDiff] = useState(false);
@@ -23,8 +23,8 @@ export default function Speaking({ id, type, title, video_url, text, prompts, us
const solution = userSolutions[0].solution; const solution = userSolutions[0].solution;
if (solution.startsWith("https://")) return setSolutionURL(solution); if (solution.startsWith("https://")) return setSolutionURL(solution);
axios.post(`/api/speaking`, { path: userSolutions[0].solution }, { responseType: "arraybuffer" }).then(({ data }) => { axios.post(`/api/speaking`, {path: userSolutions[0].solution}, {responseType: "arraybuffer"}).then(({data}) => {
const blob = new Blob([data], { type: "audio/wav" }); const blob = new Blob([data], {type: "audio/wav"});
const url = URL.createObjectURL(blob); const url = URL.createObjectURL(blob);
setSolutionURL(url); setSolutionURL(url);
@@ -32,15 +32,53 @@ export default function Speaking({ id, type, title, video_url, text, prompts, us
} }
}, [userSolutions]); }, [userSolutions]);
const tooltips: { [key: string]: string } = { const tooltips: {[key: string]: string} = {
"Grammatical Range and Accuracy": "Assesses the variety and correctness of grammatical structures used. A higher score indicates a wide range of complex and accurate grammar; a lower score suggests the need for more basic grammar practice.", "Grammatical Range and Accuracy":
"Fluency and Coherence": "Evaluates smoothness and logical flow of speech. A higher score means natural, effortless speech and clear idea progression; a lower score indicates frequent pauses and difficulty in maintaining coherence.", "Assesses the variety and correctness of grammatical structures used. A higher score indicates a wide range of complex and accurate grammar; a lower score suggests the need for more basic grammar practice.",
"Pronunciation": "Measures clarity and accuracy of spoken words. A higher score reflects clear, well-articulated speech with correct intonation; a lower score shows challenges in being understood.", "Fluency and Coherence":
"Lexical Resource": "Looks at the range and appropriateness of vocabulary. A higher score demonstrates a rich and precise vocabulary; a lower score suggests limited vocabulary usage and appropriateness.", "Evaluates smoothness and logical flow of speech. A higher score means natural, effortless speech and clear idea progression; a lower score indicates frequent pauses and difficulty in maintaining coherence.",
Pronunciation:
"Measures clarity and accuracy of spoken words. A higher score reflects clear, well-articulated speech with correct intonation; a lower score shows challenges in being understood.",
"Lexical Resource":
"Looks at the range and appropriateness of vocabulary. A higher score demonstrates a rich and precise vocabulary; a lower score suggests limited vocabulary usage and appropriateness.",
}; };
return ( 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"
onClick={() =>
onBack({
exercise: id,
solutions: userSolutions,
score: {total: 100, missing: 0, correct: speakingReverseMarking[userSolutions[0]!.evaluation!.overall] || 0},
type,
})
}
className="max-w-[200px] self-end w-full">
Back
</Button>
<Button
color="purple"
onClick={() =>
onNext({
exercise: id,
solutions: userSolutions,
score: {
total: 100,
missing: 0,
correct: userSolutions[0]?.evaluation ? speakingReverseMarking[userSolutions[0]!.evaluation!.overall] || 0 : 0,
},
type,
})
}
className="max-w-[200px] self-end w-full">
Next
</Button>
</div>
<Modal title="Correction" isOpen={showDiff} onClose={() => setShowDiff(false)}> <Modal title="Correction" isOpen={showDiff} onClose={() => setShowDiff(false)}>
<> <>
{userSolutions && {userSolutions &&
@@ -58,13 +96,13 @@ export default function Speaking({ id, type, title, video_url, text, prompts, us
fontFamily: '"Open Sans", system-ui, -apple-system, "Helvetica Neue", sans-serif', fontFamily: '"Open Sans", system-ui, -apple-system, "Helvetica Neue", sans-serif',
padding: "32px 28px", padding: "32px 28px",
}, },
marker: { display: "none" }, marker: {display: "none"},
diffRemoved: { padding: "32px 28px" }, diffRemoved: {padding: "32px 28px"},
diffAdded: { padding: "32px 28px" }, diffAdded: {padding: "32px 28px"},
wordRemoved: { padding: "0px", display: "initial" }, wordRemoved: {padding: "0px", display: "initial"},
wordAdded: { padding: "0px", display: "initial" }, wordAdded: {padding: "0px", display: "initial"},
wordDiff: { padding: "0px", display: "initial" }, wordDiff: {padding: "0px", display: "initial"},
}} }}
oldValue={userSolutions[0].evaluation.transcript_1.replaceAll("\\n", "\n")} oldValue={userSolutions[0].evaluation.transcript_1.replaceAll("\\n", "\n")}
newValue={userSolutions[0].evaluation.fixed_text_1.replaceAll("\\n", "\n")} newValue={userSolutions[0].evaluation.fixed_text_1.replaceAll("\\n", "\n")}
@@ -138,20 +176,24 @@ export default function Speaking({ id, type, title, video_url, text, prompts, us
const grade: number = typeof taskResponse === "number" ? taskResponse : taskResponse.grade; const grade: number = typeof taskResponse === "number" ? taskResponse : taskResponse.grade;
return ( return (
<div className={clsx("bg-ielts-speaking text-ielts-speaking-light rounded-xl px-4 py-2 tooltip tooltip-bottom", <div
index === 0 && "tooltip-right" className={clsx(
)} key={key} data-tip={tooltips[key] || "No additional information available"}> "bg-ielts-speaking text-ielts-speaking-light rounded-xl px-4 py-2 tooltip tooltip-bottom",
index === 0 && "tooltip-right",
)}
key={key}
data-tip={tooltips[key] || "No additional information available"}>
{key}: Level {grade} {key}: Level {grade}
</div> </div>
); );
})} })}
</div> </div>
{userSolutions[0].evaluation && {userSolutions[0].evaluation &&
(userSolutions[0].evaluation.perfect_answer || userSolutions[0].evaluation.perfect_answer_1) ? ( (userSolutions[0].evaluation.perfect_answer || userSolutions[0].evaluation.perfect_answer_1) ? (
<Tab.Group> <Tab.Group>
<Tab.List className="flex space-x-1 rounded-xl bg-ielts-speaking/20 p-1"> <Tab.List className="flex space-x-1 rounded-xl bg-ielts-speaking/20 p-1">
<Tab <Tab
className={({ selected }) => className={({selected}) =>
clsx( clsx(
"w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-ielts-speaking/80", "w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-ielts-speaking/80",
"ring-white ring-opacity-60 ring-offset-2 ring-offset-ielts-speaking focus:outline-none focus:ring-2", "ring-white ring-opacity-60 ring-offset-2 ring-offset-ielts-speaking focus:outline-none focus:ring-2",
@@ -162,7 +204,7 @@ export default function Speaking({ id, type, title, video_url, text, prompts, us
General Feedback General Feedback
</Tab> </Tab>
<Tab <Tab
className={({ selected }) => className={({selected}) =>
clsx( clsx(
"w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-ielts-speaking/80", "w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-ielts-speaking/80",
"ring-white ring-opacity-60 ring-offset-2 ring-offset-ielts-speaking focus:outline-none focus:ring-2", "ring-white ring-opacity-60 ring-offset-2 ring-offset-ielts-speaking focus:outline-none focus:ring-2",
@@ -173,7 +215,7 @@ export default function Speaking({ id, type, title, video_url, text, prompts, us
Evaluation Evaluation
</Tab> </Tab>
<Tab <Tab
className={({ selected }) => className={({selected}) =>
clsx( clsx(
"w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-ielts-speaking/80", "w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-ielts-speaking/80",
"ring-white ring-opacity-60 ring-offset-2 ring-offset-ielts-speaking focus:outline-none focus:ring-2", "ring-white ring-opacity-60 ring-offset-2 ring-offset-ielts-speaking focus:outline-none focus:ring-2",
@@ -194,10 +236,16 @@ export default function Speaking({ id, type, title, video_url, text, prompts, us
return ( return (
<div key={key} className="flex flex-col gap-2"> <div key={key} className="flex flex-col gap-2">
<div className={clsx("bg-ielts-speaking text-ielts-speaking-light rounded-xl px-4 py-2 w-fit")} key={key}> <div
className={clsx(
"bg-ielts-speaking text-ielts-speaking-light rounded-xl px-4 py-2 w-fit",
)}
key={key}>
{key}: Level {grade} {key}: Level {grade}
</div> </div>
{typeof taskResponse !== "number" && <span className="px-2 py-2">{taskResponse.comment}</span>} {typeof taskResponse !== "number" && (
<span className="px-2 py-2">{taskResponse.comment}</span>
)}
</div> </div>
); );
})} })}
@@ -236,7 +284,7 @@ export default function Speaking({ id, type, title, video_url, text, prompts, us
onBack({ onBack({
exercise: id, exercise: id,
solutions: userSolutions, solutions: userSolutions,
score: { total: 100, missing: 0, correct: speakingReverseMarking[userSolutions[0]!.evaluation!.overall] || 0 }, score: {total: 100, missing: 0, correct: speakingReverseMarking[userSolutions[0]!.evaluation!.overall] || 0},
type, type,
}) })
} }
@@ -261,6 +309,6 @@ export default function Speaking({ id, type, title, video_url, text, prompts, us
Next Next
</Button> </Button>
</div> </div>
</> </div>
); );
} }

View File

@@ -10,7 +10,7 @@ type Solution = "true" | "false" | "not_given";
export default function TrueFalseSolution({prompt, type, id, questions, userSolutions, onNext, onBack}: TrueFalseExercise & CommonProps) { export default function TrueFalseSolution({prompt, type, id, questions, userSolutions, onNext, onBack}: TrueFalseExercise & CommonProps) {
const {questionIndex, setQuestionIndex, partIndex, exam} = useExamStore((state) => state); const {questionIndex, setQuestionIndex, partIndex, exam} = useExamStore((state) => state);
const calculateScore = () => { const calculateScore = () => {
const total = questions.length || 0; const total = questions.length || 0;
const correct = userSolutions.filter( const correct = userSolutions.filter(
@@ -40,7 +40,25 @@ export default function TrueFalseSolution({prompt, type, id, questions, userSolu
}; };
return ( 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: userSolutions, score: calculateScore(), type})}
className="max-w-[200px] w-full"
disabled={exam && typeof partIndex !== "undefined" && exam.module === "level" && questionIndex === 0 && partIndex === 0}>
Back
</Button>
<Button
color="purple"
onClick={() => onNext({exercise: id, solutions: userSolutions, score: calculateScore(), type})}
className="max-w-[200px] self-end w-full">
Next
</Button>
</div>
<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"> <span className="text-sm w-full leading-6">
{prompt.split("\\n").map((line, index) => ( {prompt.split("\\n").map((line, index) => (
@@ -125,13 +143,7 @@ export default function TrueFalseSolution({prompt, type, id, questions, userSolu
variant="outline" variant="outline"
onClick={() => onBack({exercise: id, solutions: userSolutions, score: calculateScore(), type})} onClick={() => onBack({exercise: id, solutions: userSolutions, score: calculateScore(), type})}
className="max-w-[200px] w-full" className="max-w-[200px] w-full"
disabled={ disabled={exam && typeof partIndex !== "undefined" && exam.module === "level" && questionIndex === 0 && partIndex === 0}>
exam &&
typeof partIndex !== "undefined" &&
exam.module === "level" &&
questionIndex === 0 &&
partIndex === 0
}>
Back Back
</Button> </Button>
@@ -142,6 +154,6 @@ export default function TrueFalseSolution({prompt, type, id, questions, userSolu
Next Next
</Button> </Button>
</div> </div>
</> </div>
); );
} }

View File

@@ -105,7 +105,25 @@ export default function WriteBlanksSolutions({
}; };
return ( return (
<> <div className="flex flex-col gap-4">
<div className="flex justify-between w-full gap-8">
<Button
color="purple"
variant="outline"
onClick={() => onBack({exercise: id, solutions: userSolutions, score: calculateScore(), type})}
className="max-w-[200px] w-full"
disabled={exam && typeof partIndex !== "undefined" && exam.module === "level" && questionIndex === 0 && partIndex === 0}>
Back
</Button>
<Button
color="purple"
onClick={() => onNext({exercise: id, solutions: userSolutions, score: calculateScore(), type})}
className="max-w-[200px] self-end w-full">
Next
</Button>
</div>
<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"> <span className="text-sm w-full leading-6">
{prompt.split("\\n").map((line, index) => ( {prompt.split("\\n").map((line, index) => (
@@ -146,13 +164,7 @@ export default function WriteBlanksSolutions({
variant="outline" variant="outline"
onClick={() => onBack({exercise: id, solutions: userSolutions, score: calculateScore(), type})} onClick={() => onBack({exercise: id, solutions: userSolutions, score: calculateScore(), type})}
className="max-w-[200px] w-full" className="max-w-[200px] w-full"
disabled={ disabled={exam && typeof partIndex !== "undefined" && exam.module === "level" && questionIndex === 0 && partIndex === 0}>
exam &&
typeof partIndex !== "undefined" &&
exam.module === "level" &&
questionIndex === 0 &&
partIndex === 0
}>
Back Back
</Button> </Button>
@@ -163,6 +175,6 @@ export default function WriteBlanksSolutions({
Next Next
</Button> </Button>
</div> </div>
</> </div>
); );
} }

View File

@@ -1,32 +1,70 @@
/* eslint-disable @next/next/no-img-element */ /* eslint-disable @next/next/no-img-element */
import { WritingExercise } from "@/interfaces/exam"; import {WritingExercise} from "@/interfaces/exam";
import { CommonProps } from "."; import {CommonProps} from ".";
import { Fragment, useEffect, useState } from "react"; import {Fragment, useEffect, useState} from "react";
import Button from "../Low/Button"; import Button from "../Low/Button";
import { Dialog, Tab, Transition } from "@headlessui/react"; import {Dialog, Tab, Transition} from "@headlessui/react";
import { writingReverseMarking } from "@/utils/score"; import {writingReverseMarking} from "@/utils/score";
import clsx from "clsx"; import clsx from "clsx";
import ReactDiffViewer, { DiffMethod } from "react-diff-viewer"; import ReactDiffViewer, {DiffMethod} from "react-diff-viewer";
import useUser from "@/hooks/useUser"; import useUser from "@/hooks/useUser";
import AIDetection from "../AIDetection"; import AIDetection from "../AIDetection";
export default function Writing({ id, type, prompt, attachment, userSolutions, onNext, onBack }: WritingExercise & CommonProps) { export default function Writing({id, type, prompt, attachment, userSolutions, onNext, onBack}: WritingExercise & CommonProps) {
const [isModalOpen, setIsModalOpen] = useState(false); const [isModalOpen, setIsModalOpen] = useState(false);
const [showDiff, setShowDiff] = useState(false); const [showDiff, setShowDiff] = useState(false);
const { user } = useUser(); const {user} = useUser();
const aiEval = userSolutions && userSolutions.length > 0 ? userSolutions[0].evaluation?.ai_detection : undefined; const aiEval = userSolutions && userSolutions.length > 0 ? userSolutions[0].evaluation?.ai_detection : undefined;
const tooltips: { [key: string]: string } = { const tooltips: {[key: string]: string} = {
"Lexical Resource": "Assesses the diversity and accuracy of vocabulary used. A higher score indicates varied and precise word choice; a lower score points to limited vocabulary and inaccuracies.", "Lexical Resource":
"Task Achievement": "Evaluates how well the task requirements are fulfilled. A higher score means all parts of the task are addressed thoroughly; a lower score shows incomplete or inadequate task response.", "Assesses the diversity and accuracy of vocabulary used. A higher score indicates varied and precise word choice; a lower score points to limited vocabulary and inaccuracies.",
"Coherence and Cohesion": "Measures logical organization and flow of writing. A higher score reflects well-structured and connected ideas; a lower score indicates disorganized writing and poor linkage between ideas.", "Task Achievement":
"Grammatical Range and Accuracy": "Looks at the range and precision of grammatical structures. A higher score shows varied and accurate grammar use; a lower score suggests frequent errors and limited range.", "Evaluates how well the task requirements are fulfilled. A higher score means all parts of the task are addressed thoroughly; a lower score shows incomplete or inadequate task response.",
"Coherence and Cohesion":
"Measures logical organization and flow of writing. A higher score reflects well-structured and connected ideas; a lower score indicates disorganized writing and poor linkage between ideas.",
"Grammatical Range and Accuracy":
"Looks at the range and precision of grammatical structures. A higher score shows varied and accurate grammar use; a lower score suggests frequent errors and limited range.",
}; };
return ( return (
<> <div className="flex flex-col gap-4 mt-4">
<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: userSolutions,
score: {total: 100, missing: 0, correct: writingReverseMarking[userSolutions[0]!.evaluation!.overall] || 0},
type,
})
}
className="max-w-[200px] self-end w-full">
Back
</Button>
<Button
color="purple"
onClick={() =>
onNext({
exercise: id,
solutions: userSolutions,
score: {
total: 100,
missing: 0,
correct: userSolutions[0]?.evaluation ? writingReverseMarking[userSolutions[0]!.evaluation!.overall] || 0 : 0,
},
type,
})
}
className="max-w-[200px] self-end w-full">
Next
</Button>
</div>
{attachment && ( {attachment && (
<Transition show={isModalOpen} as={Fragment}> <Transition show={isModalOpen} as={Fragment}>
<Dialog onClose={() => setIsModalOpen(false)} className="relative z-50"> <Dialog onClose={() => setIsModalOpen(false)} className="relative z-50">
@@ -99,13 +137,13 @@ export default function Writing({ id, type, prompt, attachment, userSolutions, o
fontFamily: '"Open Sans", system-ui, -apple-system, "Helvetica Neue", sans-serif', fontFamily: '"Open Sans", system-ui, -apple-system, "Helvetica Neue", sans-serif',
padding: "32px 28px", padding: "32px 28px",
}, },
marker: { display: "none" }, marker: {display: "none"},
diffRemoved: { padding: "32px 28px" }, diffRemoved: {padding: "32px 28px"},
diffAdded: { padding: "32px 28px" }, diffAdded: {padding: "32px 28px"},
wordRemoved: { padding: "0px", display: "initial" }, wordRemoved: {padding: "0px", display: "initial"},
wordAdded: { padding: "0px", display: "initial" }, wordAdded: {padding: "0px", display: "initial"},
wordDiff: { padding: "0px", display: "initial" }, wordDiff: {padding: "0px", display: "initial"},
}} }}
oldValue={userSolutions[0].solution.replaceAll("\\n", "\n")} oldValue={userSolutions[0].solution.replaceAll("\\n", "\n")}
newValue={userSolutions[0].evaluation!.fixed_text!.replaceAll("\\n", "\n")} newValue={userSolutions[0].evaluation!.fixed_text!.replaceAll("\\n", "\n")}
@@ -135,10 +173,13 @@ export default function Writing({ id, type, prompt, attachment, userSolutions, o
const grade: number = typeof taskResponse === "number" ? taskResponse : taskResponse.grade; const grade: number = typeof taskResponse === "number" ? taskResponse : taskResponse.grade;
return ( return (
<div className={clsx( <div
"bg-ielts-writing text-ielts-writing-light rounded-xl px-4 py-2 tooltip tooltip-bottom", className={clsx(
index === 0 && "tooltip-right" "bg-ielts-writing text-ielts-writing-light rounded-xl px-4 py-2 tooltip tooltip-bottom",
)} key={key} data-tip={tooltips[key] || "No additional information available"}> index === 0 && "tooltip-right",
)}
key={key}
data-tip={tooltips[key] || "No additional information available"}>
{key}: Level {grade} {key}: Level {grade}
</div> </div>
); );
@@ -148,7 +189,7 @@ export default function Writing({ id, type, prompt, attachment, userSolutions, o
<Tab.Group> <Tab.Group>
<Tab.List className="flex space-x-1 rounded-xl bg-ielts-writing/20 p-1"> <Tab.List className="flex space-x-1 rounded-xl bg-ielts-writing/20 p-1">
<Tab <Tab
className={({ selected }) => className={({selected}) =>
clsx( clsx(
"w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-ielts-writing/80", "w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-ielts-writing/80",
"ring-white ring-opacity-60 ring-offset-2 ring-offset-ielts-writing focus:outline-none focus:ring-2", "ring-white ring-opacity-60 ring-offset-2 ring-offset-ielts-writing focus:outline-none focus:ring-2",
@@ -159,7 +200,7 @@ export default function Writing({ id, type, prompt, attachment, userSolutions, o
General Feedback General Feedback
</Tab> </Tab>
<Tab <Tab
className={({ selected }) => className={({selected}) =>
clsx( clsx(
"w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-ielts-writing/80", "w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-ielts-writing/80",
"ring-white ring-opacity-60 ring-offset-2 ring-offset-ielts-writing focus:outline-none focus:ring-2", "ring-white ring-opacity-60 ring-offset-2 ring-offset-ielts-writing focus:outline-none focus:ring-2",
@@ -170,7 +211,7 @@ export default function Writing({ id, type, prompt, attachment, userSolutions, o
Evaluation Evaluation
</Tab> </Tab>
<Tab <Tab
className={({ selected }) => className={({selected}) =>
clsx( clsx(
"w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-ielts-writing/80", "w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-ielts-writing/80",
"ring-white ring-opacity-60 ring-offset-2 ring-offset-ielts-writing focus:outline-none focus:ring-2", "ring-white ring-opacity-60 ring-offset-2 ring-offset-ielts-writing focus:outline-none focus:ring-2",
@@ -182,7 +223,7 @@ export default function Writing({ id, type, prompt, attachment, userSolutions, o
</Tab> </Tab>
{aiEval && user?.type !== "student" && ( {aiEval && user?.type !== "student" && (
<Tab <Tab
className={({ selected }) => className={({selected}) =>
clsx( clsx(
"w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-ielts-writing/80", "w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-ielts-writing/80",
"ring-white ring-opacity-60 ring-offset-2 ring-offset-ielts-writing focus:outline-none focus:ring-2", "ring-white ring-opacity-60 ring-offset-2 ring-offset-ielts-writing focus:outline-none focus:ring-2",
@@ -204,10 +245,16 @@ export default function Writing({ id, type, prompt, attachment, userSolutions, o
return ( return (
<div key={key} className="flex flex-col gap-2"> <div key={key} className="flex flex-col gap-2">
<div className={clsx("bg-ielts-writing text-ielts-writing-light rounded-xl px-4 py-2 w-fit")} key={key}> <div
className={clsx(
"bg-ielts-writing text-ielts-writing-light rounded-xl px-4 py-2 w-fit",
)}
key={key}>
{key}: Level {grade} {key}: Level {grade}
</div> </div>
{typeof taskResponse !== "number" && <span className="px-2 py-2">{taskResponse.comment}</span>} {typeof taskResponse !== "number" && (
<span className="px-2 py-2">{taskResponse.comment}</span>
)}
</div> </div>
); );
})} })}
@@ -248,7 +295,7 @@ export default function Writing({ id, type, prompt, attachment, userSolutions, o
onBack({ onBack({
exercise: id, exercise: id,
solutions: userSolutions, solutions: userSolutions,
score: { total: 100, missing: 0, correct: writingReverseMarking[userSolutions[0]!.evaluation!.overall] || 0 }, score: {total: 100, missing: 0, correct: writingReverseMarking[userSolutions[0]!.evaluation!.overall] || 0},
type, type,
}) })
} }
@@ -273,6 +320,6 @@ export default function Writing({ id, type, prompt, attachment, userSolutions, o
Next Next
</Button> </Button>
</div> </div>
</> </div>
); );
} }