Added the speaking module to the application

This commit is contained in:
Tiago Ribeiro
2023-04-17 17:35:26 +01:00
parent 207328dade
commit c321a5cc69
8 changed files with 186 additions and 20 deletions

View File

@@ -0,0 +1,53 @@
import {errorButtonStyle, infoButtonStyle} from "@/constants/buttonStyles";
import {SpeakingExercise, WritingExercise} from "@/interfaces/exam";
import {mdiArrowLeft, mdiArrowRight} from "@mdi/js";
import Icon from "@mdi/react";
import clsx from "clsx";
import {CommonProps} from ".";
import {Fragment, useEffect, useState} from "react";
import {toast} from "react-toastify";
export default function Speaking({id, title, text, prompts, onNext, onBack}: SpeakingExercise & CommonProps) {
return (
<div className="flex flex-col h-full w-2/3 items-center justify-center gap-8">
<div className="flex flex-col max-w-2xl gap-4">
<span className="font-bold">{title}</span>
<span className="font-regular ml-8">
{text.split("\\n").map((line, index) => (
<Fragment key={index}>
<span>{line}</span>
<br />
</Fragment>
))}
</span>
<div className="flex gap-8">
<span>You should talk about the following things:</span>
<div className="flex flex-col gap-4">
{prompts.map((x, index) => (
<li className="italic" key={index}>
{x}
</li>
))}
</div>
</div>
</div>
<div className="self-end flex gap-8">
<button className={clsx("btn btn-wide gap-4 relative text-white", errorButtonStyle)} onClick={onBack}>
<div className="absolute left-4">
<Icon path={mdiArrowLeft} color="white" size={1} />
</div>
Back
</button>
<button
className={clsx("btn btn-wide gap-4 relative text-white", infoButtonStyle)}
onClick={() => onNext({id, solutions: [], score: {correct: 0, total: 0}})}>
Next
<div className="absolute right-4">
<Icon path={mdiArrowRight} color="white" size={1} />
</div>
</button>
</div>
</div>
);
}

View File

