Implemented the speaking exercise;

Cleaned up a bit of the code;
This commit is contained in:
Tiago Ribeiro
2023-06-15 15:39:40 +01:00
parent f5ec910010
commit bc7eaea911
5 changed files with 208 additions and 68 deletions

View File

@@ -6,23 +6,50 @@ 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>
{text.split("\\n").map((line, index) => ( <span className="font-regular">
<Fragment key={index}> {text.split("\\n").map((line, index) => (
<span>{line}</span> <Fragment key={index}>
<br /> <span>{line}</span>
</Fragment> <br />
))} </Fragment>
</span> ))}
<div className="flex gap-8"> </span>
<span>You should talk about the following things:</span> </div>
<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 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>
)}
/>
<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>
); );

View File

@@ -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">

View File

@@ -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,27 +39,24 @@ export default function Speaking({exam, showSolutions = false, onFinish}: Props)
}; };
const previousExercise = () => { const previousExercise = () => {
setExerciseIndex((prev) => prev - 1); if (exerciseIndex > 0) {
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"> <>
{exerciseIndex > -1 && <div className="flex flex-col h-full w-full gap-8 items-center">
exerciseIndex < exam.exercises.length && <ModuleTitle minTimer={exam.minTimer} exerciseIndex={exerciseIndex + 1} module="speaking" totalExercises={exam.exercises.length} />
!showSolutions && {exerciseIndex > -1 &&
renderExercise(exam.exercises[exerciseIndex], nextExercise, previousExercise)} exerciseIndex < exam.exercises.length &&
{exerciseIndex > -1 && !showSolutions &&
exerciseIndex < exam.exercises.length && renderExercise(exam.exercises[exerciseIndex], nextExercise, previousExercise)}
showSolutions && {exerciseIndex > -1 &&
renderSolution(exam.exercises[exerciseIndex], nextExercise, previousExercise)} exerciseIndex < exam.exercises.length &&
{exerciseIndex === -1 && ( showSolutions &&
<button className={clsx("btn btn-wide gap-4 relative text-white self-end", infoButtonStyle)} onClick={() => nextExercise()}> renderSolution(exam.exercises[exerciseIndex], nextExercise, previousExercise)}
Next </div>
<div className="absolute right-4"> </>
<Icon path={mdiArrowRight} color="white" size={1} />
</div>
</button>
)}
</div>
); );
} }

View File

@@ -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">

View File

@@ -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;
};