Implemented the Reading and Listening initial screens according to the new designs, creating new components as needed
This commit is contained in:
@@ -1,19 +1,24 @@
|
||||
import {User} from "@/interfaces/user";
|
||||
import clsx from "clsx";
|
||||
import Navbar from "../Navbar";
|
||||
import Sidebar from "../Sidebar";
|
||||
|
||||
interface Props {
|
||||
user: User;
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function Layout({user, children}: Props) {
|
||||
export default function Layout({user, children, className}: Props) {
|
||||
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} />
|
||||
<div className="h-full w-full flex py-4 pb-8 gap-2">
|
||||
<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>
|
||||
</main>
|
||||
);
|
||||
|
||||
87
src/components/Low/AudioPlayer.tsx
Normal file
87
src/components/Low/AudioPlayer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -1,22 +1,37 @@
|
||||
import {Module} from "@/interfaces";
|
||||
import clsx from "clsx";
|
||||
|
||||
interface Props {
|
||||
label: string;
|
||||
percentage: number;
|
||||
color: "blue" | "orange" | "green";
|
||||
color: "blue" | "orange" | "green" | Module;
|
||||
useColor?: boolean;
|
||||
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} = {
|
||||
blue: "bg-mti-blue-light",
|
||||
orange: "bg-mti-orange-light",
|
||||
green: "bg-mti-green-light",
|
||||
reading: "bg-ielts-reading",
|
||||
listening: "bg-ielts-listening",
|
||||
writing: "bg-ielts-writing",
|
||||
speaking: "bg-ielts-speaking",
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={clsx("relative rounded-full bg-mti-gray-anti-flash overflow-hidden flex items-center justify-center", className)}>
|
||||
<div style={{width: `${percentage}%`}} className={clsx("absolute top-0 left-0 h-full overflow-hidden", progressColorClass[color])} />
|
||||
<div
|
||||
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>
|
||||
</div>
|
||||
);
|
||||
|
||||
64
src/components/Medium/ModuleTitle.tsx
Normal file
64
src/components/Medium/ModuleTitle.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -6,6 +6,9 @@ import clsx from "clsx";
|
||||
import {infoButtonStyle} from "@/constants/buttonStyles";
|
||||
import {renderExercise} from "@/components/Exercises";
|
||||
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 {
|
||||
exam: ListeningExam;
|
||||
@@ -42,37 +45,31 @@ export default function Listening({exam, showSolutions = false, onFinish}: Props
|
||||
};
|
||||
|
||||
const renderAudioPlayer = () => (
|
||||
<>
|
||||
{exerciseIndex === -1 && (
|
||||
<div className="flex flex-col">
|
||||
<span className="text-lg font-semibold">Please listen to the following audio attentively.</span>
|
||||
{exam.audio.repeatableTimes > 0 ? (
|
||||
<span className="self-center text-sm">
|
||||
You will only be allowed to listen to the audio {exam.audio.repeatableTimes} time(s).
|
||||
</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 className="flex flex-col gap-8 w-full bg-mti-gray-seasalt rounded-xl py-8 px-16">
|
||||
<div className="flex flex-col w-full gap-2">
|
||||
<h4 className="text-xl font-semibold">Please listen to the following audio attentively.</h4>
|
||||
<span className="text-base">
|
||||
{exam.audio.repeatableTimes > 0
|
||||
? `You will only be allowed to listen to the audio ${exam.audio.repeatableTimes - timesListened} time(s).`
|
||||
: "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 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 (
|
||||
<>
|
||||
<div className="w-full relative flex flex-col gap-8 items-center justify-center p-8 px-16 overflow-hidden">
|
||||
{renderAudioPlayer()}
|
||||
<div className="flex flex-col h-full w-full gap-8 justify-between">
|
||||
<ModuleTitle exerciseIndex={exerciseIndex + 1} minTimer={exam.minTimer} module="listening" totalExercises={exam.exercises.length} />
|
||||
{exerciseIndex === -1 && renderAudioPlayer()}
|
||||
{exerciseIndex > -1 &&
|
||||
exerciseIndex < exam.exercises.length &&
|
||||
!showSolutions &&
|
||||
@@ -81,17 +78,13 @@ export default function Listening({exam, showSolutions = false, onFinish}: Props
|
||||
exerciseIndex < exam.exercises.length &&
|
||||
showSolutions &&
|
||||
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>
|
||||
|
||||
{exerciseIndex === -1 && (
|
||||
<Button color="green" onClick={() => nextExercise()} className="max-w-[200px] self-end w-full justify-self-end">
|
||||
Start now
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -9,6 +9,11 @@ import {renderExercise} from "@/components/Exercises";
|
||||
import {renderSolution} from "@/components/Solutions";
|
||||
import {Panel} from "primereact/panel";
|
||||
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 {
|
||||
exam: ReadingExam;
|
||||
@@ -102,27 +107,30 @@ export default function Reading({exam, showSolutions = false, onFinish}: Props)
|
||||
};
|
||||
|
||||
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">
|
||||
<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've read.
|
||||
</span>
|
||||
<span className="self-end text-sm">You will be allowed to read the text while doing the exercises</span>
|
||||
</h4>
|
||||
<span className="text-base">You will be allowed to read the text while doing the exercises</span>
|
||||
</div>
|
||||
<Panel header={exam.text.title}>
|
||||
<p className="overflow-auto">
|
||||
<div className="flex flex-col gap-2 w-full">
|
||||
<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) => (
|
||||
<p key={index}>{line}</p>
|
||||
))}
|
||||
</p>
|
||||
</Panel>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<TextModal {...exam.text} isOpen={showTextModal} onClose={() => setShowTextModal(false)} />
|
||||
<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">
|
||||
<div className="flex flex-col h-full w-full gap-8">
|
||||
<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 &&
|
||||
exerciseIndex < exam.exercises.length &&
|
||||
@@ -132,33 +140,12 @@ export default function Reading({exam, showSolutions = false, onFinish}: Props)
|
||||
exerciseIndex < exam.exercises.length &&
|
||||
showSolutions &&
|
||||
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>
|
||||
{exerciseIndex === -1 && (
|
||||
<Button color="green" onClick={() => nextExercise()} className="max-w-[200px] self-end w-full">
|
||||
Start now
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,7 +5,20 @@ import type {AppProps} from "next/app";
|
||||
import "primereact/resources/themes/lara-light-indigo/theme.css";
|
||||
import "primereact/resources/primereact.min.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) {
|
||||
const reset = useExamStore((state) => state.reset);
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
if (router.pathname !== "/exam") {
|
||||
reset();
|
||||
}
|
||||
}, [router.pathname, reset]);
|
||||
|
||||
return <Component {...pageProps} />;
|
||||
}
|
||||
|
||||
@@ -209,7 +209,11 @@ export default function Page() {
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
</Head>
|
||||
<ToastContainer />
|
||||
{user && <Layout user={user}>{renderScreen()}</Layout>}
|
||||
{user && (
|
||||
<Layout user={user} className="justify-between">
|
||||
{renderScreen()}
|
||||
</Layout>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -127,11 +127,11 @@ export default function Page() {
|
||||
<>
|
||||
<div className="flex gap-4 items-center">
|
||||
<span className="text-xs w-9">
|
||||
{Math.round(recordingDuration / 60)
|
||||
{Math.floor(recordingDuration / 60)
|
||||
.toString(10)
|
||||
.padStart(2, "0")}
|
||||
:
|
||||
{Math.round(recordingDuration % 60)
|
||||
{Math.floor(recordingDuration % 60)
|
||||
.toString(10)
|
||||
.padStart(2, "0")}
|
||||
</span>
|
||||
|
||||
@@ -12,17 +12,23 @@ export interface ExamState {
|
||||
setExams: (exams: Exam[]) => void;
|
||||
setShowSolutions: (showSolutions: boolean) => void;
|
||||
setSelectedModules: (modules: Module[]) => void;
|
||||
reset: () => void;
|
||||
}
|
||||
|
||||
const useExamStore = create<ExamState>((set) => ({
|
||||
export const initialState = {
|
||||
exams: [],
|
||||
userSolutions: [],
|
||||
showSolutions: false,
|
||||
selectedModules: [],
|
||||
};
|
||||
|
||||
const useExamStore = create<ExamState>((set) => ({
|
||||
...initialState,
|
||||
setUserSolutions: (userSolutions: UserSolution[]) => set(() => ({userSolutions})),
|
||||
setExams: (exams: Exam[]) => set(() => ({exams})),
|
||||
setShowSolutions: (showSolutions: boolean) => set(() => ({showSolutions})),
|
||||
setSelectedModules: (modules: Module[]) => set(() => ({selectedModules: modules})),
|
||||
reset: () => set(() => initialState),
|
||||
}));
|
||||
|
||||
export default useExamStore;
|
||||
|
||||
@@ -9,8 +9,8 @@
|
||||
"Fira Mono", "Droid Sans Mono", "Courier New", monospace;
|
||||
|
||||
--foreground-rgb: 53, 51, 56;
|
||||
--background-start-rgb: 214, 219, 220;
|
||||
--background-end-rgb: 255, 255, 255;
|
||||
--background-start-rgb: 245, 245, 245;
|
||||
--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);
|
||||
--secondary-glow: radial-gradient(rgba(255, 255, 255, 1), rgba(255, 255, 255, 0));
|
||||
|
||||
@@ -7,3 +7,15 @@ export function convertCamelCaseToReadable(camelCaseString: string): string {
|
||||
|
||||
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")}`;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user