@@ -7,7 +7,7 @@ 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";
export default function WriteBlanks({id, prompt, info, wordCounter, onNext, onBack}: WritingExercise & CommonProps) { export default function Writing({id, prompt, info, wordCounter, onNext, onBack}: WritingExercise & CommonProps) {
const [inputText, setInputText] = useState(""); const [inputText, setInputText] = useState("");
const [isSubmitEnabled, setIsSubmitEnabled] = useState(false); const [isSubmitEnabled, setIsSubmitEnabled] = useState(false);

View File

@@ -3,6 +3,7 @@ import {
FillBlanksExercise, FillBlanksExercise,
MatchSentencesExercise, MatchSentencesExercise,
MultipleChoiceExercise, MultipleChoiceExercise,
SpeakingExercise,
UserSolution, UserSolution,
WriteBlanksExercise, WriteBlanksExercise,
WritingExercise, WritingExercise,
@@ -12,6 +13,7 @@ import FillBlanks from "./FillBlanks";
import MultipleChoice from "./MultipleChoice"; import MultipleChoice from "./MultipleChoice";
import WriteBlanks from "./WriteBlanks"; import WriteBlanks from "./WriteBlanks";
import Writing from "./Writing"; import Writing from "./Writing";
import Speaking from "./Speaking";
const MatchSentences = dynamic(() => import("@/components/Exercises/MatchSentences"), {ssr: false}); const MatchSentences = dynamic(() => import("@/components/Exercises/MatchSentences"), {ssr: false});
@@ -32,5 +34,7 @@ export const renderExercise = (exercise: Exercise, onNext: (userSolutions: UserS
return <WriteBlanks {...(exercise as WriteBlanksExercise)} onNext={onNext} onBack={onBack} />; return <WriteBlanks {...(exercise as WriteBlanksExercise)} onNext={onNext} onBack={onBack} />;
case "writing": case "writing":
return <Writing {...(exercise as WritingExercise)} onNext={onNext} onBack={onBack} />; return <Writing {...(exercise as WritingExercise)} onNext={onNext} onBack={onBack} />;
case "speaking":
return <Speaking {...(exercise as SpeakingExercise)} onNext={onNext} onBack={onBack} />;
} }
}; };

View File

@@ -56,19 +56,6 @@ export default function Selection({user, onStart}: Props) {
<Icon path={mdiHeadphones} color="white" size={3} /> <Icon path={mdiHeadphones} color="white" size={3} />
<span>Listening</span> <span>Listening</span>
</div> </div>
<div
role="button"
tabIndex={0}
onClick={() => toggleModule("speaking")}
className={clsx(
"flex flex-col gap-2 items-center justify-center",
"border-ielts-speaking hover:bg-ielts-speaking text-white",
"border-2 rounded-xl p-4 h-fit w-48 cursor-pointer",
selectedModules.includes("speaking") ? "bg-ielts-speaking " : "bg-ielts-speaking-transparent ",
)}>
<Icon path={mdiAccountVoice} color="white" size={3} />
<span>Speaking</span>
</div>
<div <div
role="button" role="button"
tabIndex={0} tabIndex={0}
@@ -82,6 +69,19 @@ export default function Selection({user, onStart}: Props) {
<Icon path={mdiPen} color="white" size={3} /> <Icon path={mdiPen} color="white" size={3} />
<span>Writing</span> <span>Writing</span>
</div> </div>
<div
role="button"
tabIndex={0}
onClick={() => toggleModule("speaking")}
className={clsx(
"flex flex-col gap-2 items-center justify-center",
"border-ielts-speaking hover:bg-ielts-speaking text-white",
"border-2 rounded-xl p-4 h-fit w-48 cursor-pointer",
selectedModules.includes("speaking") ? "bg-ielts-speaking " : "bg-ielts-speaking-transparent ",
)}>
<Icon path={mdiAccountVoice} color="white" size={3} />
<span>Speaking</span>
</div>
</div> </div>
<div className="w-full flex justify-between"> <div className="w-full flex justify-between">
<button onClick={() => router.push("/")} className={clsx("btn btn-wide gap-4 relative text-white", errorButtonStyle)}> <button onClick={() => router.push("/")} className={clsx("btn btn-wide gap-4 relative text-white", errorButtonStyle)}>

79
src/exams/Speaking.tsx Normal file
View File

@@ -0,0 +1,79 @@
import {renderExercise} from "@/components/Exercises";
import {renderSolution} from "@/components/Solutions";
import {infoButtonStyle} from "@/constants/buttonStyles";
import {UserSolution, SpeakingExam} from "@/interfaces/exam";
import {mdiArrowRight} from "@mdi/js";
import Icon from "@mdi/react";
import clsx from "clsx";
import {Fragment, useEffect, useState} from "react";
import {toast} from "react-toastify";
interface Props {
exam: SpeakingExam;
showSolutions?: boolean;
onFinish: (userSolutions: UserSolution[]) => void;
}
export default function Speaking({exam, showSolutions = false, onFinish}: Props) {
const [exerciseIndex, setExerciseIndex] = useState(0);
const [timer, setTimer] = useState<number>();
const [userSolutions, setUserSolutions] = useState<UserSolution[]>([]);
useEffect(() => {
setTimer(exam.minTimer * 60);
const timerInterval = setInterval(() => setTimer((prev) => prev && prev - 1), 1000);
return () => {
clearInterval(timerInterval);
};
}, [exam.minTimer]);
const nextExercise = (solution?: UserSolution) => {
if (solution) {
setUserSolutions((prev) => [...prev.filter((x) => x.id !== solution.id), solution]);
}
if (exerciseIndex + 1 < exam.exercises.length) {
setExerciseIndex((prev) => prev + 1);
return;
}
if (solution) {
onFinish([...userSolutions.filter((x) => x.id !== solution.id), solution].map((x) => ({...x, module: "speaking", exam: exam.id})));
} else {
onFinish(userSolutions.map((x) => ({...x, module: "speaking", exam: exam.id})));
}
};
const previousExercise = () => {
setExerciseIndex((prev) => prev - 1);
};
return (
<div className="w-full h-full relative flex flex-col gap-8 items-center justify-center p-8 px-16 overflow-hidden">
{timer && (
<div className="absolute w-24 top-8 text-center right-8 font-semibold text-lg border-ielts-speaking border-2 p-2 rounded-xl bg-ielts-speaking-transparent text-white">
{Math.floor(timer / 60) < 10 ? "0" : ""}
{Math.floor(timer / 60)}:{timer % 60 < 10 ? "0" : ""}
{timer % 60}
</div>
)}
{exerciseIndex > -1 &&
exerciseIndex < exam.exercises.length &&
!showSolutions &&
renderExercise(exam.exercises[exerciseIndex], nextExercise, previousExercise)}
{exerciseIndex > -1 &&
exerciseIndex < exam.exercises.length &&
showSolutions &&
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>
);
}

View File

@@ -10,7 +10,6 @@ export default function useUser({redirectTo = "", redirectIfFound = false} = {})
const {data: user, mutate: mutateUser, isLoading} = useSWR<User>("/api/user", fetcher); const {data: user, mutate: mutateUser, isLoading} = useSWR<User>("/api/user", fetcher);
useEffect(() => { useEffect(() => {
console.log("HERE!!!");
// if no redirect needed, just return (example: already on /dashboard) // if no redirect needed, just return (example: already on /dashboard)
// if user data not yet there (fetch in progress, logged in or not) then don't do anything yet // if user data not yet there (fetch in progress, logged in or not) then don't do anything yet
if (!redirectTo || !user) return; if (!redirectTo || !user) return;

View File

@@ -1,6 +1,6 @@
import {Module} from "."; import {Module} from ".";
export type Exam = ReadingExam | ListeningExam | WritingExam; export type Exam = ReadingExam | ListeningExam | WritingExam | SpeakingExam;
export interface ReadingExam { export interface ReadingExam {
text: { text: {
@@ -47,7 +47,20 @@ interface WordCounter {
limit: number; limit: number;
} }
export type Exercise = FillBlanksExercise | MatchSentencesExercise | MultipleChoiceExercise | WriteBlanksExercise | WritingExercise; export interface SpeakingExam {
id: string;
module: "speaking";
exercises: Exercise[];
minTimer: number;
}
export type Exercise =
| FillBlanksExercise
| MatchSentencesExercise
| MultipleChoiceExercise
| WriteBlanksExercise
| WritingExercise
| SpeakingExercise;
export interface WritingExercise { export interface WritingExercise {
id: string; id: string;
@@ -57,6 +70,14 @@ export interface WritingExercise {
wordCounter: WordCounter; //* The minimum or maximum amount of words that should be written wordCounter: WordCounter; //* The minimum or maximum amount of words that should be written
} }
export interface SpeakingExercise {
id: string;
type: "speaking";
title: string;
text: string;
prompts: string[];
}
export interface FillBlanksExercise { export interface FillBlanksExercise {
prompt: string; // *EXAMPLE: "Complete the summary below. Click a blank to select the corresponding word for it." prompt: string; // *EXAMPLE: "Complete the summary below. Click a blank to select the corresponding word for it."
type: "fillBlanks"; type: "fillBlanks";

View File

@@ -6,7 +6,7 @@ import {Module} from "@/interfaces";
import Selection from "@/exams/Selection"; import Selection from "@/exams/Selection";
import Reading from "@/exams/Reading"; import Reading from "@/exams/Reading";
import {Exam, ListeningExam, ReadingExam, UserSolution, WritingExam} from "@/interfaces/exam"; import {Exam, ListeningExam, ReadingExam, SpeakingExam, UserSolution, WritingExam} from "@/interfaces/exam";
import Listening from "@/exams/Listening"; import Listening from "@/exams/Listening";
import Writing from "@/exams/Writing"; import Writing from "@/exams/Writing";
import {ToastContainer, toast} from "react-toastify"; import {ToastContainer, toast} from "react-toastify";
@@ -15,6 +15,7 @@ import axios from "axios";
import {withIronSessionSsr} from "iron-session/next"; import {withIronSessionSsr} from "iron-session/next";
import {sessionOptions} from "@/lib/session"; import {sessionOptions} from "@/lib/session";
import {User} from "@/interfaces/user"; import {User} from "@/interfaces/user";
import Speaking from "@/exams/Speaking";
export const getServerSideProps = withIronSessionSsr(({req, res}) => { export const getServerSideProps = withIronSessionSsr(({req, res}) => {
const user = req.session.user; const user = req.session.user;
@@ -68,9 +69,9 @@ export default function Page({user}: {user: User}) {
return newExam.shift() as ListeningExam; return newExam.shift() as ListeningExam;
case "writing": case "writing":
return newExam.shift() as WritingExam; return newExam.shift() as WritingExam;
case "speaking":
return newExam.shift() as SpeakingExam;
} }
return undefined;
}; };
const updateExamWithUserSolutions = (exam: Exam): Exam => { const updateExamWithUserSolutions = (exam: Exam): Exam => {
@@ -122,6 +123,15 @@ export default function Page({user}: {user: User}) {
return <Writing exam={exam} onFinish={onFinish} showSolutions={showSolutions} />; return <Writing exam={exam} onFinish={onFinish} showSolutions={showSolutions} />;
} }
if (exam && exam.module === "speaking" && showSolutions) {
setModuleIndex((prev) => prev + 1);
return <></>;
}
if (exam && exam.module === "speaking") {
return <Speaking exam={exam} onFinish={onFinish} showSolutions={showSolutions} />;
}
return <>Loading...</>; return <>Loading...</>;
}; };