Bug fixes to training, added a spinner to record while it loads, made changes to speaking as requested
This commit is contained in:
@@ -1,33 +1,34 @@
|
|||||||
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>();
|
||||||
const [audioURL, setAudioURL] = useState<string>();
|
const [audioURL, setAudioURL] = useState<string>();
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [isPromptsModalOpen, setIsPromptsModalOpen] = useState(false);
|
const [isPromptsModalOpen, setIsPromptsModalOpen] = useState(false);
|
||||||
|
const [inputText, setInputText] = useState("");
|
||||||
|
|
||||||
const hasExamEnded = useExamStore((state) => state.hasExamEnded);
|
const hasExamEnded = useExamStore((state) => state.hasExamEnded);
|
||||||
|
|
||||||
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.", "");
|
||||||
|
|
||||||
@@ -41,8 +42,8 @@ export default function Speaking({id, title, text, video_url, type, prompts, suf
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -51,7 +52,7 @@ export default function Speaking({id, title, text, video_url, type, prompts, suf
|
|||||||
|
|
||||||
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);
|
||||||
}
|
}
|
||||||
@@ -78,8 +79,8 @@ export default function Speaking({id, title, text, video_url, type, prompts, suf
|
|||||||
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,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -87,12 +88,33 @@ export default function Speaking({id, title, text, video_url, type, prompts, suf
|
|||||||
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,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleNoteWriting = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||||
|
const newText = e.target.value;
|
||||||
|
const words = newText.match(/\S+/g);
|
||||||
|
const wordCount = words ? words.length : 0;
|
||||||
|
|
||||||
|
if (wordCount <= 100) {
|
||||||
|
setInputText(newText);
|
||||||
|
} else {
|
||||||
|
let count = 0;
|
||||||
|
let lastIndex = 0;
|
||||||
|
const matches = newText.matchAll(/\S+/g);
|
||||||
|
for (const match of matches) {
|
||||||
|
count++;
|
||||||
|
if (count > 100) break;
|
||||||
|
lastIndex = match.index! + match[0].length;
|
||||||
|
}
|
||||||
|
|
||||||
|
setInputText(newText.slice(0, lastIndex));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full w-full gap-9">
|
<div className="flex flex-col h-full w-full gap-9">
|
||||||
<Modal title="Prompts" className="!w-96 aspect-square" isOpen={isPromptsModalOpen} onClose={() => setIsPromptsModalOpen(false)}>
|
<Modal title="Prompts" className="!w-96 aspect-square" isOpen={isPromptsModalOpen} onClose={() => setIsPromptsModalOpen(false)}>
|
||||||
@@ -112,7 +134,7 @@ export default function Speaking({id, title, text, video_url, type, prompts, suf
|
|||||||
<div className="flex flex-col gap-0">
|
<div className="flex flex-col gap-0">
|
||||||
<span className="font-semibold">{title}</span>
|
<span className="font-semibold">{title}</span>
|
||||||
{prompts.length > 0 && (
|
{prompts.length > 0 && (
|
||||||
<span className="font-semibold">You should talk for at least 30 seconds for your answer to be valid.</span>
|
<span className="font-semibold">You should talk for at least 1 minute and 30 seconds for your answer to be valid.</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{!video_url && (
|
{!video_url && (
|
||||||
@@ -138,10 +160,24 @@ export default function Speaking({id, title, text, video_url, type, prompts, suf
|
|||||||
</div>
|
</div>
|
||||||
</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
|
<ReactMediaRecorder
|
||||||
audio
|
audio
|
||||||
onStop={(blob) => setMediaBlob(blob)}
|
onStop={(blob) => setMediaBlob(blob)}
|
||||||
render={({status, startRecording, stopRecording, pauseRecording, resumeRecording, clearBlobUrl, mediaBlobUrl}) => (
|
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">
|
<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>
|
<p className="text-base font-normal">Record your answer:</p>
|
||||||
<div className="flex gap-8 items-center justify-center py-8">
|
<div className="flex gap-8 items-center justify-center py-8">
|
||||||
|
|||||||
@@ -78,6 +78,7 @@ interface StatsGridItemProps {
|
|||||||
users: User[];
|
users: User[];
|
||||||
training?: boolean,
|
training?: boolean,
|
||||||
selectedTrainingExams?: string[];
|
selectedTrainingExams?: string[];
|
||||||
|
maxTrainingExams?: number;
|
||||||
setSelectedTrainingExams?: React.Dispatch<React.SetStateAction<string[]>>;
|
setSelectedTrainingExams?: React.Dispatch<React.SetStateAction<string[]>>;
|
||||||
setExams: (exams: Exam[]) => void;
|
setExams: (exams: Exam[]) => void;
|
||||||
setShowSolutions: (show: boolean) => void;
|
setShowSolutions: (show: boolean) => void;
|
||||||
@@ -106,7 +107,8 @@ const StatsGridItem: React.FC<StatsGridItemProps> = ({
|
|||||||
renderPdfIcon,
|
renderPdfIcon,
|
||||||
width = undefined,
|
width = undefined,
|
||||||
height = undefined,
|
height = undefined,
|
||||||
examNumber = undefined
|
examNumber = undefined,
|
||||||
|
maxTrainingExams = undefined
|
||||||
}) => {
|
}) => {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const correct = stats.reduce((accumulator, current) => accumulator + current.score.correct, 0);
|
const correct = stats.reduce((accumulator, current) => accumulator + current.score.correct, 0);
|
||||||
@@ -132,16 +134,22 @@ const StatsGridItem: React.FC<StatsGridItemProps> = ({
|
|||||||
const { timeSpent, inactivity, session } = stats[0];
|
const { timeSpent, inactivity, session } = stats[0];
|
||||||
|
|
||||||
const selectExam = () => {
|
const selectExam = () => {
|
||||||
if (training && !isDisabled && typeof setSelectedTrainingExams !== "undefined" && typeof timestamp == "string") {
|
if (training && !isDisabled && typeof maxTrainingExams !== "undefined" && typeof setSelectedTrainingExams !== "undefined" && typeof timestamp == "string") {
|
||||||
setSelectedTrainingExams(prevExams => {
|
setSelectedTrainingExams(prevExams => {
|
||||||
const index = prevExams.indexOf(timestamp);
|
const uniqueExams = [...new Set(stats.map(stat => `${stat.module}-${stat.date}`))];
|
||||||
|
const indexes = uniqueExams.map(exam => prevExams.indexOf(exam)).filter(index => index !== -1);
|
||||||
if (index !== -1) {
|
if (indexes.length > 0) {
|
||||||
const newExams = [...prevExams];
|
const newExams = [...prevExams];
|
||||||
newExams.splice(index, 1);
|
indexes.sort((a, b) => b - a).forEach(index => {
|
||||||
|
newExams.splice(index, 1);
|
||||||
|
});
|
||||||
return newExams;
|
return newExams;
|
||||||
} else {
|
} else {
|
||||||
return [...prevExams, timestamp];
|
if (prevExams.length + uniqueExams.length <= maxTrainingExams) {
|
||||||
|
return [...prevExams, ...uniqueExams];
|
||||||
|
} else {
|
||||||
|
return prevExams;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
@@ -219,7 +227,10 @@ const StatsGridItem: React.FC<StatsGridItemProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="w-full flex flex-col gap-1">
|
<div className="w-full flex flex-col gap-1">
|
||||||
<div className="grid grid-cols-4 gap-2 place-items-start w-full -md:mt-2">
|
<div className={clsx(
|
||||||
|
"grid grid-cols-4 gap-2 place-items-start w-full -md:mt-2",
|
||||||
|
examNumber !== undefined && "pr-10"
|
||||||
|
)}>
|
||||||
{aggregatedLevels.map(({ module, level }) => (
|
{aggregatedLevels.map(({ module, level }) => (
|
||||||
<ModuleBadge key={module} module={module} level={level} />
|
<ModuleBadge key={module} module={module} level={level} />
|
||||||
))}
|
))}
|
||||||
@@ -244,8 +255,9 @@ const StatsGridItem: React.FC<StatsGridItemProps> = ({
|
|||||||
correct / total >= 0.7 && "hover:border-mti-purple",
|
correct / total >= 0.7 && "hover:border-mti-purple",
|
||||||
correct / total >= 0.3 && correct / total < 0.7 && "hover:border-mti-red",
|
correct / total >= 0.3 && correct / total < 0.7 && "hover:border-mti-red",
|
||||||
correct / total < 0.3 && "hover:border-mti-rose",
|
correct / total < 0.3 && "hover:border-mti-rose",
|
||||||
typeof selectedTrainingExams !== "undefined" && typeof timestamp === "string" && selectedTrainingExams.includes(timestamp) && "border-2 border-slate-600",
|
typeof selectedTrainingExams !== "undefined" && typeof timestamp === "string" && selectedTrainingExams.some(exam => exam.includes(timestamp)) && "border-2 border-slate-600",
|
||||||
)}
|
)}
|
||||||
|
onClick={examNumber === undefined ? selectExam : undefined}
|
||||||
style={{
|
style={{
|
||||||
...(width !== undefined && { width }),
|
...(width !== undefined && { width }),
|
||||||
...(height !== undefined && { height }),
|
...(height !== undefined && { height }),
|
||||||
|
|||||||
@@ -17,9 +17,10 @@ const TrainingScore: React.FC<TrainingScoreProps> = ({
|
|||||||
const scores = trainingContent.exams.map(exam => exam.score);
|
const scores = trainingContent.exams.map(exam => exam.score);
|
||||||
const highestScore = Math.max(...scores);
|
const highestScore = Math.max(...scores);
|
||||||
const lowestScore = Math.min(...scores);
|
const lowestScore = Math.min(...scores);
|
||||||
const averageScore = scores.length > 0
|
let averageScore = scores.length > 0
|
||||||
? scores.reduce((sum, score) => sum + score, 0) / scores.length
|
? scores.reduce((sum, score) => sum + score, 0) / scores.length
|
||||||
: 0;
|
: 0;
|
||||||
|
averageScore = Math.round(averageScore);
|
||||||
|
|
||||||
const containerClasses = clsx(
|
const containerClasses = clsx(
|
||||||
"flex flex-row mb-4",
|
"flex flex-row mb-4",
|
||||||
|
|||||||
@@ -21,7 +21,6 @@ import { uuidv4 } from "@firebase/util";
|
|||||||
import { usePDFDownload } from "@/hooks/usePDFDownload";
|
import { usePDFDownload } from "@/hooks/usePDFDownload";
|
||||||
import useRecordStore from "@/stores/recordStore";
|
import useRecordStore from "@/stores/recordStore";
|
||||||
import useTrainingContentStore from "@/stores/trainingContentStore";
|
import useTrainingContentStore from "@/stores/trainingContentStore";
|
||||||
import Button from "@/components/Low/Button";
|
|
||||||
import StatsGridItem from "@/components/StatGridItem";
|
import StatsGridItem from "@/components/StatGridItem";
|
||||||
|
|
||||||
|
|
||||||
@@ -148,10 +147,12 @@ export default function History({ user }: { user: User }) {
|
|||||||
|
|
||||||
const handleTrainingContentSubmission = () => {
|
const handleTrainingContentSubmission = () => {
|
||||||
if (groupedStats) {
|
if (groupedStats) {
|
||||||
const allStats = Object.keys(filterStatsByDate(groupedStats));
|
const groupedStatsByDate = filterStatsByDate(groupedStats);
|
||||||
const selectedStats = selectedTrainingExams.reduce<Record<string, Stat[]>>((accumulator, timestamp) => {
|
const allStats = Object.keys(groupedStatsByDate);
|
||||||
if (allStats.includes(timestamp)) {
|
const selectedStats = selectedTrainingExams.reduce<Record<string, Stat[]>>((accumulator, moduleAndTimestamp) => {
|
||||||
accumulator[timestamp] = filterStatsByDate(groupedStats)[timestamp];
|
const timestamp = moduleAndTimestamp.split("-")[1];
|
||||||
|
if (allStats.includes(timestamp) && !accumulator.hasOwnProperty(timestamp)) {
|
||||||
|
accumulator[timestamp] = groupedStatsByDate[timestamp];
|
||||||
}
|
}
|
||||||
return accumulator;
|
return accumulator;
|
||||||
}, {});
|
}, {});
|
||||||
@@ -177,6 +178,7 @@ export default function History({ user }: { user: User }) {
|
|||||||
training={training}
|
training={training}
|
||||||
selectedTrainingExams={selectedTrainingExams}
|
selectedTrainingExams={selectedTrainingExams}
|
||||||
setSelectedTrainingExams={setSelectedTrainingExams}
|
setSelectedTrainingExams={setSelectedTrainingExams}
|
||||||
|
maxTrainingExams={MAX_TRAINING_EXAMS}
|
||||||
setExams={setExams}
|
setExams={setExams}
|
||||||
setShowSolutions={setShowSolutions}
|
setShowSolutions={setShowSolutions}
|
||||||
setUserSolutions={setUserSolutions}
|
setUserSolutions={setUserSolutions}
|
||||||
@@ -323,7 +325,8 @@ export default function History({ user }: { user: User }) {
|
|||||||
)}
|
)}
|
||||||
{(training && (
|
{(training && (
|
||||||
<div className="flex flex-row">
|
<div className="flex flex-row">
|
||||||
<div className="font-semibold text-2xl mr-4">Select up to 10 exams {`(${selectedTrainingExams.length}/${MAX_TRAINING_EXAMS})`}</div>
|
<div className="font-semibold text-2xl mr-4">Select up to 10 exercises
|
||||||
|
{`(${selectedTrainingExams.length}/${MAX_TRAINING_EXAMS})`}</div>
|
||||||
<button
|
<button
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"bg-mti-purple-ultralight text-mti-purple px-4 py-2 rounded-full hover:text-white hover:bg-mti-purple-light ml-4 disabled:cursor-not-allowed",
|
"bg-mti-purple-ultralight text-mti-purple px-4 py-2 rounded-full hover:text-white hover:bg-mti-purple-light ml-4 disabled:cursor-not-allowed",
|
||||||
@@ -385,6 +388,11 @@ export default function History({ user }: { user: User }) {
|
|||||||
{groupedStats && Object.keys(groupedStats).length === 0 && !isStatsLoading && (
|
{groupedStats && Object.keys(groupedStats).length === 0 && !isStatsLoading && (
|
||||||
<span className="font-semibold ml-1">No record to display...</span>
|
<span className="font-semibold ml-1">No record to display...</span>
|
||||||
)}
|
)}
|
||||||
|
{isStatsLoading && (
|
||||||
|
<div className="absolute left-1/2 top-1/2 flex h-fit w-fit -translate-x-1/2 -translate-y-1/2 animate-pulse flex-col items-center gap-12">
|
||||||
|
<span className="loading loading-infinity w-32 bg-mti-green-light" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</Layout>
|
</Layout>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -182,7 +182,7 @@ const TrainingContent: React.FC<{ user: User }> = ({ user }) => {
|
|||||||
{trainingContent.exams.map((exam, examIndex) => (
|
{trainingContent.exams.map((exam, examIndex) => (
|
||||||
<StatsGridItem
|
<StatsGridItem
|
||||||
key={`exam-${examIndex}`}
|
key={`exam-${examIndex}`}
|
||||||
width='350px'
|
width='380px'
|
||||||
height='150px'
|
height='150px'
|
||||||
examNumber={examIndex + 1}
|
examNumber={examIndex + 1}
|
||||||
stats={exam.stats || []}
|
stats={exam.stats || []}
|
||||||
@@ -302,7 +302,7 @@ const TrainingContent: React.FC<{ user: User }> = ({ user }) => {
|
|||||||
</div>
|
</div>
|
||||||
<h3 className="text-lg font-semibold">Detailed Breakdown</h3>
|
<h3 className="text-lg font-semibold">Detailed Breakdown</h3>
|
||||||
</div>
|
</div>
|
||||||
<ul className="flex flex-col flex-grow space-y-4 pb-2 max-h-[350px] overflow-y-auto scrollbar-hide">
|
<ul className="flex flex-col flex-grow space-y-4 pb-2 overflow-y-auto scrollbar-hide">
|
||||||
{trainingContent.exams.map((exam, index) => (
|
{trainingContent.exams.map((exam, index) => (
|
||||||
<li key={index} className="border rounded-lg bg-white">
|
<li key={index} className="border rounded-lg bg-white">
|
||||||
<Dropdown title={
|
<Dropdown title={
|
||||||
|
|||||||
Reference in New Issue
Block a user