Implemented the Reading and Listening initial screens according to the new designs, creating new components as needed

This commit is contained in:
Tiago Ribeiro
2023-06-15 14:43:29 +01:00
parent 65ebdd7dde
commit 2d46bad40f
13 changed files with 272 additions and 85 deletions

View File

@@ -1,19 +1,24 @@
import {User} from "@/interfaces/user"; import {User} from "@/interfaces/user";
import clsx from "clsx";
import Navbar from "../Navbar"; import Navbar from "../Navbar";
import Sidebar from "../Sidebar"; import Sidebar from "../Sidebar";
interface Props { interface Props {
user: User; user: User;
children: React.ReactNode; children: React.ReactNode;
className?: string;
} }
export default function Layout({user, children}: Props) { export default function Layout({user, children, className}: Props) {
return ( return (
<main className="w-full h-[100vh] flex flex-col bg-mti-gray-smoke"> <main className="w-full min-h-full h-screen flex flex-col bg-mti-gray-smoke">
<Navbar user={user} /> <Navbar user={user} />
<div className="h-full w-full flex py-4 pb-8 gap-2"> <div className="h-full w-full flex py-4 pb-8 gap-2">
<Sidebar path={window.location.pathname} /> <Sidebar path={window.location.pathname} />
<div className="w-5/6 h-full mr-8 bg-white shadow-md rounded-2xl p-12 flex flex-col gap-12">{children}</div> <div
className={clsx("w-5/6 min-h-full h-fit mr-8 bg-white shadow-md rounded-2xl p-12 pb-8 flex flex-col gap-12 relative", className)}>
{children}
</div>
</div> </div>
</main> </main>
); );

View File

@@ -0,0 +1,87 @@
import {Module} from "@/interfaces";
import {formatTimeInMinutes} from "@/utils/string";
import clsx from "clsx";
import {useEffect, useRef, useState} from "react";
import {BsPauseFill, BsPlayFill} from "react-icons/bs";
import ProgressBar from "./ProgressBar";
interface Props {
src: string;
color: "blue" | "orange" | "green" | Module;
autoPlay?: boolean;
disabled?: boolean;
onEnd?: () => void;
}
export default function AudioPlayer({src, color, autoPlay = false, disabled = false, onEnd}: Props) {
const [isPlaying, setIsPlaying] = useState(false);
const [duration, setDuration] = useState(0);
const [currentTime, setCurrentTime] = useState(0);
const audioPlayerRef = useRef<HTMLAudioElement | null>(null);
useEffect(() => {
if (audioPlayerRef && audioPlayerRef.current) {
const seconds = Math.floor(audioPlayerRef.current.duration);
setDuration(seconds);
}
}, [audioPlayerRef?.current?.readyState]);
useEffect(() => {
let playingInterval: NodeJS.Timer | undefined = undefined;
if (isPlaying) {
playingInterval = setInterval(() => setCurrentTime((prev) => prev + 1), 1000);
} else if (playingInterval) {
clearInterval(playingInterval);
}
return () => {
if (playingInterval) clearInterval(playingInterval);
};
}, [isPlaying]);
const togglePlayPause = () => {
const prevValue = isPlaying;
setIsPlaying(!prevValue);
if (!prevValue) {
audioPlayerRef?.current?.play();
} else {
audioPlayerRef?.current?.pause();
}
};
return (
<div className="w-full h-fit flex gap-4 items-center mt-2">
{isPlaying && (
<BsPauseFill
className={clsx("text-mti-gray-cool cursor-pointer w-5 h-5", disabled && "opacity-60 cursor-not-allowed")}
onClick={disabled ? undefined : togglePlayPause}
/>
)}
{!isPlaying && (
<BsPlayFill
className={clsx("text-mti-gray-cool cursor-pointer w-5 h-5", disabled && "opacity-60 cursor-not-allowed")}
onClick={disabled ? undefined : togglePlayPause}
/>
)}
<audio
src={src}
autoPlay={autoPlay}
ref={audioPlayerRef}
preload="metadata"
onEnded={() => {
setIsPlaying(false);
setCurrentTime(0);
if (onEnd) onEnd();
}}
/>
<div className="flex flex-col gap-2 w-full relative">
<div className="absolute w-full flex justify-between -top-5 text-xs px-1">
<span>{formatTimeInMinutes(currentTime)}</span>
<span>{formatTimeInMinutes(duration)}</span>
</div>
<ProgressBar label="" color={color} useColor percentage={(currentTime * 100) / duration} className="h-3 w-full" />
</div>
</div>
);
}

View File

@@ -1,22 +1,37 @@
import {Module} from "@/interfaces";
import clsx from "clsx"; import clsx from "clsx";
interface Props { interface Props {
label: string; label: string;
percentage: number; percentage: number;
color: "blue" | "orange" | "green"; color: "blue" | "orange" | "green" | Module;
useColor?: boolean;
className?: string; className?: string;
} }
export default function ProgressBar({label, percentage, color, className}: Props) { export default function ProgressBar({label, percentage, color, useColor = false, className}: Props) {
const progressColorClass: {[key in typeof color]: string} = { const progressColorClass: {[key in typeof color]: string} = {
blue: "bg-mti-blue-light", blue: "bg-mti-blue-light",
orange: "bg-mti-orange-light", orange: "bg-mti-orange-light",
green: "bg-mti-green-light", green: "bg-mti-green-light",
reading: "bg-ielts-reading",
listening: "bg-ielts-listening",
writing: "bg-ielts-writing",
speaking: "bg-ielts-speaking",
}; };
return ( return (
<div className={clsx("relative rounded-full bg-mti-gray-anti-flash overflow-hidden flex items-center justify-center", className)}> <div
<div style={{width: `${percentage}%`}} className={clsx("absolute top-0 left-0 h-full overflow-hidden", progressColorClass[color])} /> className={clsx(
"relative rounded-full overflow-hidden flex items-center justify-center",
className,
!useColor ? "bg-mti-gray-anti-flash" : progressColorClass[color],
useColor && "bg-opacity-20",
)}>
<div
style={{width: `${percentage}%`}}
className={clsx("absolute transition-all duration-300 ease-in-out top-0 left-0 h-full overflow-hidden", progressColorClass[color])}
/>
<span className="z-10 justify-self-center text-white text-sm font-bold">{label}</span> <span className="z-10 justify-self-center text-white text-sm font-bold">{label}</span>
</div> </div>
); );

View File

@@ -0,0 +1,64 @@
import {Module} from "@/interfaces";
import {ReactNode, useEffect, useState} from "react";
import {BsBook, BsHeadphones, BsPen, BsStopwatch} from "react-icons/bs";
import ProgressBar from "../Low/ProgressBar";
interface Props {
minTimer: number;
module: Module;
exerciseIndex: number;
totalExercises: number;
}
export default function ModuleTitle({minTimer, module, exerciseIndex, totalExercises}: Props) {
const [timer, setTimer] = useState(minTimer * 60);
useEffect(() => {
const timerInterval = setInterval(() => setTimer((prev) => prev - 1), 1000);
return () => {
clearInterval(timerInterval);
};
}, [minTimer]);
const moduleIcon: {[key in Module]: ReactNode} = {
reading: <BsBook className="text-ielts-reading w-6 h-6" />,
listening: <BsHeadphones className="text-ielts-listening w-6 h-6" />,
writing: <BsPen className="text-ielts-writing w-6 h-6" />,
speaking: <BsBook className="text-ielts-speaking w-6 h-6" />,
};
return (
<>
<div className="absolute top-4 right-6 bg-mti-gray-seasalt px-3 py-2 flex items-center gap-2 rounded-full text-mti-gray-davy">
<BsStopwatch className="w-4 h-4" />
<span className="text-sm font-semibold w-11">
{timer > 0 && (
<>
{Math.floor(timer / 60)
.toString(10)
.padStart(2, "0")}
:
{Math.floor(timer % 60)
.toString(10)
.padStart(2, "0")}
</>
)}
{timer <= 0 && <>00:00</>}
</span>
</div>
<div className="flex gap-6 w-full h-fit items-center mt-5">
<div className="w-12 h-12 bg-mti-gray-smoke flex items-center justify-center rounded-lg">{moduleIcon[module]}</div>
<div className="flex flex-col gap-3 w-full">
<div className="w-full flex justify-between">
<span className="text-base font-semibold">Reading exam N.19</span>
<span className="text-xs font-normal self-end text-mti-gray-davy">
Question {exerciseIndex}/{totalExercises}
</span>
</div>
<ProgressBar color={module} label="" percentage={((exerciseIndex - 1) * 100) / totalExercises} className="h-2 w-full" />
</div>
</div>
</>
);
}

View File

@@ -6,6 +6,9 @@ import clsx from "clsx";
import {infoButtonStyle} from "@/constants/buttonStyles"; import {infoButtonStyle} from "@/constants/buttonStyles";
import {renderExercise} from "@/components/Exercises"; import {renderExercise} from "@/components/Exercises";
import {renderSolution} from "@/components/Solutions"; import {renderSolution} from "@/components/Solutions";
import ModuleTitle from "@/components/Medium/ModuleTitle";
import AudioPlayer from "@/components/Low/AudioPlayer";
import Button from "@/components/Low/Button";
interface Props { interface Props {
exam: ListeningExam; exam: ListeningExam;
@@ -42,37 +45,31 @@ export default function Listening({exam, showSolutions = false, onFinish}: Props
}; };
const renderAudioPlayer = () => ( const renderAudioPlayer = () => (
<> <div className="flex flex-col gap-8 w-full bg-mti-gray-seasalt rounded-xl py-8 px-16">
{exerciseIndex === -1 && ( <div className="flex flex-col w-full gap-2">
<div className="flex flex-col"> <h4 className="text-xl font-semibold">Please listen to the following audio attentively.</h4>
<span className="text-lg font-semibold">Please listen to the following audio attentively.</span> <span className="text-base">
{exam.audio.repeatableTimes > 0 ? ( {exam.audio.repeatableTimes > 0
<span className="self-center text-sm"> ? `You will only be allowed to listen to the audio ${exam.audio.repeatableTimes - timesListened} time(s).`
You will only be allowed to listen to the audio {exam.audio.repeatableTimes} time(s). : "You may listen to the audio as many times as you would like."}
</span> </span>
) : (
<span className="self-center text-sm">You may listen to the audio as many times as you would like.</span>
)}
</div>
)}
<div className="rounded-xl flex flex-col gap-4 items-center w-full overflow-auto">
{exam.audio.repeatableTimes > 0 && (
<>{exam.audio.repeatableTimes <= timesListened && <span>You are no longer allowed to listen to the audio again.</span>}</>
)}
{exam.audio.repeatableTimes > 0 && timesListened < exam.audio.repeatableTimes && (
<audio preload="auto" controls autoPlay onPlay={() => setTimesListened((prev) => prev + 1)}>
<source src={exam.audio.source} type="audio/mpeg" />
Your browser does not support the audio element
</audio>
)}
</div> </div>
</> <div className="rounded-xl flex flex-col gap-4 items-center w-full h-fit">
<AudioPlayer
src="https://assets.mixkit.co/active_storage/sfx/213/213-preview.mp3"
color="listening"
onEnd={() => setTimesListened((prev) => prev + 1)}
disabled={timesListened === exam.audio.repeatableTimes}
/>
</div>
</div>
); );
return ( return (
<> <>
<div className="w-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 justify-between">
{renderAudioPlayer()} <ModuleTitle exerciseIndex={exerciseIndex + 1} minTimer={exam.minTimer} module="listening" totalExercises={exam.exercises.length} />
{exerciseIndex === -1 && renderAudioPlayer()}
{exerciseIndex > -1 && {exerciseIndex > -1 &&
exerciseIndex < exam.exercises.length && exerciseIndex < exam.exercises.length &&
!showSolutions && !showSolutions &&
@@ -81,17 +78,13 @@ export default function Listening({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 md:btn-wide w-full 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>
{exerciseIndex === -1 && (
<Button color="green" onClick={() => nextExercise()} className="max-w-[200px] self-end w-full justify-self-end">
Start now
</Button>
)}
</> </>
); );
} }

View File

@@ -9,6 +9,11 @@ import {renderExercise} from "@/components/Exercises";
import {renderSolution} from "@/components/Solutions"; import {renderSolution} from "@/components/Solutions";
import {Panel} from "primereact/panel"; import {Panel} from "primereact/panel";
import {Steps} from "primereact/steps"; import {Steps} from "primereact/steps";
import {BsAlarm, BsBook, BsClock, BsStopwatch} from "react-icons/bs";
import ProgressBar from "@/components/Low/ProgressBar";
import ModuleTitle from "@/components/Medium/ModuleTitle";
import {Divider} from "primereact/divider";
import Button from "@/components/Low/Button";
interface Props { interface Props {
exam: ReadingExam; exam: ReadingExam;
@@ -102,27 +107,30 @@ export default function Reading({exam, showSolutions = false, onFinish}: Props)
}; };
const renderText = () => ( const renderText = () => (
<div className="flex flex-col gap-4 w-full"> <div className="flex flex-col gap-6 w-full bg-mti-gray-seasalt rounded-xl py-8 px-16">
<div className="flex flex-col w-full gap-2"> <div className="flex flex-col w-full gap-2">
<span className="text-base md:text-lg font-semibold"> <h4 className="text-xl font-semibold">
Please read the following excerpt attentively, you will then be asked questions about the text you&apos;ve read. Please read the following excerpt attentively, you will then be asked questions about the text you&apos;ve read.
</span> </h4>
<span className="self-end text-sm">You will be allowed to read the text while doing the exercises</span> <span className="text-base">You will be allowed to read the text while doing the exercises</span>
</div> </div>
<Panel header={exam.text.title}> <div className="flex flex-col gap-2 w-full">
<p className="overflow-auto"> <h3 className="text-xl font-semibold">{exam.text.title}</h3>
<div className="border border-mti-gray-dim w-full rounded-full opacity-10" />
<span className="overflow-auto">
{exam.text.content.split("\\n").map((line, index) => ( {exam.text.content.split("\\n").map((line, index) => (
<p key={index}>{line}</p> <p key={index}>{line}</p>
))} ))}
</p> </span>
</Panel> </div>
</div> </div>
); );
return ( return (
<> <>
<TextModal {...exam.text} isOpen={showTextModal} onClose={() => setShowTextModal(false)} /> <div className="flex flex-col h-full w-full gap-8">
<div className="w-full h-full relative flex flex-col gap-8 items-center justify-center p-2 md:p-8 px-4 md:px-16 overflow-hidden"> <TextModal {...exam.text} isOpen={showTextModal} onClose={() => setShowTextModal(false)} />
<ModuleTitle minTimer={exam.minTimer} exerciseIndex={exerciseIndex + 1} module="reading" totalExercises={exam.exercises.length} />
{exerciseIndex === -1 && renderText()} {exerciseIndex === -1 && renderText()}
{exerciseIndex > -1 && {exerciseIndex > -1 &&
exerciseIndex < exam.exercises.length && exerciseIndex < exam.exercises.length &&
@@ -132,33 +140,12 @@ export default function Reading({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)}
<div
className={clsx("flex gap-8", exerciseIndex > -1 ? "w-full justify-center md:justify-between" : "self-end w-full md:w-fit flex")}>
{exerciseIndex > -1 && (
<button
className={clsx(
"btn btn-wide gap-4 relative text-white",
"border-2 border-ielts-reading hover:bg-ielts-reading hover:border-ielts-reading bg-ielts-reading-transparent",
)}
onClick={() => setShowTextModal(true)}>
Read Text
<div className="absolute right-4">
<Icon path={mdiNotebook} color="white" size={1} />
</div>
</button>
)}
{exerciseIndex === -1 && (
<button
className={clsx("btn w-full md: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> </div>
{exerciseIndex === -1 && (
<Button color="green" onClick={() => nextExercise()} className="max-w-[200px] self-end w-full">
Start now
</Button>
)}
</> </>
); );
} }

View File

@@ -5,7 +5,20 @@ import type {AppProps} from "next/app";
import "primereact/resources/themes/lara-light-indigo/theme.css"; import "primereact/resources/themes/lara-light-indigo/theme.css";
import "primereact/resources/primereact.min.css"; import "primereact/resources/primereact.min.css";
import "primeicons/primeicons.css"; import "primeicons/primeicons.css";
import {useRouter} from "next/router";
import {useEffect} from "react";
import useExamStore from "@/stores/examStore";
export default function App({Component, pageProps}: AppProps) { export default function App({Component, pageProps}: AppProps) {
const reset = useExamStore((state) => state.reset);
const router = useRouter();
useEffect(() => {
if (router.pathname !== "/exam") {
reset();
}
}, [router.pathname, reset]);
return <Component {...pageProps} />; return <Component {...pageProps} />;
} }

View File

@@ -209,7 +209,11 @@ export default function Page() {
<link rel="icon" href="/favicon.ico" /> <link rel="icon" href="/favicon.ico" />
</Head> </Head>
<ToastContainer /> <ToastContainer />
{user && <Layout user={user}>{renderScreen()}</Layout>} {user && (
<Layout user={user} className="justify-between">
{renderScreen()}
</Layout>
)}
</> </>
); );
} }

View File

@@ -127,11 +127,11 @@ export default function Page() {
<> <>
<div className="flex gap-4 items-center"> <div className="flex gap-4 items-center">
<span className="text-xs w-9"> <span className="text-xs w-9">
{Math.round(recordingDuration / 60) {Math.floor(recordingDuration / 60)
.toString(10) .toString(10)
.padStart(2, "0")} .padStart(2, "0")}
: :
{Math.round(recordingDuration % 60) {Math.floor(recordingDuration % 60)
.toString(10) .toString(10)
.padStart(2, "0")} .padStart(2, "0")}
</span> </span>

View File

@@ -12,17 +12,23 @@ export interface ExamState {
setExams: (exams: Exam[]) => void; setExams: (exams: Exam[]) => void;
setShowSolutions: (showSolutions: boolean) => void; setShowSolutions: (showSolutions: boolean) => void;
setSelectedModules: (modules: Module[]) => void; setSelectedModules: (modules: Module[]) => void;
reset: () => void;
} }
const useExamStore = create<ExamState>((set) => ({ export const initialState = {
exams: [], exams: [],
userSolutions: [], userSolutions: [],
showSolutions: false, showSolutions: false,
selectedModules: [], selectedModules: [],
};
const useExamStore = create<ExamState>((set) => ({
...initialState,
setUserSolutions: (userSolutions: UserSolution[]) => set(() => ({userSolutions})), setUserSolutions: (userSolutions: UserSolution[]) => set(() => ({userSolutions})),
setExams: (exams: Exam[]) => set(() => ({exams})), setExams: (exams: Exam[]) => set(() => ({exams})),
setShowSolutions: (showSolutions: boolean) => set(() => ({showSolutions})), setShowSolutions: (showSolutions: boolean) => set(() => ({showSolutions})),
setSelectedModules: (modules: Module[]) => set(() => ({selectedModules: modules})), setSelectedModules: (modules: Module[]) => set(() => ({selectedModules: modules})),
reset: () => set(() => initialState),
})); }));
export default useExamStore; export default useExamStore;

View File

@@ -9,8 +9,8 @@
"Fira Mono", "Droid Sans Mono", "Courier New", monospace; "Fira Mono", "Droid Sans Mono", "Courier New", monospace;
--foreground-rgb: 53, 51, 56; --foreground-rgb: 53, 51, 56;
--background-start-rgb: 214, 219, 220; --background-start-rgb: 245, 245, 245;
--background-end-rgb: 255, 255, 255; --background-end-rgb: 245, 245, 245;
--primary-glow: conic-gradient(from 180deg at 50% 50%, #16abff33 0deg, #0885ff33 55deg, #54d6ff33 120deg, #0071ff33 160deg, transparent 360deg); --primary-glow: conic-gradient(from 180deg at 50% 50%, #16abff33 0deg, #0885ff33 55deg, #54d6ff33 120deg, #0071ff33 160deg, transparent 360deg);
--secondary-glow: radial-gradient(rgba(255, 255, 255, 1), rgba(255, 255, 255, 0)); --secondary-glow: radial-gradient(rgba(255, 255, 255, 1), rgba(255, 255, 255, 0));

View File

@@ -7,3 +7,15 @@ export function convertCamelCaseToReadable(camelCaseString: string): string {
return readableString; return readableString;
} }
export function formatTimeInMinutes(time: number) {
if (time === 0) {
return "00:00";
}
return `${Math.floor(time / 60)
.toString(10)
.padStart(2, "0")}:${Math.floor(time % 60)
.toString(10)
.padStart(2, "0")}`;
}

View File

@@ -17,6 +17,7 @@ module.exports = {
cool: "#8692A6", cool: "#8692A6",
platinum: "#DBDBDB", platinum: "#DBDBDB",
"anti-flash": "#EAEBEC", "anti-flash": "#EAEBEC",
davy: "#595959",
}, },
black: "#353338", black: "#353338",
}, },