Compare commits

...

52 Commits

Author SHA1 Message Date
Tiago Ribeiro
93cef3d58f Moved the Logout button to be sticky 2023-06-23 14:17:24 +01:00
Tiago Ribeiro
60b23ce1b5 Removed unused console.log calls 2023-06-23 14:15:57 +01:00
Tiago Ribeiro
d3a37eed3e Redesigned the Record page along with solving some bugs on the FillBlanks 2023-06-23 14:14:12 +01:00
Tiago Ribeiro
447cecbf3f Removed an unneeded console.log 2023-06-23 10:29:10 +01:00
Tiago Ribeiro
b2cc706a5e Updated the Writing exercise to have the evaluation in the user solutions instead of the exercise itself 2023-06-23 10:28:33 +01:00
Tiago Ribeiro
9cbb5b93c8 Added a subtitle of the colors 2023-06-22 23:16:07 +01:00
Tiago Ribeiro
747c07f84e Updated the sidebar 2023-06-22 22:39:25 +01:00
Tiago Ribeiro
79ed521703 Redesigned the MatchSentences exercise 2023-06-22 22:28:29 +01:00
Tiago Ribeiro
fe4a97ec85 Implemented the Writing exercise's solution display 2023-06-22 16:59:13 +01:00
Tiago Ribeiro
b194a9183e Updated the text related to the finish screen depending on the level 2023-06-21 16:43:06 +01:00
Tiago Ribeiro
f369234e8a Updated the stats to have missing 2023-06-21 15:02:42 +01:00
Tiago Ribeiro
808ec6315b Updated the Finish screen along with other tweaks 2023-06-21 14:54:22 +01:00
Tiago Ribeiro
d2cf50be68 Updated the ModuleTitle 2023-06-21 11:00:14 +01:00
Tiago Ribeiro
294f00952e Updated the design of the WriteBlanks exercise 2023-06-20 22:43:28 +01:00
Tiago Ribeiro
7beb1c84e7 Solved a bug in WriteBlanks where it wasn't saving the user's answer 2023-06-20 22:21:50 +01:00
Tiago Ribeiro
3a7c29de56 Made it so the code remembers the user's previous answers to current exercises 2023-06-20 17:07:54 +01:00
Tiago Ribeiro
dd357d991c Started updating the stats page 2023-06-20 09:32:33 +01:00
Tiago Ribeiro
47b1784615 Reverted the yarn version 2023-06-18 23:46:07 +01:00
Tiago Ribeiro
d4156c83f4 Transitioned to yarn classic 2023-06-18 23:31:57 +01:00
Tiago Ribeiro
572bc25eed Removed a trailing comma 2023-06-18 23:20:34 +01:00
Tiago Ribeiro
e80b163b4a Let's try this 2023-06-18 23:11:43 +01:00
Tiago Ribeiro
87e0610c79 Also updated the MultipleChoice exercise to the new design 2023-06-18 22:57:53 +01:00
Tiago Ribeiro
52218ff8b8 Updated the FillBlanks exercise and solution to the new design 2023-06-18 22:02:48 +01:00
Tiago Ribeiro
84b0b8ac42 Removed placeholders 2023-06-15 16:53:11 +01:00
Tiago Ribeiro
989a7449bf Turned on the normalize 2023-06-15 16:35:30 +01:00
Tiago Ribeiro
bc7eaea911 Implemented the speaking exercise;
Cleaned up a bit of the code;
2023-06-15 15:39:40 +01:00
Tiago Ribeiro
f5ec910010 Did the new designs for the Writing 2023-06-15 15:27:04 +01:00
Tiago Ribeiro
2d46bad40f Implemented the Reading and Listening initial screens according to the new designs, creating new components as needed 2023-06-15 14:43:29 +01:00
Tiago Ribeiro
65ebdd7dde Extracted the Input into its own component 2023-06-15 10:10:33 +01:00
Tiago Ribeiro
60217e9a66 - Updated the icons;
- Extracted the Layout into its own component;
2023-06-15 09:12:13 +01:00
Tiago Ribeiro
ec3157870e Updated the selection page 2023-06-14 22:22:18 +01:00
Tiago Ribeiro
9cf4bf7184 Improved the appearance of the Waveform 2023-06-14 17:18:22 +01:00
Tiago Ribeiro
f5fc85e1a7 Created a waveform component to display the recording's waveform 2023-06-14 16:22:48 +01:00
Tiago Ribeiro
31f2eb510e Created a simple test page where I'll implement the recorder for the speaking module 2023-06-14 14:37:12 +01:00
Tiago Ribeiro
31e2e56833 Updated the yarn version and recorder 2023-06-14 13:28:28 +01:00
Tiago Ribeiro
efaa32cd68 Completed the rest of the Selection screen to the new design 2023-06-13 16:24:01 +01:00
Tiago Ribeiro
b41ee8e2ad Updated part of the Selection screen to the new design 2023-06-13 15:43:26 +01:00
Tiago Ribeiro
e055b84688 Moved the exam page to the root pages 2023-06-13 15:25:45 +01:00
Tiago Ribeiro
1e286bb65b Added the ability for the user to show the password they're typing 2023-06-13 15:24:27 +01:00
Tiago Ribeiro
abe986313f Updated the <a> to <Link> 2023-06-12 15:58:17 +01:00
Tiago Ribeiro
088b77a66b Created a placeholder of the register page 2023-06-12 15:47:42 +01:00
Tiago Ribeiro
72fc98fccd Completed the Login page and updated the overall colors and font 2023-06-12 15:21:30 +01:00
Tiago Ribeiro
9ce45dfc30 Recreated most of the login screen with the new designs 2023-06-12 14:57:30 +01:00
Tiago Ribeiro
e864e16064 Updated the code to use the new desired levels 2023-06-12 14:05:48 +01:00
Tiago Ribeiro
6fe8a678ea Completed more of the home page of the new designs 2023-06-12 09:31:20 +01:00
Tiago Ribeiro
b2232df0c7 Created part of the homepage of the new Figma designs 2023-06-11 17:58:06 +01:00
Tiago Ribeiro
9a7853bd05 Created a score calculator 2023-06-05 14:04:58 +01:00
Tiago Ribeiro
1e8e95da34 Continued implementing the new design;
Added an average level calculator;
2023-05-31 14:01:12 +01:00
Tiago Ribeiro
4d37bf536a Merge branch 'main' into task/design/dashboard-redesign 2023-05-29 11:57:37 +01:00
Tiago Ribeiro
d0704e573b Removed unused Navbar calls 2023-05-27 11:17:43 +01:00
Tiago Ribeiro
31dc29b812 Removed the Navbar calls 2023-05-26 20:26:11 +01:00
Tiago Ribeiro
9ed3672cb6 Started the redesign of the dashboard 2023-05-26 19:46:50 +01:00
57 changed files with 4093 additions and 2262 deletions

1
.gitignore vendored
View File

