331 lines
12 KiB
TypeScript
331 lines
12 KiB
TypeScript
/* eslint-disable @next/next/no-img-element */
|
|
import {InteractiveSpeakingExercise} from "@/interfaces/exam";
|
|
import {CommonProps} from ".";
|
|
import {useEffect, useState} from "react";
|
|
import Button from "../Low/Button";
|
|
import dynamic from "next/dynamic";
|
|
import axios from "axios";
|
|
import {speakingReverseMarking} from "@/utils/score";
|
|
import {Tab} from "@headlessui/react";
|
|
import clsx from "clsx";
|
|
import Modal from "../Modal";
|
|
import ReactDiffViewer, {DiffMethod} from "react-diff-viewer";
|
|
|
|
const Waveform = dynamic(() => import("../Waveform"), {ssr: false});
|
|
|
|
export default function InteractiveSpeaking({
|
|
id,
|
|
type,
|
|
title,
|
|
text,
|
|
prompts,
|
|
userSolutions,
|
|
onNext,
|
|
onBack,
|
|
}: InteractiveSpeakingExercise & CommonProps) {
|
|
const [solutionsURL, setSolutionsURL] = useState<string[]>([]);
|
|
const [diffNumber, setDiffNumber] = useState(0);
|
|
|
|
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.",
|
|
"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.",
|
|
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(() => {
|
|
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(
|
|
(values) => {
|
|
setSolutionsURL(
|
|
values.map(({data}) => {
|
|
const blob = new Blob([data], {type: "audio/wav"});
|
|
const url = URL.createObjectURL(blob);
|
|
|
|
return url;
|
|
}),
|
|
);
|
|
},
|
|
);
|
|
}
|
|
}, [userSolutions]);
|
|
|
|
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)}>
|
|
<>
|
|
{userSolutions &&
|
|
userSolutions.length > 0 &&
|
|
diffNumber !== 0 &&
|
|
userSolutions[0].evaluation &&
|
|
userSolutions[0].evaluation[`transcript_${diffNumber}`] &&
|
|
userSolutions[0].evaluation[`fixed_text_${diffNumber}`] && (
|
|
<div className="w-full h-full rounded-xl overflow-hidden flex flex-col mt-4">
|
|
<div className="w-full grid grid-cols-2 bg-neutral-100">
|
|
<span className="p-3 font-medium text-lg border-r border-r-neutral-200">Transcript</span>
|
|
<span className="p-3 font-medium text-lg border-l border-l-neutral-200">Recommended Improvements</span>
|
|
</div>
|
|
<ReactDiffViewer
|
|
styles={{
|
|
contentText: {
|
|
fontFamily: '"Open Sans", system-ui, -apple-system, "Helvetica Neue", sans-serif',
|
|
padding: "32px 28px",
|
|
},
|
|
marker: {display: "none"},
|
|
diffRemoved: {padding: "32px 28px"},
|
|
diffAdded: {padding: "32px 28px"},
|
|
|
|
wordRemoved: {padding: "0px", display: "initial"},
|
|
wordAdded: {padding: "0px", display: "initial"},
|
|
wordDiff: {padding: "0px", display: "initial"},
|
|
}}
|
|
oldValue={userSolutions[0].evaluation[`transcript_${diffNumber}`]?.replaceAll("\\n", "\n")}
|
|
newValue={userSolutions[0].evaluation[`fixed_text_${diffNumber}`]?.replaceAll("\\n", "\n")}
|
|
splitView
|
|
hideLineNumbers
|
|
showDiffOnly={false}
|
|
/>
|
|
</div>
|
|
)}
|
|
</>
|
|
</Modal>
|
|
|
|
<div className="flex flex-col h-full w-full gap-8 mb-20">
|
|
<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">{title}</span>
|
|
</div>
|
|
<div className="flex flex-col gap-4">
|
|
<span className="font-bold">You should talk about the following things:</span>
|
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 text-center">
|
|
{prompts.map((x, index) => (
|
|
<div className="italic flex flex-col gap-2 text-sm" key={index}>
|
|
<video key={index} controls className="">
|
|
<source src={x.video_url} />
|
|
</video>
|
|
<span>{x.text}</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="w-full h-full flex flex-col gap-8">
|
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
|
|
{solutionsURL.map((x, index) => (
|
|
<div
|
|
key={index}
|
|
className="w-full p-4 px-8 bg-transparent border-2 border-mti-gray-platinum rounded-2xl flex flex-col gap-4">
|
|
<div className="flex gap-8 items-center justify-center py-8">
|
|
<Waveform audio={x} waveColor="#FCDDEC" progressColor="#EF5DA8" />
|
|
</div>
|
|
{userSolutions &&
|
|
userSolutions.length > 0 &&
|
|
userSolutions[0].evaluation &&
|
|
userSolutions[0].evaluation[`transcript_${index + 1}`] &&
|
|
userSolutions[0].evaluation[`fixed_text_${index + 1}`] && (
|
|
<Button
|
|
className="w-full max-w-[180px] !py-2 self-center"
|
|
color="pink"
|
|
variant="outline"
|
|
onClick={() => setDiffNumber(index + 1)}>
|
|
View Correction
|
|
</Button>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
{userSolutions && userSolutions.length > 0 && userSolutions[0].evaluation && typeof userSolutions[0].evaluation !== "string" && (
|
|
<div className="flex flex-col gap-4 w-full">
|
|
<div className="flex gap-4 px-1">
|
|
{Object.keys(userSolutions[0].evaluation!.task_response).map((key, index) => {
|
|
const taskResponse = userSolutions[0].evaluation!.task_response[key];
|
|
const grade: number = typeof taskResponse === "number" ? taskResponse : taskResponse.grade;
|
|
|
|
return (
|
|
<div
|
|
className={clsx(
|
|
"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}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
{userSolutions[0].evaluation &&
|
|
Object.keys(userSolutions[0].evaluation).filter((x) => x.startsWith("perfect_answer")).length > 0 ? (
|
|
<Tab.Group>
|
|
<Tab.List className="flex space-x-1 rounded-xl bg-ielts-speaking/20 p-1">
|
|
<Tab
|
|
className={({selected}) =>
|
|
clsx(
|
|
"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",
|
|
"transition duration-300 ease-in-out",
|
|
selected ? "bg-white shadow" : "text-blue-100 hover:bg-white/[0.12] hover:text-ielts-speaking",
|
|
)
|
|
}>
|
|
General Feedback
|
|
</Tab>
|
|
<Tab
|
|
className={({selected}) =>
|
|
clsx(
|
|
"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",
|
|
"transition duration-300 ease-in-out",
|
|
selected ? "bg-white shadow" : "text-blue-100 hover:bg-white/[0.12] hover:text-ielts-speaking",
|
|
)
|
|
}>
|
|
Evaluation
|
|
</Tab>
|
|
{Object.keys(userSolutions[0].evaluation)
|
|
.filter((x) => x.startsWith("perfect_answer"))
|
|
.map((key, index) => (
|
|
<Tab
|
|
key={key}
|
|
className={({selected}) =>
|
|
clsx(
|
|
"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",
|
|
"transition duration-300 ease-in-out",
|
|
selected
|
|
? "bg-white shadow"
|
|
: "text-blue-100 hover:bg-white/[0.12] hover:text-ielts-speaking",
|
|
)
|
|
}>
|
|
Recommended Answer
|
|
<br />
|
|
(Prompt {index + 1})
|
|
</Tab>
|
|
))}
|
|
</Tab.List>
|
|
<Tab.Panels>
|
|
<Tab.Panel className="w-full bg-ielts-speaking/10 h-fit rounded-xl p-6 flex flex-col gap-4">
|
|
<div className="flex flex-col gap-4">
|
|
{Object.keys(userSolutions[0].evaluation!.task_response).map((key) => {
|
|
const taskResponse = userSolutions[0].evaluation!.task_response[key];
|
|
const grade: number = typeof taskResponse === "number" ? taskResponse : taskResponse.grade;
|
|
|
|
return (
|
|
<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}>
|
|
{key}: Level {grade}
|
|
</div>
|
|
{typeof taskResponse !== "number" && (
|
|
<span className="px-2 py-2">{taskResponse.comment}</span>
|
|
)}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</Tab.Panel>
|
|
<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>
|
|
</Tab.Panel>
|
|
{Object.keys(userSolutions[0].evaluation)
|
|
.filter((x) => x.startsWith("perfect_answer"))
|
|
.map((key, index) => (
|
|
<Tab.Panel key={key} 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 whitespace-pre-wrap">
|
|
{userSolutions[0].evaluation![`perfect_answer_${index + 1}`].answer.replaceAll(
|
|
/\s{2,}/g,
|
|
"\n\n",
|
|
)}
|
|
</span>
|
|
</Tab.Panel>
|
|
))}
|
|
</Tab.Panels>
|
|
</Tab.Group>
|
|
) : (
|
|
<div className="w-full h-full min-h-fit cursor-text px-7 py-8 bg-ielts-speaking/10 rounded-3xl">
|
|
{userSolutions[0].evaluation!.comment}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="self-end flex justify-between w-full gap-8 absolute bottom-8 left-0 px-8">
|
|
<Button
|
|
color="purple"
|
|
variant="outline"
|
|
onClick={() =>
|
|
onBack({
|
|
exercise: id,
|
|
solutions: 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>
|
|
</div>
|
|
);
|
|
}
|