Implemented the speaking exercise;
Cleaned up a bit of the code;
This commit is contained in:
@@ -6,13 +6,39 @@ import clsx from "clsx";
|
|||||||
import {CommonProps} from ".";
|
import {CommonProps} from ".";
|
||||||
import {Fragment, useEffect, useState} from "react";
|
import {Fragment, useEffect, useState} from "react";
|
||||||
import {toast} from "react-toastify";
|
import {toast} from "react-toastify";
|
||||||
|
import {BsCheckCircleFill, BsMicFill, BsPauseCircle, BsPlayCircle, BsTrashFill} from "react-icons/bs";
|
||||||
|
import dynamic from "next/dynamic";
|
||||||
|
import Button from "../Low/Button";
|
||||||
|
|
||||||
|
const Waveform = dynamic(() => import("../Waveform"), {ssr: false});
|
||||||
|
const ReactMediaRecorder = dynamic(() => import("react-media-recorder").then((mod) => mod.ReactMediaRecorder), {
|
||||||
|
ssr: false,
|
||||||
|
});
|
||||||
|
|
||||||
export default function Speaking({id, title, text, type, prompts, onNext, onBack}: SpeakingExercise & CommonProps) {
|
export default function Speaking({id, title, text, type, prompts, onNext, onBack}: SpeakingExercise & CommonProps) {
|
||||||
|
const [recordingDuration, setRecordingDuration] = useState(0);
|
||||||
|
const [isRecording, setIsRecording] = useState(false);
|
||||||
|
const [mediaBlob, setMediaBlob] = useState<string>();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let recordingInterval: NodeJS.Timer | undefined = undefined;
|
||||||
|
if (isRecording) {
|
||||||
|
recordingInterval = setInterval(() => setRecordingDuration((prev) => prev + 1), 1000);
|
||||||
|
} else if (recordingInterval) {
|
||||||
|
clearInterval(recordingInterval);
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (recordingInterval) clearInterval(recordingInterval);
|
||||||
|
};
|
||||||
|
}, [isRecording]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full w-2/3 items-center justify-center gap-8">
|
<div className="flex flex-col h-full w-full gap-9">
|
||||||
<div className="flex flex-col max-w-2xl gap-4">
|
<div className="flex flex-col w-full gap-14 bg-mti-gray-smoke rounded-xl py-8 pb-12 px-16">
|
||||||
<span className="font-bold">{title}</span>
|
<div className="flex flex-col gap-3">
|
||||||
<span className="font-regular ml-8">
|
<span className="font-semibold">{title}</span>
|
||||||
|
<span className="font-regular">
|
||||||
{text.split("\\n").map((line, index) => (
|
{text.split("\\n").map((line, index) => (
|
||||||
<Fragment key={index}>
|
<Fragment key={index}>
|
||||||
<span>{line}</span>
|
<span>{line}</span>
|
||||||
@@ -20,9 +46,10 @@ export default function Speaking({id, title, text, type, prompts, onNext, onBack
|
|||||||
</Fragment>
|
</Fragment>
|
||||||
))}
|
))}
|
||||||
</span>
|
</span>
|
||||||
<div className="flex gap-8">
|
</div>
|
||||||
<span>You should talk about the following things:</span>
|
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
|
<span className="font-bold">You should talk about the following things:</span>
|
||||||
|
<div className="flex flex-col gap-1 ml-4">
|
||||||
{prompts.map((x, index) => (
|
{prompts.map((x, index) => (
|
||||||
<li className="italic" key={index}>
|
<li className="italic" key={index}>
|
||||||
{x}
|
{x}
|
||||||
@@ -32,21 +59,132 @@ export default function Speaking({id, title, text, type, prompts, onNext, onBack
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="self-end flex flex-col-reverse items-center w-full md:justify-between md:items-start md:flex-row gap-8">
|
<ReactMediaRecorder
|
||||||
<button className={clsx("btn btn-wide gap-4 relative text-white", errorButtonStyle)} onClick={onBack}>
|
audio
|
||||||
<div className="absolute left-4">
|
onStop={(blob) => setMediaBlob(blob)}
|
||||||
<Icon path={mdiArrowLeft} color="white" size={1} />
|
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.round(recordingDuration / 60)
|
||||||
|
.toString(10)
|
||||||
|
.padStart(2, "0")}
|
||||||
|
:
|
||||||
|
{Math.round(recordingDuration % 60)
|
||||||
|
.toString(10)
|
||||||
|
.padStart(2, "0")}
|
||||||
|
</span>
|
||||||
</div>
|
</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-green-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-green-light w-8 h-8 cursor-pointer"
|
||||||
|
/>
|
||||||
|
<BsCheckCircleFill
|
||||||
|
onClick={() => {
|
||||||
|
setIsRecording(false);
|
||||||
|
stopRecording();
|
||||||
|
}}
|
||||||
|
className="text-mti-green-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();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<BsMicFill
|
||||||
|
onClick={() => {
|
||||||
|
clearBlobUrl();
|
||||||
|
setRecordingDuration(0);
|
||||||
|
startRecording();
|
||||||
|
setIsRecording(true);
|
||||||
|
}}
|
||||||
|
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="green" variant="outline" onClick={onBack} className="max-w-[200px] self-end w-full">
|
||||||
Back
|
Back
|
||||||
</button>
|
</Button>
|
||||||
<button
|
<Button
|
||||||
className={clsx("btn btn-wide gap-4 relative text-white", infoButtonStyle)}
|
color="green"
|
||||||
onClick={() => onNext({exercise: id, solutions: [], score: {correct: 1, total: 1}, type})}>
|
disabled={!mediaBlob}
|
||||||
|
onClick={() => onNext({exercise: id, solutions: [], score: {correct: 1, total: 1}, type})}
|
||||||
|
className="max-w-[200px] self-end w-full">
|
||||||
Next
|
Next
|
||||||
<div className="absolute right-4">
|
</Button>
|
||||||
<Icon path={mdiArrowRight} color="white" size={1} />
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,19 +1,14 @@
|
|||||||
/* eslint-disable @next/next/no-img-element */
|
/* eslint-disable @next/next/no-img-element */
|
||||||
import Icon from "@mdi/react";
|
import {useState} from "react";
|
||||||
import {mdiAccountVoice, mdiArrowLeft, mdiArrowRight, mdiBookOpen, mdiHeadphones, mdiPen} from "@mdi/js";
|
|
||||||
import {useEffect, useState} from "react";
|
|
||||||
import {Module} from "@/interfaces";
|
import {Module} from "@/interfaces";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import {useRouter} from "next/router";
|
|
||||||
import {errorButtonStyle, infoButtonStyle} from "@/constants/buttonStyles";
|
|
||||||
import ProfileLevel from "@/components/ProfileLevel";
|
|
||||||
import {User} from "@/interfaces/user";
|
import {User} from "@/interfaces/user";
|
||||||
import useExamStore from "@/stores/examStore";
|
|
||||||
import ProgressBar from "@/components/Low/ProgressBar";
|
import ProgressBar from "@/components/Low/ProgressBar";
|
||||||
import {BsBook, BsCheckCircle, BsFileEarmarkText, BsHeadphones, BsMegaphone, BsPen, BsPencil, BsStar} from "react-icons/bs";
|
import {BsBook, BsCheckCircle, BsHeadphones, BsMegaphone, BsPen} from "react-icons/bs";
|
||||||
import {averageScore, formatModuleTotalStats, totalExams, totalExamsByModule} from "@/utils/stats";
|
import {totalExamsByModule} from "@/utils/stats";
|
||||||
import useStats from "@/hooks/useStats";
|
import useStats from "@/hooks/useStats";
|
||||||
import Button from "@/components/Low/Button";
|
import Button from "@/components/Low/Button";
|
||||||
|
import {calculateAverageLevel} from "@/utils/score";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
user: User;
|
user: User;
|
||||||
@@ -24,10 +19,6 @@ export default function Selection({user, onStart}: Props) {
|
|||||||
const [selectedModules, setSelectedModules] = useState<Module[]>([]);
|
const [selectedModules, setSelectedModules] = useState<Module[]>([]);
|
||||||
const {stats} = useStats(user?.id);
|
const {stats} = useStats(user?.id);
|
||||||
|
|
||||||
const calculateAverageLevel = () => {
|
|
||||||
return Object.keys(user!.levels).reduce((accumulator, current) => user!.levels[current as Module] + accumulator, 0) / 4;
|
|
||||||
};
|
|
||||||
|
|
||||||
const toggleModule = (module: Module) => {
|
const toggleModule = (module: Module) => {
|
||||||
const modules = selectedModules.filter((x) => x !== module);
|
const modules = selectedModules.filter((x) => x !== module);
|
||||||
setSelectedModules((prev) => (prev.includes(module) ? modules : [...modules, module]));
|
setSelectedModules((prev) => (prev.includes(module) ? modules : [...modules, module]));
|
||||||
@@ -45,13 +36,18 @@ export default function Selection({user, onStart}: Props) {
|
|||||||
<h6 className="font-normal text-base text-mti-gray-taupe capitalize">{user.type}</h6>
|
<h6 className="font-normal text-base text-mti-gray-taupe capitalize">{user.type}</h6>
|
||||||
</div>
|
</div>
|
||||||
<ProgressBar
|
<ProgressBar
|
||||||
label={`Level ${calculateAverageLevel().toFixed(1)}`}
|
label={`Level ${calculateAverageLevel(user.levels).toFixed(1)}`}
|
||||||
percentage={Math.round((calculateAverageLevel() * 100) / 9)}
|
percentage={100}
|
||||||
color="blue"
|
color="blue"
|
||||||
className="max-w-xs w-32 self-end h-10"
|
className="max-w-xs w-32 self-end h-10"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<ProgressBar label="" percentage={70} color="blue" className="w-full h-3 drop-shadow-lg" />
|
<ProgressBar
|
||||||
|
label=""
|
||||||
|
percentage={Math.round((calculateAverageLevel(user.levels) * 100) / calculateAverageLevel(user.desiredLevels))}
|
||||||
|
color="blue"
|
||||||
|
className="w-full h-3 drop-shadow-lg"
|
||||||
|
/>
|
||||||
<div className="flex justify-between w-full mt-8">
|
<div className="flex justify-between w-full mt-8">
|
||||||
<div className="flex gap-4 items-center">
|
<div className="flex gap-4 items-center">
|
||||||
<div className="w-16 h-16 border border-mti-gray-platinum bg-mti-gray-smoke flex items-center justify-center rounded-xl">
|
<div className="w-16 h-16 border border-mti-gray-platinum bg-mti-gray-smoke flex items-center justify-center rounded-xl">
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import {renderExercise} from "@/components/Exercises";
|
import {renderExercise} from "@/components/Exercises";
|
||||||
|
import ModuleTitle from "@/components/Medium/ModuleTitle";
|
||||||
import {renderSolution} from "@/components/Solutions";
|
import {renderSolution} from "@/components/Solutions";
|
||||||
import {infoButtonStyle} from "@/constants/buttonStyles";
|
import {infoButtonStyle} from "@/constants/buttonStyles";
|
||||||
import {UserSolution, SpeakingExam} from "@/interfaces/exam";
|
import {UserSolution, SpeakingExam} from "@/interfaces/exam";
|
||||||
@@ -38,11 +39,15 @@ export default function Speaking({exam, showSolutions = false, onFinish}: Props)
|
|||||||
};
|
};
|
||||||
|
|
||||||
const previousExercise = () => {
|
const previousExercise = () => {
|
||||||
|
if (exerciseIndex > 0) {
|
||||||
setExerciseIndex((prev) => prev - 1);
|
setExerciseIndex((prev) => prev - 1);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full h-full relative flex flex-col gap-8 items-center justify-center p-8 px-16 overflow-hidden">
|
<>
|
||||||
|
<div className="flex flex-col h-full w-full gap-8 items-center">
|
||||||
|
<ModuleTitle minTimer={exam.minTimer} exerciseIndex={exerciseIndex + 1} module="speaking" totalExercises={exam.exercises.length} />
|
||||||
{exerciseIndex > -1 &&
|
{exerciseIndex > -1 &&
|
||||||
exerciseIndex < exam.exercises.length &&
|
exerciseIndex < exam.exercises.length &&
|
||||||
!showSolutions &&
|
!showSolutions &&
|
||||||
@@ -51,14 +56,7 @@ export default function Speaking({exam, showSolutions = false, onFinish}: Props)
|
|||||||
exerciseIndex < exam.exercises.length &&
|
exerciseIndex < exam.exercises.length &&
|
||||||
showSolutions &&
|
showSolutions &&
|
||||||
renderSolution(exam.exercises[exerciseIndex], nextExercise, previousExercise)}
|
renderSolution(exam.exercises[exerciseIndex], nextExercise, previousExercise)}
|
||||||
{exerciseIndex === -1 && (
|
|
||||||
<button className={clsx("btn btn-wide gap-4 relative text-white self-end", infoButtonStyle)} onClick={() => nextExercise()}>
|
|
||||||
Next
|
|
||||||
<div className="absolute right-4">
|
|
||||||
<Icon path={mdiArrowRight} color="white" size={1} />
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {capitalize} from "lodash";
|
|||||||
import {Module} from "@/interfaces";
|
import {Module} from "@/interfaces";
|
||||||
import ProgressBar from "@/components/Low/ProgressBar";
|
import ProgressBar from "@/components/Low/ProgressBar";
|
||||||
import Layout from "@/components/High/Layout";
|
import Layout from "@/components/High/Layout";
|
||||||
|
import {calculateAverageLevel} from "@/utils/score";
|
||||||
|
|
||||||
export const getServerSideProps = withIronSessionSsr(({req, res}) => {
|
export const getServerSideProps = withIronSessionSsr(({req, res}) => {
|
||||||
const user = req.session.user;
|
const user = req.session.user;
|
||||||
@@ -44,10 +45,6 @@ export default function Home() {
|
|||||||
if (user) setShowDiagnostics(user.isFirstLogin);
|
if (user) setShowDiagnostics(user.isFirstLogin);
|
||||||
}, [user]);
|
}, [user]);
|
||||||
|
|
||||||
const calculateAverageLevel = () => {
|
|
||||||
return Object.keys(user!.levels).reduce((accumulator, current) => user!.levels[current as Module] + accumulator, 0) / 4;
|
|
||||||
};
|
|
||||||
|
|
||||||
if (user && showDiagnostics) {
|
if (user && showDiagnostics) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -90,13 +87,18 @@ export default function Home() {
|
|||||||
<h6 className="font-normal text-base text-mti-gray-taupe">{capitalize(user.type)}</h6>
|
<h6 className="font-normal text-base text-mti-gray-taupe">{capitalize(user.type)}</h6>
|
||||||
</div>
|
</div>
|
||||||
<ProgressBar
|
<ProgressBar
|
||||||
label={`Level ${calculateAverageLevel().toFixed(1)}`}
|
label={`Level ${calculateAverageLevel(user.levels).toFixed(1)}`}
|
||||||
percentage={Math.round((calculateAverageLevel() * 100) / 9)}
|
percentage={100}
|
||||||
color="blue"
|
color="blue"
|
||||||
className="max-w-xs w-32 self-end h-10"
|
className="max-w-xs w-32 self-end h-10"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<ProgressBar label="" percentage={70} color="blue" className="w-full h-3 drop-shadow-lg" />
|
<ProgressBar
|
||||||
|
label=""
|
||||||
|
percentage={Math.round((calculateAverageLevel(user.levels) * 100) / calculateAverageLevel(user.desiredLevels))}
|
||||||
|
color="blue"
|
||||||
|
className="w-full h-3 drop-shadow-lg"
|
||||||
|
/>
|
||||||
<div className="flex justify-between w-full mt-8">
|
<div className="flex justify-between w-full mt-8">
|
||||||
<div className="flex gap-4 items-center">
|
<div className="flex gap-4 items-center">
|
||||||
<div className="w-16 h-16 border border-mti-gray-platinum bg-mti-gray-smoke flex items-center justify-center rounded-xl">
|
<div className="w-16 h-16 border border-mti-gray-platinum bg-mti-gray-smoke flex items-center justify-center rounded-xl">
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import {Module} from "@/interfaces";
|
||||||
|
|
||||||
type Type = "academic" | "general";
|
type Type = "academic" | "general";
|
||||||
|
|
||||||
const readingGeneralMarking: {[key: number]: number} = {
|
const readingGeneralMarking: {[key: number]: number} = {
|
||||||
@@ -59,3 +61,7 @@ export const calculateBandScore = (correct: number, total: number, module: "read
|
|||||||
|
|
||||||
return 0;
|
return 0;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const calculateAverageLevel = (levels: {[key in Module]: number}) => {
|
||||||
|
return Object.keys(levels).reduce((accumulator, current) => levels[current as Module] + accumulator, 0) / 4;
|
||||||
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user