@@ -36,3 +36,4 @@ yarn-error.log*
next-env.d.ts
.env
.yarn/*

5
.vscode/extensions.json vendored Normal file
View File

@@ -0,0 +1,5 @@
{
"recommendations": [
"dbaeumer.vscode-eslint"
]
}

View File

@@ -19,7 +19,7 @@
"axios": "^1.3.5",
"chart.js": "^4.2.1",
"clsx": "^1.2.1",
"daisyui": "^2.50.0",
"daisyui": "^3.1.5",
"eslint": "8.33.0",
"eslint-config-next": "13.1.6",
"firebase": "9.19.1",
@@ -34,19 +34,23 @@
"react-chartjs-2": "^5.2.0",
"react-dom": "18.2.0",
"react-firebase-hooks": "^5.1.1",
"react-icons": "^4.8.0",
"react-lineto": "^3.3.0",
"react-media-recorder": "^1.6.6",
"react-media-recorder": "1.6.5",
"react-player": "^2.12.0",
"react-string-replace": "^1.1.0",
"react-toastify": "^9.1.2",
"react-xarrows": "^2.0.2",
"swr": "^2.1.3",
"typescript": "4.9.5",
"uuid": "^9.0.0",
"wavesurfer.js": "^6.6.4",
"zustand": "^4.3.6"
},
"devDependencies": {
"@types/lodash": "^4.14.191",
"@types/uuid": "^9.0.1",
"@types/wavesurfer.js": "^6.0.6",
"@wixc3/react-board": "^2.2.0",
"autoprefixer": "^10.4.13",
"postcss": "^8.4.21",

Binary file not shown.

After

Width:  |  Height:  |  Size: 832 KiB

View File

@@ -27,6 +27,7 @@ export default function Diagnostic({onFinish}: Props) {
const [focus, setFocus] = useState<"academic" | "general">();
const [isInsert, setIsInsert] = useState(false);
const [levels, setLevels] = useState({reading: 0, listening: 0, writing: 0, speaking: 0});
const [desiredLevels, setDesiredLevels] = useState({reading: 9, listening: 9, writing: 9, speaking: 9});
const router = useRouter();
@@ -47,7 +48,7 @@ export default function Diagnostic({onFinish}: Props) {
const updateUser = (callback: () => void) => {
axios
.patch("/api/users/update", {focus, levels, isFirstLogin: false})
.patch("/api/users/update", {focus, levels, desiredLevels, isFirstLogin: false})
.then(callback)
.catch(() => {
toast.error("Something went wrong, please try again later!", {toastId: "user-update-error"});

View File

@@ -1,95 +1,112 @@
import {errorButtonStyle, infoButtonStyle} from "@/constants/buttonStyles";
import {FillBlanksExercise} from "@/interfaces/exam";
import {Dialog, Transition} from "@headlessui/react";
import {mdiArrowLeft, mdiArrowRight} from "@mdi/js";
import Icon from "@mdi/react";
import clsx from "clsx";
import {Fragment, useState} from "react";
import {Fragment, useEffect, useState} from "react";
import reactStringReplace from "react-string-replace";
import {CommonProps} from ".";
import Button from "../Low/Button";
interface WordsPopoutProps {
interface WordsDrawerProps {
words: {word: string; isDisabled: boolean}[];
isOpen: boolean;
blankId?: string;
previouslySelectedWord?: string;
onCancel: () => void;
onAnswer: (answer: string) => void;
}
function WordsPopout({words, isOpen, onCancel, onAnswer}: WordsPopoutProps) {
function WordsDrawer({words, isOpen, blankId, previouslySelectedWord, onCancel, onAnswer}: WordsDrawerProps) {
const [selectedWord, setSelectedWord] = useState<string | undefined>(previouslySelectedWord);
return (
<Transition appear show={isOpen} as={Fragment}>
<Dialog as="div" className="relative z-10" onClose={onCancel}>
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="ease-in duration-200"
leaveFrom="opacity-100"
leaveTo="opacity-0">
<div className="fixed inset-0 bg-black bg-opacity-25" />
</Transition.Child>
<div className="fixed inset-0 overflow-y-auto">
<div className="flex min-h-full items-center justify-center p-4 text-center">
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0 scale-95"
enterTo="opacity-100 scale-100"
leave="ease-in duration-200"
leaveFrom="opacity-100 scale-100"
leaveTo="opacity-0 scale-95">
<Dialog.Panel className="w-fit transform overflow-hidden rounded-2xl bg-white p-6 text-left align-middle shadow-xl transition-all flex flex-col">
<Dialog.Title as="h3" className="text-lg font-medium leading-6 text-gray-900">
List of words
</Dialog.Title>
<div className="mt-4 grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{words.map((word) => (
<button
key={word.word}
onClick={() => onAnswer(word.word)}
disabled={word.isDisabled}
className={clsx("btn sm:btn-wide gap-4 relative text-white", infoButtonStyle)}>
{word.word}
</button>
))}
</div>
<div className="mt-4 self-end">
<button onClick={onCancel} className={clsx("btn md:btn-wide gap-4 relative text-white", errorButtonStyle)}>
Close
</button>
</div>
</Dialog.Panel>
</Transition.Child>
</div>
<>
<div
className={clsx(
"w-full h-full absolute top-0 left-0 bg-gradient-to-t from-mti-black to-transparent z-10",
isOpen ? "visible opacity-10" : "invisible opacity-0",
)}
/>
<div
className={clsx(
"absolute w-full bg-white px-7 py-8 bottom-0 left-0 shadow-2xl rounded-2xl z-20 flex flex-col gap-8 transition-opacity duration-300 ease-in-out",
isOpen ? "visible opacity-100" : "invisible opacity-0",
)}>
<div className="w-full flex gap-2">
<div className="rounded-full w-6 h-6 flex items-center justify-center text-white bg-mti-green-light">{blankId}</div>
<span> Choose the correct word:</span>
</div>
</Dialog>
</Transition>
<div className="grid grid-cols-6 gap-6">
{words.map(({word, isDisabled}) => (
<button
key={word}
onClick={() => setSelectedWord((prev) => (prev === word ? undefined : word))}
className={clsx(
"rounded-full py-3 text-center transition duration-300 ease-in-out",
selectedWord === word ? "text-white bg-mti-green-light" : "bg-mti-green-ultralight",
!isDisabled && "hover:text-white hover:bg-mti-green",
"disabled:cursor-not-allowed disabled:text-mti-gray-dim",
)}
disabled={isDisabled}>
{word}
</button>
))}
</div>
<div className="flex justify-between w-full">
<Button color="green" variant="outline" className="max-w-[200px] w-full" onClick={onCancel}>
Back
</Button>
<Button color="green" className="max-w-[200px] w-full" onClick={() => onAnswer(selectedWord!)} disabled={!selectedWord}>
Confirm
</Button>
</div>
</div>
</>
);
}
export default function FillBlanks({id, allowRepetition, type, prompt, solutions, text, words, onNext, onBack}: FillBlanksExercise & CommonProps) {
const [userSolutions, setUserSolutions] = useState<{id: string; solution: string}[]>([]);
export default function FillBlanks({
id,
allowRepetition,
type,
prompt,
solutions,
text,
words,
userSolutions,
onNext,
onBack,
}: FillBlanksExercise & CommonProps) {
const [answers, setAnswers] = useState<{id: string; solution: string}[]>(userSolutions);
const [currentBlankId, setCurrentBlankId] = useState<string>();
const [isDrawerShowing, setIsDrawerShowing] = useState(false);
useEffect(() => {
setTimeout(() => setIsDrawerShowing(!!currentBlankId), 100);
}, [currentBlankId]);
const calculateScore = () => {
const total = text.match(/({{\d+}})/g)?.length || 0;
const correct = userSolutions.filter((x) => solutions.find((y) => x.id === y.id)?.solution === x.solution.toLowerCase() || false).length;
const correct = answers.filter((x) => solutions.find((y) => x.id === y.id)?.solution === x.solution.toLowerCase() || false).length;
const missing = total - answers.filter((x) => solutions.find((y) => x.id === y.id)).length;
return {total, correct};
return {total, correct, missing};
};
const renderLines = (line: string) => {
return (
<span>
<span className="text-base leading-5">
{reactStringReplace(line, /({{\d+}})/g, (match) => {
const id = match.replaceAll(/[\{\}]/g, "");
const userSolution = userSolutions.find((x) => x.id === id);
const userSolution = answers.find((x) => x.id === id);
return (
<button className="border-2 rounded-xl px-4 text-blue-400 border-blue-400 my-2" onClick={() => setCurrentBlankId(id)}>
<button
className={clsx(
"rounded-full hover:text-white hover:bg-mti-green transition duration-300 ease-in-out my-1",
!userSolution && "w-6 h-6 text-center text-mti-green-light bg-mti-green-ultralight",
currentBlankId === id && "text-white !bg-mti-green-light ",
userSolution && "px-5 py-2 text-center text-white bg-mti-green-light",
)}
onClick={() => setCurrentBlankId(id)}>
{userSolution ? userSolution.solution : id}
</button>
);
@@ -100,17 +117,21 @@ export default function FillBlanks({id, allowRepetition, type, prompt, solutions
return (
<>
<div className="flex flex-col gap-4">
<WordsPopout
words={words.map((word) => ({word, isDisabled: allowRepetition ? false : userSolutions.map((x) => x.solution).includes(word)}))}
isOpen={!!currentBlankId}
onCancel={() => setCurrentBlankId(undefined)}
onAnswer={(solution: string) => {
setUserSolutions((prev) => [...prev.filter((x) => x.id !== currentBlankId), {id: currentBlankId!, solution}]);
setCurrentBlankId(undefined);
}}
/>
<span className="text-base md:text-lg font-medium text-center px-2 md:px-4 lg:px-48">
<div className="flex flex-col gap-4 mt-4 h-full mb-20">
{(!!currentBlankId || isDrawerShowing) && (
<WordsDrawer
blankId={currentBlankId}
words={words.map((word) => ({word, isDisabled: allowRepetition ? false : answers.map((x) => x.solution).includes(word)}))}
previouslySelectedWord={currentBlankId ? answers.find((x) => x.id === currentBlankId)?.solution : undefined}
isOpen={isDrawerShowing}
onCancel={() => setCurrentBlankId(undefined)}
onAnswer={(solution: string) => {
setAnswers((prev) => [...prev.filter((x) => x.id !== currentBlankId), {id: currentBlankId!, solution}]);
setCurrentBlankId(undefined);
}}
/>
)}
<span className="text-sm w-full leading-6">
{prompt.split("\\n").map((line, index) => (
<Fragment key={index}>
{line}
@@ -118,31 +139,31 @@ export default function FillBlanks({id, allowRepetition, type, prompt, solutions
</Fragment>
))}
</span>
<span>
<span className="bg-mti-gray-smoke rounded-xl px-5 py-6">
{text.split("\\n").map((line, index) => (
<Fragment key={index}>
<p key={index}>
{renderLines(line)}
<br />
</Fragment>
</p>
))}
</span>
</div>
<div className="self-end flex flex-col-reverse items-center w-full md:justify-between md:items-start md:flex-row 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>
<div className="self-end flex justify-between w-full gap-8 absolute bottom-8 left-0 px-8">
<Button
color="green"
variant="outline"
onClick={() => onBack({exercise: id, solutions: answers, score: calculateScore(), type})}
className="max-w-[200px] w-full">
Back
</button>
<button
className={clsx("btn btn-wide gap-4 relative text-white", infoButtonStyle)}
onClick={() => onNext({exercise: id, solutions: userSolutions, score: calculateScore(), type})}>
</Button>
<Button
color="green"
onClick={() => onNext({exercise: id, solutions: answers, score: calculateScore(), type})}
className="max-w-[200px] self-end w-full">
Next
<div className="absolute right-4">
<Icon path={mdiArrowRight} color="white" size={1} />
</div>
</button>
</Button>
</div>
</>
);

View File

@@ -6,21 +6,24 @@ import clsx from "clsx";
import {Fragment, useState} from "react";
import LineTo from "react-lineto";
import {CommonProps} from ".";
import Button from "../Low/Button";
import Xarrow from "react-xarrows";
export default function MatchSentences({id, options, type, prompt, sentences, onNext, onBack}: MatchSentencesExercise & CommonProps) {
export default function MatchSentences({id, options, type, prompt, sentences, userSolutions, onNext, onBack}: MatchSentencesExercise & CommonProps) {
const [selectedQuestion, setSelectedQuestion] = useState<string>();
const [userSolutions, setUserSolutions] = useState<{question: string; option: string}[]>([]);
const [answers, setAnswers] = useState<{question: string; option: string}[]>(userSolutions);
const calculateScore = () => {
const total = sentences.length;
const correct = userSolutions.filter((x) => sentences.find((y) => y.id === x.question)?.solution === x.option || false).length;
const correct = answers.filter((x) => sentences.find((y) => y.id === x.question)?.solution === x.option || false).length;
const missing = total - answers.filter((x) => sentences.find((y) => y.id === x.question)).length;
return {total, correct};
return {total, correct, missing};
};
const selectOption = (option: string) => {
if (!selectedQuestion) return;
setUserSolutions((prev) => [...prev.filter((x) => x.question !== selectedQuestion), {question: selectedQuestion, option}]);
setAnswers((prev) => [...prev.filter((x) => x.question !== selectedQuestion), {question: selectedQuestion, option}]);
setSelectedQuestion(undefined);
};
@@ -30,8 +33,8 @@ export default function MatchSentences({id, options, type, prompt, sentences, on
return (
<>
<div className="flex flex-col items-center gap-8">
<span className="text-base md:text-lg font-medium text-center px-2 md:px-4 lg:px-48">
<div className="flex flex-col gap-4 mt-4 h-full mb-20">
<span className="text-sm w-full leading-6">
{prompt.split("\\n").map((line, index) => (
<Fragment key={index}>
{line}
@@ -39,74 +42,63 @@ export default function MatchSentences({id, options, type, prompt, sentences, on
</Fragment>
))}
</span>
<div className="grid grid-cols-2 gap-16 place-items-center">
<div className="flex flex-col gap-1">
<div className="flex gap-8 w-full items-center justify-between bg-mti-gray-smoke rounded-xl px-24 py-6">
<div className="flex flex-col gap-4">
{sentences.map(({sentence, id, color}) => (
<div
key={`question_${id}`}
className="flex items-center justify-end gap-2 cursor-pointer"
onClick={() => setSelectedQuestion((prev) => (prev === id ? undefined : id))}>
<span>
<span className="font-semibold">{id}.</span> {sentence}{" "}
</span>
<div
style={{borderColor: color, backgroundColor: selectedQuestion === id ? color : "transparent"}}
className={clsx("border-2 border-blue-500 w-4 h-4 rounded-full", id)}
/>
<div key={`question_${id}`} className="flex items-center justify-end gap-2 cursor-pointer">
<span>{sentence} </span>
<button
id={id}
onClick={() => setSelectedQuestion((prev) => (prev === id ? undefined : id))}
className={clsx(
"bg-mti-green-ultralight text-mti-green hover:text-white hover:bg-mti-green w-8 h-8 rounded-full z-10",
"transition duration-300 ease-in-out",
selectedQuestion === id && "!text-white !bg-mti-green",
id,
)}>
{id}
</button>
</div>
))}
</div>
<div className="flex flex-col gap-1">
<div className="flex flex-col gap-4">
{options.map(({sentence, id}) => (
<div
key={`answer_${id}`}
className={clsx("flex items-center justify-start gap-2 cursor-pointer")}
onClick={() => selectOption(id)}>
<div
style={
userSolutions.find((x) => x.option === id)
? {
border: `2px solid ${getSentenceColor(userSolutions.find((x) => x.option === id)!.question)}`,
}
: {}
}
className={clsx("border-2 border-green-500 bg-transparent w-4 h-4 rounded-full", id)}
/>
<span>
<span className="font-semibold">{id}.</span> {sentence}{" "}
</span>
<div key={`answer_${id}`} className={clsx("flex items-center justify-start gap-2 cursor-pointer")}>
<button
id={id}
onClick={() => selectOption(id)}
className={clsx(
"bg-mti-green-ultralight text-mti-green hover:text-white hover:bg-mti-green w-8 h-8 rounded-full z-10",
"transition duration-300 ease-in-out",
id,
)}>
{id}
</button>
<span>{sentence}</span>
</div>
))}
</div>
{userSolutions.map((solution, index) => (
<div key={`solution_${index}`} className="absolute">
<LineTo
className="rounded-full"
from={solution.question}
to={solution.option}
borderColor={sentences.find((x) => x.id === solution.question)!.color}
borderWidth={5}
/>
</div>
{answers.map((solution, index) => (
<Xarrow key={index} start={solution.question} end={solution.option} lineColor="#307912" showHead={false} />
))}
</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">
<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>
<div className="self-end flex justify-between w-full gap-8 absolute bottom-8 left-0 px-8">
<Button
color="green"
variant="outline"
onClick={() => onBack({exercise: id, solutions: answers, score: calculateScore(), type})}
className="max-w-[200px] w-full">
Back
</button>
<button
className={clsx("btn btn-wide gap-4 relative text-white", infoButtonStyle)}
onClick={() => onNext({exercise: id, solutions: userSolutions, score: calculateScore(), type})}>
</Button>
<Button
color="green"
onClick={() => onNext({exercise: id, solutions: answers, score: calculateScore(), type})}
className="max-w-[200px] self-end w-full">
Next
<div className="absolute right-4">
<Icon path={mdiArrowRight} color="white" size={1} />
</div>
</button>
</Button>
</div>
</>
);

View File

@@ -1,71 +1,32 @@
/* eslint-disable @next/next/no-img-element */
import {errorButtonStyle, infoButtonStyle} from "@/constants/buttonStyles";
import {MultipleChoiceExercise, MultipleChoiceQuestion} from "@/interfaces/exam";
import {mdiArrowLeft, mdiArrowRight, mdiCheck, mdiClose} from "@mdi/js";
import Icon from "@mdi/react";
import clsx from "clsx";
import {useState} from "react";
import {CommonProps} from ".";
import Button from "../Low/Button";
function Question({
variant,
prompt,
solution,
options,
userSolution,
onSelectOption,
showSolution = false,
}: MultipleChoiceQuestion & {userSolution: string | undefined; onSelectOption?: (option: string) => void; showSolution?: boolean}) {
const optionColor = (option: string) => {
if (!showSolution) {
return userSolution === option ? "border-blue-400" : "";
}
if (option === solution) {
return "border-green-500 text-green-500";
}
return userSolution === option ? "border-red-500 text-red-500" : "";
};
const optionBadge = (option: string) => {
if (option === userSolution) {
if (solution === option) {
return (
<div className="badge badge-lg bg-green-500 border-green-500 absolute -top-2 -right-4">
<div className="tooltip" data-tip="You have correctly answered!">
<Icon path={mdiCheck} color="white" size={0.8} />
</div>
</div>
);
}
return (
<div className="badge badge-lg bg-red-500 border-red-500 absolute -top-2 -right-4">
<div className="tooltip" data-tip="You have wrongly answered!">
<Icon path={mdiClose} color="white" size={0.8} />
</div>
</div>
);
}
};
return (
<div className="flex flex-col items-center gap-4">
<span className="text-center">{prompt}</span>
<div className="grid grid-rows-4 md:grid-rows-1 md:grid-cols-4 gap-4 place-items-center">
<div className="flex flex-col gap-10">
<span className="">{prompt}</span>
<div className="flex justify-between">
{variant === "image" &&
options.map((option) => (
<div
key={option.id}
onClick={() => (onSelectOption ? onSelectOption(option.id) : null)}
className={clsx(
"flex flex-col items-center border-2 p-4 rounded-xl gap-4 cursor-pointer bg-white relative",
optionColor(option.id),
"flex flex-col items-center border border-mti-gray-platinum p-4 px-8 rounded-xl gap-4 cursor-pointer bg-white relative",
userSolution === option.id && "border-mti-green-light",
)}>
{showSolution && optionBadge(option.id)}
<span className={clsx("text-sm", userSolution !== option.id && "opacity-50")}>{option.id}</span>
<img src={option.src!} alt={`Option ${option.id}`} />
<span>{option.id}</span>
</div>
))}
{variant === "text" &&
@@ -73,8 +34,11 @@ function Question({
<div
key={option.id}
onClick={() => (onSelectOption ? onSelectOption(option.id) : null)}
className={clsx("flex border-2 p-4 rounded-xl gap-2 cursor-pointer bg-white", optionColor(option.id))}>
<span className="font-bold">{option.id}.</span>
className={clsx(
"flex border p-4 rounded-xl gap-2 cursor-pointer bg-white text-sm",
userSolution === option.id && "border-mti-green-light",
)}>
<span className="font-semibold">{option.id}.</span>
<span>{option.text}</span>
</div>
))}
@@ -83,25 +47,26 @@ function Question({
);
}
export default function MultipleChoice({id, prompt, type, questions, onNext, onBack}: MultipleChoiceExercise & CommonProps) {
const [userSolutions, setUserSolutions] = useState<{question: string; option: string}[]>([]);
export default function MultipleChoice({id, prompt, type, questions, userSolutions, onNext, onBack}: MultipleChoiceExercise & CommonProps) {
const [answers, setAnswers] = useState<{question: string; option: string}[]>(userSolutions);
const [questionIndex, setQuestionIndex] = useState(0);
const onSelectOption = (option: string) => {
const question = questions[questionIndex];
setUserSolutions((prev) => [...prev.filter((x) => x.question !== question.id), {option, question: question.id}]);
setAnswers((prev) => [...prev.filter((x) => x.question !== question.id), {option, question: question.id}]);
};
const calculateScore = () => {
const total = questions.length;
const correct = userSolutions.filter((x) => questions.find((y) => y.id === x.question)?.solution === x.option || false).length;
const correct = answers.filter((x) => questions.find((y) => y.id === x.question)?.solution === x.option || false).length;
const missing = total - answers.filter((x) => questions.find((y) => y.id === x.question)).length;
return {total, correct};
return {total, correct, missing};
};
const next = () => {
if (questionIndex === questions.length - 1) {
onNext({exercise: id, solutions: userSolutions, score: calculateScore(), type});
onNext({exercise: id, solutions: answers, score: calculateScore(), type});
} else {
setQuestionIndex((prev) => prev + 1);
}
@@ -109,7 +74,7 @@ export default function MultipleChoice({id, prompt, type, questions, onNext, onB
const back = () => {
if (questionIndex === 0) {
onBack();
onBack({exercise: id, solutions: answers, score: calculateScore(), type});
} else {
setQuestionIndex((prev) => prev - 1);
}
@@ -117,30 +82,25 @@ export default function MultipleChoice({id, prompt, type, questions, onNext, onB
return (
<>
<div className="flex flex-col items-center gap-4">
<span className="text-base md:text-lg font-medium text-center px-2 md:px-4 lg:px-48">{prompt}</span>
<div className="flex flex-col gap-2 mt-4 h-full mb-20 bg-mti-gray-smoke rounded-xl px-16 py-8">
<span className="text-xl font-semibold">{prompt}</span>
{questionIndex < questions.length && (
<Question
{...questions[questionIndex]}
userSolution={userSolutions.find((x) => questions[questionIndex].id === x.question)?.option}
userSolution={answers.find((x) => questions[questionIndex].id === x.question)?.option}
onSelectOption={onSelectOption}
/>
)}
</div>
<div className="self-end flex flex-col-reverse items-center w-full md:justify-between md:items-start md:flex-row gap-8">
<button className={clsx("btn btn-wide gap-4 relative text-white", errorButtonStyle)} onClick={back}>
<div className="absolute left-4">
<Icon path={mdiArrowLeft} color="white" size={1} />
</div>
<div className="self-end flex justify-between w-full gap-8 absolute bottom-8 left-0 px-8">
<Button color="green" variant="outline" onClick={back} className="max-w-[200px] w-full">
Back
</button>
<button className={clsx("btn btn-wide gap-4 relative text-white", infoButtonStyle)} onClick={next}>
</Button>
<Button color="green" onClick={next} className="max-w-[200px] self-end w-full">
Next
<div className="absolute right-4">
<Icon path={mdiArrowRight} color="white" size={1} />
</div>
</button>
</Button>
</div>
</>
);

View File

@@ -6,23 +6,50 @@ import clsx from "clsx";
import {CommonProps} from ".";
import {Fragment, useEffect, useState} from "react";
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) {
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 (
<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">
<div className="flex flex-col h-full w-full gap-9">
<div className="flex flex-col w-full gap-14 bg-mti-gray-smoke rounded-xl py-8 pb-12 px-16">
<div className="flex flex-col gap-3">
<span className="font-semibold">{title}</span>
<span className="font-regular">
{text.split("\\n").map((line, index) => (
<Fragment key={index}>
<span>{line}</span>
<br />
</Fragment>
))}
</span>
</div>
<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) => (
<li className="italic" key={index}>
{x}
@@ -32,21 +59,138 @@ export default function Speaking({id, title, text, type, prompts, onNext, onBack
</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">
<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} />
<ReactMediaRecorder
audio
onStop={(blob) => setMediaBlob(blob)}
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();
setMediaBlob(undefined);
}}
/>
<BsMicFill
onClick={() => {
clearBlobUrl();
setRecordingDuration(0);
startRecording();
setIsRecording(true);
setMediaBlob(undefined);
}}
className="h-5 w-5 text-mti-gray-cool cursor-pointer"
/>
</div>
</>
)}
</div>
</div>
)}
/>
<div className="self-end flex justify-between w-full gap-8">
<Button
color="green"
variant="outline"
onClick={() => onBack({exercise: id, solutions: [], score: {correct: 1, total: 1, missing: 0}, type})}
className="max-w-[200px] self-end w-full">
Back
</button>
<button
className={clsx("btn btn-wide gap-4 relative text-white", infoButtonStyle)}
onClick={() => onNext({exercise: id, solutions: [], score: {correct: 1, total: 1}, type})}>
</Button>
<Button
color="green"
disabled={!mediaBlob}
onClick={() => onNext({exercise: id, solutions: [], score: {correct: 1, total: 1, missing: 0}, type})}
className="max-w-[200px] self-end w-full">
Next
<div className="absolute right-4">
<Icon path={mdiArrowRight} color="white" size={1} />
</div>
</button>
</Button>
</div>
</div>
);

View File

@@ -3,14 +3,16 @@ import {WriteBlanksExercise} from "@/interfaces/exam";
import {mdiArrowLeft, mdiArrowRight} from "@mdi/js";
import Icon from "@mdi/react";
import clsx from "clsx";
import {useEffect, useState} from "react";
import {Fragment, useEffect, useState} from "react";
import reactStringReplace from "react-string-replace";
import {CommonProps} from ".";
import {toast} from "react-toastify";
import Button from "../Low/Button";
function Blank({
id,
maxWords,
userSolution,
showSolutions = false,
setUserSolution,
}: {
@@ -19,9 +21,9 @@ function Blank({
userSolution?: string;
maxWords: number;
showSolutions?: boolean;
setUserSolution?: (solution: string) => void;
setUserSolution: (solution: string) => void;
}) {
const [userInput, setUserInput] = useState("");
const [userInput, setUserInput] = useState(userSolution || "");
useEffect(() => {
const words = userInput.split(" ").filter((x) => x !== "");
@@ -33,39 +35,41 @@ function Blank({
return (
<input
className={clsx("input border rounded-xl px-2 py-1 bg-white text-blue-400 border-blue-400 my-2")}
className="py-2 px-3 rounded-2xl w-48 bg-white focus:outline-none my-2"
placeholder={id}
onChange={(e) => setUserInput(e.target.value)}
onBlur={() => setUserSolution(userInput)}
value={userInput}
contentEditable={showSolutions}
/>
);
}
export default function WriteBlanks({id, prompt, type, maxWords, solutions, text, onNext, onBack}: WriteBlanksExercise & CommonProps) {
const [userSolutions, setUserSolutions] = useState<{id: string; solution: string}[]>([]);
export default function WriteBlanks({id, prompt, type, maxWords, solutions, userSolutions, text, onNext, onBack}: WriteBlanksExercise & CommonProps) {
const [answers, setAnswers] = useState<{id: string; solution: string}[]>(userSolutions);
const calculateScore = () => {
const total = text.match(/({{\d+}})/g)?.length || 0;
const correct = userSolutions.filter(
const correct = answers.filter(
(x) =>
solutions
.find((y) => x.id === y.id)
?.solution.map((y) => y.toLowerCase())
.includes(x.solution.toLowerCase()) || false,
).length;
const missing = total - answers.filter((x) => solutions.find((y) => x.id === y.id)).length;
return {total, correct};
return {total, correct, missing};
};
const renderLines = (line: string) => {
return (
<span>
<span className="text-base leading-5">
{reactStringReplace(line, /({{\d+}})/g, (match) => {
const id = match.replaceAll(/[\{\}]/g, "");
const userSolution = userSolutions.find((x) => x.id === id);
const userSolution = answers.find((x) => x.id === id);
const setUserSolution = (solution: string) => {
setUserSolutions((prev) => [...prev.filter((x) => x.id !== id), {id, solution}]);
setAnswers((prev) => [...prev.filter((x) => x.id !== id), {id, solution}]);
};
return <Blank userSolution={userSolution?.solution} maxWords={maxWords} id={id} setUserSolution={setUserSolution} />;
@@ -76,33 +80,40 @@ export default function WriteBlanks({id, prompt, type, maxWords, solutions, text
return (
<>
<div className="flex flex-col">
<span className="text-lg font-medium text-center px-48">{prompt}</span>
<span>
{text.split("\\n").map((line) => (
<>
<div className="flex flex-col gap-4 mt-4 h-full mb-20">
<span className="text-sm w-full leading-6">
{prompt.split("\\n").map((line, index) => (
<Fragment key={index}>
{line}
<br />
</Fragment>
))}
</span>
<span className="bg-mti-gray-smoke rounded-xl px-5 py-6">
{text.split("\\n").map((line, index) => (
<p key={index}>
{renderLines(line)}
<br />
</>
</p>
))}
</span>
</div>
<div className="self-end flex flex-col-reverse items-center w-full md:justify-between md:items-start md:flex-row 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>
<div className="self-end flex justify-between w-full gap-8 absolute bottom-8 left-0 px-8">
<Button
color="green"
variant="outline"
onClick={() => onBack({exercise: id, solutions: answers, score: calculateScore(), type})}
className="max-w-[200px] w-full">
Back
</button>
<button
className={clsx("btn btn-wide gap-4 relative text-white", infoButtonStyle)}
onClick={() => onNext({exercise: id, solutions: userSolutions, score: calculateScore(), type})}>
</Button>
<Button
color="green"
onClick={() => onNext({exercise: id, solutions: answers, score: calculateScore(), type})}
className="max-w-[200px] self-end w-full">
Next
<div className="absolute right-4">
<Icon path={mdiArrowRight} color="white" size={1} />
</div>
</button>
</Button>
</div>
</>
);

View File

@@ -7,9 +7,12 @@ import clsx from "clsx";
import {CommonProps} from ".";
import {Fragment, useEffect, useState} from "react";
import {toast} from "react-toastify";
import Button from "../Low/Button";
import {Dialog, Transition} from "@headlessui/react";
export default function Writing({id, prompt, info, type, wordCounter, attachment, onNext, onBack}: WritingExercise & CommonProps) {
const [inputText, setInputText] = useState("");
export default function Writing({id, prompt, info, type, wordCounter, attachment, userSolutions, onNext, onBack}: WritingExercise & CommonProps) {
const [isModalOpen, setIsModalOpen] = useState(false);
const [inputText, setInputText] = useState(userSolutions.length === 1 ? userSolutions[0].solution : "");
const [isSubmitEnabled, setIsSubmitEnabled] = useState(false);
useEffect(() => {
@@ -27,59 +30,88 @@ export default function Writing({id, prompt, info, type, wordCounter, attachment
}, [inputText, wordCounter]);
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-2">
<span>{info}</span>
<span className="font-bold ml-8">
{prompt.split("\\n").map((line, index) => (
<Fragment key={index}>
<span>{line}</span>
<br />
</Fragment>
))}
</span>
<span>
You should write {wordCounter.type === "min" ? "at least" : "at most"} {wordCounter.limit} words.
</span>
{attachment && <img src={attachment} alt="Exercise attachment" />}
<>
{attachment && (
<Transition show={isModalOpen} as={Fragment}>
<Dialog onClose={() => setIsModalOpen(false)} className="relative z-50">
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="ease-in duration-200"
leaveFrom="opacity-100"
leaveTo="opacity-0">
<div className="fixed inset-0 bg-black/30" />
</Transition.Child>
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0 scale-95"
enterTo="opacity-100 scale-100"
leave="ease-in duration-200"
leaveFrom="opacity-100 scale-100"
leaveTo="opacity-0 scale-95">
<div className="fixed inset-0 flex items-center justify-center p-4">
<Dialog.Panel className="w-fit h-fit rounded-xl bg-white">
<img src={attachment.url} alt={attachment.description} className="max-w-4xl w-full self-center rounded-xl p-4" />
</Dialog.Panel>
</div>
</Transition.Child>
</Dialog>
</Transition>
)}
<div className="flex flex-col h-full w-full gap-9 mb-20">
<div className="flex flex-col w-full gap-7 bg-mti-gray-smoke rounded-xl py-8 pb-12 px-16">
<span>{info}</span>
<span className="font-semibold">
{prompt.split("\\n").map((line, index) => (
<Fragment key={index}>
<p>{line}</p>
<br />
</Fragment>
))}
</span>
{attachment && (
<img
onClick={() => setIsModalOpen(true)}
src={attachment.url}
alt={attachment.description}
className="max-w-md self-center rounded-xl cursor-pointer"
/>
)}
</div>
<div className="w-full h-full flex flex-col gap-4">
<span>
You should write {wordCounter.type === "min" ? "at least" : "at most"} {wordCounter.limit} words.
</span>
<textarea
className="w-full h-full min-h-[148px] cursor-text px-7 py-8 input border-2 border-mti-gray-platinum bg-white rounded-3xl"
onChange={(e) => setInputText(e.target.value)}
value={inputText}
placeholder="Write your text here..."
/>
</div>
</div>
<textarea
className="w-full h-1/3 cursor-text p-2 input input-bordered bg-white"
onChange={(e) => setInputText(e.target.value)}
value={inputText}
placeholder="Write your text here..."
/>
<div className="self-end flex flex-col-reverse items-center w-full md:justify-between md:items-start md:flex-row 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>
<div className="self-end flex justify-between w-full gap-8 absolute bottom-8 left-0 px-8">
<Button
color="green"
variant="outline"
onClick={() => onBack({exercise: id, solutions: [inputText], score: {correct: 1, total: 1, missing: 0}, type})}
className="max-w-[200px] self-end w-full">
Back
</button>
{!isSubmitEnabled && (
<div className="tooltip" data-tip={`You have not yet reached your minimum word count of ${wordCounter.limit} words!`}>
<button
className={clsx("btn btn-wide gap-4 relative text-white", infoButtonStyle)}
disabled={!isSubmitEnabled}
onClick={() => onNext({exercise: id, solutions: [inputText], score: {correct: 1, total: 1}, type})}>
Next
</button>
</div>
)}
{isSubmitEnabled && (
<button
className={clsx("btn btn-wide gap-4 relative text-white", infoButtonStyle)}
disabled={!isSubmitEnabled}
onClick={() => onNext({exercise: id, solutions: [inputText], score: {correct: 1, total: 1}, type})}>
Next
<div className="absolute right-4">
<Icon path={mdiArrowRight} color="white" size={1} />
</div>
</button>
)}
</Button>
<Button
color="green"
disabled={!isSubmitEnabled}
onClick={() => onNext({exercise: id, solutions: [{id, solution: inputText}], score: {correct: 1, total: 1, missing: 0}, type})}
className="max-w-[200px] self-end w-full">
Next
</Button>
</div>
</div>
</>
);
}

View File

@@ -19,10 +19,10 @@ const MatchSentences = dynamic(() => import("@/components/Exercises/MatchSentenc
export interface CommonProps {
onNext: (userSolutions: UserSolution) => void;
onBack: () => void;
onBack: (userSolutions: UserSolution) => void;
}
export const renderExercise = (exercise: Exercise, onNext: (userSolutions: UserSolution) => void, onBack: () => void) => {
export const renderExercise = (exercise: Exercise, onNext: (userSolutions: UserSolution) => void, onBack: (userSolutions: UserSolution) => void) => {
switch (exercise.type) {
case "fillBlanks":
return <FillBlanks {...(exercise as FillBlanksExercise)} onNext={onNext} onBack={onBack} />;

View File

@@ -0,0 +1,31 @@
import {User} from "@/interfaces/user";
import clsx from "clsx";
import {useRouter} from "next/router";
import Navbar from "../Navbar";
import Sidebar from "../Sidebar";
interface Props {
user: User;
children: React.ReactNode;
className?: string;
}
export default function Layout({user, children, className}: Props) {
const router = useRouter();
return (
<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={router.pathname} />
<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 overflow-hidden",
className,
)}>
{children}
</div>
</div>
</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

@@ -0,0 +1,44 @@
import clsx from "clsx";
import {ReactNode} from "react";
interface Props {
children: ReactNode;
color?: "orange" | "green" | "blue";
variant?: "outline" | "solid";
className?: string;
disabled?: boolean;
onClick?: () => void;
}
export default function Button({color = "green", variant = "solid", disabled = false, className, children, onClick}: Props) {
const colorClassNames: {[key in typeof color]: {[key in typeof variant]: string}} = {
green: {
solid: "bg-mti-green-light text-white hover:bg-mti-green disabled:text-mti-green disabled:bg-mti-green-ultralight selection:bg-mti-green-dark",
outline:
"bg-transparent text-mti-green-light border border-mti-green-light hover:bg-mti-green-light disabled:text-mti-green disabled:bg-mti-green-ultralight selection:bg-mti-green-dark hover:text-white selection:text-white",
},
blue: {
solid: "bg-mti-blue-light text-white hover:bg-mti-blue disabled:text-mti-blue disabled:bg-mti-blue-ultralight selection:bg-mti-blue-dark",
outline:
"bg-transparent text-mti-blue-light border border-mti-blue-light hover:bg-mti-blue-light disabled:text-mti-blue disabled:bg-mti-blue-ultralight selection:bg-mti-blue-dark hover:text-white selection:text-white",
},
orange: {
solid: "bg-mti-orange-light text-white hover:bg-mti-orange disabled:text-mti-orange disabled:bg-mti-orange-ultralight selection:bg-mti-orange-dark",
outline:
"bg-transparent text-mti-orange-light border border-mti-orange-light hover:bg-mti-orange-light disabled:text-mti-orange disabled:bg-mti-orange-ultralight selection:bg-mti-orange-dark hover:text-white selection:text-white",
},
};
return (
<button
onClick={onClick}
className={clsx(
"py-4 px-6 rounded-full transition ease-in-out duration-300 disabled:cursor-not-allowed",
className,
colorClassNames[color][variant],
)}
disabled={disabled}>
{children}
</button>
);
}

View File

@@ -0,0 +1,61 @@
import {useState} from "react";
interface Props {
type: "email" | "text" | "password";
required?: boolean;
label?: string;
placeholder?: string;
name: string;
onChange: (value: string) => void;
}
export default function Input({type, label, placeholder, name, required = false, onChange}: Props) {
const [showPassword, setShowPassword] = useState(false);
if (type === "password") {
return (
<div className="relative flex flex-col gap-3 w-full">
{label && (
<label className="font-normal text-base text-mti-gray-dim">
{label}
{required ? " *" : ""}
</label>
)}
<div className="w-full h-fit relative">
<input
type={showPassword ? "text" : "password"}
name={name}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
className="w-full px-8 py-6 text-sm font-normal placeholder:text-mti-gray-cool bg-white rounded-full border border-mti-gray-platinum focus:outline-none"
/>
<p
role="button"
onClick={() => setShowPassword((prev) => !prev)}
className="text-xs cursor-pointer absolute bottom-1/2 translate-y-1/2 right-8">
{showPassword ? "Hide" : "Show"}
</p>
</div>
</div>
);
}
return (
<div className="flex flex-col gap-3 w-full">
{label && (
<label className="font-normal text-base text-mti-gray-dim">
{label}
{required ? " *" : ""}
</label>
)}
<input
type={type}
name={name}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
className="px-8 py-6 text-sm font-normal placeholder:text-mti-gray-cool bg-white rounded-full border border-mti-gray-platinum focus:outline-none"
required={required}
/>
</div>
);
}

View File

@@ -0,0 +1,38 @@
import {Module} from "@/interfaces";
import clsx from "clsx";
interface Props {
label: string;
percentage: number;
color: "blue" | "orange" | "green" | Module;
useColor?: boolean;
className?: string;
}
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 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>
);
}

View File

@@ -0,0 +1,71 @@
import {Module} from "@/interfaces";
import {moduleLabels} from "@/utils/moduleUtils";
import {ReactNode, useEffect, useState} from "react";
import {BsBook, BsHeadphones, BsMegaphone, BsPen, BsStopwatch} from "react-icons/bs";
import ProgressBar from "../Low/ProgressBar";
interface Props {
minTimer: number;
module: Module;
label?: string;
exerciseIndex: number;
totalExercises: number;
disableTimer?: boolean;
}
export default function ModuleTitle({minTimer, module, label, exerciseIndex, totalExercises, disableTimer = false}: Props) {
const [timer, setTimer] = useState(minTimer * 60);
useEffect(() => {
if (!disableTimer) {
const timerInterval = setInterval(() => setTimer((prev) => prev - 1), 1000);
return () => {
clearInterval(timerInterval);
};
}
}, [disableTimer, 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: <BsMegaphone 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">
{moduleLabels[module]} exam {label && `- ${label}`}
</span>
<span className="text-xs font-normal self-end text-mti-gray-davy">
Question {exerciseIndex}/{totalExercises}
</span>
</div>
<ProgressBar color={module} label="" percentage={(exerciseIndex * 100) / totalExercises} className="h-2 w-full" />
</div>
</div>
</>
);
}

View File

@@ -1,77 +1,22 @@
import {Type} from "@/interfaces/user";
import axios from "axios";
import Link from "next/link";
import {useRouter} from "next/router";
import {Button} from "primereact/button";
import {Menubar} from "primereact/menubar";
import {MenuItem} from "primereact/menuitem";
import {User} from "@/interfaces/user";
import {Avatar} from "primereact/avatar";
interface Props {
profilePicture: string;
userType: Type;
timer?: number;
showExamEnd?: boolean;
user: User;
}
/* eslint-disable @next/next/no-img-element */
export default function Navbar({profilePicture, userType, timer, showExamEnd = false}: Props) {
const router = useRouter();
const logout = async () => {
axios.post("/api/logout").finally(() => {
router.push("/login");
});
};
const items: MenuItem[] = [
{
label: "Home",
icon: "pi pi-fw pi-home",
url: "/",
},
{
label: "Account",
icon: "pi pi-fw pi-user",
url: "/profile",
},
{
label: "Exam",
icon: "pi pi-fw pi-plus-circle",
url: "/exam",
},
{
label: "Users",
icon: "pi pi-fw pi-users",
items: [
...(userType === "student" ? [] : [{label: "List", icon: "pi pi-fw pi-users", url: "/users"}]),
{label: "Stats", icon: "pi pi-fw pi-chart-pie", url: "/stats"},
{label: "History", icon: "pi pi-fw pi-history", url: "/history"},
],
},
{
label: "Logout",
icon: "pi pi-fw pi-power-off",
command: logout,
},
];
const endTimer = timer && (
<span className="pr-2 font-semibold">
{Math.floor(timer / 60) < 10 ? "0" : ""}
{Math.floor(timer / 60)}:{timer % 60 < 10 ? "0" : ""}
{timer % 60}
</span>
);
const endNewExam = (
<Link href="/exam" className="pr-2">
<Button text label="Exam" severity="secondary" size="small" />
</Link>
);
export default function Navbar({user}: Props) {
return (
<div className="bg-neutral-100 z-10 w-full p-2">
<Menubar model={items} end={showExamEnd ? endNewExam : endTimer} />
</div>
<header className="w-full bg-transparent py-4 gap-2 flex items-center">
<h1 className="font-bold text-2xl w-1/6 px-8">eCrop</h1>
<div className="flex justify-between w-5/6 mr-8">
<input type="text" placeholder="Search..." className="rounded-full py-4 px-6 border border-mti-gray-platinum outline-none" />
<div className="flex gap-3 items-center justify-end">
<img src={user.profilePicture} alt={user.name} className="w-10 h-10 rounded-full" />
<span className="text-right">{user.name}</span>
</div>
</div>
</header>
);
}

View File

@@ -0,0 +1,67 @@
import clsx from "clsx";
import {IconType} from "react-icons";
import {MdSpaceDashboard} from "react-icons/md";
import {BsFileEarmarkText, BsClockHistory, BsPencil, BsGraphUp} from "react-icons/bs";
import {RiLogoutBoxFill} from "react-icons/ri";
import {SlPencil} from "react-icons/sl";
import {FaAward} from "react-icons/fa";
import Link from "next/link";
import {useRouter} from "next/router";
import axios from "axios";
interface Props {
path: string;
}
interface NavProps {
Icon: IconType;
label: string;
path: string;
keyPath: string;
}
const Nav = ({Icon, label, path, keyPath}: NavProps) => (
<Link
href={keyPath}
className={clsx(
"p-4 px-8 rounded-full flex gap-4 items-center cursor-pointer text-gray-500 hover:bg-mti-green-light hover:text-white transition duration-300 ease-in-out",
path === keyPath && "bg-mti-green-light text-white",
)}>
<Icon size={20} />
<span className="text-lg font-semibold">{label}</span>
</Link>
);
export default function Sidebar({path}: Props) {
const router = useRouter();
const logout = async () => {
axios.post("/api/logout").finally(() => {
router.push("/login");
});
};
return (
<section className="h-full flex bg-transparent flex-col justify-between w-1/6 px-4">
<div className="flex flex-col gap-3">
<Nav Icon={MdSpaceDashboard} label="Dashboard" path={path} keyPath="/" />
<Nav Icon={BsFileEarmarkText} label="Exams" path={path} keyPath="/exam" />
<Nav Icon={BsPencil} label="Exercises" path={path} keyPath="/#" />
<Nav Icon={BsGraphUp} label="Stats" path={path} keyPath="/stats" />
<Nav Icon={BsClockHistory} label="Record" path={path} keyPath="/record" />
</div>
<div
role="button"
tabIndex={1}
onClick={logout}
className={clsx(
"p-4 px-8 rounded-full flex gap-4 items-center cursor-pointer text-black hover:text-mti-orange transition duration-300 ease-in-out",
"absolute bottom-8",
)}>
<RiLogoutBoxFill size={20} />
<span className="text-lg font-medium">Log Out</span>
</div>
</section>
);
}

View File

@@ -1,11 +1,9 @@
import {errorButtonStyle, infoButtonStyle} from "@/constants/buttonStyles";
import {FillBlanksExercise} from "@/interfaces/exam";
import {mdiArrowLeft, mdiArrowRight} from "@mdi/js";
import Icon from "@mdi/react";
import clsx from "clsx";
import reactStringReplace from "react-string-replace";
import {CommonProps} from ".";
import {Fragment} from "react";
import Button from "../Low/Button";
export default function FillBlanksSolutions({prompt, solutions, text, userSolutions, onNext, onBack}: FillBlanksExercise & CommonProps) {
const renderLines = (line: string) => {
@@ -18,23 +16,45 @@ export default function FillBlanksSolutions({prompt, solutions, text, userSoluti
if (!userSolution) {
return (
<>
<button className={clsx("border-2 rounded-xl px-4 text-gray-500 border-gray-500 my-2")}>{solution.solution}</button>
</>
<button
className={clsx(
"rounded-full hover:text-white hover:bg-mti-blue transition duration-300 ease-in-out my-1 px-5 py-2 text-center text-white bg-mti-blue-light",
)}>
{solution.solution}
</button>
);
}
if (userSolution.solution === solution.solution) {
return <button className={clsx("border-2 rounded-xl px-4 text-green-500 border-green-500 my-2")}>{solution.solution}</button>;
return (
<button
className={clsx(
"rounded-full hover:text-white hover:bg-mti-green transition duration-300 ease-in-out my-1",
userSolution && "px-5 py-2 text-center text-white bg-mti-green-light",
)}>
{solution.solution}
</button>
);
}
if (userSolution.solution !== solution.solution) {
return (
<>
<button className={clsx("border-2 rounded-xl px-4 text-red-500 border-red-500 mr-1 my-2")}>
<button
className={clsx(
"rounded-full hover:text-white hover:bg-mti-orange transition duration-300 ease-in-out my-1 mr-1",
userSolution && "px-5 py-2 text-center text-white bg-mti-orange-light",
)}>
{userSolution.solution}
</button>
<button className={clsx("border-2 rounded-xl px-4 text-green-400 border-green-400 my-2")}>{solution.solution}</button>
<button
className={clsx(
"rounded-full hover:text-white hover:bg-mti-green transition duration-300 ease-in-out my-1",
userSolution && "px-5 py-2 text-center text-white bg-mti-green-light",
)}>
{solution.solution}
</button>
</>
);
}
@@ -45,8 +65,8 @@ export default function FillBlanksSolutions({prompt, solutions, text, userSoluti
return (
<>
<div className="flex flex-col gap-4">
<span className="text-base md:text-lg font-medium text-center px-2 md:px-4 lg:px-48">
<div className="flex flex-col gap-4 mt-4 h-full mb-20">
<span className="text-sm w-full leading-6">
{prompt.split("\\n").map((line, index) => (
<Fragment key={index}>
{line}
@@ -54,29 +74,38 @@ export default function FillBlanksSolutions({prompt, solutions, text, userSoluti
</Fragment>
))}
</span>
<span>
<span className="bg-mti-gray-smoke rounded-xl px-5 py-6">
{text.split("\\n").map((line, index) => (
<Fragment key={index}>
<p key={index}>
{renderLines(line)}
<br />
</Fragment>
</p>
))}
</span>
<div className="flex gap-4 items-center">
<div className="flex gap-2 items-center">
<div className="w-4 h-4 rounded-full bg-mti-green" />
Correct
</div>
<div className="flex gap-2 items-center">
<div className="w-4 h-4 rounded-full bg-mti-blue" />
Unanswered
</div>
<div className="flex gap-2 items-center">
<div className="w-4 h-4 rounded-full bg-mti-orange" />
Wrong
</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">
<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>
<div className="self-end flex justify-between w-full gap-8 absolute bottom-8 left-0 px-8">
<Button color="green" variant="outline" onClick={onBack} className="max-w-[200px] w-full">
Back
</button>
<button className={clsx("btn btn-wide gap-4 relative text-white", infoButtonStyle)} onClick={onNext}>
</Button>
<Button color="green" onClick={() => onNext()} className="max-w-[200px] self-end w-full">
Next
<div className="absolute right-4">
<Icon path={mdiArrowRight} color="white" size={1} />
</div>
</button>
</Button>
</div>
</>
);

View File

@@ -6,12 +6,14 @@ import {errorButtonStyle, infoButtonStyle} from "@/constants/buttonStyles";
import {mdiArrowLeft, mdiArrowRight} from "@mdi/js";
import Icon from "@mdi/react";
import {Fragment} from "react";
import Button from "../Low/Button";
import Xarrow from "react-xarrows";
export default function MatchSentencesSolutions({options, prompt, sentences, userSolutions, onNext, onBack}: MatchSentencesExercise & CommonProps) {
return (
<>
<div className="flex flex-col items-center gap-8">
<span className="text-base md:text-lg font-medium text-center px-2 md:px-4 lg:px-48">
<div className="flex flex-col gap-4 mt-4 h-full mb-20">
<span className="text-sm w-full leading-6">
{prompt.split("\\n").map((line, index) => (
<Fragment key={index}>
{line}
@@ -19,63 +21,77 @@ export default function MatchSentencesSolutions({options, prompt, sentences, use
</Fragment>
))}
</span>
<div className="grid grid-cols-2 gap-16 place-items-center">
<div className="flex flex-col gap-1">
{sentences.map(({sentence, id, color, solution}) => (
<div
key={`question_${id}`}
className={clsx(
"flex items-center justify-end gap-2 cursor-pointer",
userSolutions.find((x) => x.question === id)?.option === solution ? "text-green-500" : "text-red-500",
)}>
<span>
<span className="font-semibold">{id}.</span> {sentence}{" "}
</span>
<div style={{borderColor: color}} className={clsx("border-2 border-blue-500 w-4 h-4 rounded-full", id)} />
<div className="flex gap-8 w-full items-center justify-between bg-mti-gray-smoke rounded-xl px-24 py-6">
<div className="flex flex-col gap-4">
{sentences.map(({sentence, id, solution}) => (
<div key={`question_${id}`} className="flex items-center justify-end gap-2 cursor-pointer">
<span>{sentence} </span>
<button
id={id}
className={clsx(
"w-8 h-8 rounded-full z-10 text-white",
"transition duration-300 ease-in-out",
!userSolutions.find((x) => x.question === id) && "!bg-mti-blue",
userSolutions.find((x) => x.question === id)?.option === solution && "bg-mti-green",
userSolutions.find((x) => x.question === id)?.option !== solution && "bg-mti-orange",
)}>
{id}
</button>
</div>
))}
</div>
<div className="flex flex-col gap-1">
<div className="flex flex-col gap-4">
{options.map(({sentence, id}) => (
<div key={`answer_${id}`} className={clsx("flex items-center justify-start gap-2 cursor-pointer")}>
<div
style={
sentences.find((x) => x.solution === id)
? {
border: `2px solid ${sentences.find((x) => x.solution === id)!.color}`,
}
: {}
}
className={clsx("border-2 border-green-500 bg-transparent w-4 h-4 rounded-full", id)}
/>
<span>
<span className="font-semibold">{id}.</span> {sentence}{" "}
</span>
<button
id={id}
className={clsx(
"bg-mti-green-ultralight text-mti-green hover:text-white hover:bg-mti-green w-8 h-8 rounded-full z-10",
"transition duration-300 ease-in-out",
)}>
{id}
</button>
<span>{sentence}</span>
</div>
))}
</div>
{sentences.map((sentence, index) => (
<div key={`solution_${index}`} className="absolute">
<LineTo className="rounded-full" from={sentence.id} to={sentence.solution} borderColor={sentence.color} borderWidth={5} />
</div>
<Xarrow
key={index}
start={sentence.id}
end={sentence.solution}
lineColor={
!userSolutions.find((x) => x.question === sentence.id)
? "#0696ff"
: userSolutions.find((x) => x.question === sentence.id)?.option === sentence.solution
? "#307912"
: "#FF6000"
}
showHead={false}
/>
))}
</div>
<div className="flex gap-4 items-center">
<div className="flex gap-2 items-center">
<div className="w-4 h-4 rounded-full bg-mti-green" /> Correct
</div>
<div className="flex gap-2 items-center">
<div className="w-4 h-4 rounded-full bg-mti-blue" /> Unanswered
</div>
<div className="flex gap-2 items-center">
<div className="w-4 h-4 rounded-full bg-mti-orange" /> Wrong
</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">
<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>
<div className="self-end flex justify-between w-full gap-8 absolute bottom-8 left-0 px-8">
<Button color="green" variant="outline" onClick={() => onBack()} className="max-w-[200px] w-full">
Back
</button>
<button className={clsx("btn btn-wide gap-4 relative text-white", infoButtonStyle)} onClick={onNext}>
</Button>
<Button color="green" onClick={() => onNext()} className="max-w-[200px] self-end w-full">
Next
<div className="absolute right-4">
<Icon path={mdiArrowRight} color="white" size={1} />
</div>
</button>
</Button>
</div>
</>
);

View File

@@ -1,11 +1,9 @@
/* eslint-disable @next/next/no-img-element */
import {errorButtonStyle, infoButtonStyle} from "@/constants/buttonStyles";
import {MultipleChoiceExercise, MultipleChoiceQuestion} from "@/interfaces/exam";
import {mdiArrowLeft, mdiArrowRight, mdiCheck, mdiClose} from "@mdi/js";
import Icon from "@mdi/react";
import clsx from "clsx";
import {useState} from "react";
import {CommonProps} from ".";
import Button from "../Low/Button";
function Question({
variant,
@@ -13,41 +11,17 @@ function Question({
solution,
options,
userSolution,
onSelectOption,
showSolution = false,
}: MultipleChoiceQuestion & {userSolution: string | undefined; onSelectOption?: (option: string) => void; showSolution?: boolean}) {
const optionColor = (option: string) => {
if (!showSolution) {
return userSolution === option ? "border-blue-400" : "";
if (option === solution && !userSolution) {
return "!border-mti-blue-light !text-mti-blue-light";
}
if (option === solution) {
return "border-green-500 text-green-500";
return "!border-mti-green-light !text-mti-green-light";
}
return userSolution === option ? "border-red-500 text-red-500" : "";
};
const optionBadge = (option: string) => {
if (option === userSolution) {
if (solution === option) {
return (
<div className="badge badge-lg bg-green-500 border-green-500 absolute -top-2 -right-4">
<div className="tooltip" data-tip="You have correctly answered!">
<Icon path={mdiCheck} color="white" size={0.8} />
</div>
</div>
);
}
return (
<div className="badge badge-lg bg-red-500 border-red-500 absolute -top-2 -right-4">
<div className="tooltip" data-tip="You have wrongly answered!">
<Icon path={mdiClose} color="white" size={0.8} />
</div>
</div>
);
}
return userSolution === option ? "!border-mti-orange-light !text-mti-orange-light" : "";
};
return (
@@ -58,24 +32,20 @@ function Question({
options.map((option) => (
<div
key={option.id}
onClick={() => (onSelectOption ? onSelectOption(option.id) : null)}
className={clsx(
"flex flex-col items-center border-2 p-4 rounded-xl gap-4 cursor-pointer bg-white relative",
"flex flex-col items-center border border-mti-gray-platinum p-4 px-8 rounded-xl gap-4 cursor-pointer bg-white relative",
optionColor(option.id),
)}>
{showSolution && optionBadge(option.id)}
<span className={clsx("text-sm", solution !== option.id && userSolution !== option.id && "opacity-50")}>{option.id}</span>
<img src={option.src!} alt={`Option ${option.id}`} />
<span>{option.id}</span>
</div>
))}
{variant === "text" &&
options.map((option) => (
<div
key={option.id}
onClick={() => (onSelectOption ? onSelectOption(option.id) : null)}
className={clsx("flex border-2 p-4 rounded-xl gap-2 cursor-pointer bg-white", optionColor(option.id))}>
{showSolution && optionBadge(option.id)}
<span className="font-bold">{option.id}.</span>
className={clsx("flex border p-4 rounded-xl gap-2 cursor-pointer bg-white text-sm", optionColor(option.id))}>
<span className="font-semibold">{option.id}.</span>
<span>{option.text}</span>
</div>
))}
@@ -105,30 +75,40 @@ export default function MultipleChoice({prompt, questions, userSolutions, onNext
return (
<>
<div className="flex flex-col items-center gap-4">
<span className="text-base md:text-lg font-medium text-center px-2 md:px-4 lg:px-48">{prompt}</span>
{questionIndex < questions.length && (
<Question
{...questions[questionIndex]}
userSolution={userSolutions.find((x) => questions[questionIndex].id === x.question)?.option}
showSolution
/>
)}
<div className="flex flex-col gap-4 w-full h-full mb-20">
<div className="flex flex-col gap-2 mt-4 h-full bg-mti-gray-smoke rounded-xl px-16 py-8">
<span className="text-xl font-semibold">{prompt}</span>
{questionIndex < questions.length && (
<Question
{...questions[questionIndex]}
userSolution={userSolutions.find((x) => questions[questionIndex].id === x.question)?.option}
/>
)}
</div>
<div className="flex gap-4 items-center">
<div className="flex gap-2 items-center">
<div className="w-4 h-4 rounded-full bg-mti-green" />
Correct
</div>
<div className="flex gap-2 items-center">
<div className="w-4 h-4 rounded-full bg-mti-blue" />
Unanswered
</div>
<div className="flex gap-2 items-center">
<div className="w-4 h-4 rounded-full bg-mti-orange" />
Wrong
</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">
<button className={clsx("btn btn-wide gap-4 relative text-white", errorButtonStyle)} onClick={back}>
<div className="absolute left-4">
<Icon path={mdiArrowLeft} color="white" size={1} />
</div>
<div className="self-end flex justify-between w-full gap-8 absolute bottom-8 left-0 px-8">
<Button color="green" variant="outline" onClick={back} className="max-w-[200px] w-full">
Back
</button>
<button className={clsx("btn btn-wide gap-4 relative text-white", infoButtonStyle)} onClick={next}>
</Button>
<Button color="green" onClick={next} className="max-w-[200px] self-end w-full">
Next
<div className="absolute right-4">
<Icon path={mdiArrowRight} color="white" size={1} />
</div>
</button>
</Button>
</div>
</>
);

View File

@@ -3,10 +3,11 @@ import {WriteBlanksExercise} from "@/interfaces/exam";
import {mdiArrowLeft, mdiArrowRight} from "@mdi/js";
import Icon from "@mdi/react";
import clsx from "clsx";
import {useEffect, useState} from "react";
import {Fragment, useEffect, useState} from "react";
import reactStringReplace from "react-string-replace";
import {CommonProps} from ".";
import {toast} from "react-toastify";
import Button from "../Low/Button";
function Blank({
id,
@@ -17,7 +18,7 @@ function Blank({
setUserSolution,
}: {
id: string;
solutions?: string[];
solutions: string[];
userSolution?: string;
maxWords: number;
disabled?: boolean;
@@ -34,22 +35,35 @@ function Blank({
}
}, [maxWords, userInput, setUserSolution]);
const isUserSolutionCorrect = () => userSolution && solutions.map((x) => x.trim().toLowerCase()).includes(userSolution.trim().toLowerCase());
const getSolutionStyling = () => {
if (solutions && userSolution) {
if (solutions.map((x) => x.trim().toLowerCase()).includes(userSolution.trim().toLowerCase())) return "text-green-500 border-green-500";
if (!userSolution) {
return "bg-mti-blue-ultralight text-mti-blue-light";
}
return "text-red-500 border-red-500";
return "bg-mti-green-ultralight text-mti-green-light";
};
return (
<input
className={clsx("input border rounded-xl px-2 py-1 bg-white my-2", !solutions && "text-blue-400 border-blue-400", getSolutionStyling())}
placeholder={id}
onChange={(e) => setUserInput(e.target.value)}
value={!solutions ? userInput : solutions.join(" / ")}
contentEditable={disabled}
/>
<span className="inline-flex gap-2">
{userSolution && !isUserSolutionCorrect() && (
<input
className="py-2 px-3 rounded-2xl w-48 focus:outline-none my-2 bg-mti-orange-ultralight text-mti-orange-light"
placeholder={id}
onChange={(e) => setUserInput(e.target.value)}
value={userSolution}
contentEditable={disabled}
/>
)}
<input
className={clsx("py-2 px-3 rounded-2xl w-48 focus:outline-none my-2", getSolutionStyling())}
placeholder={id}
onChange={(e) => setUserInput(e.target.value)}
value={!solutions ? userInput : solutions.join(" / ")}
contentEditable={disabled}
/>
</span>
);
}
@@ -65,7 +79,7 @@ export default function WriteBlanksSolutions({
}: WriteBlanksExercise & CommonProps) {
const renderLines = (line: string) => {
return (
<span>
<span className="text-base leading-5">
{reactStringReplace(line, /({{\d+}})/g, (match) => {
const id = match.replaceAll(/[\{\}]/g, "");
const userSolution = userSolutions.find((x) => x.id === id);
@@ -79,31 +93,47 @@ export default function WriteBlanksSolutions({
return (
<>
<div className="flex flex-col">
<span className="text-lg font-medium text-center px-48">{prompt}</span>
<span>
{text.split("\\n").map((line) => (
<>
{renderLines(line)}
<div className="flex flex-col gap-4 mt-4 h-full mb-20">
<span className="text-sm w-full leading-6">
{prompt.split("\\n").map((line, index) => (
<Fragment key={index}>
{line}
<br />
</>
</Fragment>
))}
</span>
<span className="bg-mti-gray-smoke rounded-xl px-5 py-6">
{text.split("\\n").map((line, index) => (
<p key={index}>
{renderLines(line)}
<br />
</p>
))}
</span>
<div className="flex gap-4 items-center">
<div className="flex gap-2 items-center">
<div className="w-4 h-4 rounded-full bg-mti-green" />
Correct
</div>
<div className="flex gap-2 items-center">
<div className="w-4 h-4 rounded-full bg-mti-blue" />
Unanswered
</div>
<div className="flex gap-2 items-center">
<div className="w-4 h-4 rounded-full bg-mti-orange" />
Wrong
</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">
<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>
<div className="self-end flex justify-between w-full gap-8 absolute bottom-8 left-0 px-8">
<Button color="green" variant="outline" onClick={() => onBack()} className="max-w-[200px] w-full">
Back
</button>
<button className={clsx("btn btn-wide gap-4 relative text-white", infoButtonStyle)} onClick={onNext}>
</Button>
<Button color="green" onClick={() => onNext()} className="max-w-[200px] self-end w-full">
Next
<div className="absolute right-4">
<Icon path={mdiArrowRight} color="white" size={1} />
</div>
</button>
</Button>
</div>
</>
);

View File

@@ -0,0 +1,109 @@
/* eslint-disable @next/next/no-img-element */
import {errorButtonStyle, infoButtonStyle} from "@/constants/buttonStyles";
import {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";
import Button from "../Low/Button";
import {Dialog, Transition} from "@headlessui/react";
export default function Writing({id, prompt, info, attachment, userSolutions, onNext, onBack}: WritingExercise & CommonProps) {
const [isModalOpen, setIsModalOpen] = useState(false);
return (
<>
{attachment && (
<Transition show={isModalOpen} as={Fragment}>
<Dialog onClose={() => setIsModalOpen(false)} className="relative z-50">
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="ease-in duration-200"
leaveFrom="opacity-100"
leaveTo="opacity-0">
<div className="fixed inset-0 bg-black/30" />
</Transition.Child>
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0 scale-95"
enterTo="opacity-100 scale-100"
leave="ease-in duration-200"
leaveFrom="opacity-100 scale-100"
leaveTo="opacity-0 scale-95">
<div className="fixed inset-0 flex items-center justify-center p-4">
<Dialog.Panel className="w-fit h-fit rounded-xl bg-white">
<img src={attachment.url} alt={attachment.description} className="max-w-4xl w-full self-center rounded-xl p-4" />
</Dialog.Panel>
</div>
</Transition.Child>
</Dialog>
</Transition>
)}
<div className="flex flex-col h-full w-full gap-8 mb-20">
<div className="flex w-full gap-7 bg-mti-gray-smoke rounded-xl py-8 pb-12 px-16">
<span className="font-semibold">
{prompt.split("\\n").map((line, index) => (
<Fragment key={index}>
<p>{line}</p>
<br />
</Fragment>
))}
</span>
{attachment && (
<img
onClick={() => setIsModalOpen(true)}
src={attachment.url}
alt={attachment.description}
className="max-w-[200px] self-center rounded-xl cursor-pointer"
/>
)}
</div>
<div className="w-full h-full flex flex-col gap-8">
{userSolutions && (
<div className="flex flex-col gap-4 w-full">
<span>Your answer:</span>
<textarea
className="w-full h-full min-h-[320px] cursor-text px-7 py-8 input border-2 border-mti-gray-platinum bg-white rounded-3xl"
contentEditable={false}
readOnly
value={userSolutions[0]!.solution}
/>
</div>
)}
{userSolutions && userSolutions.length > 0 && (
<div className="flex flex-col gap-4 w-full">
<div className="flex gap-4 px-1">
{Object.keys(userSolutions[0].evaluation!.task_response).map((key) => (
<div className="bg-ielts-writing text-ielts-writing-light rounded-xl px-4 py-2" key={key}>
{key}: Level {userSolutions[0].evaluation!.task_response[key]}
</div>
))}
</div>
<div className="w-full h-full min-h-fit cursor-text px-7 py-8 bg-mti-gray-smoke rounded-3xl">
{userSolutions[0].evaluation!.comment}
</div>
</div>
)}
</div>
</div>
<div className="self-end flex justify-between w-full gap-8 absolute bottom-8 left-0 px-8">
<Button color="green" variant="outline" onClick={onBack} className="max-w-[200px] w-full">
Back
</Button>
<Button color="green" onClick={() => onNext()} className="max-w-[200px] self-end w-full">
Next
</Button>
</div>
</>
);
}

View File

@@ -1,8 +1,9 @@
import {Exercise, FillBlanksExercise, MatchSentencesExercise, MultipleChoiceExercise, WriteBlanksExercise} from "@/interfaces/exam";
import {Exercise, FillBlanksExercise, MatchSentencesExercise, MultipleChoiceExercise, WriteBlanksExercise, WritingExercise} from "@/interfaces/exam";
import dynamic from "next/dynamic";
import FillBlanks from "./FillBlanks";
import MultipleChoice from "./MultipleChoice";
import WriteBlanks from "./WriteBlanks";
import Writing from "./Writing";
const MatchSentences = dynamic(() => import("@/components/Solutions/MatchSentences"), {ssr: false});
@@ -21,5 +22,7 @@ export const renderSolution = (exercise: Exercise, onNext: () => void, onBack: (
return <MultipleChoice {...(exercise as MultipleChoiceExercise)} onNext={onNext} onBack={onBack} />;
case "writeBlanks":
return <WriteBlanks {...(exercise as WriteBlanksExercise)} onNext={onNext} onBack={onBack} />;
case "writing":
return <Writing {...(exercise as WritingExercise)} onNext={onNext} onBack={onBack} />;
}
};

View File

@@ -0,0 +1,72 @@
import React, {useEffect, useRef, useState} from "react";
import {BsPauseFill, BsPlayFill} from "react-icons/bs";
import WaveSurfer from "wavesurfer.js";
interface Props {
audio: string;
waveColor: string;
progressColor: string;
}
const Waveform = ({audio, waveColor, progressColor}: Props) => {
const containerRef = useRef(null);
const waveSurferRef = useRef<WaveSurfer | null>();
const [isPlaying, setIsPlaying] = useState(false);
useEffect(() => {
const waveSurfer = WaveSurfer.create({
container: containerRef?.current || "",
responsive: true,
cursorWidth: 0,
height: 24,
waveColor,
progressColor,
barGap: 5,
barWidth: 8,
barRadius: 4,
fillParent: true,
hideScrollbar: true,
normalize: true,
autoCenter: true,
ignoreSilenceMode: true,
barMinHeight: 4,
});
waveSurfer.load(audio);
waveSurfer.on("ready", () => {
waveSurferRef.current = waveSurfer;
});
waveSurfer.on("finish", () => setIsPlaying(false));
return () => {
waveSurfer.destroy();
};
}, [audio, progressColor, waveColor]);
return (
<>
{isPlaying && (
<BsPauseFill
className="text-mti-gray-cool cursor-pointer w-5 h-5"
onClick={() => {
setIsPlaying((prev) => !prev);
waveSurferRef.current?.playPause();
}}
/>
)}
{!isPlaying && (
<BsPlayFill
className="text-mti-gray-cool cursor-pointer w-5 h-5"
onClick={() => {
setIsPlaying((prev) => !prev);
waveSurferRef.current?.playPause();
}}
/>
)}
<div className="w-full max-w-4xl h-fit" ref={containerRef} />
</>
);
};
export default Waveform;

View File

@@ -1,8 +0,0 @@
import {Module} from "@/interfaces";
export const BAND_SCORES: {[key in Module]: number[]} = {
reading: [0, 2.5, 3, 3.5, 4, 4.5, 5, 5.5, 6, 6.5, 7, 7.5, 8, 8.5, 9],
listening: [0, 2.5, 3, 3.5, 4, 4.5, 5, 5.5, 6, 6.5, 7, 7.5, 8, 8.5, 9],
writing: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9],
speaking: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9],
};

84
src/constants/ielts.tsx Normal file
View File

@@ -0,0 +1,84 @@
import {Module} from "@/interfaces";
export const BAND_SCORES: {[key in Module]: number[]} = {
reading: [0, 2.5, 3, 3.5, 4, 4.5, 5, 5.5, 6, 6.5, 7, 7.5, 8, 8.5, 9],
listening: [0, 2.5, 3, 3.5, 4, 4.5, 5, 5.5, 6, 6.5, 7, 7.5, 8, 8.5, 9],
writing: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9],
speaking: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9],
};
export const LEVEL_TEXT = {
excellent:
"Congratulations on your exam performance! You achieved an impressive {{level}}, demonstrating excellent mastery of the assessed knowledge.\n\nIf you disagree with the result, you can request a review by a qualified teacher. We are committed to the accuracy and transparency of the results.\n\nPlease contact us for further information. Congratulations again on your outstanding achievement! We are here to support you on your academic journey.",
high: "Congratulations on your exam performance! You achieved a commendable {{level}}, demonstrating a good understanding of the assessed knowledge.\n\nIf you have any concerns about the result, you can request a review by a qualified teacher. We are committed to the accuracy and transparency of the results.\n\nPlease contact us for further information. Congratulations again on your achievement! We are here to support you on your academic journey.",
medium: "Congratulations on your exam performance! You achieved a {{level}}, demonstrating a satisfactory understanding of the assessed knowledge.\n\nIf you have any concerns about the result, you can request a review by a qualified teacher. We are committed to the accuracy and transparency of the results.\n\nPlease contact us for further information. Congratulations again on your achievement! We are here to support you on your academic journey.",
low: "Thank you for taking the exam. You achieved a {{level}}, but unfortunately, it did not meet the required standards.\n\nIf you have any concerns about the result, you can request a review by a qualified teacher. We are committed to the accuracy and transparency of the results.\n\nPlease contact us for further information. We encourage you to continue your studies and wish you the best of luck in your future endeavors.",
};
export const levelText = (level: number) => {
if (level === 9) {
return (
<>
Congratulations on your exam performance! You achieved an impressive <span className="font-bold">level {level}</span>, demonstrating
excellent mastery of the assessed knowledge.
<br />
<br />
If you disagree with the result, you can request a review by a qualified teacher. We are committed to the accuracy and transparency of
the results.
<br />
<br />
Please contact us for further information. Congratulations again on your outstanding achievement! We are here to support you on your
academic journey.
</>
);
}
if (level >= 6) {
return (
<>
Congratulations on your exam performance! You achieved a commendable <span className="font-bold">level {level}</span>, demonstrating a
good understanding of the assessed knowledge.
<br />
<br />
If you have any concerns about the result, you can request a review by a qualified teacher. We are committed to the accuracy and
transparency of the results.
<br />
<br />
Please contact us for further information. Congratulations again on your achievement! We are here to support you on your academic
journey.
</>
);
}
if (level >= 3) {
return (
<>
Congratulations on your exam performance! You achieved a <span className="font-bold">level of {level}</span>, demonstrating a
satisfactory understanding of the assessed knowledge.
<br />
<br />
If you have any concerns about the result, you can request a review by a qualified teacher. We are committed to the accuracy and
transparency of the results.
<br />
<br />
Please contact us for further information. Congratulations again on your achievement! We are here to support you on your academic
journey.
</>
);
}
return (
<>
Thank you for taking the exam. You achieved a <span className="font-bold">level {level}</span>, but unfortunately, it did not meet the
required standards.
<br />
<br />
If you have any concerns about the result, you can request a review by a qualified teacher. We are committed to the accuracy and
transparency of the results.
<br />
<br />
Please contact us for further information. We encourage you to continue your studies and wish you the best of luck in your future
endeavors.
</>
);
};

View File

@@ -1,92 +1,202 @@
import ProfileLevel from "@/components/ProfileLevel";
import {errorButtonStyle, infoButtonStyle} from "@/constants/buttonStyles";
import Button from "@/components/Low/Button";
import ModuleTitle from "@/components/Medium/ModuleTitle";
import {levelText, LEVEL_TEXT} from "@/constants/ielts";
import {Module} from "@/interfaces";
import {User} from "@/interfaces/user";
import {ICONS} from "@/resources/modules";
import {mdiArrowLeft, mdiArrowRight} from "@mdi/js";
import Icon from "@mdi/react";
import useExamStore from "@/stores/examStore";
import {calculateBandScore} from "@/utils/score";
import clsx from "clsx";
import Link from "next/link";
import router from "next/router";
import {useRouter} from "next/router";
import {Fragment, useEffect, useState} from "react";
import {BsArrowCounterclockwise, BsBook, BsEyeFill, BsHeadphones, BsMegaphone, BsPen, BsShareFill} from "react-icons/bs";
interface Score {
module: Module;
correct: number;
total: number;
missing: number;
}
interface Props {
user: User;
modules: Module[];
scores: {
module?: Module;
correct: number;
total: number;
}[];
scores: Score[];
isLoading: boolean;
onViewResults: () => void;
}
export default function Finish({user, scores, modules, onViewResults}: Props) {
const renderModuleScore = (module: Module) => {
const moduleScores = scores.filter((x) => x.module === module);
if (moduleScores.length === 0) {
return <>0</>;
}
export default function Finish({user, scores, modules, isLoading, onViewResults}: Props) {
const [selectedModule, setSelectedModule] = useState(modules[0]);
const [selectedScore, setSelectedScore] = useState<Score>(scores.find((x) => x.module === modules[0])!);
return moduleScores.map((x, index) => (
<span key={index}>
{x.correct} / {x.total}
</span>
));
};
const exams = useExamStore((state) => state.exams);
const renderModuleTotal = (module: Module) => {
const moduleScores = scores.filter((x) => x.module === module);
if (moduleScores.length === 0) {
return <>0</>;
}
useEffect(() => setSelectedScore(scores.find((x) => x.module === selectedModule)!), [scores, selectedModule]);
const total = moduleScores.reduce((accumulator, current) => accumulator + current.total, 0);
const correct = moduleScores.reduce((accumulator, current) => accumulator + current.correct, 0);
return (
<span>
{correct} / {total} | {Math.floor((correct / total) * 100)}%
</span>
);
const moduleColors: {[key in Module]: {progress: string; inner: string}} = {
reading: {
progress: "text-ielts-reading",
inner: "bg-ielts-reading-light",
},
listening: {
progress: "text-ielts-listening",
inner: "bg-ielts-listening-light",
},
writing: {
progress: "text-ielts-writing",
inner: "bg-ielts-writing-light",
},
speaking: {
progress: "text-ielts-speaking",
inner: "bg-ielts-speaking-light",
},
};
return (
<div className="w-full h-full relative">
<section className="h-full w-full flex flex-col items-center justify-center gap-4">
<ProfileLevel user={user} className="h-1/2" />
<div className="h-2/3 w-2/3 flex flex-col items-center gap-4">
<div className="rounded-xl p-2 md:p-4 items-center flex lg:justify-center gap-2 md:gap-4 max-w-[100%] overflow-auto">
{modules.map((module) => (
<div
className={`flex flex-col gap-12 min-w-[176px] items-center justify-center border-2 border-ielts-${module} bg-ielts-${module}-transparent py-4 px-2 rounded-xl text-white font-semibold`}
key={module}>
<div className="flex flex-col items-center">
<Icon path={ICONS[module]} color="white" size={2} />
<span>{module.toUpperCase()}</span>
</div>
<div className="flex flex-col">{renderModuleScore(module)}</div>
<div>TOTAL: {renderModuleTotal(module)}</div>
</div>
))}
</div>
<div className="w-full flex flex-col gap-4 lg:gap-0 md:flex-row justify-center items-center lg:justify-between">
<Link href="/">
<button className={clsx("btn btn-wide gap-4 relative text-white", errorButtonStyle)}>
<div className="absolute left-4">
<Icon path={mdiArrowLeft} color="white" size={1} />
</div>
Go Home
</button>
</Link>
<button className={clsx("btn btn-wide gap-4 relative text-white", infoButtonStyle)} onClick={onViewResults}>
View Solutions
<div className="absolute right-4">
<Icon path={mdiArrowRight} color="white" size={1} />
</div>
</button>
</div>
<>
<div className="w-full min-h-full h-fit flex flex-col items-center justify-between gap-8">
<ModuleTitle
module={selectedModule}
totalExercises={exams.find((x) => x.module === selectedModule)!.exercises.length}
exerciseIndex={exams.find((x) => x.module === selectedModule)!.exercises.length}
minTimer={exams.find((x) => x.module === selectedModule)!.minTimer}
disableTimer
/>
<div className="flex gap-4 self-start">
{modules.includes("reading") && (
<div
onClick={() => setSelectedModule("reading")}
className={clsx(
"flex gap-2 items-center rounded-xl p-4 cursor-pointer hover:shadow-lg transition duration-300 ease-in-out hover:bg-ielts-reading hover:text-white",
selectedModule === "reading" ? "bg-ielts-reading text-white" : "bg-mti-gray-smoke text-ielts-reading",
)}>
<BsBook className="w-6 h-6" />
<span className="font-semibold">Reading</span>
</div>
)}
{modules.includes("listening") && (
<div
onClick={() => setSelectedModule("listening")}
className={clsx(
"flex gap-2 items-center rounded-xl p-4 cursor-pointer hover:shadow-lg transition duration-300 ease-in-out hover:bg-ielts-listening hover:text-white",
selectedModule === "listening" ? "bg-ielts-listening text-white" : "bg-mti-gray-smoke text-ielts-listening",
)}>
<BsHeadphones className="w-6 h-6" />
<span className="font-semibold">Listening</span>
</div>
)}
{modules.includes("writing") && (
<div
onClick={() => setSelectedModule("writing")}
className={clsx(
"flex gap-2 items-center rounded-xl p-4 cursor-pointer hover:shadow-lg transition duration-300 ease-in-out hover:bg-ielts-writing hover:text-white",
selectedModule === "writing" ? "bg-ielts-writing text-white" : "bg-mti-gray-smoke text-ielts-writing",
)}>
<BsPen className="w-6 h-6" />
<span className="font-semibold">Writing</span>
</div>
)}
{modules.includes("speaking") && (
<div
onClick={() => setSelectedModule("speaking")}
className={clsx(
"flex gap-2 items-center rounded-xl p-4 cursor-pointer hover:shadow-lg transition duration-300 ease-in-out hover:bg-ielts-speaking hover:text-white",
selectedModule === "speaking" ? "bg-ielts-speaking text-white" : "bg-mti-gray-smoke text-ielts-speaking",
)}>
<BsMegaphone className="w-6 h-6" />
<span className="font-semibold">Speaking</span>
</div>
)}
</div>
</section>
</div>
{isLoading && (
<div className="w-fit h-fit absolute top-1/2 -translate-y-1/2 left-1/2 -translate-x-1/2 animate-pulse flex flex-col gap-12 items-center">
<span className={clsx("loading loading-infinity w-32", moduleColors[selectedModule].progress)} />
<span className={clsx("font-bold text-2xl", moduleColors[selectedModule].progress)}>Evaluating your answers...</span>
</div>
)}
{!isLoading && (
<div className="w-full flex gap-9 mt-32 items-center justify-between">
<span className="max-w-3xl">
{levelText(calculateBandScore(selectedScore.correct, selectedScore.total, selectedModule, user.focus))}
</span>
<div className="flex gap-9 px-16">
<div
className={clsx("radial-progress overflow-hidden", moduleColors[selectedModule].progress)}
style={
{"--value": (selectedScore.correct / selectedScore.total) * 100, "--thickness": "12px", "--size": "13rem"} as any
}>
<div
className={clsx(
"w-48 h-48 rounded-full flex flex-col items-center justify-center",
moduleColors[selectedModule].inner,
)}>
<span className="text-xl">Level</span>
<span className="text-3xl font-bold">
{calculateBandScore(selectedScore.correct, selectedScore.total, selectedModule, user.focus)}
</span>
</div>
</div>
<div className="flex flex-col gap-5">
<div className="flex gap-2">
<div className="w-3 h-3 bg-mti-blue-light rounded-full mt-1" />
<div className="flex flex-col">
<span className="text-mti-blue-light">
{(((selectedScore.total - selectedScore.missing) / selectedScore.total) * 100).toFixed(0)}%
</span>
<span className="text-lg">Completion</span>
</div>
</div>
<div className="flex gap-2">
<div className="w-3 h-3 bg-mti-green-light rounded-full mt-1" />
<div className="flex flex-col">
<span className="text-mti-green-light">{selectedScore.correct.toString().padStart(2, "0")}</span>
<span className="text-lg">Correct</span>
</div>
</div>
<div className="flex gap-2">
<div className="w-3 h-3 bg-mti-orange-light rounded-full mt-1" />
<div className="flex flex-col">
<span className="text-mti-orange-light">
{(selectedScore.total - selectedScore.correct).toString().padStart(2, "0")}
</span>
<span className="text-lg">Wrong</span>
</div>
</div>
</div>
</div>
</div>
)}
</div>
{!isLoading && (
<div className="self-end flex justify-between w-full gap-8 absolute bottom-8 left-0 px-8">
<div className="flex gap-8">
<div className="w-fit flex flex-col items-center gap-1 cursor-pointer">
<button
onClick={() => window.location.reload()}
className="w-11 h-11 rounded-full bg-mti-green-light hover:bg-mti-green flex items-center justify-center transition duration-300 ease-in-out">
<BsArrowCounterclockwise className="text-white w-7 h-7" />
</button>
<span>Play Again</span>
</div>
<div className="w-fit flex flex-col items-center gap-1 cursor-pointer">
<button
onClick={onViewResults}
className="w-11 h-11 rounded-full bg-mti-green-light hover:bg-mti-green flex items-center justify-center transition duration-300 ease-in-out">
<BsEyeFill className="text-white w-7 h-7" />
</button>
<span>Review Answers</span>
</div>
</div>
<Link href="/" className="max-w-[200px] w-full self-end">
<Button color="green" className="max-w-[200px] self-end w-full">
Dashboard
</Button>
</Link>
</div>
)}
</>
);
}

View File

@@ -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;
@@ -37,61 +40,69 @@ export default function Listening({exam, showSolutions = false, onFinish}: Props
}
};
const previousExercise = () => {
const previousExercise = (solution?: UserSolution) => {
if (solution) {
setUserSolutions((prev) => [...prev.filter((x) => x.exercise !== solution.exercise), solution]);
}
setExerciseIndex((prev) => prev - 1);
};
const getExercise = () => {
const exercise = exam.exercises[exerciseIndex];
return {
...exercise,
userSolutions: userSolutions.find((x) => x.exercise === exercise.id)?.solutions || [],
};
};
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={exam.audio.source}
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}
disableTimer={showSolutions}
/>
{exerciseIndex === -1 && renderAudioPlayer()}
{exerciseIndex > -1 &&
exerciseIndex < exam.exercises.length &&
!showSolutions &&
renderExercise(exam.exercises[exerciseIndex], nextExercise, previousExercise)}
renderExercise(getExercise(), nextExercise, previousExercise)}
{exerciseIndex > -1 &&
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>
)}
</>
);
}

View File

@@ -4,11 +4,17 @@ import Icon from "@mdi/react";
import {mdiArrowRight, mdiNotebook} from "@mdi/js";
import clsx from "clsx";
import {infoButtonStyle} from "@/constants/buttonStyles";
import {convertCamelCaseToReadable} from "@/utils/string";
import {Dialog, Transition} from "@headlessui/react";
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;
@@ -97,68 +103,69 @@ export default function Reading({exam, showSolutions = false, onFinish}: Props)
}
};
const previousExercise = () => {
const previousExercise = (solution?: UserSolution) => {
if (solution) {
setUserSolutions((prev) => [...prev.filter((x) => x.exercise !== solution.exercise), solution]);
}
setExerciseIndex((prev) => prev - 1);
};
const getExercise = () => {
const exercise = exam.exercises[exerciseIndex];
return {
...exercise,
userSolutions: userSolutions.find((x) => x.exercise === exercise.id)?.solutions || [],
};
};
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&apos;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}
disableTimer={showSolutions}
label={exerciseIndex === -1 ? undefined : convertCamelCaseToReadable(exam.exercises[exerciseIndex].type)}
/>
{exerciseIndex === -1 && renderText()}
{exerciseIndex > -1 &&
exerciseIndex < exam.exercises.length &&
!showSolutions &&
renderExercise(exam.exercises[exerciseIndex], nextExercise, previousExercise)}
renderExercise(getExercise(), nextExercise, previousExercise)}
{exerciseIndex > -1 &&
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>
)}
</>
);
}

View File

@@ -1,14 +1,15 @@
/* eslint-disable @next/next/no-img-element */
import Icon from "@mdi/react";
import {mdiAccountVoice, mdiArrowLeft, mdiArrowRight, mdiBookOpen, mdiHeadphones, mdiPen} from "@mdi/js";
import {useEffect, useState} from "react";
import {useState} from "react";
import {Module} from "@/interfaces";
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 useExamStore from "@/stores/examStore";
import ProgressBar from "@/components/Low/ProgressBar";
import {BsBook, BsCheckCircle, BsHeadphones, BsMegaphone, BsPen} from "react-icons/bs";
import {totalExamsByModule} from "@/utils/stats";
import useStats from "@/hooks/useStats";
import Button from "@/components/Low/Button";
import {calculateAverageLevel} from "@/utils/score";
import {sortByModuleName} from "@/utils/moduleUtils";
interface Props {
user: User;
@@ -17,8 +18,7 @@ interface Props {
export default function Selection({user, onStart}: Props) {
const [selectedModules, setSelectedModules] = useState<Module[]>([]);
const router = useRouter();
const {stats} = useStats(user?.id);
const toggleModule = (module: Module) => {
const modules = selectedModules.filter((x) => x !== module);
@@ -27,82 +27,150 @@ export default function Selection({user, onStart}: Props) {
return (
<>
<div className="w-full h-full relative">
<section className="h-full w-full flex flex-col items-center justify-center gap-8">
<ProfileLevel user={user} className="h-1/2" />
<div className="h-1/2 flex flex-col gap-8">
<div className="h-1/2 items-center flex flex-col lg:flex-row gap-8">
<div
role="button"
tabIndex={0}
onClick={() => toggleModule("reading")}
className={clsx(
"flex flex-col gap-2 items-center justify-center",
"border-ielts-reading hover:bg-ielts-reading text-white",
"border-2 rounded-xl p-4 h-fit w-48 cursor-pointer",
selectedModules.includes("reading") ? "bg-ielts-reading " : "bg-ielts-reading-transparent ",
)}>
<Icon path={mdiBookOpen} color="white" size={3} />
<span>Reading</span>
</div>
<div
role="button"
tabIndex={0}
onClick={() => toggleModule("listening")}
className={clsx(
"flex flex-col gap-2 items-center justify-center",
"border-ielts-listening hover:bg-ielts-listening text-white",
"border-2 rounded-xl p-4 h-fit w-48 cursor-pointer",
selectedModules.includes("listening") ? "bg-ielts-listening " : "bg-ielts-listening-transparent ",
)}>
<Icon path={mdiHeadphones} color="white" size={3} />
<span>Listening</span>
</div>
<div
role="button"
tabIndex={0}
onClick={() => toggleModule("writing")}
className={clsx(
"flex flex-col gap-2 items-center justify-center",
"border-ielts-writing hover:bg-ielts-writing text-white",
"border-2 rounded-xl p-4 h-fit w-48 cursor-pointer",
selectedModules.includes("writing") ? "bg-ielts-writing " : "bg-ielts-writing-transparent ",
)}>
<Icon path={mdiPen} color="white" size={3} />
<span>Writing</span>
</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 className="w-full h-full relative flex flex-col gap-16">
<section className="w-full flex gap-8">
<img src={user.profilePicture} alt={user.name} className="aspect-square h-64 rounded-3xl drop-shadow-xl" />
<div className="flex flex-col gap-4 py-4 w-full">
<div className="flex justify-between w-full gap-8">
<div className="flex flex-col gap-2 py-2">
<h1 className="font-bold text-4xl">{user.name}</h1>
<h6 className="font-normal text-base text-mti-gray-taupe capitalize">{user.type}</h6>
</div>
<ProgressBar
label={`Level ${calculateAverageLevel(user.levels).toFixed(1)}`}
percentage={100}
color="blue"
className="max-w-xs w-32 self-end h-10"
/>
</div>
<div className="w-full flex flex-col gap-4 md:flex-row justify-between">
<button onClick={() => router.push("/")} className={clsx("btn btn-wide gap-4 relative text-white", errorButtonStyle)}>
<div className="absolute left-4">
<Icon path={mdiArrowLeft} color="white" size={1} />
<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 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">
<BsBook className="text-ielts-reading w-8 h-8" />
</div>
Back
</button>
<button
className={clsx("btn btn-wide gap-4 relative text-white", infoButtonStyle)}
onClick={() => onStart(selectedModules)}>
Start
<div className="absolute right-4">
<Icon path={mdiArrowRight} color="white" size={1} />
<div className="flex flex-col">
<span className="font-bold text-xl">{totalExamsByModule(stats, "reading")}</span>
<span className="font-normal text-base text-mti-gray-dim">Reading</span>
</div>
</button>
</div>
<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">
<BsHeadphones className="text-ielts-listening w-8 h-8" />
</div>
<div className="flex flex-col">
<span className="font-bold text-xl">{totalExamsByModule(stats, "listening")}</span>
<span className="font-normal text-base text-mti-gray-dim">Listening</span>
</div>
</div>
<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">
<BsPen className="text-ielts-writing w-8 h-8" />
</div>
<div className="flex flex-col">
<span className="font-bold text-xl">{totalExamsByModule(stats, "writing")}</span>
<span className="font-normal text-base text-mti-gray-dim">Writing</span>
</div>
</div>
<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">
<BsMegaphone className="text-ielts-speaking w-8 h-8" />
</div>
<div className="flex flex-col">
<span className="font-bold text-xl">{totalExamsByModule(stats, "speaking")}</span>
<span className="font-normal text-base text-mti-gray-dim">Speaking</span>
</div>
</div>
</div>
</div>
</section>
<section className="flex flex-col gap-3">
<span className="font-bold text-lg">About Exams</span>
<span className="text-mti-gray-taupe">
This comprehensive test will assess your proficiency in reading, listening, writing, and speaking English. Be prepared to dive
into a variety of interesting and challenging topics while showcasing your ability to communicate effectively in English.
Master the vocabulary, grammar, and interpretation skills required to succeed in this high-level exam. Are you ready to
demonstrate your mastery of the English language to the world?
</span>
</section>
<section className="w-full flex justify-between gap-8 mt-8">
<div
onClick={() => toggleModule("reading")}
className={clsx(
"relative w-fit max-w-xs flex flex-col items-center bg-mti-white-alt transition duration-300 ease-in-out border p-5 rounded-xl gap-2 pt-12 cursor-pointer",
selectedModules.includes("reading") ? "border-mti-green-light" : "border-mti-gray-platinum",
)}>
<div className="absolute w-16 h-16 flex items-center justify-center rounded-full bg-ielts-reading top-0 -translate-y-1/2">
<BsBook className="text-white w-7 h-7" />
</div>
<span className="font-semibold">Reading:</span>
<p className="text-center text-xs">
Expand your vocabulary, improve your reading comprehension and improve your ability to interpret texts in English.
</p>
{!selectedModules.includes("reading") && <div className="border border-mti-gray-platinum w-8 h-8 rounded-full mt-4" />}
{selectedModules.includes("reading") && <BsCheckCircle className="mt-4 text-mti-green-light w-8 h-8" />}
</div>
<div
onClick={() => toggleModule("listening")}
className={clsx(
"relative w-fit max-w-xs flex flex-col items-center bg-mti-white-alt transition duration-300 ease-in-out border p-5 rounded-xl gap-2 pt-12 cursor-pointer",
selectedModules.includes("listening") ? "border-mti-green-light" : "border-mti-gray-platinum",
)}>
<div className="absolute w-16 h-16 flex items-center justify-center rounded-full bg-ielts-listening top-0 -translate-y-1/2">
<BsHeadphones className="text-white w-7 h-7" />
</div>
<span className="font-semibold">Listening:</span>
<p className="text-center text-xs">
Improve your ability to follow conversations in English and your ability to understand different accents and intonations.
</p>
{!selectedModules.includes("listening") && <div className="border border-mti-gray-platinum w-8 h-8 rounded-full mt-4" />}
{selectedModules.includes("listening") && <BsCheckCircle className="mt-4 text-mti-green-light w-8 h-8" />}
</div>
<div
onClick={() => toggleModule("writing")}
className={clsx(
"relative w-fit max-w-xs flex flex-col items-center bg-mti-white-alt transition duration-300 ease-in-out border p-5 rounded-xl gap-2 pt-12 cursor-pointer",
selectedModules.includes("writing") ? "border-mti-green-light" : "border-mti-gray-platinum",
)}>
<div className="absolute w-16 h-16 flex items-center justify-center rounded-full bg-ielts-writing top-0 -translate-y-1/2">
<BsPen className="text-white w-7 h-7" />
</div>
<span className="font-semibold">Writing:</span>
<p className="text-center text-xs">
Allow you to practice writing in a variety of formats, from simple paragraphs to complex essays.
</p>
{!selectedModules.includes("writing") && <div className="border border-mti-gray-platinum w-8 h-8 rounded-full mt-4" />}
{selectedModules.includes("writing") && <BsCheckCircle className="mt-4 text-mti-green-light w-8 h-8" />}
</div>
<div
onClick={() => toggleModule("speaking")}
className={clsx(
"relative w-fit max-w-xs flex flex-col items-center bg-mti-white-alt transition duration-300 ease-in-out border p-5 rounded-xl gap-2 pt-12 cursor-pointer",
selectedModules.includes("speaking") ? "border-mti-green-light" : "border-mti-gray-platinum",
)}>
<div className="absolute w-16 h-16 flex items-center justify-center rounded-full bg-ielts-speaking top-0 -translate-y-1/2">
<BsMegaphone className="text-white w-7 h-7" />
</div>
<span className="font-semibold">Speaking:</span>
<p className="text-center text-xs">
You&apos;ll have access to interactive dialogs, pronunciation exercises and speech recordings.
</p>
{!selectedModules.includes("speaking") && <div className="border border-mti-gray-platinum w-8 h-8 rounded-full mt-4" />}
{selectedModules.includes("speaking") && <BsCheckCircle className="mt-4 text-mti-green-light w-8 h-8" />}
</div>
</section>
<Button
onClick={() => onStart(selectedModules.sort(sortByModuleName))}
color="green"
className="px-12 w-full max-w-xs self-end"
disabled={selectedModules.length === 0}>
Start Exam
</Button>
</div>
</>
);

View File

@@ -1,4 +1,5 @@
import {renderExercise} from "@/components/Exercises";
import ModuleTitle from "@/components/Medium/ModuleTitle";
import {renderSolution} from "@/components/Solutions";
import {infoButtonStyle} from "@/constants/buttonStyles";
import {UserSolution, SpeakingExam} from "@/interfaces/exam";
@@ -37,28 +38,43 @@ export default function Speaking({exam, showSolutions = false, onFinish}: Props)
}
};
const previousExercise = () => {
setExerciseIndex((prev) => prev - 1);
const previousExercise = (solution?: UserSolution) => {
if (solution) {
setUserSolutions((prev) => [...prev.filter((x) => x.exercise !== solution.exercise), solution]);
}
if (exerciseIndex > 0) {
setExerciseIndex((prev) => prev - 1);
}
};
const getExercise = () => {
const exercise = exam.exercises[exerciseIndex];
return {
...exercise,
userSolutions: userSolutions.find((x) => x.exercise === exercise.id)?.solutions || [],
};
};
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 &&
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>
<>
<div className="flex flex-col h-full w-full gap-8 items-center">
<ModuleTitle
minTimer={exam.minTimer}
exerciseIndex={exerciseIndex + 1}
module="speaking"
totalExercises={exam.exercises.length}
disableTimer={showSolutions}
/>
{exerciseIndex > -1 &&
exerciseIndex < exam.exercises.length &&
!showSolutions &&
renderExercise(getExercise(), nextExercise, previousExercise)}
{exerciseIndex > -1 &&
exerciseIndex < exam.exercises.length &&
showSolutions &&
renderSolution(exam.exercises[exerciseIndex], nextExercise, previousExercise)}
</div>
</>
);
}

View File

@@ -1,4 +1,5 @@
import {renderExercise} from "@/components/Exercises";
import ModuleTitle from "@/components/Medium/ModuleTitle";
import {renderSolution} from "@/components/Solutions";
import {infoButtonStyle} from "@/constants/buttonStyles";
import {UserSolution, WritingExam} from "@/interfaces/exam";
@@ -37,28 +38,43 @@ export default function Writing({exam, showSolutions = false, onFinish}: Props)
}
};
const previousExercise = () => {
setExerciseIndex((prev) => prev - 1);
const previousExercise = (solution?: UserSolution) => {
if (solution) {
setUserSolutions((prev) => [...prev.filter((x) => x.exercise !== solution.exercise), solution]);
}
if (exerciseIndex > 0) {
setExerciseIndex((prev) => prev - 1);
}
};
const getExercise = () => {
const exercise = exam.exercises[exerciseIndex];
return {
...exercise,
userSolutions: userSolutions.find((x) => x.exercise === exercise.id)?.solutions || [],
};
};
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 &&
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>
<>
<div className="flex flex-col h-full w-full gap-8 items-center">
<ModuleTitle
minTimer={exam.minTimer}
exerciseIndex={exerciseIndex + 1}
module="writing"
totalExercises={exam.exercises.length}
disableTimer={showSolutions}
/>
{exerciseIndex > -1 &&
exerciseIndex < exam.exercises.length &&
!showSolutions &&
renderExercise(getExercise(), nextExercise, previousExercise)}
{exerciseIndex > -1 &&
exerciseIndex < exam.exercises.length &&
showSolutions &&
renderSolution(exam.exercises[exerciseIndex], nextExercise, previousExercise)}
</div>
</>
);
}

View File

@@ -35,6 +35,7 @@ export interface UserSolution {
score: {
correct: number;
total: number;
missing: number;
};
exercise: string;
}
@@ -68,16 +69,27 @@ export type Exercise =
| WritingExercise
| SpeakingExercise;
export interface WritingEvaluation {
comment: string;
overall: number;
task_response: {[key: string]: number};
}
export interface WritingExercise {
id: string;
type: "writing";
info: string; //* The information about the task, like the amount of time they should spend on it
prompt: string; //* The context given to the user containing what they should write about
wordCounter: WordCounter; //* The minimum or maximum amount of words that should be written
attachment?: string; //* The url for an image to work as an attachment to show the user
attachment?: {
url: string;
description: string;
}; //* The url for an image to work as an attachment to show the user
evaluation?: WritingEvaluation;
userSolutions: {
id: string;
solution: string;
evaluation?: WritingEvaluation;
}[];
}

View File

@@ -9,6 +9,7 @@ export interface User {
isFirstLogin: boolean;
focus: "academic" | "general";
levels: {[key in Module]: number};
desiredLevels: {[key in Module]: number};
type: Type;
}
@@ -24,6 +25,7 @@ export interface Stat {
score: {
correct: number;
total: number;
missing: number;
};
}

View File

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

View File

@@ -0,0 +1,36 @@
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
import type {NextApiRequest, NextApiResponse} from "next";
import {getFirestore, doc, getDoc} from "firebase/firestore";
import {withIronSessionApiRoute} from "iron-session/next";
import {sessionOptions} from "@/lib/session";
import axios from "axios";
interface Body {
question: string;
answer: string;
}
export default withIronSessionApiRoute(handler, sessionOptions);
async function handler(req: NextApiRequest, res: NextApiResponse) {
if (!req.session.user) {
res.status(401).json({ok: false});
return;
}
const {module} = req.query as {module: string};
if (module === "writing") {
const backendRequest = await axios.post(`${process.env.BACKEND_URL}/writing_task2`, req.body as Body, {
headers: {
Authorization: `Bearer ${process.env.BACKEND_JWT}`,
},
});
res.status(backendRequest.status).json(backendRequest.data);
return;
}
res.status(404).json({ok: false});
return;
}

View File

@@ -6,7 +6,7 @@ import {Module} from "@/interfaces";
import Selection from "@/exams/Selection";
import Reading from "@/exams/Reading";
import {Exam, ListeningExam, ReadingExam, SpeakingExam, UserSolution, WritingExam} from "@/interfaces/exam";
import {Exam, ListeningExam, ReadingExam, SpeakingExam, UserSolution, WritingEvaluation, WritingExam, WritingExercise} from "@/interfaces/exam";
import Listening from "@/exams/Listening";
import Writing from "@/exams/Writing";
import {ToastContainer, toast} from "react-toastify";
@@ -19,6 +19,10 @@ import Speaking from "@/exams/Speaking";
import {v4 as uuidv4} from "uuid";
import useUser from "@/hooks/useUser";
import useExamStore from "@/stores/examStore";
import Sidebar from "@/components/Sidebar";
import Layout from "@/components/High/Layout";
import {sortByModule} from "@/utils/moduleUtils";
import {writingReverseMarking} from "@/utils/score";
export const getServerSideProps = withIronSessionSsr(({req, res}) => {
const user = req.session.user;
@@ -44,7 +48,7 @@ export default function Page() {
const [moduleIndex, setModuleIndex] = useState(0);
const [sessionId, setSessionId] = useState("");
const [exam, setExam] = useState<Exam>();
const [timer, setTimer] = useState(-1);
const [isEvaluationLoading, setIsEvaluationLoading] = useState(false);
const [exams, setExams] = useExamStore((state) => [state.exams, state.setExams]);
const [userSolutions, setUserSolutions] = useExamStore((state) => [state.userSolutions, state.setUserSolutions]);
@@ -79,37 +83,24 @@ export default function Page() {
}, [selectedModules, setExams, exams]);
useEffect(() => {
(async () => {
if (selectedModules.length > 0 && exams.length === 0 && moduleIndex >= selectedModules.length && !hasBeenUploaded) {
const newStats: Stat[] = userSolutions.map((solution) => ({
...solution,
session: sessionId,
exam: solution.exam!,
module: solution.module!,
user: user?.id || "",
date: new Date().getTime(),
}));
if (selectedModules.length > 0 && exams.length !== 0 && moduleIndex >= selectedModules.length && !hasBeenUploaded) {
const newStats: Stat[] = userSolutions.map((solution) => ({
...solution,
session: sessionId,
exam: solution.exam!,
module: solution.module!,
user: user?.id || "",
date: new Date().getTime(),
}));
axios
.post<{ok: boolean}>("/api/stats", newStats)
.then((response) => setHasBeenUploaded(response.data.ok))
.catch(() => setHasBeenUploaded(false));
}
})();
axios
.post<{ok: boolean}>("/api/stats", newStats)
.then((response) => setHasBeenUploaded(response.data.ok))
.catch(() => setHasBeenUploaded(false));
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedModules, moduleIndex, hasBeenUploaded]);
useEffect(() => {
if (exam) {
setTimer(exam.minTimer * 60);
const timerInterval = setInterval(() => setTimer((prev) => (prev && prev > 0 ? prev - 1 : 0)), 1000);
return () => {
clearInterval(timerInterval);
};
}
}, [exam]);
const getExam = async (module: Module): Promise<Exam | undefined> => {
const examRequest = await axios<Exam[]>(`/api/exam/${module}`);
if (examRequest.status !== 200) {
@@ -131,6 +122,31 @@ export default function Page() {
}
};
const evaluateWritingAnswer = async (examId: string, exerciseId: string, solution: UserSolution) => {
const writingExam = exams.find((x) => x.id === examId)!;
const exercise = writingExam.exercises.find((x) => x.id === exerciseId)! as WritingExercise;
const response = await axios.post<WritingEvaluation>("/api/exam/writing/evaluate", {
question: `${exercise.prompt} ${exercise.attachment ? exercise.attachment.description : ""}`.replaceAll("\n", ""),
answer: solution.solutions[0].solution.trim().replaceAll("\n", " "),
});
if (response.status === 200) {
setUserSolutions([
...userSolutions.filter((x) => x.exercise !== exerciseId),
{
...solution,
score: {
correct: writingReverseMarking[response.data.overall],
missing: 0,
total: 100,
},
solutions: [{id: exerciseId, solution: solution.solutions[0].solution, evaluation: response.data}],
},
]);
}
};
const updateExamWithUserSolutions = (exam: Exam): Exam => {
const exercises = exam.exercises.map((x) =>
Object.assign(x, !x.userSolutions ? {userSolutions: userSolutions.find((y) => x.id === y.exercise)?.solutions} : x.userSolutions),
@@ -142,10 +158,58 @@ export default function Page() {
const onFinish = (solutions: UserSolution[]) => {
const solutionIds = solutions.map((x) => x.exercise);
if (exam && exam.module === "writing" && solutions.length > 0 && !showSolutions) {
setHasBeenUploaded(true);
setIsEvaluationLoading(true);
Promise.all(
exam.exercises.map((exercise) => evaluateWritingAnswer(exam.id, exercise.id, solutions.find((x) => x.exercise === exercise.id)!)),
).finally(() => {
setIsEvaluationLoading(false);
setHasBeenUploaded(false);
});
}
setUserSolutions([...userSolutions.filter((x) => !solutionIds.includes(x.exercise)), ...solutions]);
setModuleIndex((prev) => prev + 1);
};
const aggregateScoresByModule = (answers: UserSolution[]): {module: Module; total: number; missing: number; correct: number}[] => {
const scores: {[key in Module]: {total: number; missing: number; correct: number}} = {
reading: {
total: 0,
correct: 0,
missing: 0,
},
listening: {
total: 0,
correct: 0,
missing: 0,
},
writing: {
total: 0,
correct: 0,
missing: 0,
},
speaking: {
total: 0,
correct: 0,
missing: 0,
},
};
answers.forEach((x) => {
scores[x.module!] = {
total: scores[x.module!].total + x.score.total,
correct: scores[x.module!].correct + x.score.correct,
missing: scores[x.module!].missing + x.score.missing,
};
});
return Object.keys(scores)
.filter((x) => scores[x as Module].total > 0)
.map((x) => ({module: x as Module, ...scores[x as Module]}));
};
const renderScreen = () => {
if (selectedModules.length === 0) {
return <Selection user={user!} onStart={setSelectedModules} />;
@@ -154,6 +218,7 @@ export default function Page() {
if (moduleIndex >= selectedModules.length) {
return (
<Finish
isLoading={isEvaluationLoading}
user={user!}
modules={selectedModules}
onViewResults={() => {
@@ -161,7 +226,7 @@ export default function Page() {
setModuleIndex(0);
setExam(exams[0]);
}}
scores={userSolutions.map((x) => ({...x.score, module: x.module}))}
scores={aggregateScoresByModule(userSolutions)}
/>
);
}
@@ -174,11 +239,6 @@ export default function Page() {
return <Listening exam={exam} onFinish={onFinish} showSolutions={showSolutions} />;
}
if (exam && exam.module === "writing" && showSolutions) {
setModuleIndex((prev) => prev + 1);
return <></>;
}
if (exam && exam.module === "writing") {
return <Writing exam={exam} onFinish={onFinish} showSolutions={showSolutions} />;
}
@@ -208,10 +268,9 @@ export default function Page() {
</Head>
<ToastContainer />
{user && (
<main className="w-full h-full min-h-[100vh] flex flex-col items-center bg-neutral-100 text-black pb-4 gap-4">
<Navbar userType={user.type} profilePicture={user.profilePicture} timer={exam ? timer : undefined} />
<Layout user={user} className="justify-between">
{renderScreen()}
</main>
</Layout>
)}
</>
);

View File

@@ -1,154 +0,0 @@
/* eslint-disable @next/next/no-img-element */
import Head from "next/head";
import SingleDatasetChart from "@/components/UserResultChart";
import Navbar from "@/components/Navbar";
import ProfileCard from "@/components/ProfileCard";
import {withIronSessionSsr} from "iron-session/next";
import {sessionOptions} from "@/lib/session";
import {Stat, User} from "@/interfaces/user";
import {useEffect, useState} from "react";
import useStats from "@/hooks/useStats";
import {averageScore, convertToUserSolutions, formatModuleTotalStats, groupByDate, groupBySession, totalExams} from "@/utils/stats";
import {Divider} from "primereact/divider";
import useUser from "@/hooks/useUser";
import {Timeline} from "primereact/timeline";
import moment from "moment";
import {AutoComplete} from "primereact/autocomplete";
import useUsers from "@/hooks/useUsers";
import {Dropdown} from "primereact/dropdown";
import useExamStore from "@/stores/examStore";
import {Exam, ListeningExam, ReadingExam, SpeakingExam, WritingExam} from "@/interfaces/exam";
import {Module} from "@/interfaces";
import axios from "axios";
import {toast} from "react-toastify";
import {useRouter} from "next/router";
import Icon from "@mdi/react";
import {mdiArrowRight, mdiChevronRight} from "@mdi/js";
import {uniqBy} from "lodash";
import {getExamById} from "@/utils/exams";
import {sortByModule} from "@/utils/moduleUtils";
export const getServerSideProps = withIronSessionSsr(({req, res}) => {
const user = req.session.user;
if (!user) {
res.setHeader("location", "/login");
res.statusCode = 302;
res.end();
return {
props: {
user: null,
},
};
}
return {
props: {user: req.session.user},
};
}, sessionOptions);
export default function History({user}: {user: User}) {
const [selectedUser, setSelectedUser] = useState<User>(user);
const [groupedStats, setGroupedStats] = useState<{[key: string]: Stat[]}>();
const {users, isLoading: isUsersLoading} = useUsers();
const {stats, isLoading: isStatsLoading} = useStats(selectedUser?.id);
const setExams = useExamStore((state) => state.setExams);
const setShowSolutions = useExamStore((state) => state.setShowSolutions);
const setUserSolutions = useExamStore((state) => state.setUserSolutions);
const setSelectedModules = useExamStore((state) => state.setSelectedModules);
const router = useRouter();
useEffect(() => {
if (stats && !isStatsLoading) {
setGroupedStats(groupByDate(stats));
}
}, [stats, isStatsLoading]);
const formatTimestamp = (timestamp: string) => {
const date = moment(parseInt(timestamp));
const formatter = "YYYY/MM/DD - HH:mm";
return date.format(formatter);
};
const customContent = (timestamp: string) => {
if (!groupedStats) return <></>;
const dateStats = groupedStats[timestamp];
const correct = dateStats.reduce((accumulator, current) => accumulator + current.score.correct, 0);
const total = dateStats.reduce((accumulator, current) => accumulator + current.score.total, 0);
const selectExam = () => {
const examPromises = uniqBy(dateStats, "exam").map((stat) => getExamById(stat.module, stat.exam));
Promise.all(examPromises).then((exams) => {
if (exams.every((x) => !!x)) {
setUserSolutions(convertToUserSolutions(dateStats));
setShowSolutions(true);
setExams(exams.map((x) => x!).sort(sortByModule));
setSelectedModules(
exams
.map((x) => x!)
.sort(sortByModule)
.map((x) => x!.module),
);
router.push("/exam");
}
});
};
return (
<div className="flex flex-col gap-2">
<span>{formatTimestamp(timestamp)}</span>
<div
className="bg-white p-4 rounded-xl mb-4 flex justify-between items-center drop-shadow-lg cursor-pointer hover:bg-neutral-100 hover:drop-shadow-xl focus:bg-neutral-100 focus:drop-shadow-xl transition ease-in-out duration-300"
onClick={selectExam}
role="button">
<div className="flex flex-col gap-2 ">
<span>
Modules:{" "}
{formatModuleTotalStats(dateStats)
.filter((x) => x.value > 0)
.map((x) => x.label)
.join(", ")}
</span>
<span>
Score: {correct}/{total} | {((correct / total) * 100).toFixed(2)}%
</span>
</div>
<Icon path={mdiChevronRight} color="black" size={1} className="cursor-pointer" />
</div>
</div>
);
};
return (
<>
<Head>
<title>IELTS GPT | Muscat Training Institute</title>
<meta
name="description"
content="A training platform for the IELTS exam provided by the Muscat Training Institute and developed by eCrop."
/>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" href="/favicon.ico" />
</Head>
<main className="w-full h-full min-h-[100vh] flex flex-col bg-neutral-100 text-black">
<Navbar userType={user.type} profilePicture={user.profilePicture} />
<div className="w-fit self-center">
{!isUsersLoading && user.type !== "student" && (
<Dropdown value={selectedUser} options={users} optionLabel="name" onChange={(e) => setSelectedUser(e.target.value)} />
)}
</div>
<div className="w-2/3 h-full p-4 relative flex flex-col gap-8">
{groupedStats && !isStatsLoading && (
<Timeline value={Object.keys(groupedStats).sort((a, b) => parseInt(b) - parseInt(a))} content={customContent} />
)}
</div>
</main>
</>
);
}

View File

@@ -1,18 +1,21 @@
/* eslint-disable @next/next/no-img-element */
import Head from "next/head";
import SingleDatasetChart from "@/components/UserResultChart";
import Navbar from "@/components/Navbar";
import ProfileCard from "@/components/ProfileCard";
import {BsFileEarmarkText, BsPencil, BsStar, BsBook, BsHeadphones, BsPen, BsMegaphone} from "react-icons/bs";
import {withIronSessionSsr} from "iron-session/next";
import {sessionOptions} from "@/lib/session";
import {User} from "@/interfaces/user";
import {useEffect, useState} from "react";
import useStats from "@/hooks/useStats";
import {averageScore, formatModuleTotalStats, totalExams} from "@/utils/stats";
import {Divider} from "primereact/divider";
import {averageScore, totalExams} from "@/utils/stats";
import useUser from "@/hooks/useUser";
import Sidebar from "@/components/Sidebar";
import Diagnostic from "@/components/Diagnostic";
import {ToastContainer} from "react-toastify";
import {capitalize} from "lodash";
import {Module} from "@/interfaces";
import ProgressBar from "@/components/Low/ProgressBar";
import Layout from "@/components/High/Layout";
import {calculateAverageLevel} from "@/utils/score";
export const getServerSideProps = withIronSessionSsr(({req, res}) => {
const user = req.session.user;
@@ -34,15 +37,10 @@ export const getServerSideProps = withIronSessionSsr(({req, res}) => {
}, sessionOptions);
export default function Home() {
const [showEndExam, setShowEndExam] = useState(false);
const [windowWidth, setWindowWidth] = useState(0);
const [showDiagnostics, setShowDiagnostics] = useState(false);
const {stats, isLoading} = useStats();
const {user} = useUser({redirectTo: "/login"});
const {stats} = useStats(user?.id);
useEffect(() => setShowEndExam(window.innerWidth <= 960), []);
useEffect(() => setWindowWidth(window.innerWidth), []);
useEffect(() => {
if (user) setShowDiagnostics(user.isFirstLogin);
}, [user]);
@@ -59,7 +57,7 @@ export default function Home() {
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" href="/favicon.ico" />
</Head>
<main className="w-full h-full min-h-[100vh] flex flex-col items-center justify-center bg-neutral-100 text-black">
<main className="w-full h-full min-h-[100vh] flex flex-col items-center justify-center bg-neutral-100">
<Diagnostic user={user} onFinish={() => setShowDiagnostics(false)} />
</main>
</>
@@ -79,34 +77,159 @@ export default function Home() {
</Head>
<ToastContainer />
{user && (
<main className="w-full h-full min-h-[100vh] flex flex-col items-center bg-neutral-100 text-black">
<Navbar userType={user.type} profilePicture={user.profilePicture} showExamEnd={showEndExam} />
<div className="w-full h-full p-4 relative flex flex-col gap-8">
<section className="h-full w-full flex lg:gap-8 flex-col lg:flex-row justify-center md:justify-start md:items-start">
<section className="w-full h-full flex items-center">
<ProfileCard user={user} className="text-black self-start" />
</section>
{windowWidth <= 960 && <Divider />}
<div className="flex flex-col w-full gap-4">
<span className="font-bold text-2xl">Statistics</span>
{!isLoading && stats && (
<div className="text-neutral-600 flex flex-wrap gap-2 md:gap-4 w-full justify-between md:justify-start">
<div className="bg-white p-4 rounded-xl drop-shadow-xl flex flex-col gap-2 md:gap-4 w-full">
<span className="font-bold text-xl">Exams: {totalExams(stats)}</span>
<span className="font-bold text-xl">Exercises: {stats.length}</span>
<span className="font-bold text-xl">Average Score: {averageScore(stats)}%</span>
</div>
</div>
)}
<Layout user={user}>
<section className="w-full flex gap-8">
<img src={user.profilePicture} alt={user.name} className="aspect-square h-64 rounded-3xl drop-shadow-xl" />
<div className="flex flex-col gap-4 py-4 w-full">
<div className="flex justify-between w-full gap-8">
<div className="flex flex-col gap-2 py-2">
<h1 className="font-bold text-4xl">{user.name}</h1>
<h6 className="font-normal text-base text-mti-gray-taupe">{capitalize(user.type)}</h6>
</div>
<ProgressBar
label={`Level ${calculateAverageLevel(user.levels).toFixed(1)}`}
percentage={100}
color="blue"
className="max-w-xs w-32 self-end h-10"
/>
</div>
</section>
{!isLoading && stats && (
<section className="w-full lg:w-1/3 h-full flex items-center justify-center">
<SingleDatasetChart type="polarArea" data={formatModuleTotalStats(stats)} title="Exams per Module" />
</section>
)}
</div>
</main>
<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 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">
<BsFileEarmarkText className="w-8 h-8 text-mti-blue-light" />
</div>
<div className="flex flex-col">
<span className="font-bold text-xl">{totalExams(stats)}</span>
<span className="font-normal text-base text-mti-gray-dim">Exams</span>
</div>
</div>
<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">
<BsPencil className="w-8 h-8 text-mti-blue-light" />
</div>
<div className="flex flex-col">
<span className="font-bold text-xl">{stats.length}</span>
<span className="font-normal text-base text-mti-gray-dim">Exercises</span>
</div>
</div>
<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">
<BsStar className="w-8 h-8 text-mti-blue-light" />
</div>
<div className="flex flex-col">
<span className="font-bold text-xl">{averageScore(stats)}%</span>
<span className="font-normal text-base text-mti-gray-dim">Average Score</span>
</div>
</div>
</div>
</div>
</section>
<section className="flex flex-col gap-3">
<span className="font-bold text-lg">Bio</span>
<span className="text-mti-gray-taupe">
Patricia Smith is a dedicated and enthusiastic student. Her passion for knowledge drives her to constantly seek new
academic challenges. She is recognized for her exemplary work ethic, active participation in the classroom, and commitment
to helping her peers. Her insatiable curiosity has led her to explore a wide range of areas of study, making her a
versatile and adaptable learner. Patricia is a true academic leader, inspiring other students to pursue their own
educational goals.
</span>
</section>
<section className="flex flex-col gap-3">
<span className="font-bold text-lg">Score History</span>
<div className="grid grid-cols-2 gap-6">
<div className="border border-mti-gray-anti-flash rounded-xl flex flex-col gap-2 p-4">
<div className="flex gap-3 items-center">
<div className="w-12 h-12 bg-mti-gray-smoke flex items-center justify-center rounded-xl">
<BsBook className="text-ielts-reading w-5 h-5" />
</div>
<div className="flex justify-between w-full">
<span className="font-extrabold text-sm">Reading</span>
<span className="text-sm font-normal text-mti-gray-dim">
Level {user.levels.reading} / Level {user.desiredLevels.reading}
</span>
</div>
</div>
<div className="pl-14">
<ProgressBar
color="blue"
label=""
percentage={Math.round((user.levels.reading * 100) / user.desiredLevels.reading)}
className="w-full h-2"
/>
</div>
</div>
<div className="border border-mti-gray-anti-flash rounded-xl flex flex-col gap-2 p-4">
<div className="flex gap-3 items-center">
<div className="w-12 h-12 bg-mti-gray-smoke flex items-center justify-center rounded-xl">
<BsPen className="text-ielts-writing w-5 h-5" />
</div>
<div className="flex justify-between w-full">
<span className="font-extrabold text-sm">Writing</span>
<span className="text-sm font-normal text-mti-gray-dim">
Level {user.levels.writing} / Level {user.desiredLevels.writing}
</span>
</div>
</div>
<div className="pl-14">
<ProgressBar
color="blue"
label=""
percentage={Math.round((user.levels.writing * 100) / user.desiredLevels.writing)}
className="w-full h-2"
/>
</div>
</div>
<div className="border border-mti-gray-anti-flash rounded-xl flex flex-col gap-2 p-4">
<div className="flex gap-3 items-center">
<div className="w-12 h-12 bg-mti-gray-smoke flex items-center justify-center rounded-xl">
<BsHeadphones className="text-ielts-listening w-5 h-5" />
</div>
<div className="flex justify-between w-full">
<span className="font-extrabold text-sm">Listening</span>
<span className="text-sm font-normal text-mti-gray-dim">
Level {user.levels.listening} / Level {user.desiredLevels.listening}
</span>
</div>
</div>
<div className="pl-14">
<ProgressBar
color="blue"
label=""
percentage={Math.round((user.levels.listening * 100) / user.desiredLevels.listening)}
className="w-full h-2"
/>
</div>
</div>
<div className="border border-mti-gray-anti-flash rounded-xl flex flex-col gap-2 p-4">
<div className="flex gap-3 items-center">
<div className="w-12 h-12 bg-mti-gray-smoke flex items-center justify-center rounded-xl">
<BsMegaphone className="text-ielts-speaking w-5 h-5" />
</div>
<div className="flex justify-between w-full">
<span className="font-extrabold text-sm">Speaking</span>
<span className="text-sm font-normal text-mti-gray-dim">
Level {user.levels.speaking} / Level {user.desiredLevels.speaking}
</span>
</div>
</div>
<div className="pl-14">
<ProgressBar
color="blue"
label=""
percentage={Math.round((user.levels.speaking * 100) / user.desiredLevels.speaking)}
className="w-full h-2"
/>
</div>
</div>
</div>
</section>
</Layout>
)}
</>
);

View File

@@ -1,16 +1,20 @@
/* eslint-disable @next/next/no-img-element */
import {User} from "@/interfaces/user";
import {toast, ToastContainer} from "react-toastify";
import axios from "axios";
import {FormEvent, useState} from "react";
import Head from "next/head";
import useUser from "@/hooks/useUser";
import {InputText} from "primereact/inputtext";
import {Button} from "primereact/button";
import {Password} from "primereact/password";
import {Divider} from "primereact/divider";
import Button from "@/components/Low/Button";
import {BsArrowRepeat} from "react-icons/bs";
import Link from "next/link";
import Input from "@/components/Low/Input";
export default function Login() {
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [showPassword, setShowPassword] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const {mutateUser} = useUser({
@@ -46,38 +50,37 @@ export default function Login() {
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" href="/favicon.ico" />
</Head>
<main className="w-full h-screen flex flex-col items-center justify-center bg-neutral-100">
<main className="w-full h-[100vh] flex bg-white text-black">
<ToastContainer />
<form className="p-4 rounded-xl bg-white drop-shadow-xl flex flex-col gap-4" onSubmit={login}>
<div className="p-inputgroup">
<span className="p-inputgroup-addon">
<i className="pi pi-inbox"></i>
</span>
<InputText
placeholder="E-mail..."
type="email"
required
disabled={isLoading}
onChange={(e) => setEmail(e.target.value)}
autoComplete="username"
/>
<section className="h-full w-fit min-w-fit relative">
<div className="absolute h-full w-full bg-mti-orange-light z-10 bg-opacity-50" />
<img src="/people-talking-tablet.png" alt="People smiling looking at a tablet" className="h-full aspect-auto" />
</section>
<section className="h-full w-full flex flex-col items-center justify-center gap-2">
<div className="flex flex-col gap-2 items-center">
<h1 className="font-bold text-4xl">Login to your account</h1>
<p className="self-start text-base font-normal text-mti-gray-cool">with your registered Email Address</p>
</div>
<div className="p-inputgroup">
<span className="p-inputgroup-addon">
<i className="pi pi-star"></i>
</span>
<Password
placeholder="Password..."
type="password"
required
feedback={false}
disabled={isLoading}
onChange={(e) => setPassword(e.target.value)}
autoComplete="current-password"
/>
</div>
<Button loading={isLoading} iconPos="right" label="Login" icon="pi pi-check" />
</form>
<Divider className="max-w-md" />
<form className="flex flex-col items-center gap-6 w-1/2" onSubmit={login}>
<Input type="email" name="email" onChange={(e) => setEmail(e)} placeholder="Enter email address" />
<Input type="password" name="password" onChange={(e) => setPassword(e)} placeholder="Password" />
<Button className="mt-8 w-full" color="green" disabled={isLoading}>
{!isLoading && "Login"}
{isLoading && (
<div className="flex items-center justify-center">
<BsArrowRepeat className="text-white animate-spin" size={25} />
</div>
)}
</Button>
</form>
<span className="text-mti-gray-cool text-sm font-normal mt-8">
Don&apos;t have an account?{" "}
<Link className="text-mti-green-light" href="/register">
Sign up
</Link>
</span>
</section>
</main>
</>
);

View File

@@ -37,7 +37,6 @@ export default function Profile({user}: {user: User}) {
<link rel="icon" href="/favicon.ico" />
</Head>
<main className="w-full h-screen flex flex-col items-center bg-neutral-100">
<Navbar userType={user.type} profilePicture={user.profilePicture} />
<div className="w-full h-full flex flex-col items-center justify-center p-4 relative">
<section className="bg-white drop-shadow-xl p-4 rounded-xl w-96 flex flex-col items-center">
<Avatar image={user.profilePicture} size="xlarge" shape="circle" />

239
src/pages/record.tsx Normal file
View File

@@ -0,0 +1,239 @@
/* eslint-disable @next/next/no-img-element */
import Head from "next/head";
import SingleDatasetChart from "@/components/UserResultChart";
import Navbar from "@/components/Navbar";
import ProfileCard from "@/components/ProfileCard";
import {withIronSessionSsr} from "iron-session/next";
import {sessionOptions} from "@/lib/session";
import {Stat, User} from "@/interfaces/user";
import {useEffect, useState} from "react";
import useStats from "@/hooks/useStats";
import {averageScore, convertToUserSolutions, formatModuleTotalStats, groupByDate, groupBySession, totalExams} from "@/utils/stats";
import {Divider} from "primereact/divider";
import useUser from "@/hooks/useUser";
import {Timeline} from "primereact/timeline";
import moment from "moment";
import {AutoComplete} from "primereact/autocomplete";
import useUsers from "@/hooks/useUsers";
import {Dropdown} from "primereact/dropdown";
import useExamStore from "@/stores/examStore";
import {Exam, ListeningExam, ReadingExam, SpeakingExam, WritingExam} from "@/interfaces/exam";
import {Module} from "@/interfaces";
import axios from "axios";
import {toast, ToastContainer} from "react-toastify";
import {useRouter} from "next/router";
import Icon from "@mdi/react";
import {mdiArrowRight, mdiChevronRight} from "@mdi/js";
import {uniqBy} from "lodash";
import {getExamById} from "@/utils/exams";
import {sortByModule} from "@/utils/moduleUtils";
import Layout from "@/components/High/Layout";
import clsx from "clsx";
import {calculateAverageLevel, calculateBandScore} from "@/utils/score";
import {BsBook, BsHeadphones, BsMegaphone, BsPen} from "react-icons/bs";
export const getServerSideProps = withIronSessionSsr(({req, res}) => {
const user = req.session.user;
if (!user) {
res.setHeader("location", "/login");
res.statusCode = 302;
res.end();
return {
props: {
user: null,
},
};
}
return {
props: {user: req.session.user},
};
}, sessionOptions);
export default function History({user}: {user: User}) {
const [selectedUser, setSelectedUser] = useState<User>(user);
const [groupedStats, setGroupedStats] = useState<{[key: string]: Stat[]}>();
const {users, isLoading: isUsersLoading} = useUsers();
const {stats, isLoading: isStatsLoading} = useStats(selectedUser?.id);
const setExams = useExamStore((state) => state.setExams);
const setShowSolutions = useExamStore((state) => state.setShowSolutions);
const setUserSolutions = useExamStore((state) => state.setUserSolutions);
const setSelectedModules = useExamStore((state) => state.setSelectedModules);
const router = useRouter();
useEffect(() => {
if (stats && !isStatsLoading) {
setGroupedStats(groupByDate(stats));
}
}, [stats, isStatsLoading]);
const formatTimestamp = (timestamp: string) => {
const date = moment(parseInt(timestamp));
const formatter = "YYYY/MM/DD - HH:mm";
return date.format(formatter);
};
const aggregateScoresByModule = (stats: Stat[]): {module: Module; total: number; missing: number; correct: number}[] => {
const scores: {[key in Module]: {total: number; missing: number; correct: number}} = {
reading: {
total: 0,
correct: 0,
missing: 0,
},
listening: {
total: 0,
correct: 0,
missing: 0,
},
writing: {
total: 0,
correct: 0,
missing: 0,
},
speaking: {
total: 0,
correct: 0,
missing: 0,
},
};
stats.forEach((x) => {
scores[x.module!] = {
total: scores[x.module!].total + x.score.total,
correct: scores[x.module!].correct + x.score.correct,
missing: scores[x.module!].missing + x.score.missing,
};
});
return Object.keys(scores)
.filter((x) => scores[x as Module].total > 0)
.map((x) => ({module: x as Module, ...scores[x as Module]}));
};
const customContent = (timestamp: string) => {
if (!groupedStats) return <></>;
const dateStats = groupedStats[timestamp];
const correct = dateStats.reduce((accumulator, current) => accumulator + current.score.correct, 0);
const total = dateStats.reduce((accumulator, current) => accumulator + current.score.total, 0);
const aggregatedScores = aggregateScoresByModule(dateStats).filter((x) => x.total > 0);
const aggregatedLevels = aggregatedScores.map((x) => ({
module: x.module,
level: calculateBandScore(x.correct, x.total, x.module, user.focus),
}));
const selectExam = () => {
const examPromises = uniqBy(dateStats, "exam").map((stat) => getExamById(stat.module, stat.exam));
Promise.all(examPromises).then((exams) => {
if (exams.every((x) => !!x)) {
setUserSolutions(convertToUserSolutions(dateStats));
setShowSolutions(true);
setExams(exams.map((x) => x!).sort(sortByModule));
setSelectedModules(
exams
.map((x) => x!)
.sort(sortByModule)
.map((x) => x!.module),
);
router.push("/exam");
}
});
};
return (
<div
key={timestamp}
className={clsx(
"flex flex-col gap-4 border border-mti-gray-platinum p-4 cursor-pointer rounded-xl transition ease-in-out duration-300",
correct / total >= 0.7 && "hover:border-mti-green",
correct / total >= 0.3 && correct / total < 0.7 && "hover:border-mti-blue",
correct / total < 0.3 && "hover:border-mti-orange",
)}
onClick={selectExam}
role="button">
<div className="w-full flex justify-between items-center">
<span className="font-medium">{formatTimestamp(timestamp)}</span>
<span
className={clsx(
correct / total >= 0.7 && "text-mti-green",
correct / total >= 0.3 && correct / total < 0.7 && "text-mti-blue",
correct / total < 0.3 && "text-mti-orange",
)}>
Level{" "}
{(aggregatedLevels.reduce((accumulator, current) => accumulator + current.level, 0) / aggregatedLevels.length).toFixed(1)}
</span>
</div>
<div className="grid grid-cols-4 gap-4 place-items-center w-full">
{aggregatedLevels.map(({module, level}) => (
<div
key={module}
className={clsx(
"flex gap-2 items-center w-fit text-white px-4 py-2 rounded-xl",
module === "reading" && "bg-ielts-reading",
module === "listening" && "bg-ielts-listening",
module === "writing" && "bg-ielts-writing",
module === "speaking" && "bg-ielts-speaking",
)}>
{module === "reading" && <BsBook className="w-4 h-4" />}
{module === "listening" && <BsHeadphones className="w-4 h-4" />}
{module === "writing" && <BsPen className="w-4 h-4" />}
{module === "speaking" && <BsMegaphone className="w-4 h-4" />}
<span className="text-sm">{level.toFixed(1)}</span>
</div>
))}
</div>
</div>
);
};
return (
<>
<Head>
<title>IELTS GPT | Muscat Training Institute</title>
<meta
name="description"
content="A training platform for the IELTS exam provided by the Muscat Training Institute and developed by eCrop."
/>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" href="/favicon.ico" />
</Head>
<ToastContainer />
{user && (
<Layout user={user}>
<div className="w-fit">
{!isUsersLoading && user.type !== "student" && (
<>
<select
className="select w-full max-w-xs bg-white border border-mti-gray-platinum outline-none font-normal text-base"
onChange={(e) => setSelectedUser(users.find((x) => x.id === e.target.value)!)}>
{users.map((x) => (
<option key={x.id} selected={selectedUser.id === x.id} value={x.id}>
{x.name}
</option>
))}
</select>
</>
)}
</div>
{groupedStats && Object.keys(groupedStats).length > 0 && !isStatsLoading && (
<div className="grid grid-cols-3 w-full gap-6">
{Object.keys(groupedStats)
.sort((a, b) => parseInt(b) - parseInt(a))
.map(customContent)}
</div>
)}
{groupedStats && Object.keys(groupedStats).length === 0 && !isStatsLoading && (
<span className="font-semibold ml-1">No record to display...</span>
)}
</Layout>
)}
</>
);
}

68
src/pages/register.tsx Normal file
View File

@@ -0,0 +1,68 @@
/* eslint-disable @next/next/no-img-element */
import {ToastContainer} from "react-toastify";
import {useState} from "react";
import Head from "next/head";
import useUser from "@/hooks/useUser";
import Button from "@/components/Low/Button";
import {BsArrowRepeat} from "react-icons/bs";
import Link from "next/link";
import Input from "@/components/Low/Input";
export default function Register() {
const [name, setName] = useState("");
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [showPassword, setShowPassword] = useState(false);
const [confirmPassword, setConfirmPassword] = useState("");
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const {mutateUser} = useUser({
redirectTo: "/",
redirectIfFound: true,
});
return (
<>
<Head>
<title>Register | IELTS GPT</title>
<meta name="description" content="Generated by create next app" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" href="/favicon.ico" />
</Head>
<main className="w-full h-[100vh] flex bg-white text-black">
<ToastContainer />
<section className="h-full w-fit min-w-fit relative">
<div className="absolute h-full w-full bg-mti-orange-light z-10 bg-opacity-50" />
<img src="/people-talking-tablet.png" alt="People smiling looking at a tablet" className="h-full aspect-auto" />
</section>
<section className="h-full w-full flex flex-col items-center justify-center gap-12">
<h1 className="font-bold text-4xl">Create new account</h1>
<form className="flex flex-col items-center gap-6 w-1/2">
<Input type="text" name="name" onChange={(e) => setName(e)} placeholder="Enter your name" required />
<Input type="email" name="email" onChange={(e) => setEmail(e)} placeholder="Enter email address" required />
<Input type="password" name="password" onChange={(e) => setPassword(e)} placeholder="Enter your password" required />
<Input
type="password"
name="confirmPassword"
onChange={(e) => setConfirmPassword(e)}
placeholder="Confirm your password"
required
/>
<Button className="mt-8 w-full" color="green" disabled={isLoading}>
{!isLoading && "Create account"}
{isLoading && (
<div className="flex items-center justify-center">
<BsArrowRepeat className="text-white animate-spin" size={25} />
</div>
)}
</Button>
</form>
<Link className="text-mti-green-light text-sm font-normal" href="/login">
Sign in instead
</Link>
</section>
</main>
</>
);
}

View File

@@ -1,17 +1,24 @@
import Navbar from "@/components/Navbar";
import SingleDatasetChart from "@/components/UserResultChart";
import useStats from "@/hooks/useStats";
import useUsers from "@/hooks/useUsers";
import {User} from "@/interfaces/user";
import {sessionOptions} from "@/lib/session";
import {formatExerciseAverageScoreStats, formatExerciseTotalStats, formatModuleAverageScoreStats, formatModuleTotalStats} from "@/utils/stats";
import {withIronSessionSsr} from "iron-session/next";
/* eslint-disable @next/next/no-img-element */
import Head from "next/head";
import {AutoComplete} from "primereact/autocomplete";
import {Divider} from "primereact/divider";
import Navbar from "@/components/Navbar";
import {BsFileEarmarkText, BsPencil, BsStar, BsBook, BsHeadphones, BsPen, BsMegaphone} from "react-icons/bs";
import {ArcElement} from "chart.js";
import {withIronSessionSsr} from "iron-session/next";
import {sessionOptions} from "@/lib/session";
import {useEffect, useState} from "react";
import chartColors from "@/constants/chartColors.json";
import {shuffle} from "lodash";
import useStats from "@/hooks/useStats";
import {averageScore, totalExams, totalExamsByModule, groupBySession} from "@/utils/stats";
import useUser from "@/hooks/useUser";
import Sidebar from "@/components/Sidebar";
import Diagnostic from "@/components/Diagnostic";
import {ToastContainer} from "react-toastify";
import {capitalize} from "lodash";
import {Module} from "@/interfaces";
import ProgressBar from "@/components/Low/ProgressBar";
import Layout from "@/components/High/Layout";
import {calculateAverageLevel} from "@/utils/score";
import {MODULE_ARRAY} from "@/utils/moduleUtils";
import {Chart} from "react-chartjs-2";
export const getServerSideProps = withIronSessionSsr(({req, res}) => {
const user = req.session.user;
@@ -32,22 +39,25 @@ export const getServerSideProps = withIronSessionSsr(({req, res}) => {
};
}, sessionOptions);
export default function Stats({user}: {user: User}) {
const [autocompleteValue, setAutocompleteValue] = useState(user.name);
const [selectedUser, setSelectedUser] = useState<User>();
const [items, setItems] = useState<User[]>([]);
export default function Stats() {
const {user} = useUser({redirectTo: "/login"});
const {stats} = useStats(user?.id);
const {users, isLoading} = useUsers();
const {stats, isLoading: isStatsLoading} = useStats(selectedUser?.id);
const search = (event: {query: string}) => {
setItems(event.query ? users.filter((x) => x.name.startsWith(event.query)) : users);
const totalExamsData = {
labels: MODULE_ARRAY.map((x) => capitalize(x)),
datasets: [
{
label: "Total exams",
data: MODULE_ARRAY.map((x) => totalExamsByModule(stats, x)),
backgroundColor: ["#1EB3FF", "#FF790A", "#3D9F11", "#EF5DA8"],
},
],
};
return (
<>
<Head>
<title>IELTS GPT | Stats</title>
<title>Stats | IELTS GPT</title>
<meta
name="description"
content="A training platform for the IELTS exam provided by the Muscat Training Institute and developed by eCrop."
@@ -55,86 +65,133 @@ export default function Stats({user}: {user: User}) {
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" href="/favicon.ico" />
</Head>
<main className="w-full h-full min-h-[100vh] flex flex-col items-center bg-neutral-100 text-neutral-600">
<Navbar userType={user.type} profilePicture={user.profilePicture} />
<div className="w-full h-full flex flex-col items-center justify-center p-4 relative gap-8">
{!isLoading && user.type !== "student" && (
<AutoComplete
value={autocompleteValue}
suggestions={items}
field="name"
onChange={(e) => setAutocompleteValue(e.target.value)}
completeMethod={search}
onSelect={(e) => setSelectedUser(e.value)}
dropdown
/>
)}
<section className="flex flex-col gap-2 md:gap-4 w-full">
<div className="flex flex-col">
<span className="font-semibold">Module Statistics</span>
<Divider />
</div>
<div className="flex flex-col md:grid md:grid-cols-3 w-full gap-4">
{!isStatsLoading && stats && (
<>
<div className="max-w-lg">
<SingleDatasetChart
type="polarArea"
data={formatModuleTotalStats(stats)}
title="Exams per Module"
label="Total"
/>
<ToastContainer />
{user && (
<Layout user={user}>
<section className="w-full flex gap-8">
<img src={user.profilePicture} alt={user.name} className="aspect-square h-64 rounded-3xl drop-shadow-xl" />
<div className="flex flex-col gap-4 py-4 w-full">
<div className="flex justify-between w-full gap-8">
<div className="flex flex-col gap-2 py-2">
<h1 className="font-bold text-4xl">{user.name}</h1>
<h6 className="font-normal text-base text-mti-gray-taupe">{capitalize(user.type)}</h6>
</div>
<ProgressBar
label={`Level ${calculateAverageLevel(user.levels).toFixed(1)}`}
percentage={100}
color="blue"
className="max-w-xs w-32 self-end h-10"
/>
</div>
<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 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">
<BsFileEarmarkText className="w-8 h-8 text-mti-blue-light" />
</div>
<div className="max-w-lg">
<SingleDatasetChart
type="polarArea"
data={formatModuleAverageScoreStats(stats)}
title="Average Score per Module"
label="Score (in %)"
/>
<div className="flex flex-col">
<span className="font-bold text-xl">{Object.keys(groupBySession(stats)).length}</span>
<span className="font-normal text-base text-mti-gray-dim">Exams</span>
</div>
<div className="max-w-lg">
<SingleDatasetChart type="polarArea" data={formatModuleTotalStats(stats)} title="Total exams" />
</div>
<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">
<BsPencil className="w-8 h-8 text-mti-blue-light" />
</div>
</>
)}
<div className="flex flex-col">
<span className="font-bold text-xl">{stats.length}</span>
<span className="font-normal text-base text-mti-gray-dim">Exercises</span>
</div>
</div>
<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">
<BsStar className="w-8 h-8 text-mti-blue-light" />
</div>
<div className="flex flex-col">
<span className="font-bold text-xl">{averageScore(stats)}%</span>
<span className="font-normal text-base text-mti-gray-dim">Average Score</span>
</div>
</div>
</div>
</div>
</section>
<section className="flex flex-col gap-2 md:gap-4 w-full">
<div className="flex flex-col">
<span className="font-semibold">Exam Statistics</span>
<Divider />
</div>
<div className="flex flex-col md:grid md:grid-cols-3 w-full gap-4">
{!isStatsLoading && stats && (
<>
<div className="max-w-lg">
<SingleDatasetChart
type="polarArea"
data={formatExerciseTotalStats(stats)}
title="Exercises per Type"
label="Total"
colors={chartColors}
<section className="flex flex-col gap-3">
<span className="font-semi text-lg">Module Statistics</span>
<div className="flex gap-4 justify-between">
<div className="flex flex-col gap-12 border w-full max-w-xs border-mti-gray-platinum p-4 pb-12 rounded-xl">
<span className="text-sm font-bold">Exams per Module</span>
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-2">
<div className="flex justify-between items-end">
<span className="text-xs">
<span className="font-medium">{totalExamsByModule(stats, "reading")}</span> of{" "}
<span className="font-medium">{Object.keys(groupBySession(stats)).length}</span>
</span>
<span className="text-xs">Reading</span>
</div>
<ProgressBar
color="reading"
percentage={(totalExamsByModule(stats, "reading") * 100) / Object.keys(groupBySession(stats)).length}
label=""
className="h-1"
/>
</div>
<div className="max-w-lg">
<SingleDatasetChart
type="polarArea"
data={formatExerciseAverageScoreStats(stats)}
title="Average Score by Exercise"
label="Average"
colors={chartColors}
<div className="flex flex-col gap-2">
<div className="flex justify-between items-end">
<span className="text-xs">
<span className="font-medium">{totalExamsByModule(stats, "listening")}</span> of{" "}
<span className="font-medium">{Object.keys(groupBySession(stats)).length}</span>
</span>
<span className="text-xs">Listening</span>
</div>
<ProgressBar
color="listening"
percentage={(totalExamsByModule(stats, "listening") * 100) / Object.keys(groupBySession(stats)).length}
label=""
className="h-1"
/>
</div>
</>
)}
<div className="flex flex-col gap-2">
<div className="flex justify-between items-end">
<span className="text-xs">
<span className="font-medium">{totalExamsByModule(stats, "writing")}</span> of{" "}
<span className="font-medium">{Object.keys(groupBySession(stats)).length}</span>
</span>
<span className="text-xs">Writing</span>
</div>
<ProgressBar
color="writing"
percentage={(totalExamsByModule(stats, "writing") * 100) / Object.keys(groupBySession(stats)).length}
label=""
className="h-1"
/>
</div>
<div className="flex flex-col gap-2">
<div className="flex justify-between items-end">
<span className="text-xs">
<span className="font-medium">{totalExamsByModule(stats, "speaking")}</span> of{" "}
<span className="font-medium">{Object.keys(groupBySession(stats)).length}</span>
</span>
<span className="text-xs">Speaking</span>
</div>
<ProgressBar
color="speaking"
percentage={(totalExamsByModule(stats, "speaking") * 100) / Object.keys(groupBySession(stats)).length}
label=""
className="h-1"
/>
</div>
</div>
</div>
</div>
</section>
</div>
</main>
</Layout>
)}
</>
);
}

190
src/pages/test.tsx Normal file
View File

@@ -0,0 +1,190 @@
/* eslint-disable @next/next/no-img-element */
import Head from "next/head";
import Navbar from "@/components/Navbar";
import {ToastContainer} from "react-toastify";
import {withIronSessionSsr} from "iron-session/next";
import {sessionOptions} from "@/lib/session";
import useUser from "@/hooks/useUser";
import Sidebar from "@/components/Sidebar";
import dynamic from "next/dynamic";
import {BsCheckCircleFill, BsMicFill, BsPauseCircle, BsPauseFill, BsPlayCircle, BsPlayFill, BsTrashFill} from "react-icons/bs";
import {useEffect, useState} from "react";
import Layout from "@/components/High/Layout";
const Waveform = dynamic(() => import("../components/Waveform"), {ssr: false});
const ReactMediaRecorder = dynamic(() => import("react-media-recorder").then((mod) => mod.ReactMediaRecorder), {
ssr: false,
});
export const getServerSideProps = withIronSessionSsr(({req, res}) => {
const user = req.session.user;
if (!user) {
res.setHeader("location", "/login");
res.statusCode = 302;
res.end();
return {
props: {
user: null,
},
};
}
return {
props: {user: req.session.user},
};
}, sessionOptions);
export default function Page() {
const {user} = useUser({redirectTo: "/login"});
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 (
<>
<Head>
<title>Exam | IELTS GPT</title>
<meta
name="description"
content="A training platform for the IELTS exam provided by the Muscat Training Institute and developed by eCrop."
/>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" href="/favicon.ico" />
</Head>
<ToastContainer />
{user && (
<Layout user={user}>
<ReactMediaRecorder
audio
onStop={(blob) => setMediaBlob(blob)}
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>
)}
/>
</Layout>
)}
</>
);
}

View File

@@ -2,7 +2,6 @@ import {withIronSessionSsr} from "iron-session/next";
import {sessionOptions} from "@/lib/session";
import {Type, User} from "@/interfaces/user";
import Head from "next/head";
import Navbar from "@/components/Navbar";
import {Avatar} from "primereact/avatar";
import {useEffect, useState} from "react";
import {FilterMatchMode, FilterOperator} from "primereact/api";
@@ -67,7 +66,6 @@ export default function Users({user}: {user: User}) {
<link rel="icon" href="/favicon.ico" />
</Head>
<main className="w-full h-full min-h-[100vh] flex flex-col items-center bg-neutral-100">
<Navbar userType={user.type} profilePicture={user.profilePicture} />
<div className="w-full h-full flex flex-col items-center justify-center p-4 relative">
<DataTable
dataKey="id"

View File

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

View File

@@ -8,9 +8,9 @@
--font-mono: ui-monospace, Menlo, Monaco, "Cascadia Mono", "Segoe UI Mono", "Roboto Mono", "Oxygen Mono", "Ubuntu Monospace", "Source Code Pro",
"Fira Mono", "Droid Sans Mono", "Courier New", monospace;
--foreground-rgb: 0, 0, 0;
--background-start-rgb: 214, 219, 220;
--background-end-rgb: 255, 255, 255;
--foreground-rgb: 53, 51, 56;
--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));
@@ -25,26 +25,6 @@
--card-border-rgb: 131, 134, 135;
}
@media (prefers-color-scheme: dark) {
:root {
--foreground-rgb: 255, 255, 255;
--background-start-rgb: 0, 0, 0;
--background-end-rgb: 0, 0, 0;
--primary-glow: radial-gradient(rgba(1, 65, 255, 0.4), rgba(1, 65, 255, 0));
--secondary-glow: linear-gradient(to bottom right, rgba(1, 65, 255, 0), rgba(1, 65, 255, 0), rgba(1, 65, 255, 0.3));
--tile-start-rgb: 2, 13, 46;
--tile-end-rgb: 2, 5, 19;
--tile-border: conic-gradient(#ffffff80, #ffffff40, #ffffff30, #ffffff20, #ffffff10, #ffffff10, #ffffff80);
--callout-rgb: 20, 20, 20;
--callout-border-rgb: 108, 108, 108;
--card-rgb: 100, 100, 100;
--card-border-rgb: 200, 200, 200;
}
}
* {
box-sizing: border-box;
padding: 0;
@@ -53,8 +33,11 @@
html,
body {
min-height: 100vh !important;
height: 100%;
max-width: 100vw;
overflow-x: hidden;
font-family: "Open Sans", system-ui, -apple-system, "Helvetica Neue", sans-serif;
}
body {
@@ -66,9 +49,3 @@ a {
color: inherit;
text-decoration: none;
}
@media (prefers-color-scheme: dark) {
html {
color-scheme: dark;
}
}

View File

@@ -1,6 +1,6 @@
import {Module} from "@/interfaces";
const MODULE_ARRAY: Module[] = ["reading", "listening", "writing", "speaking"];
export const MODULE_ARRAY: Module[] = ["reading", "listening", "writing", "speaking"];
export const moduleLabels: {[key in Module]: string} = {
listening: "Listening",
@@ -12,3 +12,7 @@ export const moduleLabels: {[key in Module]: string} = {
export const sortByModule = (a: {module: Module}, b: {module: Module}) => {
return MODULE_ARRAY.findIndex((x) => a.module === x) - MODULE_ARRAY.findIndex((x) => b.module === x);
};
export const sortByModuleName = (a: string, b: string) => {
return MODULE_ARRAY.findIndex((x) => a === x) - MODULE_ARRAY.findIndex((x) => b === x);
};

101
src/utils/score.ts Normal file
View File

@@ -0,0 +1,101 @@
import {Module} from "@/interfaces";
type Type = "academic" | "general";
export const writingReverseMarking: {[key: number]: number} = {
9: 90,
8: 80,
7: 70,
6: 60,
5: 50,
4: 40,
3: 30,
2: 20,
1: 10,
0: 0,
};
const writingMarking: {[key: number]: number} = {
90: 9,
80: 8,
70: 7,
60: 6,
50: 5,
40: 4,
30: 3,
20: 2,
10: 1,
0: 0,
};
const readingGeneralMarking: {[key: number]: number} = {
100: 9,
97.5: 8.5,
92.5: 8,
90: 7.5,
85: 7,
80: 6.5,
75: 6,
67.5: 5.5,
57.5: 5,
45.5: 4.5,
37.5: 4,
30: 3.5,
22.5: 3,
15: 2.5,
};
const academicMarking: {[key: number]: number} = {
97.5: 9,
92.5: 8.5,
87.5: 8,
80: 7.5,
75: 7,
65: 6.5,
57.5: 6,
45: 5.5,
40: 5,
32.5: 4.5,
25: 4,
20: 3.5,
15: 3,
10: 2.5,
};
const moduleMarkings: {[key in Module]: {[key in Type]: {[key: number]: number}}} = {
reading: {
academic: academicMarking,
general: readingGeneralMarking,
},
listening: {
academic: academicMarking,
general: academicMarking,
},
writing: {
academic: writingMarking,
general: writingMarking,
},
speaking: {
academic: academicMarking,
general: academicMarking,
},
};
export const calculateBandScore = (correct: number, total: number, module: Module, type: Type) => {
const marking = moduleMarkings[module][type];
const percentage = (correct * 100) / total;
for (const value of Object.keys(marking)
.map((x) => parseFloat(x))
.sort((a, b) => b - a)) {
if (percentage >= value) {
return marking[value];
}
}
return 0;
};
export const calculateAverageLevel = (levels: {[key in Module]: number}) => {
return Object.keys(levels).reduce((accumulator, current) => levels[current as Module] + accumulator, 0) / 4;
};

View File

@@ -2,6 +2,7 @@ import {Stat} from "@/interfaces/user";
import {capitalize, groupBy} from "lodash";
import {convertCamelCaseToReadable} from "@/utils/string";
import {UserSolution} from "@/interfaces/exam";
import {Module} from "@/interfaces";
export const totalExams = (stats: Stat[]): number => {
const moduleStats = formatModuleTotalStats(stats);
@@ -35,6 +36,22 @@ export const formatModuleTotalStats = (stats: Stat[]): {label: string; value: nu
}));
};
export const totalExamsByModule = (stats: Stat[], module: Module): number => {
const moduleSessions: {[key: string]: string[]} = {};
stats.forEach((stat) => {
if (stat.module in moduleSessions) {
if (!moduleSessions[stat.module].includes(stat.session)) {
moduleSessions[stat.module] = [...moduleSessions[stat.module], stat.session];
}
} else {
moduleSessions[stat.module] = [stat.session];
}
});
return moduleSessions[module]?.length || 0;
};
export const formatModuleAverageScoreStats = (stats: Stat[]): {label: string; value: number}[] => {
const moduleScores: {[key: string]: {correct: number; total: number}} = {};

View File

@@ -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")}`;
}

View File

@@ -4,11 +4,28 @@ module.exports = {
theme: {
extend: {
colors: {
mti: {
orange: {DEFAULT: "#FF6000", dark: "#cc4402", light: "#ff790a", ultralight: "#ffdaa5"},
green: {DEFAULT: "#307912", dark: "#2a6014", light: "#3d9f11", ultralight: "#c6edaf"},
blue: {DEFAULT: "#0696ff", dark: "#007ff8", light: "#1eb3ff", ultralight: "#b5edff"},
white: {DEFAULT: "#ffffff", alt: "#FDFDFD"},
gray: {
seasalt: "#F9F9F9",
smoke: "#F5F5F5",
taupe: "#898492",
dim: "#696F79",
cool: "#8692A6",
platinum: "#DBDBDB",
"anti-flash": "#EAEBEC",
davy: "#595959",
},
black: "#353338",
},
ielts: {
reading: {DEFAULT: "#FF6384", transparent: "rgba(255, 99, 132, 0.5)"},
listening: {DEFAULT: "#36A2EB", transparent: "rgba(54, 162, 235, 0.5)"},
writing: {DEFAULT: "#FFCE56", transparent: "rgba(255, 206, 86, 0.5)"},
speaking: {DEFAULT: "#4bc0c0", transparent: "rgba(75, 192, 192, 0.5)"},
reading: {DEFAULT: "#1EB3FF", light: "#F0F9FF", transparent: "rgba(255, 99, 132, 0.5)"},
listening: {DEFAULT: "#FF790A", light: "#FFF1E5", transparent: "rgba(54, 162, 235, 0.5)"},
writing: {DEFAULT: "#3D9F11", light: "#E8FCDF", transparent: "rgba(255, 206, 86, 0.5)"},
speaking: {DEFAULT: "#EF5DA8", light: "#FEF6FA", transparent: "rgba(75, 192, 192, 0.5)"},
},
},
},

2056
yarn.lock

File diff suppressed because it is too large Load Diff