Compare commits

...

36 Commits

Author SHA1 Message Date
Cristiano Ferreira
7962857a95 Sidebar and button created. 2023-08-21 17:36:04 +01:00
Joao Ramos
78c5b7027e Added abandon exam/exercise handler 2023-08-16 19:32:39 +01:00
Joao Ramos
cd71cf4833 Added abandon popup 2023-08-16 00:38:54 +01:00
Joao Ramos
93a5bcf40f Added initial focus trap during exercises/exams 2023-08-16 00:08:20 +01:00
Tiago Ribeiro
dd0acbea61 Added more onClicks 2023-08-13 21:50:12 +01:00
Tiago Ribeiro
ef736bc63e Resolved the Questions Blank bug 2023-08-12 00:03:35 +01:00
Tiago Ribeiro
d9ca0e84a6 Some light bug solving 2023-08-11 23:54:09 +01:00
Tiago Ribeiro
db54d58bab - Added a new type of exercise
- Updated all solutions to solve a huge bug where after reviewing, it would reset the score
2023-08-11 14:23:09 +01:00
Tiago Ribeiro
5099721b9b Finished up the Diagnostic page 2023-08-08 00:06:01 +01:00
Tiago Ribeiro
2c2fbffd8c Well, removed unused thingy 2023-08-07 23:48:48 +01:00
Tiago Ribeiro
3fee4292f1 Updated the Writing to work with another format 2023-08-07 23:39:29 +01:00
Tiago Ribeiro
7e9e28f134 Updated the styling of the Diagnostic page 2023-08-07 22:52:10 +01:00
Tiago Ribeiro
d879f4afab Made it so the timer is more dynamic 2023-07-27 20:25:57 +01:00
Tiago Ribeiro
d38ca76182 Seems to have solved some other issues 2023-07-27 15:41:34 +01:00
Tiago Ribeiro
77692d270e Made it so when the timer ends, the module ends 2023-07-27 13:59:00 +01:00
Tiago Ribeiro
f5c3abb310 Fully implemented the register flow 2023-07-25 19:53:48 +01:00
Tiago Ribeiro
02260d496c Solved some more bugs and styling 2023-07-25 00:09:25 +01:00
Tiago Ribeiro
581adbb56e - Updated the colors of the application;
- Added the ability for a user to partially update their profile
2023-07-22 10:11:10 +01:00
Tiago Ribeiro
6ade34d243 Updated the platform colors to the new ones 2023-07-22 07:18:28 +01:00
Tiago Ribeiro
16ea0b497e Ooopsy 2023-07-21 13:42:07 +01:00
Tiago Ribeiro
ea41875e36 Updated the Formidable to work with serverless (supposedly) 2023-07-21 13:37:41 +01:00
Tiago Ribeiro
eae0a4ae4e Updated the clock of the Speaking timer 2023-07-21 12:41:44 +01:00
Tiago Ribeiro
fea788bdc4 Updated the next.config.js 2023-07-21 10:43:15 +01:00
Tiago Ribeiro
86c69e5993 Removed the --link flag 2023-07-21 10:30:14 +01:00
Tiago Ribeiro
f01794fed8 Updated the Dockerfile 2023-07-21 10:29:06 +01:00
Tiago Ribeiro
cc4b38fbbd Added Docker support to the application 2023-07-21 10:17:38 +01:00
Tiago Ribeiro
121ac8ba4d Finallyyyyyy finished the whole Speaking flow along with the solution page 2023-07-14 14:15:07 +01:00
Tiago Ribeiro
2c10a203a5 Finalized the Speaking module exercise 2023-07-14 12:08:25 +01:00
Tiago Ribeiro
6a2fab4f88 Commented a bit of code that is not yet ready 2023-07-11 00:30:05 +01:00
Tiago Ribeiro
9637cb6477 Made it so the Speaking is sent to the backend and saved to Firebase 2023-07-11 00:29:32 +01:00
Tiago Ribeiro
ce90de1b74 Updated the code so the user levels update depending on their performance 2023-07-04 21:03:36 +01:00
Tiago Ribeiro
49e24865a3 Created a profile editing page 2023-07-04 13:21:36 +01:00
Tiago Ribeiro
dceff807e9 Added the ability to read the text on Reading exercises 2023-06-30 21:18:13 +01:00
Tiago Ribeiro
3c4dba69db Added a confirmation dialog for when the user leaves questions unanswered 2023-06-29 15:28:50 +01:00
Tiago Ribeiro
3fac92b54d Added the exercises page which will work as the current exam page, while the exam page will mandatorily be the full exam 2023-06-29 00:18:39 +01:00
Tiago Ribeiro
139f527fdd Added the ability to filter by month, week and day on the record 2023-06-29 00:08:48 +01:00
75 changed files with 5478 additions and 3722 deletions

7
.dockerignore Normal file
View File

@@ -0,0 +1,7 @@
Dockerfile
.dockerignore
node_modules
npm-debug.log
README.md
.next
.git

1
.gitignore vendored
View File

@@ -37,3 +37,4 @@ next-env.d.ts
.env .env
.yarn/* .yarn/*
.history*

57
Dockerfile Normal file
View File

@@ -0,0 +1,57 @@
#syntax=docker/dockerfile:1.4
FROM node:18-alpine AS base
# Install dependencies only when needed
FROM base AS deps
# Check https://github.com/nodejs/docker-node/tree/b4117f9333da4138b03a546ec926ef50a31506c3#nodealpine to understand why libc6-compat might be needed.
RUN apk add --no-cache libc6-compat
WORKDIR /app
# Install dependencies based on the preferred package manager
COPY package.json yarn.lock* ./
RUN yarn --frozen-lockfile
# Rebuild the source code only when needed
FROM base AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
# Next.js collects completely anonymous telemetry data about general usage.
# Learn more here: https://nextjs.org/telemetry
# Uncomment the following line in case you want to disable telemetry during the build.
# ENV NEXT_TELEMETRY_DISABLED 1
RUN yarn build
# If using npm comment out above and use below instead
# RUN npm run build
# Production image, copy all the files and run next
FROM base AS runner
WORKDIR /app
ENV NODE_ENV production
# Uncomment the following line in case you want to disable telemetry during runtime.
ENV NEXT_TELEMETRY_DISABLED 1
RUN \
addgroup --system --gid 1001 nodejs; \
adduser --system --uid 1001 nextjs
COPY --from=builder /app/public ./public
# Automatically leverage output traces to reduce image size
# https://nextjs.org/docs/advanced-features/output-file-tracing
COPY --from=builder --chown=1001:1001 /app/.next/standalone ./
COPY --from=builder --chown=1001:1001 /app/.next/static ./.next/static
USER nextjs
EXPOSE 3000
ENV PORT 3000
ENV HOSTNAME localhost
CMD ["node", "server.js"]

View File

@@ -1,6 +1,7 @@
/** @type {import('next').NextConfig} */ /** @type {import('next').NextConfig} */
const nextConfig = { const nextConfig = {
reactStrictMode: true, reactStrictMode: true,
} output: "standalone",
};
module.exports = nextConfig module.exports = nextConfig;

810
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -23,6 +23,8 @@
"eslint": "8.33.0", "eslint": "8.33.0",
"eslint-config-next": "13.1.6", "eslint-config-next": "13.1.6",
"firebase": "9.19.1", "firebase": "9.19.1",
"formidable": "^3.5.0",
"formidable-serverless": "^1.1.1",
"framer-motion": "^9.0.2", "framer-motion": "^9.0.2",
"iron-session": "^6.3.1", "iron-session": "^6.3.1",
"lodash": "^4.17.21", "lodash": "^4.17.21",
@@ -48,6 +50,7 @@
"zustand": "^4.3.6" "zustand": "^4.3.6"
}, },
"devDependencies": { "devDependencies": {
"@types/formidable": "^3.4.0",
"@types/lodash": "^4.14.191", "@types/lodash": "^4.14.191",
"@types/uuid": "^9.0.1", "@types/uuid": "^9.0.1",
"@types/wavesurfer.js": "^6.0.6", "@types/wavesurfer.js": "^6.0.6",

BIN
public/defaultAvatar.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

View File

@@ -0,0 +1,62 @@
import {Dialog, Transition} from "@headlessui/react";
import {Fragment} from "react";
import Button from "./Low/Button";
interface Props {
isOpen: boolean;
abandonPopupTitle: string;
abandonPopupDescription: string;
abandonConfirmButtonText: string;
onAbandon: Function;
onCancel: Function;
}
export default function AbandonPopup({
isOpen,
abandonPopupTitle,
abandonPopupDescription,
abandonConfirmButtonText,
onAbandon,
onCancel,
}: Props) {
return (
<Transition show={isOpen} as={Fragment}>
<Dialog onClose={onCancel} 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-full max-w-2xl h-fit p-8 rounded-xl bg-white flex flex-col gap-4">
<Dialog.Title className="font-bold text-xl">{abandonPopupTitle}</Dialog.Title>
<span>{abandonPopupDescription}</span>
<div className="w-full flex justify-between mt-8">
<Button color="purple" onClick={onCancel} variant="outline" className="max-w-[200px] self-end w-full">
Cancel
</Button>
<Button color="purple" onClick={onAbandon} className="max-w-[200px] self-end w-full">
{abandonConfirmButtonText}
</Button>
</div>
</Dialog.Panel>
</div>
</Transition.Child>
</Dialog>
</Transition>
);
}

View File

@@ -0,0 +1,56 @@
import {Dialog, Transition} from "@headlessui/react";
import {Fragment} from "react";
import Button from "./Low/Button";
interface Props {
isOpen: boolean;
onClose: (next?: boolean) => void;
}
export default function BlankQuestionsModal({isOpen, onClose}: Props) {
return (
<Transition show={isOpen} as={Fragment}>
<Dialog onClose={() => onClose(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-full max-w-2xl h-fit p-8 rounded-xl bg-white flex flex-col gap-4">
<Dialog.Title className="font-bold text-xl">Questions Unanswered</Dialog.Title>
<span>
Please note that you are finishing the current module and once you proceed to the next module, you will no longer be
able to change the answers in the current one, including your unanswered questions. <br />
<br />
Are you sure you want to continue without completing those questions?
</span>
<div className="w-full flex justify-between mt-8">
<Button color="purple" onClick={() => onClose(false)} variant="outline" className="max-w-[200px] self-end w-full">
Go Back
</Button>
<Button color="purple" onClick={() => onClose(true)} className="max-w-[200px] self-end w-full">
Continue
</Button>
</div>
</Dialog.Panel>
</div>
</Transition.Child>
</Dialog>
</Transition>
);
}

View File

@@ -4,12 +4,16 @@ import {Module} from "@/interfaces";
import {User} from "@/interfaces/user"; import {User} from "@/interfaces/user";
import useExamStore from "@/stores/examStore"; import useExamStore from "@/stores/examStore";
import {getExamById} from "@/utils/exams"; import {getExamById} from "@/utils/exams";
import {writingMarking} from "@/utils/score";
import {Menu} from "@headlessui/react";
import axios from "axios"; import axios from "axios";
import clsx from "clsx"; import clsx from "clsx";
import {capitalize} from "lodash"; import {capitalize} from "lodash";
import {useRouter} from "next/router"; import {useRouter} from "next/router";
import {useState} from "react"; import {useState} from "react";
import {BsBook, BsChevronDown, BsHeadphones, BsMegaphone, BsPen, BsQuestionSquare} from "react-icons/bs";
import {toast} from "react-toastify"; import {toast} from "react-toastify";
import Button from "./Low/Button";
interface Props { interface Props {
user: User; user: User;
@@ -25,8 +29,7 @@ const DIAGNOSTIC_EXAMS = [
export default function Diagnostic({onFinish}: Props) { export default function Diagnostic({onFinish}: Props) {
const [focus, setFocus] = useState<"academic" | "general">(); const [focus, setFocus] = useState<"academic" | "general">();
const [isInsert, setIsInsert] = useState(false); const [levels, setLevels] = useState({reading: -1, listening: -1, writing: -1, speaking: -1});
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 [desiredLevels, setDesiredLevels] = useState({reading: 9, listening: 9, writing: 9, speaking: 9});
const router = useRouter(); const router = useRouter();
@@ -34,6 +37,11 @@ export default function Diagnostic({onFinish}: Props) {
const setExams = useExamStore((state) => state.setExams); const setExams = useExamStore((state) => state.setExams);
const setSelectedModules = useExamStore((state) => state.setSelectedModules); const setSelectedModules = useExamStore((state) => state.setSelectedModules);
const isNextDisabled = () => {
if (!focus) return true;
return Object.values(levels).includes(-1);
};
const selectExam = () => { const selectExam = () => {
const examPromises = DIAGNOSTIC_EXAMS.map((exam) => getExamById(exam[0] as Module, exam[1])); const examPromises = DIAGNOSTIC_EXAMS.map((exam) => getExamById(exam[0] as Module, exam[1]));
@@ -48,72 +56,172 @@ export default function Diagnostic({onFinish}: Props) {
const updateUser = (callback: () => void) => { const updateUser = (callback: () => void) => {
axios axios
.patch("/api/users/update", {focus, levels, desiredLevels, isFirstLogin: false}) .patch("/api/users/update", {
focus,
levels: Object.values(levels).includes(-1) ? {reading: -1, listening: -1, writing: -1, speaking: -1} : levels,
desiredLevels,
isFirstLogin: false,
})
.then(callback) .then(callback)
.catch(() => { .catch(() => {
toast.error("Something went wrong, please try again later!", {toastId: "user-update-error"}); toast.error("Something went wrong, please try again later!", {toastId: "user-update-error"});
}); });
}; };
if (!focus) {
return (
<div className="bg-white p-16 rounded-2xl flex flex-col items-center justify-center gap-8 h-96 relative shadow-md">
<h2 className="absolute top-8 font-semibold text-xl">What is your focus?</h2>
<div className="flex flex-col gap-4 justify-self-stretch">
<button onClick={() => setFocus("academic")} className={clsx("btn btn-wide gap-4 relative text-white", infoButtonStyle)}>
Academic
</button>
<button onClick={() => setFocus("general")} className={clsx("btn btn-wide gap-4 relative text-white", infoButtonStyle)}>
General
</button>
</div>
</div>
);
}
if (isInsert) {
return (
<div className="bg-white p-16 rounded-2xl flex flex-col items-center justify-center gap-8 shadow-md">
<h2 className="font-semibold text-xl">What is your level?</h2>
<div className="flex w-full flex-col gap-4 justify-self-stretch">
{Object.keys(levels).map((module) => (
<div key={module} className="flex items-center gap-4 justify-between">
<span className="font-medium text-lg">{capitalize(module)}</span>
<input
type="number"
className={clsx(
"input input-bordered bg-white w-24",
!BAND_SCORES[module as Module].includes(levels[module as keyof typeof levels]) && "input-error",
)}
value={levels[module as keyof typeof levels]}
min={0}
max={9}
step={0.5}
onChange={(e) => setLevels((prev) => ({...prev, [module]: parseFloat(e.target.value)}))}
/>
</div>
))}
</div>
<button
onClick={() => updateUser(onFinish)}
className={clsx("btn btn-wide gap-4 relative text-white", infoButtonStyle)}
disabled={!Object.keys(levels).every((module) => BAND_SCORES[module as Module].includes(levels[module as keyof typeof levels]))}>
Next
</button>
</div>
);
}
return ( return (
<div className="bg-white p-16 rounded-2xl flex flex-col items-center justify-center gap-8 h-96 relative shadow-md"> <div className="flex flex-col items-center justify-center gap-12 w-full">
<h2 className="absolute top-8 font-semibold text-xl">What is your current IELTS level?</h2> <div className="flex flex-col items-center justify-center gap-8 w-full">
<div className="flex flex-col gap-4"> <h2 className="font-semibold text-xl">What is your current focus?</h2>
<button onClick={() => setIsInsert(true)} className={clsx("btn btn-wide gap-4 relative text-white", infoButtonStyle)}> <div className="flex flex-col gap-16 w-full">
Insert my IELTS level <div className="grid grid-cols-2 gap-y-4 gap-x-16">
</button> <button
<button onClick={() => updateUser(selectExam)} className={clsx("btn btn-wide gap-4 relative text-white", infoButtonStyle)}> onClick={() => setFocus("academic")}
Perform a Diagnosis Test className={clsx(
</button> "w-full border border-mti-gray-platinum rounded-full px-6 py-4 flex justify-center items-center gap-12 bg-white",
"hover:bg-mti-purple-light hover:text-white",
focus === "academic" && "!bg-mti-purple-light !text-white",
"transition duration-300 ease-in-out",
)}>
Academic
</button>
<button
onClick={() => setFocus("general")}
className={clsx(
"w-full border border-mti-gray-platinum rounded-full px-6 py-4 flex justify-center items-center gap-12 bg-white",
"hover:bg-mti-purple-light hover:text-white",
focus === "general" && "!bg-mti-purple-light !text-white",
"transition duration-300 ease-in-out",
)}>
General
</button>
</div>
</div>
</div>
<div className="flex flex-col items-center justify-center gap-8 w-full">
<h2 className="font-semibold text-xl">What is your current IELTS level?</h2>
<div className="flex flex-col gap-16 w-full">
<div className="grid grid-cols-2 gap-y-4 gap-x-16">
<div className="w-full flex flex-col gap-3.5 relative">
<span className="text-sm text-mti-gray-dim">
<span className="font-bold">Reading</span> level
</span>
<Menu>
<Menu.Button className="w-full border border-mti-gray-platinum rounded-full px-6 py-4 flex justify-between items-center gap-12 bg-white">
<BsBook className="text-ielts-reading" size={34} />
<span className="text-mti-gray-cool text-sm">
{levels.reading === -1 ? "Select your reading level" : `Level ${levels.reading}`}
</span>
<BsChevronDown className="text-mti-gray-cool" size={12} />
</Menu.Button>
<Menu.Items className="absolute origin-top top-full bg-white flex flex-col items-center w-full z-20 drop-shadow-lg rounded-2xl overflow-hidden">
{Object.values(writingMarking).map((x) => (
<Menu.Item key={x}>
<span
onClick={() => setLevels((prev) => ({...prev, reading: x}))}
className="w-full py-4 text-center cursor-pointer bg-white hover:bg-mti-gray-platinum transition ease-in-out duration-300">
Level {x}
</span>
</Menu.Item>
))}
</Menu.Items>
</Menu>
</div>
<div className="w-full flex flex-col gap-3.5 relative">
<span className="text-sm text-mti-gray-dim">
<span className="font-bold">Listening</span> level
</span>
<Menu>
<Menu.Button className="w-full border border-mti-gray-platinum rounded-full px-6 py-4 flex justify-between items-center gap-12 bg-white">
<BsHeadphones className="text-ielts-listening" size={34} />
<span className="text-mti-gray-cool text-sm">
{levels.listening === -1 ? "Select your listening level" : `Level ${levels.listening}`}
</span>
<BsChevronDown className="text-mti-gray-cool" size={12} />
</Menu.Button>
<Menu.Items className="absolute origin-top top-full bg-white flex flex-col items-center w-full z-20 drop-shadow-lg rounded-2xl overflow-hidden">
{Object.values(writingMarking).map((x) => (
<Menu.Item key={x}>
<span
onClick={() => setLevels((prev) => ({...prev, listening: x}))}
className="w-full py-5 text-center cursor-pointer bg-white hover:bg-mti-gray-platinum transition ease-in-out duration-300">
Level {x}
</span>
</Menu.Item>
))}
</Menu.Items>
</Menu>
</div>
<div className="w-full flex flex-col gap-3.5 relative">
<span className="text-sm text-mti-gray-dim">
<span className="font-bold">Writing</span> level
</span>
<Menu>
<Menu.Button className="w-full border border-mti-gray-platinum rounded-full px-6 py-4 flex justify-between items-center gap-12 bg-white">
<BsPen className="text-ielts-writing" size={34} />
<span className="text-mti-gray-cool text-sm">
{levels.writing === -1 ? "Select your writing level" : `Level ${levels.writing}`}
</span>
<BsChevronDown className="text-mti-gray-cool" size={12} />
</Menu.Button>
<Menu.Items className="absolute origin-top top-full bg-white flex flex-col items-center w-full z-20 drop-shadow-lg rounded-2xl overflow-hidden">
{Object.values(writingMarking).map((x) => (
<Menu.Item key={x}>
<span
onClick={() => setLevels((prev) => ({...prev, writing: x}))}
className="w-full py-5 text-center cursor-pointer bg-white hover:bg-mti-gray-platinum transition ease-in-out duration-300">
Level {x}
</span>
</Menu.Item>
))}
</Menu.Items>
</Menu>
</div>
<div className="w-full flex flex-col gap-3.5 relative">
<span className="text-sm text-mti-gray-dim">
<span className="font-bold">Speaking</span> level
</span>
<Menu>
<Menu.Button className="w-full border border-mti-gray-platinum rounded-full px-6 py-4 flex justify-between items-center gap-12 bg-white">
<BsMegaphone className="text-ielts-speaking" size={34} />
<span className="text-mti-gray-cool text-sm">
{levels.speaking === -1 ? "Select your speaking level" : `Level ${levels.speaking}`}
</span>
<BsChevronDown className="text-mti-gray-cool" size={12} />
</Menu.Button>
<Menu.Items className="absolute origin-top top-full bg-white flex flex-col items-center w-full z-20 drop-shadow-lg rounded-2xl">
{Object.values(writingMarking).map((x) => (
<Menu.Item key={x}>
<span
onClick={() => setLevels((prev) => ({...prev, speaking: x}))}
className="w-full py-5 text-center cursor-pointer bg-white hover:bg-mti-gray-platinum transition ease-in-out duration-300">
Level {x}
</span>
</Menu.Item>
))}
</Menu.Items>
</Menu>
</div>
</div>
<div className="self-end flex justify-between w-full gap-8 absolute bottom-8 left-0 px-8">
<Button
onClick={() => updateUser(selectExam)}
color="purple"
variant="outline"
className="group flex items-center justify-center gap-6 relative max-w-[400px] w-full"
disabled={!focus}>
<BsQuestionSquare
className="text-mti-purple-light group-hover:text-white transition duration-300 ease-in-out"
size={20}
onClick={() => updateUser(selectExam)}
/>
<span onClick={() => updateUser(selectExam)}>Perform diagnostic test instead</span>
</Button>
<Button color="purple" className="max-w-[400px] w-full" onClick={() => updateUser(onFinish)} disabled={isNextDisabled()}>
Next Step
</Button>
</div>
</div>
</div> </div>
</div> </div>
); );

View File

@@ -1,4 +1,5 @@
import {FillBlanksExercise} from "@/interfaces/exam"; import {FillBlanksExercise} from "@/interfaces/exam";
import useExamStore from "@/stores/examStore";
import clsx from "clsx"; import clsx from "clsx";
import {Fragment, useEffect, useState} from "react"; import {Fragment, useEffect, useState} from "react";
import reactStringReplace from "react-string-replace"; import reactStringReplace from "react-string-replace";
@@ -31,7 +32,7 @@ function WordsDrawer({words, isOpen, blankId, previouslySelectedWord, onCancel,
isOpen ? "visible opacity-100" : "invisible opacity-0", isOpen ? "visible opacity-100" : "invisible opacity-0",
)}> )}>
<div className="w-full flex gap-2"> <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> <div className="rounded-full w-6 h-6 flex items-center justify-center text-white bg-mti-purple-light">{blankId}</div>
<span> Choose the correct word:</span> <span> Choose the correct word:</span>
</div> </div>
<div className="grid grid-cols-6 gap-6"> <div className="grid grid-cols-6 gap-6">
@@ -41,8 +42,8 @@ function WordsDrawer({words, isOpen, blankId, previouslySelectedWord, onCancel,
onClick={() => setSelectedWord((prev) => (prev === word ? undefined : word))} onClick={() => setSelectedWord((prev) => (prev === word ? undefined : word))}
className={clsx( className={clsx(
"rounded-full py-3 text-center transition duration-300 ease-in-out", "rounded-full py-3 text-center transition duration-300 ease-in-out",
selectedWord === word ? "text-white bg-mti-green-light" : "bg-mti-green-ultralight", selectedWord === word ? "text-white bg-mti-purple-light" : "bg-mti-purple-ultralight",
!isDisabled && "hover:text-white hover:bg-mti-green", !isDisabled && "hover:text-white hover:bg-mti-purple",
"disabled:cursor-not-allowed disabled:text-mti-gray-dim", "disabled:cursor-not-allowed disabled:text-mti-gray-dim",
)} )}
disabled={isDisabled}> disabled={isDisabled}>
@@ -51,10 +52,10 @@ function WordsDrawer({words, isOpen, blankId, previouslySelectedWord, onCancel,
))} ))}
</div> </div>
<div className="flex justify-between w-full"> <div className="flex justify-between w-full">
<Button color="green" variant="outline" className="max-w-[200px] w-full" onClick={onCancel}> <Button color="purple" variant="outline" className="max-w-[200px] w-full" onClick={onCancel}>
Back Back
</Button> </Button>
<Button color="green" className="max-w-[200px] w-full" onClick={() => onAnswer(selectedWord!)} disabled={!selectedWord}> <Button color="purple" className="max-w-[200px] w-full" onClick={() => onAnswer(selectedWord!)} disabled={!selectedWord}>
Confirm Confirm
</Button> </Button>
</div> </div>
@@ -79,10 +80,17 @@ export default function FillBlanks({
const [currentBlankId, setCurrentBlankId] = useState<string>(); const [currentBlankId, setCurrentBlankId] = useState<string>();
const [isDrawerShowing, setIsDrawerShowing] = useState(false); const [isDrawerShowing, setIsDrawerShowing] = useState(false);
const hasExamEnded = useExamStore((state) => state.hasExamEnded);
useEffect(() => { useEffect(() => {
setTimeout(() => setIsDrawerShowing(!!currentBlankId), 100); setTimeout(() => setIsDrawerShowing(!!currentBlankId), 100);
}, [currentBlankId]); }, [currentBlankId]);
useEffect(() => {
if (hasExamEnded) onNext({exercise: id, solutions: answers, score: calculateScore(), type});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [hasExamEnded]);
const calculateScore = () => { const calculateScore = () => {
const total = text.match(/({{\d+}})/g)?.length || 0; const total = text.match(/({{\d+}})/g)?.length || 0;
const correct = answers.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;
@@ -101,10 +109,10 @@ export default function FillBlanks({
return ( return (
<button <button
className={clsx( className={clsx(
"rounded-full hover:text-white hover:bg-mti-green transition duration-300 ease-in-out my-1", "rounded-full hover:text-white hover:bg-mti-purple transition duration-300 ease-in-out my-1",
!userSolution && "w-6 h-6 text-center text-mti-green-light bg-mti-green-ultralight", !userSolution && "w-6 h-6 text-center text-mti-purple-light bg-mti-purple-ultralight",
currentBlankId === id && "text-white !bg-mti-green-light ", currentBlankId === id && "text-white !bg-mti-purple-light ",
userSolution && "px-5 py-2 text-center text-white bg-mti-green-light", userSolution && "px-5 py-2 text-center text-white bg-mti-purple-light",
)} )}
onClick={() => setCurrentBlankId(id)}> onClick={() => setCurrentBlankId(id)}>
{userSolution ? userSolution.solution : id} {userSolution ? userSolution.solution : id}
@@ -151,7 +159,7 @@ export default function FillBlanks({
<div className="self-end flex justify-between w-full gap-8 absolute bottom-8 left-0 px-8"> <div className="self-end flex justify-between w-full gap-8 absolute bottom-8 left-0 px-8">
<Button <Button
color="green" color="purple"
variant="outline" variant="outline"
onClick={() => onBack({exercise: id, solutions: answers, score: calculateScore(), type})} onClick={() => onBack({exercise: id, solutions: answers, score: calculateScore(), type})}
className="max-w-[200px] w-full"> className="max-w-[200px] w-full">
@@ -159,7 +167,7 @@ export default function FillBlanks({
</Button> </Button>
<Button <Button
color="green" color="purple"
onClick={() => onNext({exercise: id, solutions: answers, score: calculateScore(), type})} onClick={() => onNext({exercise: id, solutions: answers, score: calculateScore(), type})}
className="max-w-[200px] self-end w-full"> className="max-w-[200px] self-end w-full">
Next Next

View File

@@ -3,16 +3,19 @@ import {MatchSentencesExercise} from "@/interfaces/exam";
import {mdiArrowLeft, mdiArrowRight} from "@mdi/js"; import {mdiArrowLeft, mdiArrowRight} from "@mdi/js";
import Icon from "@mdi/react"; import Icon from "@mdi/react";
import clsx from "clsx"; import clsx from "clsx";
import {Fragment, useState} from "react"; import {Fragment, useEffect, useState} from "react";
import LineTo from "react-lineto"; import LineTo from "react-lineto";
import {CommonProps} from "."; import {CommonProps} from ".";
import Button from "../Low/Button"; import Button from "../Low/Button";
import Xarrow from "react-xarrows"; import Xarrow from "react-xarrows";
import useExamStore from "@/stores/examStore";
export default function MatchSentences({id, options, type, prompt, sentences, userSolutions, onNext, onBack}: MatchSentencesExercise & CommonProps) { export default function MatchSentences({id, options, type, prompt, sentences, userSolutions, onNext, onBack}: MatchSentencesExercise & CommonProps) {
const [selectedQuestion, setSelectedQuestion] = useState<string>(); const [selectedQuestion, setSelectedQuestion] = useState<string>();
const [answers, setAnswers] = useState<{question: string; option: string}[]>(userSolutions); const [answers, setAnswers] = useState<{question: string; option: string}[]>(userSolutions);
const hasExamEnded = useExamStore((state) => state.hasExamEnded);
const calculateScore = () => { const calculateScore = () => {
const total = sentences.length; const total = sentences.length;
const correct = answers.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;
@@ -27,9 +30,10 @@ export default function MatchSentences({id, options, type, prompt, sentences, us
setSelectedQuestion(undefined); setSelectedQuestion(undefined);
}; };
const getSentenceColor = (id: string) => { useEffect(() => {
return sentences.find((x) => x.id === id)?.color || ""; if (hasExamEnded) onNext({exercise: id, solutions: answers, score: calculateScore(), type});
}; // eslint-disable-next-line react-hooks/exhaustive-deps
}, [hasExamEnded]);
return ( return (
<> <>
@@ -44,16 +48,16 @@ export default function MatchSentences({id, options, type, prompt, sentences, us
</span> </span>
<div className="flex gap-8 w-full items-center justify-between bg-mti-gray-smoke rounded-xl px-24 py-6"> <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"> <div className="flex flex-col gap-4">
{sentences.map(({sentence, id, color}) => ( {sentences.map(({sentence, id}) => (
<div key={`question_${id}`} className="flex items-center justify-end gap-2 cursor-pointer"> <div key={`question_${id}`} className="flex items-center justify-end gap-2 cursor-pointer">
<span>{sentence} </span> <span>{sentence} </span>
<button <button
id={id} id={id}
onClick={() => setSelectedQuestion((prev) => (prev === id ? undefined : id))} onClick={() => setSelectedQuestion((prev) => (prev === id ? undefined : id))}
className={clsx( className={clsx(
"bg-mti-green-ultralight text-mti-green hover:text-white hover:bg-mti-green w-8 h-8 rounded-full z-10", "bg-mti-purple-ultralight text-mti-purple hover:text-white hover:bg-mti-purple w-8 h-8 rounded-full z-10",
"transition duration-300 ease-in-out", "transition duration-300 ease-in-out",
selectedQuestion === id && "!text-white !bg-mti-green", selectedQuestion === id && "!text-white !bg-mti-purple",
id, id,
)}> )}>
{id} {id}
@@ -68,7 +72,7 @@ export default function MatchSentences({id, options, type, prompt, sentences, us
id={id} id={id}
onClick={() => selectOption(id)} onClick={() => selectOption(id)}
className={clsx( className={clsx(
"bg-mti-green-ultralight text-mti-green hover:text-white hover:bg-mti-green w-8 h-8 rounded-full z-10", "bg-mti-purple-ultralight text-mti-purple hover:text-white hover:bg-mti-purple w-8 h-8 rounded-full z-10",
"transition duration-300 ease-in-out", "transition duration-300 ease-in-out",
id, id,
)}> )}>
@@ -79,14 +83,14 @@ export default function MatchSentences({id, options, type, prompt, sentences, us
))} ))}
</div> </div>
{answers.map((solution, index) => ( {answers.map((solution, index) => (
<Xarrow key={index} start={solution.question} end={solution.option} lineColor="#307912" showHead={false} /> <Xarrow key={index} start={solution.question} end={solution.option} lineColor="#7872BF" showHead={false} />
))} ))}
</div> </div>
</div> </div>
<div className="self-end flex justify-between w-full gap-8 absolute bottom-8 left-0 px-8"> <div className="self-end flex justify-between w-full gap-8 absolute bottom-8 left-0 px-8">
<Button <Button
color="green" color="purple"
variant="outline" variant="outline"
onClick={() => onBack({exercise: id, solutions: answers, score: calculateScore(), type})} onClick={() => onBack({exercise: id, solutions: answers, score: calculateScore(), type})}
className="max-w-[200px] w-full"> className="max-w-[200px] w-full">
@@ -94,7 +98,7 @@ export default function MatchSentences({id, options, type, prompt, sentences, us
</Button> </Button>
<Button <Button
color="green" color="purple"
onClick={() => onNext({exercise: id, solutions: answers, score: calculateScore(), type})} onClick={() => onNext({exercise: id, solutions: answers, score: calculateScore(), type})}
className="max-w-[200px] self-end w-full"> className="max-w-[200px] self-end w-full">
Next Next

View File

@@ -1,7 +1,8 @@
/* eslint-disable @next/next/no-img-element */ /* eslint-disable @next/next/no-img-element */
import {MultipleChoiceExercise, MultipleChoiceQuestion} from "@/interfaces/exam"; import {MultipleChoiceExercise, MultipleChoiceQuestion} from "@/interfaces/exam";
import useExamStore from "@/stores/examStore";
import clsx from "clsx"; import clsx from "clsx";
import {useState} from "react"; import {useEffect, useState} from "react";
import {CommonProps} from "."; import {CommonProps} from ".";
import Button from "../Low/Button"; import Button from "../Low/Button";
@@ -23,7 +24,7 @@ function Question({
onClick={() => (onSelectOption ? onSelectOption(option.id) : null)} onClick={() => (onSelectOption ? onSelectOption(option.id) : null)}
className={clsx( className={clsx(
"flex flex-col items-center border border-mti-gray-platinum p-4 px-8 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",
userSolution === option.id && "border-mti-green-light", userSolution === option.id && "border-mti-purple-light",
)}> )}>
<span className={clsx("text-sm", userSolution !== option.id && "opacity-50")}>{option.id}</span> <span className={clsx("text-sm", userSolution !== option.id && "opacity-50")}>{option.id}</span>
<img src={option.src!} alt={`Option ${option.id}`} /> <img src={option.src!} alt={`Option ${option.id}`} />
@@ -36,7 +37,7 @@ function Question({
onClick={() => (onSelectOption ? onSelectOption(option.id) : null)} onClick={() => (onSelectOption ? onSelectOption(option.id) : null)}
className={clsx( className={clsx(
"flex border p-4 rounded-xl gap-2 cursor-pointer bg-white text-sm", "flex border p-4 rounded-xl gap-2 cursor-pointer bg-white text-sm",
userSolution === option.id && "border-mti-green-light", userSolution === option.id && "border-mti-purple-light",
)}> )}>
<span className="font-semibold">{option.id}.</span> <span className="font-semibold">{option.id}.</span>
<span>{option.text}</span> <span>{option.text}</span>
@@ -51,6 +52,13 @@ export default function MultipleChoice({id, prompt, type, questions, userSolutio
const [answers, setAnswers] = useState<{question: string; option: string}[]>(userSolutions); const [answers, setAnswers] = useState<{question: string; option: string}[]>(userSolutions);
const [questionIndex, setQuestionIndex] = useState(0); const [questionIndex, setQuestionIndex] = useState(0);
const hasExamEnded = useExamStore((state) => state.hasExamEnded);
useEffect(() => {
if (hasExamEnded) onNext({exercise: id, solutions: answers, score: calculateScore(), type});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [hasExamEnded]);
const onSelectOption = (option: string) => { const onSelectOption = (option: string) => {
const question = questions[questionIndex]; const question = questions[questionIndex];
setAnswers((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}]);
@@ -94,11 +102,11 @@ export default function MultipleChoice({id, prompt, type, questions, userSolutio
</div> </div>
<div className="self-end flex justify-between w-full gap-8 absolute bottom-8 left-0 px-8"> <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"> <Button color="purple" variant="outline" onClick={back} className="max-w-[200px] w-full">
Back Back
</Button> </Button>
<Button color="green" onClick={next} className="max-w-[200px] self-end w-full"> <Button color="purple" onClick={next} className="max-w-[200px] self-end w-full">
Next Next
</Button> </Button>
</div> </div>

View File

@@ -1,14 +1,10 @@
import {errorButtonStyle, infoButtonStyle} from "@/constants/buttonStyles"; import {SpeakingExercise} from "@/interfaces/exam";
import {SpeakingExercise, WritingExercise} from "@/interfaces/exam";
import {mdiArrowLeft, mdiArrowRight} from "@mdi/js";
import Icon from "@mdi/react";
import clsx from "clsx";
import {CommonProps} from "."; import {CommonProps} from ".";
import {Fragment, useEffect, useState} from "react"; import {Fragment, useEffect, useState} from "react";
import {toast} from "react-toastify";
import {BsCheckCircleFill, BsMicFill, BsPauseCircle, BsPlayCircle, BsTrashFill} from "react-icons/bs"; import {BsCheckCircleFill, BsMicFill, BsPauseCircle, BsPlayCircle, BsTrashFill} from "react-icons/bs";
import dynamic from "next/dynamic"; import dynamic from "next/dynamic";
import Button from "../Low/Button"; import Button from "../Low/Button";
import useExamStore from "@/stores/examStore";
const Waveform = dynamic(() => import("../Waveform"), {ssr: false}); const Waveform = dynamic(() => import("../Waveform"), {ssr: false});
const ReactMediaRecorder = dynamic(() => import("react-media-recorder").then((mod) => mod.ReactMediaRecorder), { const ReactMediaRecorder = dynamic(() => import("react-media-recorder").then((mod) => mod.ReactMediaRecorder), {
@@ -20,6 +16,20 @@ export default function Speaking({id, title, text, type, prompts, onNext, onBack
const [isRecording, setIsRecording] = useState(false); const [isRecording, setIsRecording] = useState(false);
const [mediaBlob, setMediaBlob] = useState<string>(); const [mediaBlob, setMediaBlob] = useState<string>();
const hasExamEnded = useExamStore((state) => state.hasExamEnded);
useEffect(() => {
if (hasExamEnded) {
onNext({
exercise: id,
solutions: mediaBlob ? [{id, solution: mediaBlob}] : [],
score: {correct: 1, total: 1, missing: 0},
type,
});
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [hasExamEnded]);
useEffect(() => { useEffect(() => {
let recordingInterval: NodeJS.Timer | undefined = undefined; let recordingInterval: NodeJS.Timer | undefined = undefined;
if (isRecording) { if (isRecording) {
@@ -47,16 +57,18 @@ export default function Speaking({id, title, text, type, prompts, onNext, onBack
))} ))}
</span> </span>
</div> </div>
<div className="flex flex-col gap-4"> {prompts && prompts.length > 0 && (
<span className="font-bold">You should talk about the following things:</span> <div className="flex flex-col gap-4">
<div className="flex flex-col gap-1 ml-4"> <span className="font-bold">You should talk about the following things:</span>
{prompts.map((x, index) => ( <div className="flex flex-col gap-1 ml-4">
<li className="italic" key={index}> {prompts.map((x, index) => (
{x} <li className="italic" key={index}>
</li> {x}
))} </li>
))}
</div>
</div> </div>
</div> )}
</div> </div>
<ReactMediaRecorder <ReactMediaRecorder
@@ -85,11 +97,11 @@ export default function Speaking({id, title, text, type, prompts, onNext, onBack
<> <>
<div className="flex gap-4 items-center"> <div className="flex gap-4 items-center">
<span className="text-xs w-9"> <span className="text-xs w-9">
{Math.round(recordingDuration / 60) {Math.floor(recordingDuration / 60)
.toString(10) .toString(10)
.padStart(2, "0")} .padStart(2, "0")}
: :
{Math.round(recordingDuration % 60) {Math.floor(recordingDuration % 60)
.toString(10) .toString(10)
.padStart(2, "0")} .padStart(2, "0")}
</span> </span>
@@ -108,7 +120,7 @@ export default function Speaking({id, title, text, type, prompts, onNext, onBack
setIsRecording(false); setIsRecording(false);
stopRecording(); stopRecording();
}} }}
className="text-mti-green-light w-8 h-8 cursor-pointer" className="text-mti-purple-light w-8 h-8 cursor-pointer"
/> />
</div> </div>
</> </>
@@ -133,14 +145,14 @@ export default function Speaking({id, title, text, type, prompts, onNext, onBack
setIsRecording(true); setIsRecording(true);
resumeRecording(); resumeRecording();
}} }}
className="text-mti-green-light w-8 h-8 cursor-pointer" className="text-mti-purple-light w-8 h-8 cursor-pointer"
/> />
<BsCheckCircleFill <BsCheckCircleFill
onClick={() => { onClick={() => {
setIsRecording(false); setIsRecording(false);
stopRecording(); stopRecording();
}} }}
className="text-mti-green-light w-8 h-8 cursor-pointer" className="text-mti-purple-light w-8 h-8 cursor-pointer"
/> />
</div> </div>
</> </>
@@ -178,16 +190,30 @@ export default function Speaking({id, title, text, type, prompts, onNext, onBack
<div className="self-end flex justify-between w-full gap-8"> <div className="self-end flex justify-between w-full gap-8">
<Button <Button
color="green" color="purple"
variant="outline" variant="outline"
onClick={() => onBack({exercise: id, solutions: [], score: {correct: 1, total: 1, missing: 0}, type})} onClick={() =>
onBack({
exercise: id,
solutions: mediaBlob ? [{id, solution: mediaBlob}] : [],
score: {correct: 1, total: 1, missing: 0},
type,
})
}
className="max-w-[200px] self-end w-full"> className="max-w-[200px] self-end w-full">
Back Back
</Button> </Button>
<Button <Button
color="green" color="purple"
disabled={!mediaBlob} disabled={!mediaBlob}
onClick={() => onNext({exercise: id, solutions: [], score: {correct: 1, total: 1, missing: 0}, type})} onClick={() =>
onNext({
exercise: id,
solutions: mediaBlob ? [{id, solution: mediaBlob}] : [],
score: {correct: 1, total: 1, missing: 0},
type,
})
}
className="max-w-[200px] self-end w-full"> className="max-w-[200px] self-end w-full">
Next Next
</Button> </Button>

View File

@@ -0,0 +1,111 @@
import {TrueFalseExercise} from "@/interfaces/exam";
import useExamStore from "@/stores/examStore";
import {Fragment, useEffect, useState} from "react";
import {CommonProps} from ".";
import Button from "../Low/Button";
export default function TrueFalse({id, type, prompt, questions, userSolutions, onNext, onBack}: TrueFalseExercise & CommonProps) {
const [answers, setAnswers] = useState<{id: string; solution: "true" | "false" | "not_given"}[]>(userSolutions);
const hasExamEnded = useExamStore((state) => state.hasExamEnded);
useEffect(() => {
if (hasExamEnded) onNext({exercise: id, solutions: answers, score: calculateScore(), type});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [hasExamEnded]);
const calculateScore = () => {
const total = questions.length || 0;
const correct = answers.filter((x) => questions.find((y) => x.id === y.id)?.solution === x.solution.toLowerCase() || false).length;
const missing = total - answers.filter((x) => questions.find((y) => x.id === y.id)).length;
return {total, correct, missing};
};
const toggleAnswer = (solution: "true" | "false" | "not_given", questionId: string) => {
const answer = answers.find((x) => x.id === questionId);
if (answer && answer.solution === solution) {
setAnswers((prev) => prev.filter((x) => x.id !== questionId));
return;
}
setAnswers((prev) => [...prev.filter((x) => x.id !== questionId), {id: questionId, solution}]);
};
return (
<>
<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>
<div className="flex flex-col gap-6 mb-4">
<p>For each of the questions below, select</p>
<div className="pl-8 flex gap-8">
<span className="flex flex-col gap-4">
<span className="font-bold italic">TRUE</span>
<span className="font-bold italic">FALSE</span>
<span className="font-bold italic">NOT GIVEN</span>
</span>
<span className="flex flex-col gap-4">
<span>if the statement agrees with the information</span>
<span>if the statement contradicts with the information</span>
<span>if there is no information on this</span>
</span>
</div>
</div>
<span className="text-sm w-full leading-6">You can click a selected option again to deselect it.</span>
<div className="bg-mti-gray-smoke rounded-xl px-5 py-6 flex flex-col gap-8">
{questions.map((question, index) => (
<div key={question.id} className="flex flex-col gap-4">
<span>
{index + 1}. {question.prompt}
</span>
<div className="flex gap-4">
<Button
variant={answers.find((x) => x.id === question.id)?.solution === "true" ? "solid" : "outline"}
onClick={() => toggleAnswer("true", question.id)}
className="!py-2">
True
</Button>
<Button
variant={answers.find((x) => x.id === question.id)?.solution === "false" ? "solid" : "outline"}
onClick={() => toggleAnswer("false", question.id)}
className="!py-2">
False
</Button>
<Button
variant={answers.find((x) => x.id === question.id)?.solution === "not_given" ? "solid" : "outline"}
onClick={() => toggleAnswer("not_given", question.id)}
className="!py-2">
Not Given
</Button>
</div>
</div>
))}
</div>
</div>
<div className="self-end flex justify-between w-full gap-8 absolute bottom-8 left-0 px-8">
<Button
color="purple"
variant="outline"
onClick={() => onBack({exercise: id, solutions: answers, score: calculateScore(), type})}
className="max-w-[200px] w-full">
Back
</Button>
<Button
color="purple"
onClick={() => onNext({exercise: id, solutions: answers, score: calculateScore(), type})}
className="max-w-[200px] self-end w-full">
Next
</Button>
</div>
</>
);
}

View File

@@ -8,6 +8,7 @@ import reactStringReplace from "react-string-replace";
import {CommonProps} from "."; import {CommonProps} from ".";
import {toast} from "react-toastify"; import {toast} from "react-toastify";
import Button from "../Low/Button"; import Button from "../Low/Button";
import useExamStore from "@/stores/examStore";
function Blank({ function Blank({
id, id,
@@ -48,6 +49,13 @@ function Blank({
export default function WriteBlanks({id, prompt, type, maxWords, solutions, userSolutions, text, onNext, onBack}: WriteBlanksExercise & CommonProps) { 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 [answers, setAnswers] = useState<{id: string; solution: string}[]>(userSolutions);
const hasExamEnded = useExamStore((state) => state.hasExamEnded);
useEffect(() => {
if (hasExamEnded) onNext({exercise: id, solutions: answers, score: calculateScore(), type});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [hasExamEnded]);
const calculateScore = () => { const calculateScore = () => {
const total = text.match(/({{\d+}})/g)?.length || 0; const total = text.match(/({{\d+}})/g)?.length || 0;
const correct = answers.filter( const correct = answers.filter(
@@ -101,7 +109,7 @@ export default function WriteBlanks({id, prompt, type, maxWords, solutions, user
<div className="self-end flex justify-between w-full gap-8 absolute bottom-8 left-0 px-8"> <div className="self-end flex justify-between w-full gap-8 absolute bottom-8 left-0 px-8">
<Button <Button
color="green" color="purple"
variant="outline" variant="outline"
onClick={() => onBack({exercise: id, solutions: answers, score: calculateScore(), type})} onClick={() => onBack({exercise: id, solutions: answers, score: calculateScore(), type})}
className="max-w-[200px] w-full"> className="max-w-[200px] w-full">
@@ -109,7 +117,7 @@ export default function WriteBlanks({id, prompt, type, maxWords, solutions, user
</Button> </Button>
<Button <Button
color="green" color="purple"
onClick={() => onNext({exercise: id, solutions: answers, score: calculateScore(), type})} onClick={() => onNext({exercise: id, solutions: answers, score: calculateScore(), type})}
className="max-w-[200px] self-end w-full"> className="max-w-[200px] self-end w-full">
Next Next

View File

@@ -1,20 +1,35 @@
/* eslint-disable @next/next/no-img-element */ /* eslint-disable @next/next/no-img-element */
import {errorButtonStyle, infoButtonStyle} from "@/constants/buttonStyles";
import {WritingExercise} from "@/interfaces/exam"; import {WritingExercise} from "@/interfaces/exam";
import {mdiArrowLeft, mdiArrowRight} from "@mdi/js";
import Icon from "@mdi/react";
import clsx from "clsx";
import {CommonProps} from "."; import {CommonProps} from ".";
import {Fragment, useEffect, useState} from "react"; import {Fragment, useEffect, useState} from "react";
import {toast} from "react-toastify"; import {toast} from "react-toastify";
import Button from "../Low/Button"; import Button from "../Low/Button";
import {Dialog, Transition} from "@headlessui/react"; import {Dialog, Transition} from "@headlessui/react";
import useExamStore from "@/stores/examStore";
export default function Writing({id, prompt, info, type, wordCounter, attachment, userSolutions, onNext, onBack}: WritingExercise & CommonProps) { export default function Writing({
id,
prompt,
prefix,
suffix,
type,
wordCounter,
attachment,
userSolutions,
onNext,
onBack,
}: WritingExercise & CommonProps) {
const [isModalOpen, setIsModalOpen] = useState(false); const [isModalOpen, setIsModalOpen] = useState(false);
const [inputText, setInputText] = useState(userSolutions.length === 1 ? userSolutions[0].solution : ""); const [inputText, setInputText] = useState(userSolutions.length === 1 ? userSolutions[0].solution : "");
const [isSubmitEnabled, setIsSubmitEnabled] = useState(false); const [isSubmitEnabled, setIsSubmitEnabled] = useState(false);
const hasExamEnded = useExamStore((state) => state.hasExamEnded);
useEffect(() => {
if (hasExamEnded) onNext({exercise: id, solutions: [{id, solution: inputText}], score: {correct: 1, total: 1, missing: 0}, type});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [hasExamEnded]);
useEffect(() => { useEffect(() => {
const words = inputText.split(" ").filter((x) => x !== ""); const words = inputText.split(" ").filter((x) => x !== "");
@@ -64,7 +79,14 @@ export default function Writing({id, prompt, info, type, wordCounter, attachment
)} )}
<div className="flex flex-col h-full w-full gap-9 mb-20"> <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"> <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>
{prefix.split("\\n").map((line) => (
<>
{line}
<br />
</>
))}
</span>
<span className="font-semibold"> <span className="font-semibold">
{prompt.split("\\n").map((line, index) => ( {prompt.split("\\n").map((line, index) => (
<Fragment key={index}> <Fragment key={index}>
@@ -85,7 +107,12 @@ export default function Writing({id, prompt, info, type, wordCounter, attachment
<div className="w-full h-full flex flex-col gap-4"> <div className="w-full h-full flex flex-col gap-4">
<span> <span>
You should write {wordCounter.type === "min" ? "at least" : "at most"} {wordCounter.limit} words. {suffix.split("\\n").map((line) => (
<>
{line}
<br />
</>
))}
</span> </span>
<textarea <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" 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"
@@ -98,14 +125,14 @@ export default function Writing({id, prompt, info, type, wordCounter, attachment
<div className="self-end flex justify-between w-full gap-8 absolute bottom-8 left-0 px-8"> <div className="self-end flex justify-between w-full gap-8 absolute bottom-8 left-0 px-8">
<Button <Button
color="green" color="purple"
variant="outline" variant="outline"
onClick={() => onBack({exercise: id, solutions: [inputText], score: {correct: 1, total: 1, missing: 0}, type})} onClick={() => onBack({exercise: id, solutions: [{id, solution: inputText}], score: {correct: 1, total: 1, missing: 0}, type})}
className="max-w-[200px] self-end w-full"> className="max-w-[200px] self-end w-full">
Back Back
</Button> </Button>
<Button <Button
color="green" color="purple"
disabled={!isSubmitEnabled} disabled={!isSubmitEnabled}
onClick={() => onNext({exercise: id, solutions: [{id, solution: inputText}], score: {correct: 1, total: 1, missing: 0}, type})} onClick={() => onNext({exercise: id, solutions: [{id, solution: inputText}], score: {correct: 1, total: 1, missing: 0}, type})}
className="max-w-[200px] self-end w-full"> className="max-w-[200px] self-end w-full">

View File

@@ -4,6 +4,7 @@ import {
MatchSentencesExercise, MatchSentencesExercise,
MultipleChoiceExercise, MultipleChoiceExercise,
SpeakingExercise, SpeakingExercise,
TrueFalseExercise,
UserSolution, UserSolution,
WriteBlanksExercise, WriteBlanksExercise,
WritingExercise, WritingExercise,
@@ -14,6 +15,7 @@ import MultipleChoice from "./MultipleChoice";
import WriteBlanks from "./WriteBlanks"; import WriteBlanks from "./WriteBlanks";
import Writing from "./Writing"; import Writing from "./Writing";
import Speaking from "./Speaking"; import Speaking from "./Speaking";
import TrueFalse from "./TrueFalse";
const MatchSentences = dynamic(() => import("@/components/Exercises/MatchSentences"), {ssr: false}); const MatchSentences = dynamic(() => import("@/components/Exercises/MatchSentences"), {ssr: false});
@@ -26,6 +28,8 @@ export const renderExercise = (exercise: Exercise, onNext: (userSolutions: UserS
switch (exercise.type) { switch (exercise.type) {
case "fillBlanks": case "fillBlanks":
return <FillBlanks {...(exercise as FillBlanksExercise)} onNext={onNext} onBack={onBack} />; return <FillBlanks {...(exercise as FillBlanksExercise)} onNext={onNext} onBack={onBack} />;
case "trueFalse":
return <TrueFalse {...(exercise as TrueFalseExercise)} onNext={onNext} onBack={onBack} />;
case "matchSentences": case "matchSentences":
return <MatchSentences {...(exercise as MatchSentencesExercise)} onNext={onNext} onBack={onBack} />; return <MatchSentences {...(exercise as MatchSentencesExercise)} onNext={onNext} onBack={onBack} />;
case "multipleChoice": case "multipleChoice":

View File

@@ -0,0 +1,13 @@
import {useEffect, useState} from "react";
interface Props {
onFocusLayerMouseEnter: Function,
}
export default function FocusLayer({
onFocusLayerMouseEnter,
}: Props) {
return (
<div className="bg-gray-700 bg-opacity-30 absolute top-0 left-0 bottom-0 right-0" onMouseEnter={onFocusLayerMouseEnter}/>
);
}

View File

@@ -8,19 +8,22 @@ interface Props {
user: User; user: User;
children: React.ReactNode; children: React.ReactNode;
className?: string; className?: string;
navDisabled?: boolean;
focusMode?: boolean
onFocusLayerMouseEnter?: Function;
} }
export default function Layout({user, children, className}: Props) { export default function Layout({user, children, className, navDisabled = false, focusMode = false, onFocusLayerMouseEnter }: Props) {
const router = useRouter(); const router = useRouter();
return ( return (
<main className="w-full min-h-full h-screen flex flex-col bg-mti-gray-smoke"> <main className="w-full min-h-full h-screen flex flex-col bg-mti-gray-smoke">
<Navbar user={user} /> <Navbar user={user} navDisabled={navDisabled} focusMode={focusMode} onFocusLayerMouseEnter={onFocusLayerMouseEnter} />
<div className="h-full w-full flex py-4 pb-8 gap-2"> <div className="h-full w-full flex gap-2">
<Sidebar path={router.pathname} /> <Sidebar path={router.pathname} navDisabled={navDisabled} focusMode={focusMode} onFocusLayerMouseEnter={onFocusLayerMouseEnter}/>
<div <div
className={clsx( 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", "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 mt-2",
className, className,
)}> )}>
{children} {children}

View File

@@ -7,7 +7,7 @@ import ProgressBar from "./ProgressBar";
interface Props { interface Props {
src: string; src: string;
color: "blue" | "orange" | "green" | Module; color: "red" | "rose" | "purple" | Module;
autoPlay?: boolean; autoPlay?: boolean;
disabled?: boolean; disabled?: boolean;
onEnd?: () => void; onEnd?: () => void;

View File

@@ -3,29 +3,29 @@ import {ReactNode} from "react";
interface Props { interface Props {
children: ReactNode; children: ReactNode;
color?: "orange" | "green" | "blue"; color?: "rose" | "purple" | "red";
variant?: "outline" | "solid"; variant?: "outline" | "solid";
className?: string; className?: string;
disabled?: boolean; disabled?: boolean;
onClick?: () => void; onClick?: () => void;
} }
export default function Button({color = "green", variant = "solid", disabled = false, className, children, onClick}: Props) { export default function Button({color = "purple", variant = "solid", disabled = false, className, children, onClick}: Props) {
const colorClassNames: {[key in typeof color]: {[key in typeof variant]: string}} = { const colorClassNames: {[key in typeof color]: {[key in typeof variant]: string}} = {
green: { purple: {
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", solid: "bg-mti-purple-light text-white border border-mti-purple-light hover:bg-mti-purple disabled:text-mti-purple disabled:bg-mti-purple-ultralight selection:bg-mti-purple-dark",
outline: 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", "bg-transparent text-mti-purple-light border border-mti-purple-light hover:bg-mti-purple-light disabled:text-mti-purple disabled:bg-mti-purple-ultralight disabled:border-none selection:bg-mti-purple-dark hover:text-white selection:text-white",
}, },
blue: { red: {
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", solid: "bg-mti-red-light text-white border border-mti-red-light hover:bg-mti-red disabled:text-mti-red disabled:bg-mti-red-ultralight selection:bg-mti-red-dark",
outline: 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", "bg-transparent text-mti-red-light border border-mti-red-light hover:bg-mti-red-light disabled:text-mti-red disabled:bg-mti-red-ultralight disabled:border-none selection:bg-mti-red-dark hover:text-white selection:text-white",
}, },
orange: { rose: {
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", solid: "bg-mti-rose-light text-white border border-mti-rose-light hover:bg-mti-rose disabled:text-mti-rose disabled:bg-mti-rose-ultralight selection:bg-mti-rose-dark",
outline: 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", "bg-transparent text-mti-rose-light border border-mti-rose-light hover:bg-mti-rose-light disabled:text-mti-rose disabled:bg-mti-rose-ultralight disabled:border-none selection:bg-mti-rose-dark hover:text-white selection:text-white",
}, },
}; };

View File

@@ -5,11 +5,12 @@ interface Props {
required?: boolean; required?: boolean;
label?: string; label?: string;
placeholder?: string; placeholder?: string;
defaultValue?: string;
name: string; name: string;
onChange: (value: string) => void; onChange: (value: string) => void;
} }
export default function Input({type, label, placeholder, name, required = false, onChange}: Props) { export default function Input({type, label, placeholder, name, required = false, defaultValue, onChange}: Props) {
const [showPassword, setShowPassword] = useState(false); const [showPassword, setShowPassword] = useState(false);
if (type === "password") { if (type === "password") {
@@ -55,6 +56,7 @@ export default function Input({type, label, placeholder, name, required = false,
placeholder={placeholder} 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" 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} required={required}
defaultValue={defaultValue}
/> />
</div> </div>
); );

View File

@@ -4,16 +4,16 @@ import clsx from "clsx";
interface Props { interface Props {
label: string; label: string;
percentage: number; percentage: number;
color: "blue" | "orange" | "green" | Module; color: "red" | "rose" | "purple" | Module;
useColor?: boolean; useColor?: boolean;
className?: string; className?: string;
} }
export default function ProgressBar({label, percentage, color, useColor = false, className}: Props) { export default function ProgressBar({label, percentage, color, useColor = false, className}: Props) {
const progressColorClass: {[key in typeof color]: string} = { const progressColorClass: {[key in typeof color]: string} = {
blue: "bg-mti-blue-light", red: "bg-mti-red-light",
orange: "bg-mti-orange-light", rose: "bg-mti-rose-light",
green: "bg-mti-green-light", purple: "bg-mti-purple-light",
reading: "bg-ielts-reading", reading: "bg-ielts-reading",
listening: "bg-ielts-listening", listening: "bg-ielts-listening",
writing: "bg-ielts-writing", writing: "bg-ielts-writing",

View File

@@ -1,8 +1,12 @@
import {Module} from "@/interfaces"; import {Module} from "@/interfaces";
import useExamStore from "@/stores/examStore";
import {moduleLabels} from "@/utils/moduleUtils"; import {moduleLabels} from "@/utils/moduleUtils";
import clsx from "clsx";
import {motion} from "framer-motion";
import {ReactNode, useEffect, useState} from "react"; import {ReactNode, useEffect, useState} from "react";
import {BsBook, BsHeadphones, BsMegaphone, BsPen, BsStopwatch} from "react-icons/bs"; import {BsBook, BsHeadphones, BsMegaphone, BsPen, BsStopwatch} from "react-icons/bs";
import ProgressBar from "../Low/ProgressBar"; import ProgressBar from "../Low/ProgressBar";
import TimerEndedModal from "../TimerEndedModal";
interface Props { interface Props {
minTimer: number; minTimer: number;
@@ -15,6 +19,9 @@ interface Props {
export default function ModuleTitle({minTimer, module, label, exerciseIndex, totalExercises, disableTimer = false}: Props) { export default function ModuleTitle({minTimer, module, label, exerciseIndex, totalExercises, disableTimer = false}: Props) {
const [timer, setTimer] = useState(minTimer * 60); const [timer, setTimer] = useState(minTimer * 60);
const [showModal, setShowModal] = useState(false);
const [warningMode, setWarningMode] = useState(false);
const setHasExamEnded = useExamStore((state) => state.setHasExamEnded);
useEffect(() => { useEffect(() => {
if (!disableTimer) { if (!disableTimer) {
@@ -26,6 +33,14 @@ export default function ModuleTitle({minTimer, module, label, exerciseIndex, tot
} }
}, [disableTimer, minTimer]); }, [disableTimer, minTimer]);
useEffect(() => {
if (timer <= 0) setShowModal(true);
}, [timer]);
useEffect(() => {
if (timer < 300 && !warningMode) setWarningMode(true);
}, [timer, warningMode]);
const moduleIcon: {[key in Module]: ReactNode} = { const moduleIcon: {[key in Module]: ReactNode} = {
reading: <BsBook className="text-ielts-reading w-6 h-6" />, reading: <BsBook className="text-ielts-reading w-6 h-6" />,
listening: <BsHeadphones className="text-ielts-listening w-6 h-6" />, listening: <BsHeadphones className="text-ielts-listening w-6 h-6" />,
@@ -35,7 +50,21 @@ export default function ModuleTitle({minTimer, module, label, exerciseIndex, tot
return ( 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"> <TimerEndedModal
isOpen={showModal}
onClose={() => {
setHasExamEnded(true);
setShowModal(false);
}}
/>
<motion.div
className={clsx(
"absolute top-4 right-6 bg-mti-gray-seasalt px-3 py-2 flex items-center gap-2 rounded-full text-mti-gray-davy",
warningMode && !disableTimer && "bg-mti-red-light text-mti-gray-seasalt",
)}
initial={{scale: warningMode && !disableTimer ? 0.8 : 1}}
animate={{scale: warningMode && !disableTimer ? 1.1 : 1}}
transition={{repeat: Infinity, repeatType: "reverse", duration: 0.5, ease: "easeInOut"}}>
<BsStopwatch className="w-4 h-4" /> <BsStopwatch className="w-4 h-4" />
<span className="text-sm font-semibold w-11"> <span className="text-sm font-semibold w-11">
{timer > 0 && ( {timer > 0 && (
@@ -51,7 +80,7 @@ export default function ModuleTitle({minTimer, module, label, exerciseIndex, tot
)} )}
{timer <= 0 && <>00:00</>} {timer <= 0 && <>00:00</>}
</span> </span>
</div> </motion.div>
<div className="flex gap-6 w-full h-fit items-center mt-5"> <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="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="flex flex-col gap-3 w-full">

View File

@@ -1,22 +1,32 @@
import {User} from "@/interfaces/user"; import {User} from "@/interfaces/user";
import Link from "next/link";
import {Avatar} from "primereact/avatar"; import {Avatar} from "primereact/avatar";
import FocusLayer from '@/components/FocusLayer';
import { preventNavigation } from "@/utils/navigation.disabled";
interface Props { interface Props {
user: User; user: User;
navDisabled?: boolean;
focusMode?: boolean;
onFocusLayerMouseEnter?: Function;
} }
/* eslint-disable @next/next/no-img-element */ /* eslint-disable @next/next/no-img-element */
export default function Navbar({user}: Props) { export default function Navbar({user, navDisabled = false, focusMode = false, onFocusLayerMouseEnter}: Props) {
const disableNavigation = preventNavigation(navDisabled, focusMode);
return ( return (
<header className="w-full bg-transparent py-4 gap-2 flex items-center"> <header className="w-full bg-transparent py-4 gap-2 flex items-center relative">
<h1 className="font-bold text-2xl w-1/6 px-8">eCrop</h1> <h1 className="font-bold text-2xl w-1/6 px-8">EnCoach</h1>
<div className="flex justify-between w-5/6 mr-8"> <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" /> <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"> <Link href={disableNavigation ? "" : "/profile"} className="flex gap-3 items-center justify-end">
<img src={user.profilePicture} alt={user.name} className="w-10 h-10 rounded-full" /> <img src={user.profilePicture} alt={user.name} className="w-10 h-10 rounded-full object-cover" />
<span className="text-right">{user.name}</span> <span className="text-right">{user.name}</span>
</div> </Link>
</div> </div>
{focusMode && <FocusLayer onFocusLayerMouseEnter={onFocusLayerMouseEnter}/>}
</header> </header>
); );
} }

View File

@@ -15,7 +15,7 @@ export default function ProfileCard({user, className}: Props) {
<div className={clsx("bg-white drop-shadow-xl p-4 md:p-8 rounded-xl w-full flex flex-col gap-6", className)}> <div className={clsx("bg-white drop-shadow-xl p-4 md:p-8 rounded-xl w-full flex flex-col gap-6", className)}>
<div className="flex w-full items-center gap-8"> <div className="flex w-full items-center gap-8">
<div className="w-16 md:w-24 h-16 md:h-24 rounded-full border-2 md:border-4 border-white drop-shadow-md md:drop-shadow-xl"> <div className="w-16 md:w-24 h-16 md:h-24 rounded-full border-2 md:border-4 border-white drop-shadow-md md:drop-shadow-xl">
{user.profilePicture.length > 0 && <img src={user.profilePicture} alt="Profile picture" className="rounded-full" />} {user.profilePicture.length > 0 && <img src={user.profilePicture} alt="Profile picture" className="rounded-full object-cover" />}
{user.profilePicture.length === 0 && ( {user.profilePicture.length === 0 && (
<Avatar size="xlarge" style={{width: "100%", height: "100%"}} label={user.name.slice(0, 1)} shape="circle" /> <Avatar size="xlarge" style={{width: "100%", height: "100%"}} label={user.name.slice(0, 1)} shape="circle" />
)} )}

View File

@@ -17,7 +17,7 @@ export default function ProfileLevel({user, className}: Props) {
return ( return (
<div className={clsx("flex flex-col items-center justify-center gap-4", className)}> <div className={clsx("flex flex-col items-center justify-center gap-4", className)}>
<div className="w-16 md:w-24 h-16 md:h-24 rounded-full"> <div className="w-16 md:w-24 h-16 md:h-24 rounded-full">
{user.profilePicture.length > 0 && <img src={user.profilePicture} alt="Profile picture" className="rounded-full" />} {user.profilePicture.length > 0 && <img src={user.profilePicture} alt="Profile picture" className="rounded-full object-cover" />}
{user.profilePicture.length === 0 && ( {user.profilePicture.length === 0 && (
<Avatar size="xlarge" style={{width: "100%", height: "100%"}} label={user.name.slice(0, 1)} shape="circle" /> <Avatar size="xlarge" style={{width: "100%", height: "100%"}} label={user.name.slice(0, 1)} shape="circle" />
)} )}

View File

@@ -1,16 +1,20 @@
import clsx from "clsx"; import clsx from "clsx";
import {IconType} from "react-icons"; import {IconType} from "react-icons";
import {MdSpaceDashboard} from "react-icons/md"; import {MdSpaceDashboard} from "react-icons/md";
import {BsFileEarmarkText, BsClockHistory, BsPencil, BsGraphUp} from "react-icons/bs"; import {BsFileEarmarkText, BsClockHistory, BsPencil, BsGraphUp, BsShield} from "react-icons/bs";
import {RiLogoutBoxFill} from "react-icons/ri"; import {RiLogoutBoxFill} from "react-icons/ri";
import {SlPencil} from "react-icons/sl"; import {SlPencil} from "react-icons/sl";
import {FaAward} from "react-icons/fa"; import {FaAward} from "react-icons/fa";
import Link from "next/link"; import Link from "next/link";
import {useRouter} from "next/router"; import {useRouter} from "next/router";
import axios from "axios"; import axios from "axios";
import FocusLayer from '@/components/FocusLayer';
import { preventNavigation } from "@/utils/navigation.disabled";
interface Props { interface Props {
path: string; path: string;
navDisabled?: boolean;
focusMode?: boolean;
onFocusLayerMouseEnter?: Function;
} }
interface NavProps { interface NavProps {
@@ -18,50 +22,55 @@ interface NavProps {
label: string; label: string;
path: string; path: string;
keyPath: string; keyPath: string;
disabled?: boolean;
} }
const Nav = ({Icon, label, path, keyPath}: NavProps) => ( const Nav = ({Icon, label, path, keyPath, disabled = false}: NavProps) => (
<Link <Link
href={keyPath} href={!disabled ? keyPath : ""}
className={clsx( 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", "p-4 px-8 rounded-full flex gap-4 items-center cursor-pointer text-gray-500 hover:bg-mti-purple-light hover:text-white transition duration-300 ease-in-out",
path === keyPath && "bg-mti-green-light text-white", path === keyPath && "bg-mti-purple-light text-white",
)}> )}>
<Icon size={20} /> <Icon size={20} />
<span className="text-lg font-semibold">{label}</span> <span className="text-lg font-semibold">{label}</span>
</Link> </Link>
); );
export default function Sidebar({path}: Props) { export default function Sidebar({path, navDisabled = false, focusMode = false, onFocusLayerMouseEnter}: Props) {
const router = useRouter(); const router = useRouter();
const logout = async () => { const logout = async () => {
axios.post("/api/logout").finally(() => { axios.post("/api/logout").finally(() => {
router.push("/login"); setTimeout(() => router.reload(), 500);
}); });
}; };
const disableNavigation: Boolean = preventNavigation(navDisabled, focusMode);
return ( return (
<section className="h-full flex bg-transparent flex-col justify-between w-1/6 px-4"> <section className="h-full flex bg-transparent flex-col justify-between w-1/6 px-4 py-4 pb-8 relative">
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
<Nav Icon={MdSpaceDashboard} label="Dashboard" path={path} keyPath="/" /> <Nav disabled={disableNavigation} Icon={MdSpaceDashboard} label="Dashboard" path={path} keyPath="/" />
<Nav Icon={BsFileEarmarkText} label="Exams" path={path} keyPath="/exam" /> <Nav disabled={disableNavigation} Icon={BsFileEarmarkText} label="Exams" path={path} keyPath="/exam" />
<Nav Icon={BsPencil} label="Exercises" path={path} keyPath="/#" /> <Nav disabled={disableNavigation} Icon={BsPencil} label="Exercises" path={path} keyPath="/exercises" />
<Nav Icon={BsGraphUp} label="Stats" path={path} keyPath="/stats" /> <Nav disabled={disableNavigation} Icon={BsGraphUp} label="Stats" path={path} keyPath="/stats" />
<Nav Icon={BsClockHistory} label="Record" path={path} keyPath="/record" /> <Nav disabled={disableNavigation} Icon={BsClockHistory} label="Record" path={path} keyPath="/record" />
<Nav disabled={disableNavigation} Icon={BsShield} label="Admin" path={path} keyPath="/admin" />
</div> </div>
<div <div
role="button" role="button"
tabIndex={1} tabIndex={1}
onClick={logout} onClick={focusMode ? () => {} : logout}
className={clsx( 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", "p-4 px-8 rounded-full flex gap-4 items-center cursor-pointer text-black hover:text-mti-rose transition duration-300 ease-in-out",
"absolute bottom-8", "absolute bottom-8",
)}> )}>
<RiLogoutBoxFill size={20} /> <RiLogoutBoxFill size={20} />
<span className="text-lg font-medium">Log Out</span> <span className="text-lg font-medium">Log Out</span>
</div> </div>
{focusMode && <FocusLayer onFocusLayerMouseEnter={onFocusLayerMouseEnter} />}
</section> </section>
); );
} }

View File

@@ -5,7 +5,15 @@ import {CommonProps} from ".";
import {Fragment} from "react"; import {Fragment} from "react";
import Button from "../Low/Button"; import Button from "../Low/Button";
export default function FillBlanksSolutions({prompt, solutions, text, userSolutions, onNext, onBack}: FillBlanksExercise & CommonProps) { export default function FillBlanksSolutions({id, type, prompt, solutions, text, userSolutions, onNext, onBack}: FillBlanksExercise & CommonProps) {
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 missing = total - userSolutions.filter((x) => solutions.find((y) => x.id === y.id)).length;
return {total, correct, missing};
};
const renderLines = (line: string) => { const renderLines = (line: string) => {
return ( return (
<span> <span>
@@ -18,7 +26,7 @@ export default function FillBlanksSolutions({prompt, solutions, text, userSoluti
return ( return (
<button <button
className={clsx( 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", "rounded-full hover:text-white hover:bg-mti-red transition duration-300 ease-in-out my-1 px-5 py-2 text-center text-white bg-mti-red-light",
)}> )}>
{solution.solution} {solution.solution}
</button> </button>
@@ -29,8 +37,8 @@ export default function FillBlanksSolutions({prompt, solutions, text, userSoluti
return ( return (
<button <button
className={clsx( className={clsx(
"rounded-full hover:text-white hover:bg-mti-green transition duration-300 ease-in-out my-1", "rounded-full hover:text-white hover:bg-mti-purple transition duration-300 ease-in-out my-1",
userSolution && "px-5 py-2 text-center text-white bg-mti-green-light", userSolution && "px-5 py-2 text-center text-white bg-mti-purple-light",
)}> )}>
{solution.solution} {solution.solution}
</button> </button>
@@ -42,16 +50,16 @@ export default function FillBlanksSolutions({prompt, solutions, text, userSoluti
<> <>
<button <button
className={clsx( className={clsx(
"rounded-full hover:text-white hover:bg-mti-orange transition duration-300 ease-in-out my-1 mr-1", "rounded-full hover:text-white hover:bg-mti-rose transition duration-300 ease-in-out my-1 mr-1",
userSolution && "px-5 py-2 text-center text-white bg-mti-orange-light", userSolution && "px-5 py-2 text-center text-white bg-mti-rose-light",
)}> )}>
{userSolution.solution} {userSolution.solution}
</button> </button>
<button <button
className={clsx( className={clsx(
"rounded-full hover:text-white hover:bg-mti-green transition duration-300 ease-in-out my-1", "rounded-full hover:text-white hover:bg-mti-purple transition duration-300 ease-in-out my-1",
userSolution && "px-5 py-2 text-center text-white bg-mti-green-light", userSolution && "px-5 py-2 text-center text-white bg-mti-purple-light",
)}> )}>
{solution.solution} {solution.solution}
</button> </button>
@@ -84,26 +92,33 @@ export default function FillBlanksSolutions({prompt, solutions, text, userSoluti
</span> </span>
<div className="flex gap-4 items-center"> <div className="flex gap-4 items-center">
<div className="flex gap-2 items-center"> <div className="flex gap-2 items-center">
<div className="w-4 h-4 rounded-full bg-mti-green" /> <div className="w-4 h-4 rounded-full bg-mti-purple" />
Correct Correct
</div> </div>
<div className="flex gap-2 items-center"> <div className="flex gap-2 items-center">
<div className="w-4 h-4 rounded-full bg-mti-blue" /> <div className="w-4 h-4 rounded-full bg-mti-red" />
Unanswered Unanswered
</div> </div>
<div className="flex gap-2 items-center"> <div className="flex gap-2 items-center">
<div className="w-4 h-4 rounded-full bg-mti-orange" /> <div className="w-4 h-4 rounded-full bg-mti-rose" />
Wrong Wrong
</div> </div>
</div> </div>
</div> </div>
<div className="self-end flex justify-between w-full gap-8 absolute bottom-8 left-0 px-8"> <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"> <Button
color="purple"
variant="outline"
onClick={() => onBack({exercise: id, solutions: userSolutions, score: calculateScore(), type})}
className="max-w-[200px] w-full">
Back Back
</Button> </Button>
<Button color="green" onClick={() => onNext()} className="max-w-[200px] self-end w-full"> <Button
color="purple"
onClick={() => onNext({exercise: id, solutions: userSolutions, score: calculateScore(), type})}
className="max-w-[200px] self-end w-full">
Next Next
</Button> </Button>
</div> </div>

View File

@@ -9,7 +9,24 @@ import {Fragment} from "react";
import Button from "../Low/Button"; import Button from "../Low/Button";
import Xarrow from "react-xarrows"; import Xarrow from "react-xarrows";
export default function MatchSentencesSolutions({options, prompt, sentences, userSolutions, onNext, onBack}: MatchSentencesExercise & CommonProps) { export default function MatchSentencesSolutions({
id,
type,
options,
prompt,
sentences,
userSolutions,
onNext,
onBack,
}: MatchSentencesExercise & CommonProps) {
const calculateScore = () => {
const total = sentences.length;
const correct = userSolutions.filter((x) => sentences.find((y) => y.id === x.question)?.solution === x.option || false).length;
const missing = total - userSolutions.filter((x) => sentences.find((y) => y.id === x.question)).length;
return {total, correct, missing};
};
return ( return (
<> <>
<div className="flex flex-col gap-4 mt-4 h-full mb-20"> <div className="flex flex-col gap-4 mt-4 h-full mb-20">
@@ -31,9 +48,9 @@ export default function MatchSentencesSolutions({options, prompt, sentences, use
className={clsx( className={clsx(
"w-8 h-8 rounded-full z-10 text-white", "w-8 h-8 rounded-full z-10 text-white",
"transition duration-300 ease-in-out", "transition duration-300 ease-in-out",
!userSolutions.find((x) => x.question === id) && "!bg-mti-blue", !userSolutions.find((x) => x.question === id) && "!bg-mti-red",
userSolutions.find((x) => x.question === id)?.option === solution && "bg-mti-green", userSolutions.find((x) => x.question === id)?.option === solution && "bg-mti-purple",
userSolutions.find((x) => x.question === id)?.option !== solution && "bg-mti-orange", userSolutions.find((x) => x.question === id)?.option !== solution && "bg-mti-rose",
)}> )}>
{id} {id}
</button> </button>
@@ -46,7 +63,7 @@ export default function MatchSentencesSolutions({options, prompt, sentences, use
<button <button
id={id} id={id}
className={clsx( className={clsx(
"bg-mti-green-ultralight text-mti-green hover:text-white hover:bg-mti-green w-8 h-8 rounded-full z-10", "bg-mti-purple-ultralight text-mti-purple hover:text-white hover:bg-mti-purple w-8 h-8 rounded-full z-10",
"transition duration-300 ease-in-out", "transition duration-300 ease-in-out",
)}> )}>
{id} {id}
@@ -62,10 +79,10 @@ export default function MatchSentencesSolutions({options, prompt, sentences, use
end={sentence.solution} end={sentence.solution}
lineColor={ lineColor={
!userSolutions.find((x) => x.question === sentence.id) !userSolutions.find((x) => x.question === sentence.id)
? "#0696ff" ? "#CC5454"
: userSolutions.find((x) => x.question === sentence.id)?.option === sentence.solution : userSolutions.find((x) => x.question === sentence.id)?.option === sentence.solution
? "#307912" ? "#7872BF"
: "#FF6000" : "#CC5454"
} }
showHead={false} showHead={false}
/> />
@@ -73,23 +90,30 @@ export default function MatchSentencesSolutions({options, prompt, sentences, use
</div> </div>
<div className="flex gap-4 items-center"> <div className="flex gap-4 items-center">
<div className="flex gap-2 items-center"> <div className="flex gap-2 items-center">
<div className="w-4 h-4 rounded-full bg-mti-green" /> Correct <div className="w-4 h-4 rounded-full bg-mti-purple" /> Correct
</div> </div>
<div className="flex gap-2 items-center"> <div className="flex gap-2 items-center">
<div className="w-4 h-4 rounded-full bg-mti-blue" /> Unanswered <div className="w-4 h-4 rounded-full bg-mti-red" /> Unanswered
</div> </div>
<div className="flex gap-2 items-center"> <div className="flex gap-2 items-center">
<div className="w-4 h-4 rounded-full bg-mti-orange" /> Wrong <div className="w-4 h-4 rounded-full bg-mti-rose" /> Wrong
</div> </div>
</div> </div>
</div> </div>
<div className="self-end flex justify-between w-full gap-8 absolute bottom-8 left-0 px-8"> <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"> <Button
color="purple"
variant="outline"
onClick={() => onBack({exercise: id, solutions: userSolutions, score: calculateScore(), type})}
className="max-w-[200px] w-full">
Back Back
</Button> </Button>
<Button color="green" onClick={() => onNext()} className="max-w-[200px] self-end w-full"> <Button
color="purple"
onClick={() => onNext({exercise: id, solutions: userSolutions, score: calculateScore(), type})}
className="max-w-[200px] self-end w-full">
Next Next
</Button> </Button>
</div> </div>

View File

@@ -14,14 +14,14 @@ function Question({
}: MultipleChoiceQuestion & {userSolution: string | undefined; onSelectOption?: (option: string) => void; showSolution?: boolean}) { }: MultipleChoiceQuestion & {userSolution: string | undefined; onSelectOption?: (option: string) => void; showSolution?: boolean}) {
const optionColor = (option: string) => { const optionColor = (option: string) => {
if (option === solution && !userSolution) { if (option === solution && !userSolution) {
return "!border-mti-blue-light !text-mti-blue-light"; return "!border-mti-red-light !text-mti-red-light";
} }
if (option === solution) { if (option === solution) {
return "!border-mti-green-light !text-mti-green-light"; return "!border-mti-purple-light !text-mti-purple-light";
} }
return userSolution === option ? "!border-mti-orange-light !text-mti-orange-light" : ""; return userSolution === option ? "!border-mti-rose-light !text-mti-rose-light" : "";
}; };
return ( return (
@@ -54,12 +54,20 @@ function Question({
); );
} }
export default function MultipleChoice({prompt, questions, userSolutions, onNext, onBack}: MultipleChoiceExercise & CommonProps) { export default function MultipleChoice({id, type, prompt, questions, userSolutions, onNext, onBack}: MultipleChoiceExercise & CommonProps) {
const [questionIndex, setQuestionIndex] = useState(0); const [questionIndex, setQuestionIndex] = useState(0);
const calculateScore = () => {
const total = questions.length;
const correct = userSolutions.filter((x) => questions.find((y) => y.id === x.question)?.solution === x.option || false).length;
const missing = total - userSolutions.filter((x) => questions.find((y) => y.id === x.question)).length;
return {total, correct, missing};
};
const next = () => { const next = () => {
if (questionIndex === questions.length - 1) { if (questionIndex === questions.length - 1) {
onNext(); onNext({exercise: id, solutions: userSolutions, score: calculateScore(), type});
} else { } else {
setQuestionIndex((prev) => prev + 1); setQuestionIndex((prev) => prev + 1);
} }
@@ -67,7 +75,7 @@ export default function MultipleChoice({prompt, questions, userSolutions, onNext
const back = () => { const back = () => {
if (questionIndex === 0) { if (questionIndex === 0) {
onBack(); onBack({exercise: id, solutions: userSolutions, score: calculateScore(), type});
} else { } else {
setQuestionIndex((prev) => prev - 1); setQuestionIndex((prev) => prev - 1);
} }
@@ -87,26 +95,26 @@ export default function MultipleChoice({prompt, questions, userSolutions, onNext
</div> </div>
<div className="flex gap-4 items-center"> <div className="flex gap-4 items-center">
<div className="flex gap-2 items-center"> <div className="flex gap-2 items-center">
<div className="w-4 h-4 rounded-full bg-mti-green" /> <div className="w-4 h-4 rounded-full bg-mti-purple" />
Correct Correct
</div> </div>
<div className="flex gap-2 items-center"> <div className="flex gap-2 items-center">
<div className="w-4 h-4 rounded-full bg-mti-blue" /> <div className="w-4 h-4 rounded-full bg-mti-red" />
Unanswered Unanswered
</div> </div>
<div className="flex gap-2 items-center"> <div className="flex gap-2 items-center">
<div className="w-4 h-4 rounded-full bg-mti-orange" /> <div className="w-4 h-4 rounded-full bg-mti-rose" />
Wrong Wrong
</div> </div>
</div> </div>
</div> </div>
<div className="self-end flex justify-between w-full gap-8 absolute bottom-8 left-0 px-8"> <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"> <Button color="purple" variant="outline" onClick={back} className="max-w-[200px] w-full">
Back Back
</Button> </Button>
<Button color="green" onClick={next} className="max-w-[200px] self-end w-full"> <Button color="purple" onClick={next} className="max-w-[200px] self-end w-full">
Next Next
</Button> </Button>
</div> </div>

View File

@@ -0,0 +1,104 @@
/* eslint-disable @next/next/no-img-element */
import {SpeakingExercise} from "@/interfaces/exam";
import {CommonProps} from ".";
import {Fragment, useEffect, useState} from "react";
import Button from "../Low/Button";
import dynamic from "next/dynamic";
import axios from "axios";
const Waveform = dynamic(() => import("../Waveform"), {ssr: false});
export default function Speaking({id, type, title, text, prompts, userSolutions, onNext, onBack}: SpeakingExercise & CommonProps) {
const [solutionURL, setSolutionURL] = useState<string>();
useEffect(() => {
axios.post(`/api/speaking`, {path: userSolutions[0].solution}, {responseType: "arraybuffer"}).then(({data}) => {
const blob = new Blob([data], {type: "audio/wav"});
const url = URL.createObjectURL(blob);
setSolutionURL(url);
});
}, [userSolutions]);
return (
<>
<div className="flex flex-col h-full w-full gap-8 mb-20">
<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}
</li>
))}
</div>
</div>
</div>
<div className="w-full h-full flex flex-col gap-8">
<div className="w-full p-4 px-8 bg-transparent border-2 border-mti-gray-platinum rounded-2xl flex-col gap-8 items-center">
<div className="flex gap-8 items-center justify-center py-8">
{solutionURL && <Waveform audio={solutionURL} waveColor="#FCDDEC" progressColor="#EF5DA8" />}
</div>
</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-speaking text-ielts-speaking-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="purple"
variant="outline"
onClick={() =>
onBack({
exercise: id,
solutions: userSolutions,
score: {correct: 1, total: 1, missing: 0},
type,
})
}
className="max-w-[200px] self-end w-full">
Back
</Button>
<Button
color="purple"
onClick={() =>
onNext({
exercise: id,
solutions: userSolutions,
score: {correct: 1, total: 1, missing: 0},
type,
})
}
className="max-w-[200px] self-end w-full">
Next
</Button>
</div>
</>
);
}

View File

@@ -0,0 +1,131 @@
import {FillBlanksExercise, TrueFalseExercise} from "@/interfaces/exam";
import clsx from "clsx";
import reactStringReplace from "react-string-replace";
import {CommonProps} from ".";
import {Fragment} from "react";
import Button from "../Low/Button";
type Solution = "true" | "false" | "not_given";
export default function TrueFalseSolution({prompt, type, id, questions, userSolutions, onNext, onBack}: TrueFalseExercise & CommonProps) {
const calculateScore = () => {
const total = questions.length || 0;
const correct = userSolutions.filter((x) => questions.find((y) => x.id === y.id)?.solution === x.solution.toLowerCase() || false).length;
const missing = total - userSolutions.filter((x) => questions.find((y) => x.id === y.id)).length;
return {total, correct, missing};
};
const getButtonColor = (buttonSolution: Solution, solution: Solution, userSolution: Solution | undefined) => {
if (buttonSolution !== userSolution && buttonSolution !== solution) return "purple";
if (userSolution) {
if (userSolution === buttonSolution && solution === buttonSolution) {
return "purple";
}
if (solution === buttonSolution) {
return "purple";
}
return "rose";
}
return "red";
};
return (
<>
<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>
<div className="flex flex-col gap-6 mb-4">
<p>For each of the questions below, select</p>
<div className="pl-8 flex gap-8">
<span className="flex flex-col gap-4">
<span className="font-bold italic">TRUE</span>
<span className="font-bold italic">FALSE</span>
<span className="font-bold italic">NOT GIVEN</span>
</span>
<span className="flex flex-col gap-4">
<span>if the statement agrees with the information</span>
<span>if the statement contradicts with the information</span>
<span>if there is no information on this</span>
</span>
</div>
</div>
<span className="text-sm w-full leading-6">You can click a selected option again to deselect it.</span>
<div className="bg-mti-gray-smoke rounded-xl px-5 py-6 flex flex-col gap-8">
{questions.map((question, index) => {
const userSolution = userSolutions.find((x) => x.id === question.id);
return (
<div key={question.id} className="flex flex-col gap-4">
<span>
{index + 1}. {question.prompt}
</span>
<div className="flex gap-4">
<Button
variant={question.solution === "true" || userSolution?.solution === "true" ? "solid" : "outline"}
className="!py-2"
color={getButtonColor("true", question.solution, userSolution?.solution)}>
True
</Button>
<Button
variant={question.solution === "false" || userSolution?.solution === "false" ? "solid" : "outline"}
className="!py-2"
color={getButtonColor("false", question.solution, userSolution?.solution)}>
False
</Button>
<Button
variant={question.solution === "not_given" || userSolution?.solution === "not_given" ? "solid" : "outline"}
className="!py-2"
color={getButtonColor("not_given", question.solution, userSolution?.solution)}>
Not Given
</Button>
</div>
</div>
);
})}
</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-purple" />
Correct
</div>
<div className="flex gap-2 items-center">
<div className="w-4 h-4 rounded-full bg-mti-red" />
Unanswered
</div>
<div className="flex gap-2 items-center">
<div className="w-4 h-4 rounded-full bg-mti-rose" />
Wrong
</div>
</div>
</div>
<div className="self-end flex justify-between w-full gap-8 absolute bottom-8 left-0 px-8">
<Button
color="purple"
variant="outline"
onClick={() => onBack({exercise: id, solutions: userSolutions, score: calculateScore(), type})}
className="max-w-[200px] w-full">
Back
</Button>
<Button
color="purple"
onClick={() => onNext({exercise: id, solutions: userSolutions, score: calculateScore(), type})}
className="max-w-[200px] self-end w-full">
Next
</Button>
</div>
</>
);
}

View File

@@ -39,17 +39,17 @@ function Blank({
const getSolutionStyling = () => { const getSolutionStyling = () => {
if (!userSolution) { if (!userSolution) {
return "bg-mti-blue-ultralight text-mti-blue-light"; return "bg-mti-red-ultralight text-mti-red-light";
} }
return "bg-mti-green-ultralight text-mti-green-light"; return "bg-mti-purple-ultralight text-mti-purple-light";
}; };
return ( return (
<span className="inline-flex gap-2"> <span className="inline-flex gap-2">
{userSolution && !isUserSolutionCorrect() && ( {userSolution && !isUserSolutionCorrect() && (
<input <input
className="py-2 px-3 rounded-2xl w-48 focus:outline-none my-2 bg-mti-orange-ultralight text-mti-orange-light" className="py-2 px-3 rounded-2xl w-48 focus:outline-none my-2 bg-mti-rose-ultralight text-mti-rose-light"
placeholder={id} placeholder={id}
onChange={(e) => setUserInput(e.target.value)} onChange={(e) => setUserInput(e.target.value)}
value={userSolution} value={userSolution}
@@ -69,6 +69,7 @@ function Blank({
export default function WriteBlanksSolutions({ export default function WriteBlanksSolutions({
id, id,
type,
prompt, prompt,
maxWords, maxWords,
solutions, solutions,
@@ -77,6 +78,20 @@ export default function WriteBlanksSolutions({
onNext, onNext,
onBack, onBack,
}: WriteBlanksExercise & CommonProps) { }: WriteBlanksExercise & CommonProps) {
const calculateScore = () => {
const total = text.match(/({{\d+}})/g)?.length || 0;
const correct = userSolutions.filter(
(x) =>
solutions
.find((y) => x.id === y.id)
?.solution.map((y) => y.toLowerCase())
.includes(x.solution.toLowerCase()) || false,
).length;
const missing = total - userSolutions.filter((x) => solutions.find((y) => x.id === y.id)).length;
return {total, correct, missing};
};
const renderLines = (line: string) => { const renderLines = (line: string) => {
return ( return (
<span className="text-base leading-5"> <span className="text-base leading-5">
@@ -112,26 +127,33 @@ export default function WriteBlanksSolutions({
</span> </span>
<div className="flex gap-4 items-center"> <div className="flex gap-4 items-center">
<div className="flex gap-2 items-center"> <div className="flex gap-2 items-center">
<div className="w-4 h-4 rounded-full bg-mti-green" /> <div className="w-4 h-4 rounded-full bg-mti-purple" />
Correct Correct
</div> </div>
<div className="flex gap-2 items-center"> <div className="flex gap-2 items-center">
<div className="w-4 h-4 rounded-full bg-mti-blue" /> <div className="w-4 h-4 rounded-full bg-mti-red" />
Unanswered Unanswered
</div> </div>
<div className="flex gap-2 items-center"> <div className="flex gap-2 items-center">
<div className="w-4 h-4 rounded-full bg-mti-orange" /> <div className="w-4 h-4 rounded-full bg-mti-rose" />
Wrong Wrong
</div> </div>
</div> </div>
</div> </div>
<div className="self-end flex justify-between w-full gap-8 absolute bottom-8 left-0 px-8"> <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"> <Button
color="purple"
variant="outline"
onClick={() => onBack({exercise: id, solutions: userSolutions, score: calculateScore(), type})}
className="max-w-[200px] w-full">
Back Back
</Button> </Button>
<Button color="green" onClick={() => onNext()} className="max-w-[200px] self-end w-full"> <Button
color="purple"
onClick={() => onNext({exercise: id, solutions: userSolutions, score: calculateScore(), type})}
className="max-w-[200px] self-end w-full">
Next Next
</Button> </Button>
</div> </div>

View File

@@ -10,7 +10,7 @@ import {toast} from "react-toastify";
import Button from "../Low/Button"; import Button from "../Low/Button";
import {Dialog, Transition} from "@headlessui/react"; import {Dialog, Transition} from "@headlessui/react";
export default function Writing({id, prompt, info, attachment, userSolutions, onNext, onBack}: WritingExercise & CommonProps) { export default function Writing({id, type, prompt, attachment, userSolutions, onNext, onBack}: WritingExercise & CommonProps) {
const [isModalOpen, setIsModalOpen] = useState(false); const [isModalOpen, setIsModalOpen] = useState(false);
return ( return (
@@ -96,11 +96,17 @@ export default function Writing({id, prompt, info, attachment, userSolutions, on
</div> </div>
<div className="self-end flex justify-between w-full gap-8 absolute bottom-8 left-0 px-8"> <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"> <Button
color="purple"
variant="outline"
onClick={() => onBack({exercise: id, solutions: userSolutions, score: {correct: 1, total: 1, missing: 0}, type})}
className="max-w-[200px] self-end w-full">
Back Back
</Button> </Button>
<Button
<Button color="green" onClick={() => onNext()} className="max-w-[200px] self-end w-full"> color="purple"
onClick={() => onNext({exercise: id, solutions: userSolutions, score: {correct: 1, total: 1, missing: 0}, type})}
className="max-w-[200px] self-end w-full">
Next Next
</Button> </Button>
</div> </div>

View File

@@ -1,21 +1,35 @@
import {Exercise, FillBlanksExercise, MatchSentencesExercise, MultipleChoiceExercise, WriteBlanksExercise, WritingExercise} from "@/interfaces/exam"; import {
Exercise,
FillBlanksExercise,
MatchSentencesExercise,
MultipleChoiceExercise,
SpeakingExercise,
TrueFalseExercise,
UserSolution,
WriteBlanksExercise,
WritingExercise,
} from "@/interfaces/exam";
import dynamic from "next/dynamic"; import dynamic from "next/dynamic";
import FillBlanks from "./FillBlanks"; import FillBlanks from "./FillBlanks";
import MultipleChoice from "./MultipleChoice"; import MultipleChoice from "./MultipleChoice";
import Speaking from "./Speaking";
import TrueFalseSolution from "./TrueFalse";
import WriteBlanks from "./WriteBlanks"; import WriteBlanks from "./WriteBlanks";
import Writing from "./Writing"; import Writing from "./Writing";
const MatchSentences = dynamic(() => import("@/components/Solutions/MatchSentences"), {ssr: false}); const MatchSentences = dynamic(() => import("@/components/Solutions/MatchSentences"), {ssr: false});
export interface CommonProps { export interface CommonProps {
onNext: () => void; onNext: (userSolutions: UserSolution) => void;
onBack: () => void; onBack: (userSolutions: UserSolution) => void;
} }
export const renderSolution = (exercise: Exercise, onNext: () => void, onBack: () => void) => { export const renderSolution = (exercise: Exercise, onNext: () => void, onBack: () => void) => {
switch (exercise.type) { switch (exercise.type) {
case "fillBlanks": case "fillBlanks":
return <FillBlanks {...(exercise as FillBlanksExercise)} onNext={onNext} onBack={onBack} />; return <FillBlanks {...(exercise as FillBlanksExercise)} onNext={onNext} onBack={onBack} />;
case "trueFalse":
return <TrueFalseSolution {...(exercise as TrueFalseExercise)} onNext={onNext} onBack={onBack} />;
case "matchSentences": case "matchSentences":
return <MatchSentences {...(exercise as MatchSentencesExercise)} onNext={onNext} onBack={onBack} />; return <MatchSentences {...(exercise as MatchSentencesExercise)} onNext={onNext} onBack={onBack} />;
case "multipleChoice": case "multipleChoice":
@@ -24,5 +38,7 @@ export const renderSolution = (exercise: Exercise, onNext: () => void, onBack: (
return <WriteBlanks {...(exercise as WriteBlanksExercise)} onNext={onNext} onBack={onBack} />; return <WriteBlanks {...(exercise as WriteBlanksExercise)} onNext={onNext} onBack={onBack} />;
case "writing": case "writing":
return <Writing {...(exercise as WritingExercise)} onNext={onNext} onBack={onBack} />; return <Writing {...(exercise as WritingExercise)} onNext={onNext} onBack={onBack} />;
case "speaking":
return <Speaking {...(exercise as SpeakingExercise)} onNext={onNext} onBack={onBack} />;
} }
}; };

View File

@@ -0,0 +1,49 @@
import {Dialog, Transition} from "@headlessui/react";
import {Fragment} from "react";
import Button from "./Low/Button";
interface Props {
isOpen: boolean;
onClose: () => void;
}
export default function TimerEndedModal({isOpen, onClose}: Props) {
return (
<Transition show={isOpen} as={Fragment}>
<Dialog onClose={onClose} 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-full max-w-2xl h-fit p-8 rounded-xl bg-white flex flex-col gap-4">
<Dialog.Title className="font-bold text-xl">Time&apos;s up!</Dialog.Title>
<span>
The timer has ended! Your answers have been registered and saved, you will now move on to the next module (or to the
finish screen, if this was the last one).
</span>
<Button color="purple" onClick={onClose} className="max-w-[200px] self-end w-full mt-8">
Continue
</Button>
</Dialog.Panel>
</div>
</Transition.Child>
</Dialog>
</Transition>
);
}

10
src/constants/errors.ts Normal file
View File

@@ -0,0 +1,10 @@
export type Error = "E001" | "E002";
export interface ErrorMessage {
error: Error;
message: string;
}
export const errorMessages: {[key in Error]: string} = {
E001: "Wrong password!",
E002: "Invalid e-mail",
};

View File

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

View File

@@ -139,25 +139,25 @@ export default function Finish({user, scores, modules, isLoading, onViewResults}
</div> </div>
<div className="flex flex-col gap-5"> <div className="flex flex-col gap-5">
<div className="flex gap-2"> <div className="flex gap-2">
<div className="w-3 h-3 bg-mti-blue-light rounded-full mt-1" /> <div className="w-3 h-3 bg-mti-red-light rounded-full mt-1" />
<div className="flex flex-col"> <div className="flex flex-col">
<span className="text-mti-blue-light"> <span className="text-mti-red-light">
{(((selectedScore.total - selectedScore.missing) / selectedScore.total) * 100).toFixed(0)}% {(((selectedScore.total - selectedScore.missing) / selectedScore.total) * 100).toFixed(0)}%
</span> </span>
<span className="text-lg">Completion</span> <span className="text-lg">Completion</span>
</div> </div>
</div> </div>
<div className="flex gap-2"> <div className="flex gap-2">
<div className="w-3 h-3 bg-mti-green-light rounded-full mt-1" /> <div className="w-3 h-3 bg-mti-purple-light rounded-full mt-1" />
<div className="flex flex-col"> <div className="flex flex-col">
<span className="text-mti-green-light">{selectedScore.correct.toString().padStart(2, "0")}</span> <span className="text-mti-purple-light">{selectedScore.correct.toString().padStart(2, "0")}</span>
<span className="text-lg">Correct</span> <span className="text-lg">Correct</span>
</div> </div>
</div> </div>
<div className="flex gap-2"> <div className="flex gap-2">
<div className="w-3 h-3 bg-mti-orange-light rounded-full mt-1" /> <div className="w-3 h-3 bg-mti-rose-light rounded-full mt-1" />
<div className="flex flex-col"> <div className="flex flex-col">
<span className="text-mti-orange-light"> <span className="text-mti-rose-light">
{(selectedScore.total - selectedScore.correct).toString().padStart(2, "0")} {(selectedScore.total - selectedScore.correct).toString().padStart(2, "0")}
</span> </span>
<span className="text-lg">Wrong</span> <span className="text-lg">Wrong</span>
@@ -175,7 +175,7 @@ export default function Finish({user, scores, modules, isLoading, onViewResults}
<div className="w-fit flex flex-col items-center gap-1 cursor-pointer"> <div className="w-fit flex flex-col items-center gap-1 cursor-pointer">
<button <button
onClick={() => window.location.reload()} 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"> className="w-11 h-11 rounded-full bg-mti-purple-light hover:bg-mti-purple flex items-center justify-center transition duration-300 ease-in-out">
<BsArrowCounterclockwise className="text-white w-7 h-7" /> <BsArrowCounterclockwise className="text-white w-7 h-7" />
</button> </button>
<span>Play Again</span> <span>Play Again</span>
@@ -183,7 +183,7 @@ export default function Finish({user, scores, modules, isLoading, onViewResults}
<div className="w-fit flex flex-col items-center gap-1 cursor-pointer"> <div className="w-fit flex flex-col items-center gap-1 cursor-pointer">
<button <button
onClick={onViewResults} 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"> className="w-11 h-11 rounded-full bg-mti-purple-light hover:bg-mti-purple flex items-center justify-center transition duration-300 ease-in-out">
<BsEyeFill className="text-white w-7 h-7" /> <BsEyeFill className="text-white w-7 h-7" />
</button> </button>
<span>Review Answers</span> <span>Review Answers</span>
@@ -191,7 +191,7 @@ export default function Finish({user, scores, modules, isLoading, onViewResults}
</div> </div>
<Link href="/" className="max-w-[200px] w-full self-end"> <Link href="/" className="max-w-[200px] w-full self-end">
<Button color="green" className="max-w-[200px] self-end w-full"> <Button color="purple" className="max-w-[200px] self-end w-full">
Dashboard Dashboard
</Button> </Button>
</Link> </Link>

View File

@@ -1,5 +1,5 @@
import {ListeningExam, UserSolution} from "@/interfaces/exam"; import {ListeningExam, UserSolution} from "@/interfaces/exam";
import {useState} from "react"; import {useEffect, useState} from "react";
import Icon from "@mdi/react"; import Icon from "@mdi/react";
import {mdiArrowRight} from "@mdi/js"; import {mdiArrowRight} from "@mdi/js";
import clsx from "clsx"; import clsx from "clsx";
@@ -9,6 +9,9 @@ import {renderSolution} from "@/components/Solutions";
import ModuleTitle from "@/components/Medium/ModuleTitle"; import ModuleTitle from "@/components/Medium/ModuleTitle";
import AudioPlayer from "@/components/Low/AudioPlayer"; import AudioPlayer from "@/components/Low/AudioPlayer";
import Button from "@/components/Low/Button"; import Button from "@/components/Low/Button";
import BlankQuestionsModal from "@/components/BlankQuestionsModal";
import useExamStore from "@/stores/examStore";
import {defaultUserSolutions} from "@/utils/exams";
interface Props { interface Props {
exam: ListeningExam; exam: ListeningExam;
@@ -19,18 +22,50 @@ interface Props {
export default function Listening({exam, showSolutions = false, onFinish}: Props) { export default function Listening({exam, showSolutions = false, onFinish}: Props) {
const [exerciseIndex, setExerciseIndex] = useState(-1); const [exerciseIndex, setExerciseIndex] = useState(-1);
const [timesListened, setTimesListened] = useState(0); const [timesListened, setTimesListened] = useState(0);
const [userSolutions, setUserSolutions] = useState<UserSolution[]>([]); const [userSolutions, setUserSolutions] = useState<UserSolution[]>(exam.exercises.map((x) => defaultUserSolutions(x, exam)));
const [showBlankModal, setShowBlankModal] = useState(false);
const [hasExamEnded, setHasExamEnded] = useExamStore((state) => [state.hasExamEnded, state.setHasExamEnded]);
useEffect(() => {
if (hasExamEnded && exerciseIndex === -1) {
setExerciseIndex((prev) => prev + 1);
}
}, [hasExamEnded, exerciseIndex]);
const confirmFinishModule = (keepGoing?: boolean) => {
if (!keepGoing) {
setShowBlankModal(false);
return;
}
onFinish(userSolutions.map((x) => ({...x, module: "listening", exam: exam.id})));
};
const nextExercise = (solution?: UserSolution) => { const nextExercise = (solution?: UserSolution) => {
if (solution) { if (solution) {
setUserSolutions((prev) => [...prev.filter((x) => x.exercise !== solution.exercise), solution]); setUserSolutions((prev) => [...prev.filter((x) => x.exercise !== solution.exercise), solution]);
} }
if (exerciseIndex + 1 < exam.exercises.length) { if (exerciseIndex + 1 < exam.exercises.length && !hasExamEnded) {
setExerciseIndex((prev) => prev + 1); setExerciseIndex((prev) => prev + 1);
return; return;
} }
if (
solution &&
![...userSolutions.filter((x) => x.exercise !== solution?.exercise).map((x) => x.score.missing), solution?.score.missing].every(
(x) => x === 0,
) &&
!showSolutions &&
!hasExamEnded
) {
setShowBlankModal(true);
return;
}
setHasExamEnded(false);
if (solution) { if (solution) {
onFinish( onFinish(
[...userSolutions.filter((x) => x.exercise !== solution.exercise), solution].map((x) => ({...x, module: "listening", exam: exam.id})), [...userSolutions.filter((x) => x.exercise !== solution.exercise), solution].map((x) => ({...x, module: "listening", exam: exam.id})),
@@ -79,6 +114,7 @@ export default function Listening({exam, showSolutions = false, onFinish}: Props
return ( return (
<> <>
<BlankQuestionsModal isOpen={showBlankModal} onClose={confirmFinishModule} />
<div className="flex flex-col h-full w-full gap-8 justify-between"> <div className="flex flex-col h-full w-full gap-8 justify-between">
<ModuleTitle <ModuleTitle
exerciseIndex={exerciseIndex + 1} exerciseIndex={exerciseIndex + 1}
@@ -99,7 +135,7 @@ export default function Listening({exam, showSolutions = false, onFinish}: Props
</div> </div>
{exerciseIndex === -1 && ( {exerciseIndex === -1 && (
<Button color="green" onClick={() => nextExercise()} className="max-w-[200px] self-end w-full justify-self-end"> <Button color="purple" onClick={() => nextExercise()} className="max-w-[200px] self-end w-full justify-self-end">
Start now Start now
</Button> </Button>
)} )}

View File

@@ -15,6 +15,9 @@ import ProgressBar from "@/components/Low/ProgressBar";
import ModuleTitle from "@/components/Medium/ModuleTitle"; import ModuleTitle from "@/components/Medium/ModuleTitle";
import {Divider} from "primereact/divider"; import {Divider} from "primereact/divider";
import Button from "@/components/Low/Button"; import Button from "@/components/Low/Button";
import BlankQuestionsModal from "@/components/BlankQuestionsModal";
import useExamStore from "@/stores/examStore";
import {defaultUserSolutions} from "@/utils/exams";
interface Props { interface Props {
exam: ReadingExam; exam: ReadingExam;
@@ -47,12 +50,12 @@ function TextModal({isOpen, title, content, onClose}: {isOpen: boolean; title: s
leave="ease-in duration-200" leave="ease-in duration-200"
leaveFrom="opacity-100 scale-100" leaveFrom="opacity-100 scale-100"
leaveTo="opacity-0 scale-95"> leaveTo="opacity-0 scale-95">
<Dialog.Panel className="w-full max-w-4xl transform rounded-2xl bg-white p-6 text-left align-middle shadow-xl transition-all"> <Dialog.Panel className="w-full relative max-w-4xl transform rounded-2xl bg-white p-6 text-left align-middle shadow-xl transition-all">
<Dialog.Title as="h3" className="text-lg font-medium leading-6 text-gray-900"> <Dialog.Title as="h3" className="text-lg font-medium leading-6 text-gray-900">
{title} {title}
</Dialog.Title> </Dialog.Title>
<div className="mt-2 overflow-auto"> <div className="mt-2 overflow-auto mb-28">
<p className="text-sm text-gray-500"> <p className="text-sm">
{content.split("\\n").map((line, index) => ( {content.split("\\n").map((line, index) => (
<Fragment key={index}> <Fragment key={index}>
{line} {line}
@@ -62,13 +65,10 @@ function TextModal({isOpen, title, content, onClose}: {isOpen: boolean; title: s
</p> </p>
</div> </div>
<div className="mt-4"> <div className="absolute bottom-8 right-8 max-w-[200px] self-end w-full">
<button <Button color="purple" variant="outline" className="max-w-[200px] self-end w-full" onClick={onClose}>
type="button" Close
className="inline-flex justify-center rounded-md border border-transparent bg-blue-100 px-4 py-2 text-sm font-medium text-blue-900 hover:bg-blue-200 focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2" </Button>
onClick={onClose}>
Got it, thanks!
</button>
</div> </div>
</Dialog.Panel> </Dialog.Panel>
</Transition.Child> </Transition.Child>
@@ -82,18 +82,50 @@ function TextModal({isOpen, title, content, onClose}: {isOpen: boolean; title: s
export default function Reading({exam, showSolutions = false, onFinish}: Props) { export default function Reading({exam, showSolutions = false, onFinish}: Props) {
const [exerciseIndex, setExerciseIndex] = useState(-1); const [exerciseIndex, setExerciseIndex] = useState(-1);
const [showTextModal, setShowTextModal] = useState(false); const [showTextModal, setShowTextModal] = useState(false);
const [userSolutions, setUserSolutions] = useState<UserSolution[]>([]); const [userSolutions, setUserSolutions] = useState<UserSolution[]>(exam.exercises.map((x) => defaultUserSolutions(x, exam)));
const [showBlankModal, setShowBlankModal] = useState(false);
const [hasExamEnded, setHasExamEnded] = useExamStore((state) => [state.hasExamEnded, state.setHasExamEnded]);
useEffect(() => {
if (hasExamEnded && exerciseIndex === -1) {
setExerciseIndex((prev) => prev + 1);
}
}, [hasExamEnded, exerciseIndex]);
const confirmFinishModule = (keepGoing?: boolean) => {
if (!keepGoing) {
setShowBlankModal(false);
return;
}
onFinish(userSolutions.map((x) => ({...x, module: "reading", exam: exam.id})));
};
const nextExercise = (solution?: UserSolution) => { const nextExercise = (solution?: UserSolution) => {
if (solution) { if (solution) {
setUserSolutions((prev) => [...prev.filter((x) => x.exercise !== solution.exercise), solution]); setUserSolutions((prev) => [...prev.filter((x) => x.exercise !== solution.exercise), solution]);
} }
if (exerciseIndex + 1 < exam.exercises.length) { if (exerciseIndex + 1 < exam.exercises.length && !hasExamEnded) {
setExerciseIndex((prev) => prev + 1); setExerciseIndex((prev) => prev + 1);
return; return;
} }
if (
solution &&
![...userSolutions.filter((x) => x.exercise !== solution?.exercise).map((x) => x.score.missing), solution?.score.missing].every(
(x) => x === 0,
) &&
!showSolutions &&
!hasExamEnded
) {
setShowBlankModal(true);
return;
}
setHasExamEnded(false);
if (solution) { if (solution) {
onFinish( onFinish(
[...userSolutions.filter((x) => x.exercise !== solution.exercise), solution].map((x) => ({...x, module: "reading", exam: exam.id})), [...userSolutions.filter((x) => x.exercise !== solution.exercise), solution].map((x) => ({...x, module: "reading", exam: exam.id})),
@@ -142,6 +174,7 @@ export default function Reading({exam, showSolutions = false, onFinish}: Props)
return ( return (
<> <>
<div className="flex flex-col h-full w-full gap-8"> <div className="flex flex-col h-full w-full gap-8">
<BlankQuestionsModal isOpen={showBlankModal} onClose={confirmFinishModule} />
<TextModal {...exam.text} isOpen={showTextModal} onClose={() => setShowTextModal(false)} /> <TextModal {...exam.text} isOpen={showTextModal} onClose={() => setShowTextModal(false)} />
<ModuleTitle <ModuleTitle
minTimer={exam.minTimer} minTimer={exam.minTimer}
@@ -160,9 +193,18 @@ export default function Reading({exam, showSolutions = false, onFinish}: Props)
exerciseIndex < exam.exercises.length && exerciseIndex < exam.exercises.length &&
showSolutions && showSolutions &&
renderSolution(exam.exercises[exerciseIndex], nextExercise, previousExercise)} renderSolution(exam.exercises[exerciseIndex], nextExercise, previousExercise)}
{exerciseIndex > -1 && exerciseIndex < exam.exercises.length && (
<Button
color="purple"
variant="outline"
onClick={() => setShowTextModal(true)}
className="max-w-[200px] self-end w-full absolute bottom-[31px] right-64">
Read text
</Button>
)}
</div> </div>
{exerciseIndex === -1 && ( {exerciseIndex === -1 && (
<Button color="green" onClick={() => nextExercise()} className="max-w-[200px] self-end w-full"> <Button color="purple" onClick={() => nextExercise()} className="max-w-[200px] self-end w-full">
Start now Start now
</Button> </Button>
)} )}

View File

@@ -14,9 +14,10 @@ import {sortByModuleName} from "@/utils/moduleUtils";
interface Props { interface Props {
user: User; user: User;
onStart: (modules: Module[]) => void; onStart: (modules: Module[]) => void;
disableSelection?: boolean;
} }
export default function Selection({user, onStart}: Props) { export default function Selection({user, onStart, disableSelection = false}: Props) {
const [selectedModules, setSelectedModules] = useState<Module[]>([]); const [selectedModules, setSelectedModules] = useState<Module[]>([]);
const {stats} = useStats(user?.id); const {stats} = useStats(user?.id);
@@ -29,7 +30,7 @@ export default function Selection({user, onStart}: Props) {
<> <>
<div className="w-full h-full relative flex flex-col gap-16"> <div className="w-full h-full relative flex flex-col gap-16">
<section className="w-full flex gap-8"> <section className="w-full flex gap-8">
<img src={user.profilePicture} alt={user.name} className="aspect-square h-64 rounded-3xl drop-shadow-xl" /> <img src={user.profilePicture} alt={user.name} className="aspect-square h-64 rounded-3xl drop-shadow-xl object-cover" />
<div className="flex flex-col gap-4 py-4 w-full"> <div className="flex flex-col gap-4 py-4 w-full">
<div className="flex justify-between w-full gap-8"> <div className="flex justify-between w-full gap-8">
<div className="flex flex-col gap-2 py-2"> <div className="flex flex-col gap-2 py-2">
@@ -39,14 +40,14 @@ export default function Selection({user, onStart}: Props) {
<ProgressBar <ProgressBar
label={`Level ${calculateAverageLevel(user.levels).toFixed(1)}`} label={`Level ${calculateAverageLevel(user.levels).toFixed(1)}`}
percentage={100} percentage={100}
color="blue" color="purple"
className="max-w-xs w-32 self-end h-10" className="max-w-xs w-32 self-end h-10"
/> />
</div> </div>
<ProgressBar <ProgressBar
label="" label=""
percentage={Math.round((calculateAverageLevel(user.levels) * 100) / calculateAverageLevel(user.desiredLevels))} percentage={Math.round((calculateAverageLevel(user.levels) * 100) / calculateAverageLevel(user.desiredLevels))}
color="blue" color="red"
className="w-full h-3 drop-shadow-lg" className="w-full h-3 drop-shadow-lg"
/> />
<div className="flex justify-between w-full mt-8"> <div className="flex justify-between w-full mt-8">
@@ -100,10 +101,10 @@ export default function Selection({user, onStart}: Props) {
</section> </section>
<section className="w-full flex justify-between gap-8 mt-8"> <section className="w-full flex justify-between gap-8 mt-8">
<div <div
onClick={() => toggleModule("reading")} onClick={!disableSelection ? () => toggleModule("reading") : undefined}
className={clsx( 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", "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", selectedModules.includes("reading") || disableSelection ? "border-mti-purple-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"> <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" /> <BsBook className="text-white w-7 h-7" />
@@ -112,14 +113,18 @@ export default function Selection({user, onStart}: Props) {
<p className="text-center text-xs"> <p className="text-center text-xs">
Expand your vocabulary, improve your reading comprehension and improve your ability to interpret texts in English. Expand your vocabulary, improve your reading comprehension and improve your ability to interpret texts in English.
</p> </p>
{!selectedModules.includes("reading") && <div className="border border-mti-gray-platinum w-8 h-8 rounded-full mt-4" />} {!selectedModules.includes("reading") && !disableSelection && (
{selectedModules.includes("reading") && <BsCheckCircle className="mt-4 text-mti-green-light w-8 h-8" />} <div className="border border-mti-gray-platinum w-8 h-8 rounded-full mt-4" />
)}
{(selectedModules.includes("reading") || disableSelection) && (
<BsCheckCircle className="mt-4 text-mti-purple-light w-8 h-8" />
)}
</div> </div>
<div <div
onClick={() => toggleModule("listening")} onClick={!disableSelection ? () => toggleModule("listening") : undefined}
className={clsx( 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", "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", selectedModules.includes("listening") || disableSelection ? "border-mti-purple-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"> <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" /> <BsHeadphones className="text-white w-7 h-7" />
@@ -128,14 +133,18 @@ export default function Selection({user, onStart}: Props) {
<p className="text-center text-xs"> <p className="text-center text-xs">
Improve your ability to follow conversations in English and your ability to understand different accents and intonations. Improve your ability to follow conversations in English and your ability to understand different accents and intonations.
</p> </p>
{!selectedModules.includes("listening") && <div className="border border-mti-gray-platinum w-8 h-8 rounded-full mt-4" />} {!selectedModules.includes("listening") && !disableSelection && (
{selectedModules.includes("listening") && <BsCheckCircle className="mt-4 text-mti-green-light w-8 h-8" />} <div className="border border-mti-gray-platinum w-8 h-8 rounded-full mt-4" />
)}
{(selectedModules.includes("listening") || disableSelection) && (
<BsCheckCircle className="mt-4 text-mti-purple-light w-8 h-8" />
)}
</div> </div>
<div <div
onClick={() => toggleModule("writing")} onClick={!disableSelection ? () => toggleModule("writing") : undefined}
className={clsx( 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", "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", selectedModules.includes("writing") || disableSelection ? "border-mti-purple-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"> <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" /> <BsPen className="text-white w-7 h-7" />
@@ -144,14 +153,18 @@ export default function Selection({user, onStart}: Props) {
<p className="text-center text-xs"> <p className="text-center text-xs">
Allow you to practice writing in a variety of formats, from simple paragraphs to complex essays. Allow you to practice writing in a variety of formats, from simple paragraphs to complex essays.
</p> </p>
{!selectedModules.includes("writing") && <div className="border border-mti-gray-platinum w-8 h-8 rounded-full mt-4" />} {!selectedModules.includes("writing") && !disableSelection && (
{selectedModules.includes("writing") && <BsCheckCircle className="mt-4 text-mti-green-light w-8 h-8" />} <div className="border border-mti-gray-platinum w-8 h-8 rounded-full mt-4" />
)}
{(selectedModules.includes("writing") || disableSelection) && (
<BsCheckCircle className="mt-4 text-mti-purple-light w-8 h-8" />
)}
</div> </div>
<div <div
onClick={() => toggleModule("speaking")} onClick={!disableSelection ? () => toggleModule("speaking") : undefined}
className={clsx( 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", "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", selectedModules.includes("speaking") || disableSelection ? "border-mti-purple-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"> <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" /> <BsMegaphone className="text-white w-7 h-7" />
@@ -160,15 +173,21 @@ export default function Selection({user, onStart}: Props) {
<p className="text-center text-xs"> <p className="text-center text-xs">
You&apos;ll have access to interactive dialogs, pronunciation exercises and speech recordings. You&apos;ll have access to interactive dialogs, pronunciation exercises and speech recordings.
</p> </p>
{!selectedModules.includes("speaking") && <div className="border border-mti-gray-platinum w-8 h-8 rounded-full mt-4" />} {!selectedModules.includes("speaking") && !disableSelection && (
{selectedModules.includes("speaking") && <BsCheckCircle className="mt-4 text-mti-green-light w-8 h-8" />} <div className="border border-mti-gray-platinum w-8 h-8 rounded-full mt-4" />
)}
{(selectedModules.includes("speaking") || disableSelection) && (
<BsCheckCircle className="mt-4 text-mti-purple-light w-8 h-8" />
)}
</div> </div>
</section> </section>
<Button <Button
onClick={() => onStart(selectedModules.sort(sortByModuleName))} onClick={() =>
color="green" onStart(!disableSelection ? selectedModules.sort(sortByModuleName) : ["reading", "listening", "writing", "speaking"])
}
color="purple"
className="px-12 w-full max-w-xs self-end" className="px-12 w-full max-w-xs self-end"
disabled={selectedModules.length === 0}> disabled={selectedModules.length === 0 && !disableSelection}>
Start Exam Start Exam
</Button> </Button>
</div> </div>

View File

@@ -3,6 +3,8 @@ import ModuleTitle from "@/components/Medium/ModuleTitle";
import {renderSolution} from "@/components/Solutions"; import {renderSolution} from "@/components/Solutions";
import {infoButtonStyle} from "@/constants/buttonStyles"; import {infoButtonStyle} from "@/constants/buttonStyles";
import {UserSolution, SpeakingExam} from "@/interfaces/exam"; import {UserSolution, SpeakingExam} from "@/interfaces/exam";
import useExamStore from "@/stores/examStore";
import {defaultUserSolutions} from "@/utils/exams";
import {mdiArrowRight} from "@mdi/js"; import {mdiArrowRight} from "@mdi/js";
import Icon from "@mdi/react"; import Icon from "@mdi/react";
import clsx from "clsx"; import clsx from "clsx";
@@ -17,7 +19,8 @@ interface Props {
export default function Speaking({exam, showSolutions = false, onFinish}: Props) { export default function Speaking({exam, showSolutions = false, onFinish}: Props) {
const [exerciseIndex, setExerciseIndex] = useState(0); const [exerciseIndex, setExerciseIndex] = useState(0);
const [userSolutions, setUserSolutions] = useState<UserSolution[]>([]); const [userSolutions, setUserSolutions] = useState<UserSolution[]>(exam.exercises.map((x) => defaultUserSolutions(x, exam)));
const [hasExamEnded, setHasExamEnded] = useExamStore((state) => [state.hasExamEnded, state.setHasExamEnded]);
const nextExercise = (solution?: UserSolution) => { const nextExercise = (solution?: UserSolution) => {
if (solution) { if (solution) {
@@ -29,6 +32,10 @@ export default function Speaking({exam, showSolutions = false, onFinish}: Props)
return; return;
} }
if (exerciseIndex >= exam.exercises.length) return;
setHasExamEnded(false);
if (solution) { if (solution) {
onFinish( onFinish(
[...userSolutions.filter((x) => x.exercise !== solution.exercise), solution].map((x) => ({...x, module: "speaking", exam: exam.id})), [...userSolutions.filter((x) => x.exercise !== solution.exercise), solution].map((x) => ({...x, module: "speaking", exam: exam.id})),

View File

@@ -3,6 +3,8 @@ import ModuleTitle from "@/components/Medium/ModuleTitle";
import {renderSolution} from "@/components/Solutions"; import {renderSolution} from "@/components/Solutions";
import {infoButtonStyle} from "@/constants/buttonStyles"; import {infoButtonStyle} from "@/constants/buttonStyles";
import {UserSolution, WritingExam} from "@/interfaces/exam"; import {UserSolution, WritingExam} from "@/interfaces/exam";
import useExamStore from "@/stores/examStore";
import {defaultUserSolutions} from "@/utils/exams";
import {mdiArrowRight} from "@mdi/js"; import {mdiArrowRight} from "@mdi/js";
import Icon from "@mdi/react"; import Icon from "@mdi/react";
import clsx from "clsx"; import clsx from "clsx";
@@ -17,7 +19,9 @@ interface Props {
export default function Writing({exam, showSolutions = false, onFinish}: Props) { export default function Writing({exam, showSolutions = false, onFinish}: Props) {
const [exerciseIndex, setExerciseIndex] = useState(0); const [exerciseIndex, setExerciseIndex] = useState(0);
const [userSolutions, setUserSolutions] = useState<UserSolution[]>([]); const [userSolutions, setUserSolutions] = useState<UserSolution[]>(exam.exercises.map((x) => defaultUserSolutions(x, exam)));
const [hasExamEnded, setHasExamEnded] = useExamStore((state) => [state.hasExamEnded, state.setHasExamEnded]);
const nextExercise = (solution?: UserSolution) => { const nextExercise = (solution?: UserSolution) => {
if (solution) { if (solution) {
@@ -29,6 +33,10 @@ export default function Writing({exam, showSolutions = false, onFinish}: Props)
return; return;
} }
if (exerciseIndex >= exam.exercises.length) return;
setHasExamEnded(false);
if (solution) { if (solution) {
onFinish( onFinish(
[...userSolutions.filter((x) => x.exercise !== solution.exercise), solution].map((x) => ({...x, module: "writing", exam: exam.id})), [...userSolutions.filter((x) => x.exercise !== solution.exercise), solution].map((x) => ({...x, module: "writing", exam: exam.id})),

View File

@@ -63,33 +63,33 @@ export interface SpeakingExam {
export type Exercise = export type Exercise =
| FillBlanksExercise | FillBlanksExercise
| TrueFalseExercise
| MatchSentencesExercise | MatchSentencesExercise
| MultipleChoiceExercise | MultipleChoiceExercise
| WriteBlanksExercise | WriteBlanksExercise
| WritingExercise | WritingExercise
| SpeakingExercise; | SpeakingExercise;
export interface WritingEvaluation { export interface Evaluation {
comment: string; comment: string;
overall: number; overall: number;
task_response: {[key: string]: number}; task_response: {[key: string]: number};
} }
export interface WritingExercise { export interface WritingExercise {
id: string; id: string;
type: "writing"; type: "writing";
info: string; //* The information about the task, like the amount of time they should spend on it prefix: string; //* The information about the task, like the amount of time they should spend on it
suffix: string;
prompt: string; //* The context given to the user containing what they should write about 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 wordCounter: WordCounter; //* The minimum or maximum amount of words that should be written
attachment?: { attachment?: {
url: string; url: string;
description: string; description: string;
}; //* The url for an image to work as an attachment to show the user }; //* The url for an image to work as an attachment to show the user
evaluation?: WritingEvaluation;
userSolutions: { userSolutions: {
id: string; id: string;
solution: string; solution: string;
evaluation?: WritingEvaluation; evaluation?: Evaluation;
}[]; }[];
} }
@@ -102,6 +102,7 @@ export interface SpeakingExercise {
userSolutions: { userSolutions: {
id: string; id: string;
solution: string; solution: string;
evaluation?: Evaluation;
}[]; }[];
} }
@@ -122,6 +123,20 @@ export interface FillBlanksExercise {
}[]; }[];
} }
export interface TrueFalseExercise {
type: "trueFalse";
id: string;
prompt: string; // *EXAMPLE: "Select the appropriate option."
questions: TrueFalseQuestion[];
userSolutions: {id: string; solution: "true" | "false" | "not_given"}[];
}
export interface TrueFalseQuestion {
id: string; // *EXAMPLE: "1"
prompt: string; // *EXAMPLE: "What does her briefcase look like?"
solution: "true" | "false" | "not_given"; // *EXAMPLE: "True"
}
export interface WriteBlanksExercise { export interface WriteBlanksExercise {
prompt: string; // *EXAMPLE: "Complete the notes below by writing NO MORE THAN THREE WORDS in the spaces provided." prompt: string; // *EXAMPLE: "Complete the notes below by writing NO MORE THAN THREE WORDS in the spaces provided."
maxWords: number; // *EXAMPLE: 3 - The maximum amount of words allowed per blank, 0 for unlimited maxWords: number; // *EXAMPLE: 3 - The maximum amount of words allowed per blank, 0 for unlimited

View File

@@ -11,6 +11,7 @@ export interface User {
levels: {[key in Module]: number}; levels: {[key in Module]: number};
desiredLevels: {[key in Module]: number}; desiredLevels: {[key in Module]: number};
type: Type; type: Type;
bio: string;
} }
export interface Stat { export interface Stat {

1
src/lib/formidable-serverless.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
declare module "formidable-serverless";

View File

@@ -15,9 +15,7 @@ export default function App({Component, pageProps}: AppProps) {
const router = useRouter(); const router = useRouter();
useEffect(() => { useEffect(() => {
if (router.pathname !== "/exam") { if (router.pathname !== "/exercises") reset();
reset();
}
}, [router.pathname, reset]); }, [router.pathname, reset]);
return <Component {...pageProps} />; return <Component {...pageProps} />;

61
src/pages/admin.tsx Normal file
View File

@@ -0,0 +1,61 @@
import {withIronSessionSsr} from "iron-session/next";
import {sessionOptions} from "@/lib/session";
import Head from "next/head";
import {ToastContainer} from "react-toastify";
import useUser from "@/hooks/useUser";
import Layout from "@/components/High/Layout";
import Button from "@/components/Low/Button";
import {useRouter} from "next/router";
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 router = useRouter();
const handleManageQuestionsClick = () => {
router.push('/question-management');
};
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>
<Button
onClick={handleManageQuestionsClick}
color="purple"
className="px-12 w-full max-w-xs self-end">
Manage Questions
</Button>
</div>
</Layout>
)}
</>
);
}

View File

@@ -0,0 +1,48 @@
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
import type {NextApiRequest, NextApiResponse} from "next";
import {withIronSessionApiRoute} from "iron-session/next";
import {sessionOptions} from "@/lib/session";
import axios from "axios";
import formidable from "formidable-serverless";
import {getStorage, ref, uploadBytes} from "firebase/storage";
import fs from "fs";
import {app} from "@/firebase";
export default withIronSessionApiRoute(handler, sessionOptions);
async function handler(req: NextApiRequest, res: NextApiResponse) {
if (!req.session.user) {
res.status(401).json({ok: false});
return;
}
const storage = getStorage(app);
const form = formidable({keepExtensions: true, uploadDir: "./"});
await form.parse(req, async (err: any, fields: any, files: any) => {
const audioFile = files.audio;
const audioFileRef = ref(storage, `speaking_recordings/${(audioFile as any).path.replace("upload_", "")}`);
const binary = fs.readFileSync((audioFile as any).path).buffer;
const snapshot = await uploadBytes(audioFileRef, binary);
const backendRequest = await axios.post(
`${process.env.BACKEND_URL}/speaking_task_1`,
{question: fields.question, answer: snapshot.metadata.fullPath},
{
headers: {
Authorization: `Bearer ${process.env.BACKEND_JWT}`,
},
},
);
fs.rmSync((audioFile as any).path);
res.status(200).json({...backendRequest.data, fullPath: snapshot.metadata.fullPath});
});
}
export const config = {
api: {
bodyParser: false,
},
};

View File

@@ -18,19 +18,11 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
return; return;
} }
const {module} = req.query as {module: string}; const backendRequest = await axios.post(`${process.env.BACKEND_URL}/writing_task2`, req.body as Body, {
headers: {
Authorization: `Bearer ${process.env.BACKEND_JWT}`,
},
});
if (module === "writing") { res.status(backendRequest.status).json(backendRequest.data);
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

@@ -4,6 +4,7 @@ import {app} from "@/firebase";
import {getFirestore, collection, getDocs, query, where} from "firebase/firestore"; import {getFirestore, collection, getDocs, query, where} from "firebase/firestore";
import {withIronSessionApiRoute} from "iron-session/next"; import {withIronSessionApiRoute} from "iron-session/next";
import {sessionOptions} from "@/lib/session"; import {sessionOptions} from "@/lib/session";
import {shuffle} from "lodash";
const db = getFirestore(app); const db = getFirestore(app);
@@ -22,9 +23,11 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
const snapshot = await getDocs(q); const snapshot = await getDocs(q);
res.status(200).json( res.status(200).json(
snapshot.docs.map((doc) => ({ shuffle(
id: doc.id, snapshot.docs.map((doc) => ({
...doc.data(), id: doc.id,
})), ...doc.data(),
})),
),
); );
} }

56
src/pages/api/register.ts Normal file
View File

@@ -0,0 +1,56 @@
import {NextApiRequest, NextApiResponse} from "next";
import {createUserWithEmailAndPassword, getAuth} from "firebase/auth";
import {app} from "@/firebase";
import {sessionOptions} from "@/lib/session";
import {withIronSessionApiRoute} from "iron-session/next";
import {getFirestore, getDoc, doc, setDoc} from "firebase/firestore";
const auth = getAuth(app);
const db = getFirestore(app);
export default withIronSessionApiRoute(login, sessionOptions);
const DEFAULT_DESIRED_LEVELS = {
reading: 9,
listening: 9,
writing: 9,
speaking: 9,
};
const DEFAULT_LEVELS = {
reading: 0,
listening: 0,
writing: 0,
speaking: 0,
};
async function login(req: NextApiRequest, res: NextApiResponse) {
const {email, password} = req.body as {email: string; password: string};
createUserWithEmailAndPassword(auth, email, password)
.then(async (userCredentials) => {
const userId = userCredentials.user.uid;
delete req.body.password;
const user = {
...req.body,
desiredLevels: DEFAULT_DESIRED_LEVELS,
levels: DEFAULT_LEVELS,
bio: "",
isFirstLogin: true,
focus: "academic",
type: "student",
};
await setDoc(doc(db, "users", userId), user);
req.session.user = {...user, id: userId};
await req.session.save();
res.status(200).json({user: {...user, id: userId}});
})
.catch((error) => {
console.log(error);
res.status(401).json({error});
});
}

26
src/pages/api/speaking.ts Normal file
View File

@@ -0,0 +1,26 @@
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
import type {NextApiRequest, NextApiResponse} from "next";
import {withIronSessionApiRoute} from "iron-session/next";
import {sessionOptions} from "@/lib/session";
import {getDownloadURL, getStorage, ref} from "firebase/storage";
import {app} from "@/firebase";
import axios from "axios";
export default withIronSessionApiRoute(handler, sessionOptions);
async function handler(req: NextApiRequest, res: NextApiResponse) {
if (!req.session.user) {
res.status(401).json({ok: false});
return;
}
const storage = getStorage(app);
const {path} = req.body as {path: string};
const pathReference = ref(storage, path);
const url = await getDownloadURL(pathReference);
const response = await axios.get(url, {responseType: "arraybuffer"});
res.status(200).send(response.data);
}

View File

@@ -0,0 +1,100 @@
import {MODULES} from "@/constants/ielts";
import {app} from "@/firebase";
import {Module} from "@/interfaces";
import {Stat, User} from "@/interfaces/user";
import {sessionOptions} from "@/lib/session";
import {calculateBandScore} from "@/utils/score";
import {groupByModule, groupBySession} from "@/utils/stats";
import {getAuth} from "firebase/auth";
import {collection, doc, getDoc, getDocs, getFirestore, query, updateDoc, where} from "firebase/firestore";
import {withIronSessionApiRoute} from "iron-session/next";
import {groupBy} from "lodash";
import {NextApiRequest, NextApiResponse} from "next";
const db = getFirestore(app);
export default withIronSessionApiRoute(update, sessionOptions);
async function update(req: NextApiRequest, res: NextApiResponse) {
if (req.session.user) {
const docUser = await getDoc(doc(db, "users", req.session.user.id));
if (!docUser.exists()) {
res.status(401).json(undefined);
return;
}
const q = query(collection(db, "stats"), where("user", "==", req.session.user.id));
const stats = (await getDocs(q)).docs.map((doc) => ({
id: doc.id,
...(doc.data() as Stat),
})) as Stat[];
const groupedStats = groupBySession(stats);
const sessionLevels: {[key in Module]: {correct: number; total: number}}[] = Object.keys(groupedStats).map((key) => {
const sessionStats = groupedStats[key].map((stat) => ({module: stat.module, correct: stat.score.correct, total: stat.score.total}));
const sessionLevels = {
reading: {
correct: 0,
total: 0,
},
listening: {
correct: 0,
total: 0,
},
writing: {
correct: 0,
total: 0,
},
speaking: {
correct: 0,
total: 0,
},
};
MODULES.forEach((module: Module) => {
const moduleStats = sessionStats.filter((x) => x.module === module);
if (moduleStats.length === 0) return;
const moduleScore = moduleStats.reduce(
(accumulator, current) => ({correct: accumulator.correct + current.correct, total: accumulator.total + current.total}),
{correct: 0, total: 0},
);
sessionLevels[module] = moduleScore;
});
return sessionLevels;
});
const readingLevel = sessionLevels
.map((x) => x.reading)
.filter((x) => x.total > 0)
.reduce((acc, cur) => ({total: acc.total + cur.total, correct: acc.correct + cur.correct}), {total: 0, correct: 0});
const listeningLevel = sessionLevels
.map((x) => x.listening)
.filter((x) => x.total > 0)
.reduce((acc, cur) => ({total: acc.total + cur.total, correct: acc.correct + cur.correct}), {total: 0, correct: 0});
const writingLevel = sessionLevels
.map((x) => x.writing)
.filter((x) => x.total > 0)
.reduce((acc, cur) => ({total: acc.total + cur.total, correct: acc.correct + cur.correct}), {total: 0, correct: 0});
const speakingLevel = sessionLevels
.map((x) => x.speaking)
.filter((x) => x.total > 0)
.reduce((acc, cur) => ({total: acc.total + cur.total, correct: acc.correct + cur.correct}), {total: 0, correct: 0});
const levels = {
reading: calculateBandScore(readingLevel.correct, readingLevel.total, "reading", req.session.user.focus),
listening: calculateBandScore(listeningLevel.correct, listeningLevel.total, "listening", req.session.user.focus),
writing: calculateBandScore(writingLevel.correct, writingLevel.total, "writing", req.session.user.focus),
speaking: calculateBandScore(speakingLevel.correct, speakingLevel.total, "speaking", req.session.user.focus),
};
const userDoc = doc(db, "users", req.session.user.id);
await updateDoc(userDoc, {levels});
res.status(200).json({ok: true});
} else {
res.status(401).json(undefined);
}
}

View File

@@ -12,6 +12,12 @@ const db = getFirestore(app);
export default withIronSessionApiRoute(user, sessionOptions); export default withIronSessionApiRoute(user, sessionOptions);
async function user(req: NextApiRequest, res: NextApiResponse) { async function user(req: NextApiRequest, res: NextApiResponse) {
if (req.method === "GET") return get(req, res);
res.status(404).json(undefined);
}
async function get(req: NextApiRequest, res: NextApiResponse) {
if (req.session.user) { if (req.session.user) {
const docUser = await getDoc(doc(db, "users", req.session.user.id)); const docUser = await getDoc(doc(db, "users", req.session.user.id));
if (!docUser.exists()) { if (!docUser.exists()) {

View File

@@ -5,8 +5,13 @@ import {getFirestore, collection, getDocs, getDoc, doc, setDoc} from "firebase/f
import {withIronSessionApiRoute} from "iron-session/next"; import {withIronSessionApiRoute} from "iron-session/next";
import {sessionOptions} from "@/lib/session"; import {sessionOptions} from "@/lib/session";
import {User} from "@/interfaces/user"; import {User} from "@/interfaces/user";
import {getDownloadURL, getStorage, ref, uploadBytes} from "firebase/storage";
import {getAuth, signInWithEmailAndPassword, updateEmail, updatePassword} from "firebase/auth";
import {errorMessages} from "@/constants/errors";
const db = getFirestore(app); const db = getFirestore(app);
const storage = getStorage(app);
const auth = getAuth(app);
export default withIronSessionApiRoute(handler, sessionOptions); export default withIronSessionApiRoute(handler, sessionOptions);
@@ -17,7 +22,53 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
} }
const userRef = doc(db, "users", req.session.user.id); const userRef = doc(db, "users", req.session.user.id);
await setDoc(userRef, req.body, {merge: true}); const updatedUser = req.body as User & {password?: string; newPassword?: string};
if (updatedUser.profilePicture && updatedUser.profilePicture !== req.session.user.profilePicture) {
const profilePictureFiletype = updatedUser.profilePicture.split(";")[0].split("/")[1];
const profilePictureRef = ref(storage, `profile_pictures/${req.session.user.id}.${profilePictureFiletype}`);
const pictureBytes = Buffer.from(updatedUser.profilePicture.split(";base64,")[1], "base64url");
const pictureSnapshot = await uploadBytes(profilePictureRef, pictureBytes);
const pictureReference = ref(storage, pictureSnapshot.metadata.fullPath);
updatedUser.profilePicture = await getDownloadURL(pictureReference);
}
if (updatedUser.newPassword && updatedUser.password) {
try {
const credential = await signInWithEmailAndPassword(auth, req.session.user.email, updatedUser.password);
await updatePassword(credential.user, updatedUser.newPassword);
} catch {
res.status(400).json({error: "E001", message: errorMessages.E001});
return;
}
}
if (updatedUser.email !== req.session.user.email && updatedUser.password) {
try {
const credential = await signInWithEmailAndPassword(auth, req.session.user.email, updatedUser.password);
await updateEmail(credential.user, updatedUser.email);
} catch {
res.status(400).json({error: "E002", message: errorMessages.E002});
return;
}
}
delete updatedUser.password;
delete updatedUser.newPassword;
await setDoc(userRef, updatedUser, {merge: true});
req.session.user = {...updatedUser, id: req.session.user.id};
await req.session.save();
res.status(200).json({ok: true}); res.status(200).json({ok: true});
} }
export const config = {
api: {
bodyParser: {
sizeLimit: "20mb",
},
},
};

View File

@@ -1,12 +1,21 @@
/* eslint-disable @next/next/no-img-element */ /* eslint-disable @next/next/no-img-element */
import Head from "next/head"; import Head from "next/head";
import Navbar from "@/components/Navbar";
import {useEffect, useState} from "react"; import {useEffect, useState} from "react";
import {Module} from "@/interfaces"; import {Module} from "@/interfaces";
import Selection from "@/exams/Selection"; import Selection from "@/exams/Selection";
import Reading from "@/exams/Reading"; import Reading from "@/exams/Reading";
import {Exam, ListeningExam, ReadingExam, SpeakingExam, UserSolution, WritingEvaluation, WritingExam, WritingExercise} from "@/interfaces/exam"; import {
Exam,
ListeningExam,
ReadingExam,
SpeakingExam,
UserSolution,
Evaluation,
WritingExam,
WritingExercise,
SpeakingExercise,
} from "@/interfaces/exam";
import Listening from "@/exams/Listening"; import Listening from "@/exams/Listening";
import Writing from "@/exams/Writing"; import Writing from "@/exams/Writing";
import {ToastContainer, toast} from "react-toastify"; import {ToastContainer, toast} from "react-toastify";
@@ -19,10 +28,9 @@ import Speaking from "@/exams/Speaking";
import {v4 as uuidv4} from "uuid"; import {v4 as uuidv4} from "uuid";
import useUser from "@/hooks/useUser"; import useUser from "@/hooks/useUser";
import useExamStore from "@/stores/examStore"; import useExamStore from "@/stores/examStore";
import Sidebar from "@/components/Sidebar";
import Layout from "@/components/High/Layout"; import Layout from "@/components/High/Layout";
import {sortByModule} from "@/utils/moduleUtils"; import {speakingReverseMarking, writingReverseMarking} from "@/utils/score";
import {writingReverseMarking} from "@/utils/score"; import AbandonPopup from "@/components/AbandonPopup";
export const getServerSideProps = withIronSessionSsr(({req, res}) => { export const getServerSideProps = withIronSessionSsr(({req, res}) => {
const user = req.session.user; const user = req.session.user;
@@ -54,6 +62,8 @@ export default function Page() {
const [userSolutions, setUserSolutions] = useExamStore((state) => [state.userSolutions, state.setUserSolutions]); const [userSolutions, setUserSolutions] = useExamStore((state) => [state.userSolutions, state.setUserSolutions]);
const [showSolutions, setShowSolutions] = useExamStore((state) => [state.showSolutions, state.setShowSolutions]); const [showSolutions, setShowSolutions] = useExamStore((state) => [state.showSolutions, state.setShowSolutions]);
const [selectedModules, setSelectedModules] = useExamStore((state) => [state.selectedModules, state.setSelectedModules]); const [selectedModules, setSelectedModules] = useExamStore((state) => [state.selectedModules, state.setSelectedModules]);
const [showAbandonPopup, setShowAbandonPopup] = useState(false);
const setHasExamEnded = useExamStore((state) => state.setHasExamEnded);
const {user} = useUser({redirectTo: "/login"}); const {user} = useUser({redirectTo: "/login"});
@@ -122,11 +132,47 @@ export default function Page() {
} }
}; };
const evaluateSpeakingAnswer = async (examId: string, exerciseId: string, solution: UserSolution) => {
const speakingExam = exams.find((x) => x.id === examId)!;
const exercise = speakingExam.exercises.find((x) => x.id === exerciseId)! as SpeakingExercise;
const blobResponse = await axios.get(solution.solutions[0].solution.trim(), {responseType: "arraybuffer"});
const audioBlob = Buffer.from(blobResponse.data, "binary");
const audioFile = new File([audioBlob], "audio.wav", {type: "audio/wav"});
const formData = new FormData();
formData.append("audio", audioFile, "audio.wav");
formData.append("question", `${exercise.text.replaceAll("\n", "")} You should talk about: ${exercise.prompts.join(", ")}`);
const config = {
headers: {
"Content-Type": "audio/mp3",
},
};
const response = await axios.post("/api/evaluate/speaking", formData, config);
if (response.status === 200) {
setUserSolutions([
...userSolutions.filter((x) => x.exercise !== exerciseId),
{
...solution,
score: {
correct: speakingReverseMarking[response.data.overall] || 0,
missing: 0,
total: 100,
},
solutions: [{id: exerciseId, solution: response.data.fullPath, evaluation: response.data}],
},
]);
}
};
const evaluateWritingAnswer = async (examId: string, exerciseId: string, solution: UserSolution) => { const evaluateWritingAnswer = async (examId: string, exerciseId: string, solution: UserSolution) => {
const writingExam = exams.find((x) => x.id === examId)!; const writingExam = exams.find((x) => x.id === examId)!;
const exercise = writingExam.exercises.find((x) => x.id === exerciseId)! as WritingExercise; const exercise = writingExam.exercises.find((x) => x.id === exerciseId)! as WritingExercise;
const response = await axios.post<WritingEvaluation>("/api/exam/writing/evaluate", { const response = await axios.post<Evaluation>("/api/evaluate/writing", {
question: `${exercise.prompt} ${exercise.attachment ? exercise.attachment.description : ""}`.replaceAll("\n", ""), question: `${exercise.prompt} ${exercise.attachment ? exercise.attachment.description : ""}`.replaceAll("\n", ""),
answer: solution.solutions[0].solution.trim().replaceAll("\n", " "), answer: solution.solutions[0].solution.trim().replaceAll("\n", " "),
}); });
@@ -137,7 +183,7 @@ export default function Page() {
{ {
...solution, ...solution,
score: { score: {
correct: writingReverseMarking[response.data.overall], correct: writingReverseMarking[response.data.overall] || 0,
missing: 0, missing: 0,
total: 100, total: 100,
}, },
@@ -158,17 +204,25 @@ export default function Page() {
const onFinish = (solutions: UserSolution[]) => { const onFinish = (solutions: UserSolution[]) => {
const solutionIds = solutions.map((x) => x.exercise); const solutionIds = solutions.map((x) => x.exercise);
if (exam && exam.module === "writing" && solutions.length > 0 && !showSolutions) { if (exam && (exam.module === "writing" || exam.module === "speaking") && solutions.length > 0 && !showSolutions) {
setHasBeenUploaded(true); setHasBeenUploaded(true);
setIsEvaluationLoading(true); setIsEvaluationLoading(true);
Promise.all( Promise.all(
exam.exercises.map((exercise) => evaluateWritingAnswer(exam.id, exercise.id, solutions.find((x) => x.exercise === exercise.id)!)), exam.exercises.map((exercise) =>
(exam.module === "writing" ? evaluateWritingAnswer : evaluateSpeakingAnswer)(
exam.id,
exercise.id,
solutions.find((x) => x.exercise === exercise.id)!,
),
),
).finally(() => { ).finally(() => {
setIsEvaluationLoading(false); setIsEvaluationLoading(false);
setHasBeenUploaded(false); setHasBeenUploaded(false);
}); });
} }
axios.get("/api/stats/update");
setUserSolutions([...userSolutions.filter((x) => !solutionIds.includes(x.exercise)), ...solutions]); setUserSolutions([...userSolutions.filter((x) => !solutionIds.includes(x.exercise)), ...solutions]);
setModuleIndex((prev) => prev + 1); setModuleIndex((prev) => prev + 1);
}; };
@@ -212,7 +266,7 @@ export default function Page() {
const renderScreen = () => { const renderScreen = () => {
if (selectedModules.length === 0) { if (selectedModules.length === 0) {
return <Selection user={user!} onStart={setSelectedModules} />; return <Selection user={user!} onStart={setSelectedModules} disableSelection />;
} }
if (moduleIndex >= selectedModules.length) { if (moduleIndex >= selectedModules.length) {
@@ -243,11 +297,6 @@ export default function Page() {
return <Writing exam={exam} onFinish={onFinish} showSolutions={showSolutions} />; return <Writing exam={exam} onFinish={onFinish} showSolutions={showSolutions} />;
} }
if (exam && exam.module === "speaking" && showSolutions) {
setModuleIndex((prev) => prev + 1);
return <></>;
}
if (exam && exam.module === "speaking") { if (exam && exam.module === "speaking") {
return <Speaking exam={exam} onFinish={onFinish} showSolutions={showSolutions} />; return <Speaking exam={exam} onFinish={onFinish} showSolutions={showSolutions} />;
} }
@@ -268,8 +317,29 @@ export default function Page() {
</Head> </Head>
<ToastContainer /> <ToastContainer />
{user && ( {user && (
<Layout user={user} className="justify-between"> <Layout
{renderScreen()} user={user}
className="justify-between"
focusMode={selectedModules.length !== 0}
onFocusLayerMouseEnter={() => setShowAbandonPopup(true)}
>
<>
{renderScreen()}
<AbandonPopup
isOpen={showAbandonPopup}
abandonPopupTitle="Leave Exam"
abandonPopupDescription="Are you sure you want to leave the exam? You will lose all your progress."
abandonConfirmButtonText="Confirm"
onAbandon={() => {
console.log('TODO: Handle Abandon');
setExam(undefined);
setSelectedModules([]);
setShowAbandonPopup(false)
return true;
}}
onCancel={() => setShowAbandonPopup(false)}
/>
</>
</Layout> </Layout>
)} )}
</> </>

349
src/pages/exercises.tsx Normal file
View File

@@ -0,0 +1,349 @@
/* eslint-disable @next/next/no-img-element */
import Head from "next/head";
import Navbar from "@/components/Navbar";
import {useEffect, useState} from "react";
import {Module} from "@/interfaces";
import Selection from "@/exams/Selection";
import Reading from "@/exams/Reading";
import {
Exam,
ListeningExam,
ReadingExam,
SpeakingExam,
UserSolution,
Evaluation,
WritingExam,
WritingExercise,
SpeakingExercise,
} from "@/interfaces/exam";
import Listening from "@/exams/Listening";
import Writing from "@/exams/Writing";
import {ToastContainer, toast} from "react-toastify";
import Finish from "@/exams/Finish";
import axios from "axios";
import {withIronSessionSsr} from "iron-session/next";
import {sessionOptions} from "@/lib/session";
import {Stat} from "@/interfaces/user";
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 {speakingReverseMarking, writingReverseMarking} from "@/utils/score";
import AbandonPopup from "@/components/AbandonPopup";
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 [hasBeenUploaded, setHasBeenUploaded] = useState(false);
const [moduleIndex, setModuleIndex] = useState(0);
const [sessionId, setSessionId] = useState("");
const [exam, setExam] = useState<Exam>();
const [isEvaluationLoading, setIsEvaluationLoading] = useState(false);
const [exams, setExams] = useExamStore((state) => [state.exams, state.setExams]);
const [userSolutions, setUserSolutions] = useExamStore((state) => [state.userSolutions, state.setUserSolutions]);
const [showSolutions, setShowSolutions] = useExamStore((state) => [state.showSolutions, state.setShowSolutions]);
const [selectedModules, setSelectedModules] = useExamStore((state) => [state.selectedModules, state.setSelectedModules]);
const [showAbandonPopup, setShowAbandonPopup] = useState(false);
const {user} = useUser({redirectTo: "/login"});
useEffect(() => setSessionId(uuidv4()), []);
useEffect(() => {
(async () => {
if (selectedModules.length > 0 && exams.length > 0 && moduleIndex < selectedModules.length) {
const nextExam = exams[moduleIndex];
setExam(nextExam ? updateExamWithUserSolutions(nextExam) : undefined);
}
})();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedModules, moduleIndex, exams]);
useEffect(() => {
(async () => {
if (selectedModules.length > 0 && exams.length === 0) {
const examPromises = selectedModules.map(getExam);
Promise.all(examPromises).then((values) => {
if (values.every((x) => !!x)) {
setExams(values.map((x) => x!));
}
});
}
})();
}, [selectedModules, setExams, exams]);
useEffect(() => {
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));
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedModules, moduleIndex, hasBeenUploaded]);
const getExam = async (module: Module): Promise<Exam | undefined> => {
const examRequest = await axios<Exam[]>(`/api/exam/${module}`);
if (examRequest.status !== 200) {
toast.error("Something went wrong!");
return undefined;
}
const newExam = examRequest.data;
switch (module) {
case "reading":
return newExam.shift() as ReadingExam;
case "listening":
return newExam.shift() as ListeningExam;
case "writing":
return newExam.shift() as WritingExam;
case "speaking":
return newExam.shift() as SpeakingExam;
}
};
const evaluateSpeakingAnswer = async (examId: string, exerciseId: string, solution: UserSolution) => {
const speakingExam = exams.find((x) => x.id === examId)!;
const exercise = speakingExam.exercises.find((x) => x.id === exerciseId)! as SpeakingExercise;
const blobResponse = await axios.get(solution.solutions[0].solution.trim(), {responseType: "arraybuffer"});
const audioBlob = Buffer.from(blobResponse.data, "binary");
const audioFile = new File([audioBlob], "audio.wav", {type: "audio/wav"});
const formData = new FormData();
formData.append("audio", audioFile, "audio.wav");
formData.append("question", `${exercise.text.replaceAll("\n", "")} You should talk about: ${exercise.prompts.join(", ")}`);
const config = {
headers: {
"Content-Type": "audio/mp3",
},
};
const response = await axios.post("/api/evaluate/speaking", formData, config);
if (response.status === 200) {
setUserSolutions([
...userSolutions.filter((x) => x.exercise !== exerciseId),
{
...solution,
score: {
correct: speakingReverseMarking[response.data.overall] || 0,
missing: 0,
total: 100,
},
solutions: [{id: exerciseId, solution: response.data.fullPath, evaluation: response.data}],
},
]);
}
};
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<Evaluation>("/api/evaluate/writing", {
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] || 0,
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, {userSolutions: userSolutions.find((y) => x.id === y.exercise)?.solutions}));
return Object.assign(exam, exercises);
};
const onFinish = (solutions: UserSolution[]) => {
const solutionIds = solutions.map((x) => x.exercise);
const solutionExams = solutions.map((x) => x.exam);
if (exam && !solutionExams.includes(exam.id)) return;
if (exam && (exam.module === "writing" || exam.module === "speaking") && solutions.length > 0 && !showSolutions) {
setHasBeenUploaded(true);
setIsEvaluationLoading(true);
Promise.all(
exam.exercises.map((exercise) =>
(exam.module === "writing" ? evaluateWritingAnswer : evaluateSpeakingAnswer)(
exam.id,
exercise.id,
solutions.find((x) => x.exercise === exercise.id)!,
),
),
).finally(() => {
setIsEvaluationLoading(false);
setHasBeenUploaded(false);
});
}
axios.get("/api/stats/update");
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} />;
}
if (moduleIndex >= selectedModules.length) {
return (
<Finish
isLoading={isEvaluationLoading}
user={user!}
modules={selectedModules}
onViewResults={() => {
setShowSolutions(true);
setModuleIndex(0);
}}
scores={aggregateScoresByModule(userSolutions)}
/>
);
}
if (exam && exam.module === "reading") {
return <Reading exam={exam} onFinish={onFinish} showSolutions={showSolutions} />;
}
if (exam && exam.module === "listening") {
return <Listening exam={exam} onFinish={onFinish} showSolutions={showSolutions} />;
}
if (exam && exam.module === "writing") {
return <Writing exam={exam} onFinish={onFinish} showSolutions={showSolutions} />;
}
if (exam && exam.module === "speaking") {
return <Speaking exam={exam} onFinish={onFinish} showSolutions={showSolutions} />;
}
return <>Loading...</>;
};
return (
<>
<Head>
<title>Exercises | 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}
className="justify-between"
focusMode={selectedModules.length !== 0}
onFocusLayerMouseEnter={() => setShowAbandonPopup(true)}
>
<>
{renderScreen()}
<AbandonPopup
isOpen={showAbandonPopup}
abandonPopupTitle="Leave Exercise"
abandonPopupDescription="Are you sure you want to leave the exercise? You will lose all your progress."
abandonConfirmButtonText="Confirm"
onAbandon={() => {
setExam(undefined);
setSelectedModules([]);
setShowAbandonPopup(false)
return true;
}}
onCancel={() => setShowAbandonPopup(false)}
/>
</>
</Layout>
)}
</>
);
}

View File

@@ -16,6 +16,7 @@ import {Module} from "@/interfaces";
import ProgressBar from "@/components/Low/ProgressBar"; import ProgressBar from "@/components/Low/ProgressBar";
import Layout from "@/components/High/Layout"; import Layout from "@/components/High/Layout";
import {calculateAverageLevel} from "@/utils/score"; import {calculateAverageLevel} from "@/utils/score";
import axios from "axios";
export const getServerSideProps = withIronSessionSsr(({req, res}) => { export const getServerSideProps = withIronSessionSsr(({req, res}) => {
const user = req.session.user; const user = req.session.user;
@@ -57,9 +58,9 @@ export default function Home() {
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" href="/favicon.ico" /> <link rel="icon" href="/favicon.ico" />
</Head> </Head>
<main className="w-full h-full min-h-[100vh] flex flex-col items-center justify-center bg-neutral-100"> <Layout user={user} navDisabled>
<Diagnostic user={user} onFinish={() => setShowDiagnostics(false)} /> <Diagnostic user={user} onFinish={() => setShowDiagnostics(false)} />
</main> </Layout>
</> </>
); );
} }
@@ -79,7 +80,7 @@ export default function Home() {
{user && ( {user && (
<Layout user={user}> <Layout user={user}>
<section className="w-full flex gap-8"> <section className="w-full flex gap-8">
<img src={user.profilePicture} alt={user.name} className="aspect-square h-64 rounded-3xl drop-shadow-xl" /> <img src={user.profilePicture} alt={user.name} className="aspect-square h-64 rounded-3xl drop-shadow-xl object-cover" />
<div className="flex flex-col gap-4 py-4 w-full"> <div className="flex flex-col gap-4 py-4 w-full">
<div className="flex justify-between w-full gap-8"> <div className="flex justify-between w-full gap-8">
<div className="flex flex-col gap-2 py-2"> <div className="flex flex-col gap-2 py-2">
@@ -89,20 +90,20 @@ export default function Home() {
<ProgressBar <ProgressBar
label={`Level ${calculateAverageLevel(user.levels).toFixed(1)}`} label={`Level ${calculateAverageLevel(user.levels).toFixed(1)}`}
percentage={100} percentage={100}
color="blue" color="purple"
className="max-w-xs w-32 self-end h-10" className="max-w-xs w-32 self-end h-10"
/> />
</div> </div>
<ProgressBar <ProgressBar
label="" label=""
percentage={Math.round((calculateAverageLevel(user.levels) * 100) / calculateAverageLevel(user.desiredLevels))} percentage={Math.round((calculateAverageLevel(user.levels) * 100) / calculateAverageLevel(user.desiredLevels))}
color="blue" color="red"
className="w-full h-3 drop-shadow-lg" className="w-full h-3 drop-shadow-lg"
/> />
<div className="flex justify-between w-full mt-8"> <div className="flex justify-between w-full mt-8">
<div className="flex gap-4 items-center"> <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"> <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" /> <BsFileEarmarkText className="w-8 h-8 text-mti-red-light" />
</div> </div>
<div className="flex flex-col"> <div className="flex flex-col">
<span className="font-bold text-xl">{totalExams(stats)}</span> <span className="font-bold text-xl">{totalExams(stats)}</span>
@@ -111,7 +112,7 @@ export default function Home() {
</div> </div>
<div className="flex gap-4 items-center"> <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"> <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" /> <BsPencil className="w-8 h-8 text-mti-red-light" />
</div> </div>
<div className="flex flex-col"> <div className="flex flex-col">
<span className="font-bold text-xl">{stats.length}</span> <span className="font-bold text-xl">{stats.length}</span>
@@ -120,10 +121,10 @@ export default function Home() {
</div> </div>
<div className="flex gap-4 items-center"> <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"> <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" /> <BsStar className="w-8 h-8 text-mti-red-light" />
</div> </div>
<div className="flex flex-col"> <div className="flex flex-col">
<span className="font-bold text-xl">{averageScore(stats)}%</span> <span className="font-bold text-xl">{stats.length > 0 ? averageScore(stats) : 0}%</span>
<span className="font-normal text-base text-mti-gray-dim">Average Score</span> <span className="font-normal text-base text-mti-gray-dim">Average Score</span>
</div> </div>
</div> </div>
@@ -133,11 +134,7 @@ export default function Home() {
<section className="flex flex-col gap-3"> <section className="flex flex-col gap-3">
<span className="font-bold text-lg">Bio</span> <span className="font-bold text-lg">Bio</span>
<span className="text-mti-gray-taupe"> <span className="text-mti-gray-taupe">
Patricia Smith is a dedicated and enthusiastic student. Her passion for knowledge drives her to constantly seek new {user.bio || "Your bio will appear here, you can change it by clicking on your name in the top right corner."}
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> </span>
</section> </section>
<section className="flex flex-col gap-3"> <section className="flex flex-col gap-3">
@@ -157,7 +154,7 @@ export default function Home() {
</div> </div>
<div className="pl-14"> <div className="pl-14">
<ProgressBar <ProgressBar
color="blue" color="red"
label="" label=""
percentage={Math.round((user.levels.reading * 100) / user.desiredLevels.reading)} percentage={Math.round((user.levels.reading * 100) / user.desiredLevels.reading)}
className="w-full h-2" className="w-full h-2"
@@ -178,7 +175,7 @@ export default function Home() {
</div> </div>
<div className="pl-14"> <div className="pl-14">
<ProgressBar <ProgressBar
color="blue" color="red"
label="" label=""
percentage={Math.round((user.levels.writing * 100) / user.desiredLevels.writing)} percentage={Math.round((user.levels.writing * 100) / user.desiredLevels.writing)}
className="w-full h-2" className="w-full h-2"
@@ -199,7 +196,7 @@ export default function Home() {
</div> </div>
<div className="pl-14"> <div className="pl-14">
<ProgressBar <ProgressBar
color="blue" color="red"
label="" label=""
percentage={Math.round((user.levels.listening * 100) / user.desiredLevels.listening)} percentage={Math.round((user.levels.listening * 100) / user.desiredLevels.listening)}
className="w-full h-2" className="w-full h-2"
@@ -220,7 +217,7 @@ export default function Home() {
</div> </div>
<div className="pl-14"> <div className="pl-14">
<ProgressBar <ProgressBar
color="blue" color="red"
label="" label=""
percentage={Math.round((user.levels.speaking * 100) / user.desiredLevels.speaking)} percentage={Math.round((user.levels.speaking * 100) / user.desiredLevels.speaking)}
className="w-full h-2" className="w-full h-2"

View File

@@ -7,14 +7,15 @@ import Head from "next/head";
import useUser from "@/hooks/useUser"; import useUser from "@/hooks/useUser";
import {Divider} from "primereact/divider"; import {Divider} from "primereact/divider";
import Button from "@/components/Low/Button"; import Button from "@/components/Low/Button";
import {BsArrowRepeat} from "react-icons/bs"; import {BsArrowRepeat, BsCheck} from "react-icons/bs";
import Link from "next/link"; import Link from "next/link";
import Input from "@/components/Low/Input"; import Input from "@/components/Low/Input";
import clsx from "clsx";
export default function Login() { export default function Login() {
const [email, setEmail] = useState(""); const [email, setEmail] = useState("");
const [password, setPassword] = useState(""); const [password, setPassword] = useState("");
const [showPassword, setShowPassword] = useState(false); const [rememberPassword, setRememberPassword] = useState(false);
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const {mutateUser} = useUser({ const {mutateUser} = useUser({
@@ -53,7 +54,7 @@ export default function Login() {
<main className="w-full h-[100vh] flex bg-white text-black"> <main className="w-full h-[100vh] flex bg-white text-black">
<ToastContainer /> <ToastContainer />
<section className="h-full w-fit min-w-fit relative"> <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" /> <div className="absolute h-full w-full bg-mti-rose-light z-10 bg-opacity-50" />
<img src="/people-talking-tablet.png" alt="People smiling looking at a tablet" className="h-full aspect-auto" /> <img src="/people-talking-tablet.png" alt="People smiling looking at a tablet" className="h-full aspect-auto" />
</section> </section>
<section className="h-full w-full flex flex-col items-center justify-center gap-2"> <section className="h-full w-full flex flex-col items-center justify-center gap-2">
@@ -65,7 +66,22 @@ export default function Login() {
<form className="flex flex-col items-center gap-6 w-1/2" onSubmit={login}> <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="email" name="email" onChange={(e) => setEmail(e)} placeholder="Enter email address" />
<Input type="password" name="password" onChange={(e) => setPassword(e)} placeholder="Password" /> <Input type="password" name="password" onChange={(e) => setPassword(e)} placeholder="Password" />
<Button className="mt-8 w-full" color="green" disabled={isLoading}> <div className="flex justify-between w-full px-4">
<div className="flex gap-3 text-mti-gray-dim text-xs cursor-pointer" onClick={() => setRememberPassword((prev) => !prev)}>
<input type="checkbox" className="hidden" />
<div
className={clsx(
"w-4 h-4 rounded-sm flex items-center justify-center border border-mti-purple-light bg-white",
"transition duration-300 ease-in-out",
rememberPassword && "!bg-mti-purple-light ",
)}>
<BsCheck color="white" className="w-full h-full" />
</div>
<span>Remember my password</span>
</div>
<span className="text-mti-purple-light text-xs">Forgot Password?</span>
</div>
<Button className="mt-8 w-full" color="purple" disabled={isLoading}>
{!isLoading && "Login"} {!isLoading && "Login"}
{isLoading && ( {isLoading && (
<div className="flex items-center justify-center"> <div className="flex items-center justify-center">
@@ -76,7 +92,7 @@ export default function Login() {
</form> </form>
<span className="text-mti-gray-cool text-sm font-normal mt-8"> <span className="text-mti-gray-cool text-sm font-normal mt-8">
Don&apos;t have an account?{" "} Don&apos;t have an account?{" "}
<Link className="text-mti-green-light" href="/register"> <Link className="text-mti-purple-light" href="/register">
Sign up Sign up
</Link> </Link>
</span> </span>

View File

@@ -1,9 +1,27 @@
import {withIronSessionSsr} from "iron-session/next"; /* eslint-disable @next/next/no-img-element */
import {sessionOptions} from "@/lib/session";
import {User} from "@/interfaces/user";
import Head from "next/head"; import Head from "next/head";
import Navbar from "@/components/Navbar"; import Navbar from "@/components/Navbar";
import {Avatar} from "primereact/avatar"; import {BsFileEarmarkText, BsPencil, BsStar, BsBook, BsHeadphones, BsPen, BsMegaphone, BsArrowRepeat} from "react-icons/bs";
import {withIronSessionSsr} from "iron-session/next";
import {sessionOptions} from "@/lib/session";
import {ChangeEvent, useEffect, useRef, useState} from "react";
import useStats from "@/hooks/useStats";
import {averageScore, totalExams} from "@/utils/stats";
import useUser from "@/hooks/useUser";
import Sidebar from "@/components/Sidebar";
import Diagnostic from "@/components/Diagnostic";
import {toast, 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 Input from "@/components/Low/Input";
import Button from "@/components/Low/Button";
import {useRouter} from "next/router";
import Link from "next/link";
import axios from "axios";
import {ErrorMessage} from "@/constants/errors";
export const getServerSideProps = withIronSessionSsr(({req, res}) => { export const getServerSideProps = withIronSessionSsr(({req, res}) => {
const user = req.session.user; const user = req.session.user;
@@ -24,11 +42,79 @@ export const getServerSideProps = withIronSessionSsr(({req, res}) => {
}; };
}, sessionOptions); }, sessionOptions);
export default function Profile({user}: {user: User}) { export default function Home() {
const [bio, setBio] = useState("");
const [name, setName] = useState("");
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [newPassword, setNewPassword] = useState("");
const [isLoading, setIsLoading] = useState(false);
const [profilePicture, setProfilePicture] = useState("");
const profilePictureInput = useRef(null);
const router = useRouter();
const {user} = useUser({redirectTo: "/login"});
useEffect(() => {
if (user) {
setName(user.name);
setEmail(user.email);
setBio(user.bio);
setProfilePicture(user.profilePicture);
}
}, [user]);
const convertBase64 = (file: File) => {
return new Promise((resolve, reject) => {
const fileReader = new FileReader();
fileReader.readAsDataURL(file);
fileReader.onload = () => {
resolve(fileReader.result);
};
fileReader.onerror = (error) => {
reject(error);
};
});
};
const uploadProfilePicture = async (event: ChangeEvent<HTMLInputElement>) => {
if (event.target.files && event.target.files[0]) {
const picture = event.target.files[0];
const base64 = await convertBase64(picture);
setProfilePicture(base64 as string);
}
};
const updateUser = async () => {
setIsLoading(true);
if (email !== user?.email && !password) {
toast.error("To update your e-mail you need to input your password!");
setIsLoading(false);
return;
}
if (newPassword && !password) {
toast.error("To update your password you need to input your current one!");
setIsLoading(false);
return;
}
const request = await axios.post("/api/users/update", {bio, name, email, password, newPassword, profilePicture});
if (request.status === 200) {
toast.success("Your profile has been updated!");
setTimeout(() => router.reload(), 800);
return;
}
toast.error((request.data as ErrorMessage).message);
setIsLoading(false);
};
return ( return (
<> <>
<Head> <Head>
<title>IELTS GPT | Profile</title> <title>IELTS GPT | Muscat Training Institute</title>
<meta <meta
name="description" name="description"
content="A training platform for the IELTS exam provided by the Muscat Training Institute and developed by eCrop." content="A training platform for the IELTS exam provided by the Muscat Training Institute and developed by eCrop."
@@ -36,13 +122,86 @@ export default function Profile({user}: {user: User}) {
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" href="/favicon.ico" /> <link rel="icon" href="/favicon.ico" />
</Head> </Head>
<main className="w-full h-screen flex flex-col items-center bg-neutral-100"> <ToastContainer />
<div className="w-full h-full flex flex-col items-center justify-center p-4 relative"> {user && (
<section className="bg-white drop-shadow-xl p-4 rounded-xl w-96 flex flex-col items-center"> <Layout user={user}>
<Avatar image={user.profilePicture} size="xlarge" shape="circle" /> <section className="w-full flex flex-col gap-8 px-4 py-8">
<div className="flex w-full justify-between">
<div className="flex flex-col gap-8 w-2/3">
<h1 className="text-4xl font-bold mb-6">Edit Profile</h1>
<form className="flex flex-col items-center gap-6 w-full">
<Input
label="Name"
type="text"
name="name"
onChange={(e) => setName(e)}
placeholder="Enter your name"
defaultValue={name}
required
/>
<Input
label="E-mail Address"
type="email"
name="email"
onChange={(e) => setEmail(e)}
placeholder="Enter email address"
defaultValue={email}
required
/>
<Input
label="Old Password"
type="password"
name="password"
onChange={(e) => setPassword(e)}
placeholder="Enter your password"
required
/>
<Input
label="New Password"
type="password"
name="newPassword"
onChange={(e) => setNewPassword(e)}
placeholder="Enter your new password (optional)"
/>
</form>
</div>
<div className="flex flex-col gap-3 items-center w-48">
<img
src={profilePicture}
alt={user.name}
className="aspect-square h-48 w-48 rounded-full drop-shadow-xl self-end object-cover"
/>
<input type="file" className="hidden" onChange={uploadProfilePicture} accept="image/*" ref={profilePictureInput} />
<span
onClick={() => (profilePictureInput.current as any)?.click()}
className="cursor-pointer text-mti-purple-light text-sm">
Change picture
</span>
</div>
</div>
<div className="flex flex-col gap-4 mt-8 mb-20">
<span className="text-lg font-bold">Bio</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) => setBio(e.target.value)}
defaultValue={bio}
placeholder="Write your text here..."
/>
</div>
<div className="self-end flex justify-between w-full gap-8 absolute bottom-8 left-0 px-8">
<Link href="/" className="max-w-[200px] self-end w-full">
<Button color="purple" variant="outline" className="max-w-[200px] self-end w-full">
Back
</Button>
</Link>
<Button color="purple" className="max-w-[200px] self-end w-full" onClick={updateUser} disabled={isLoading}>
Save Changes
</Button>
</div>
</section> </section>
</div> </Layout>
</main> )}
</> </>
); );
} }

View File

@@ -1,35 +1,23 @@
/* eslint-disable @next/next/no-img-element */ /* eslint-disable @next/next/no-img-element */
import Head from "next/head"; 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 {withIronSessionSsr} from "iron-session/next";
import {sessionOptions} from "@/lib/session"; import {sessionOptions} from "@/lib/session";
import {Stat, User} from "@/interfaces/user"; import {Stat, User} from "@/interfaces/user";
import {useEffect, useState} from "react"; import {useEffect, useState} from "react";
import useStats from "@/hooks/useStats"; import useStats from "@/hooks/useStats";
import {averageScore, convertToUserSolutions, formatModuleTotalStats, groupByDate, groupBySession, totalExams} from "@/utils/stats"; import {convertToUserSolutions, groupByDate} from "@/utils/stats";
import {Divider} from "primereact/divider";
import useUser from "@/hooks/useUser";
import {Timeline} from "primereact/timeline";
import moment from "moment"; import moment from "moment";
import {AutoComplete} from "primereact/autocomplete";
import useUsers from "@/hooks/useUsers"; import useUsers from "@/hooks/useUsers";
import {Dropdown} from "primereact/dropdown";
import useExamStore from "@/stores/examStore"; import useExamStore from "@/stores/examStore";
import {Exam, ListeningExam, ReadingExam, SpeakingExam, WritingExam} from "@/interfaces/exam";
import {Module} from "@/interfaces"; import {Module} from "@/interfaces";
import axios from "axios"; import {ToastContainer} from "react-toastify";
import {toast, ToastContainer} from "react-toastify";
import {useRouter} from "next/router"; import {useRouter} from "next/router";
import Icon from "@mdi/react";
import {mdiArrowRight, mdiChevronRight} from "@mdi/js";
import {uniqBy} from "lodash"; import {uniqBy} from "lodash";
import {getExamById} from "@/utils/exams"; import {getExamById} from "@/utils/exams";
import {sortByModule} from "@/utils/moduleUtils"; import {sortByModule} from "@/utils/moduleUtils";
import Layout from "@/components/High/Layout"; import Layout from "@/components/High/Layout";
import clsx from "clsx"; import clsx from "clsx";
import {calculateAverageLevel, calculateBandScore} from "@/utils/score"; import {calculateBandScore} from "@/utils/score";
import {BsBook, BsHeadphones, BsMegaphone, BsPen} from "react-icons/bs"; import {BsBook, BsHeadphones, BsMegaphone, BsPen} from "react-icons/bs";
export const getServerSideProps = withIronSessionSsr(({req, res}) => { export const getServerSideProps = withIronSessionSsr(({req, res}) => {
@@ -54,6 +42,7 @@ export const getServerSideProps = withIronSessionSsr(({req, res}) => {
export default function History({user}: {user: User}) { export default function History({user}: {user: User}) {
const [selectedUser, setSelectedUser] = useState<User>(user); const [selectedUser, setSelectedUser] = useState<User>(user);
const [groupedStats, setGroupedStats] = useState<{[key: string]: Stat[]}>(); const [groupedStats, setGroupedStats] = useState<{[key: string]: Stat[]}>();
const [filter, setFilter] = useState<"months" | "weeks" | "days">();
const {users, isLoading: isUsersLoading} = useUsers(); const {users, isLoading: isUsersLoading} = useUsers();
const {stats, isLoading: isStatsLoading} = useStats(selectedUser?.id); const {stats, isLoading: isStatsLoading} = useStats(selectedUser?.id);
@@ -71,6 +60,27 @@ export default function History({user}: {user: User}) {
} }
}, [stats, isStatsLoading]); }, [stats, isStatsLoading]);
const toggleFilter = (value: "months" | "weeks" | "days") => {
setFilter((prev) => (prev === value ? undefined : value));
};
const filterStatsByDate = (stats: {[key: string]: Stat[]}) => {
if (filter) {
const filterDate = moment()
.subtract({[filter as string]: 1})
.format("x");
const filteredStats: {[key: string]: Stat[]} = {};
Object.keys(stats).forEach((timestamp) => {
if (timestamp >= filterDate) filteredStats[timestamp] = stats[timestamp];
});
return filteredStats;
}
return stats;
};
const formatTimestamp = (timestamp: string) => { const formatTimestamp = (timestamp: string) => {
const date = moment(parseInt(timestamp)); const date = moment(parseInt(timestamp));
const formatter = "YYYY/MM/DD - HH:mm"; const formatter = "YYYY/MM/DD - HH:mm";
@@ -142,7 +152,7 @@ export default function History({user}: {user: User}) {
.sort(sortByModule) .sort(sortByModule)
.map((x) => x!.module), .map((x) => x!.module),
); );
router.push("/exam"); router.push("/exercises");
} }
}); });
}; };
@@ -152,9 +162,9 @@ export default function History({user}: {user: User}) {
key={timestamp} key={timestamp}
className={clsx( className={clsx(
"flex flex-col gap-4 border border-mti-gray-platinum p-4 cursor-pointer rounded-xl transition ease-in-out duration-300", "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.7 && "hover:border-mti-purple",
correct / total >= 0.3 && correct / total < 0.7 && "hover:border-mti-blue", correct / total >= 0.3 && correct / total < 0.7 && "hover:border-mti-red",
correct / total < 0.3 && "hover:border-mti-orange", correct / total < 0.3 && "hover:border-mti-rose",
)} )}
onClick={selectExam} onClick={selectExam}
role="button"> role="button">
@@ -162,9 +172,9 @@ export default function History({user}: {user: User}) {
<span className="font-medium">{formatTimestamp(timestamp)}</span> <span className="font-medium">{formatTimestamp(timestamp)}</span>
<span <span
className={clsx( className={clsx(
correct / total >= 0.7 && "text-mti-green", correct / total >= 0.7 && "text-mti-purple",
correct / total >= 0.3 && correct / total < 0.7 && "text-mti-blue", correct / total >= 0.3 && correct / total < 0.7 && "text-mti-red",
correct / total < 0.3 && "text-mti-orange", correct / total < 0.3 && "text-mti-rose",
)}> )}>
Level{" "} Level{" "}
{(aggregatedLevels.reduce((accumulator, current) => accumulator + current.level, 0) / aggregatedLevels.length).toFixed(1)} {(aggregatedLevels.reduce((accumulator, current) => accumulator + current.level, 0) / aggregatedLevels.length).toFixed(1)}
@@ -207,24 +217,55 @@ export default function History({user}: {user: User}) {
<ToastContainer /> <ToastContainer />
{user && ( {user && (
<Layout user={user}> <Layout user={user}>
<div className="w-fit"> <div className="w-full flex justify-between items-center">
{!isUsersLoading && user.type !== "student" && ( <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" <select
onChange={(e) => setSelectedUser(users.find((x) => x.id === e.target.value)!)}> className="select w-full max-w-xs bg-white border border-mti-gray-platinum outline-none font-normal text-base"
{users.map((x) => ( onChange={(e) => setSelectedUser(users.find((x) => x.id === e.target.value)!)}>
<option key={x.id} selected={selectedUser.id === x.id} value={x.id}> {users.map((x) => (
{x.name} <option key={x.id} selected={selectedUser.id === x.id} value={x.id}>
</option> {x.name}
))} </option>
</select> ))}
</> </select>
)} </>
)}
</div>
<div className="flex gap-4">
<button
className={clsx(
"bg-mti-purple-ultralight text-mti-purple px-4 py-2 rounded-full hover:text-white hover:bg-mti-purple-light",
"transition duration-300 ease-in-out",
filter === "months" && "!bg-mti-purple-light !text-white",
)}
onClick={() => toggleFilter("months")}>
Last month
</button>
<button
className={clsx(
"bg-mti-purple-ultralight text-mti-purple px-4 py-2 rounded-full hover:text-white hover:bg-mti-purple-light",
"transition duration-300 ease-in-out",
filter === "weeks" && "!bg-mti-purple-light !text-white",
)}
onClick={() => toggleFilter("weeks")}>
Last week
</button>
<button
className={clsx(
"bg-mti-purple-ultralight text-mti-purple px-4 py-2 rounded-full hover:text-white hover:bg-mti-purple-light",
"transition duration-300 ease-in-out",
filter === "days" && "!bg-mti-purple-light !text-white",
)}
onClick={() => toggleFilter("days")}>
Last day
</button>
</div>
</div> </div>
{groupedStats && Object.keys(groupedStats).length > 0 && !isStatsLoading && ( {groupedStats && Object.keys(groupedStats).length > 0 && !isStatsLoading && (
<div className="grid grid-cols-3 w-full gap-6"> <div className="grid grid-cols-3 w-full gap-6">
{Object.keys(groupedStats) {Object.keys(filterStatsByDate(groupedStats))
.sort((a, b) => parseInt(b) - parseInt(a)) .sort((a, b) => parseInt(b) - parseInt(a))
.map(customContent)} .map(customContent)}
</div> </div>

View File

@@ -1,5 +1,5 @@
/* eslint-disable @next/next/no-img-element */ /* eslint-disable @next/next/no-img-element */
import {ToastContainer} from "react-toastify"; import {toast, ToastContainer} from "react-toastify";
import {useState} from "react"; import {useState} from "react";
import Head from "next/head"; import Head from "next/head";
import useUser from "@/hooks/useUser"; import useUser from "@/hooks/useUser";
@@ -7,14 +7,13 @@ import Button from "@/components/Low/Button";
import {BsArrowRepeat} from "react-icons/bs"; import {BsArrowRepeat} from "react-icons/bs";
import Link from "next/link"; import Link from "next/link";
import Input from "@/components/Low/Input"; import Input from "@/components/Low/Input";
import axios from "axios";
export default function Register() { export default function Register() {
const [name, setName] = useState(""); const [name, setName] = useState("");
const [email, setEmail] = useState(""); const [email, setEmail] = useState("");
const [password, setPassword] = useState(""); const [password, setPassword] = useState("");
const [showPassword, setShowPassword] = useState(false);
const [confirmPassword, setConfirmPassword] = useState(""); const [confirmPassword, setConfirmPassword] = useState("");
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const {mutateUser} = useUser({ const {mutateUser} = useUser({
@@ -22,6 +21,30 @@ export default function Register() {
redirectIfFound: true, redirectIfFound: true,
}); });
const register = (e: any) => {
e.preventDefault();
if (confirmPassword !== password) {
toast.error("Your passwords do not match!", {toastId: "password-not-match"});
return;
}
setIsLoading(true);
axios
.post("/api/register", {name, email, password, profilePicture: "/defaultAvatar.png"})
.then((response) => mutateUser(response.data.user))
.catch((error) => {
console.log(error.response.data);
if (error.response.status === 401) {
toast.error("There is already a user with that e-mail!");
return;
}
toast.error("There was something wrong, please try again!");
})
.finally(() => setIsLoading(false));
};
return ( return (
<> <>
<Head> <Head>
@@ -33,12 +56,12 @@ export default function Register() {
<main className="w-full h-[100vh] flex bg-white text-black"> <main className="w-full h-[100vh] flex bg-white text-black">
<ToastContainer /> <ToastContainer />
<section className="h-full w-fit min-w-fit relative"> <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" /> <div className="absolute h-full w-full bg-mti-rose-light z-10 bg-opacity-50" />
<img src="/people-talking-tablet.png" alt="People smiling looking at a tablet" className="h-full aspect-auto" /> <img src="/people-talking-tablet.png" alt="People smiling looking at a tablet" className="h-full aspect-auto" />
</section> </section>
<section className="h-full w-full flex flex-col items-center justify-center gap-12"> <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> <h1 className="font-bold text-4xl">Create new account</h1>
<form className="flex flex-col items-center gap-6 w-1/2"> <form className="flex flex-col items-center gap-6 w-1/2" onSubmit={register}>
<Input type="text" name="name" onChange={(e) => setName(e)} placeholder="Enter your name" required /> <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="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="password" onChange={(e) => setPassword(e)} placeholder="Enter your password" required />
@@ -49,7 +72,7 @@ export default function Register() {
placeholder="Confirm your password" placeholder="Confirm your password"
required required
/> />
<Button className="mt-8 w-full" color="green" disabled={isLoading}> <Button className="mt-8 w-full" color="purple" disabled={isLoading}>
{!isLoading && "Create account"} {!isLoading && "Create account"}
{isLoading && ( {isLoading && (
<div className="flex items-center justify-center"> <div className="flex items-center justify-center">
@@ -58,7 +81,7 @@ export default function Register() {
)} )}
</Button> </Button>
</form> </form>
<Link className="text-mti-green-light text-sm font-normal" href="/login"> <Link className="text-mti-purple-light text-sm font-normal" href="/login">
Sign in instead Sign in instead
</Link> </Link>
</section> </section>

View File

@@ -69,7 +69,7 @@ export default function Stats() {
{user && ( {user && (
<Layout user={user}> <Layout user={user}>
<section className="w-full flex gap-8"> <section className="w-full flex gap-8">
<img src={user.profilePicture} alt={user.name} className="aspect-square h-64 rounded-3xl drop-shadow-xl" /> <img src={user.profilePicture} alt={user.name} className="aspect-square h-64 rounded-3xl drop-shadow-xl object-cover" />
<div className="flex flex-col gap-4 py-4 w-full"> <div className="flex flex-col gap-4 py-4 w-full">
<div className="flex justify-between w-full gap-8"> <div className="flex justify-between w-full gap-8">
<div className="flex flex-col gap-2 py-2"> <div className="flex flex-col gap-2 py-2">
@@ -79,20 +79,20 @@ export default function Stats() {
<ProgressBar <ProgressBar
label={`Level ${calculateAverageLevel(user.levels).toFixed(1)}`} label={`Level ${calculateAverageLevel(user.levels).toFixed(1)}`}
percentage={100} percentage={100}
color="blue" color="purple"
className="max-w-xs w-32 self-end h-10" className="max-w-xs w-32 self-end h-10"
/> />
</div> </div>
<ProgressBar <ProgressBar
label="" label=""
percentage={Math.round((calculateAverageLevel(user.levels) * 100) / calculateAverageLevel(user.desiredLevels))} percentage={Math.round((calculateAverageLevel(user.levels) * 100) / calculateAverageLevel(user.desiredLevels))}
color="blue" color="red"
className="w-full h-3 drop-shadow-lg" className="w-full h-3 drop-shadow-lg"
/> />
<div className="flex justify-between w-full mt-8"> <div className="flex justify-between w-full mt-8">
<div className="flex gap-4 items-center"> <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"> <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" /> <BsFileEarmarkText className="w-8 h-8 text-mti-red-light" />
</div> </div>
<div className="flex flex-col"> <div className="flex flex-col">
<span className="font-bold text-xl">{Object.keys(groupBySession(stats)).length}</span> <span className="font-bold text-xl">{Object.keys(groupBySession(stats)).length}</span>
@@ -101,7 +101,7 @@ export default function Stats() {
</div> </div>
<div className="flex gap-4 items-center"> <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"> <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" /> <BsPencil className="w-8 h-8 text-mti-red-light" />
</div> </div>
<div className="flex flex-col"> <div className="flex flex-col">
<span className="font-bold text-xl">{stats.length}</span> <span className="font-bold text-xl">{stats.length}</span>
@@ -110,7 +110,7 @@ export default function Stats() {
</div> </div>
<div className="flex gap-4 items-center"> <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"> <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" /> <BsStar className="w-8 h-8 text-mti-red-light" />
</div> </div>
<div className="flex flex-col"> <div className="flex flex-col">
<span className="font-bold text-xl">{averageScore(stats)}%</span> <span className="font-bold text-xl">{averageScore(stats)}%</span>

View File

@@ -118,7 +118,7 @@ export default function Page() {
setIsRecording(false); setIsRecording(false);
stopRecording(); stopRecording();
}} }}
className="text-mti-green-light w-8 h-8 cursor-pointer" className="text-mti-purple-light w-8 h-8 cursor-pointer"
/> />
</div> </div>
</> </>
@@ -143,14 +143,14 @@ export default function Page() {
setIsRecording(true); setIsRecording(true);
resumeRecording(); resumeRecording();
}} }}
className="text-mti-green-light w-8 h-8 cursor-pointer" className="text-mti-purple-light w-8 h-8 cursor-pointer"
/> />
<BsCheckCircleFill <BsCheckCircleFill
onClick={() => { onClick={() => {
setIsRecording(false); setIsRecording(false);
stopRecording(); stopRecording();
}} }}
className="text-mti-green-light w-8 h-8 cursor-pointer" className="text-mti-purple-light w-8 h-8 cursor-pointer"
/> />
</div> </div>
</> </>

View File

@@ -1,13 +1,14 @@
import {Module} from "@/interfaces"; import {Module} from "@/interfaces";
import {Exam, UserSolution} from "@/interfaces/exam"; import {Exam, UserSolution} from "@/interfaces/exam";
import {Stat} from "@/interfaces/user";
import {create} from "zustand"; import {create} from "zustand";
export interface ExamState { export interface ExamState {
exams: Exam[]; exams: Exam[];
userSolutions: UserSolution[]; userSolutions: UserSolution[];
showSolutions: boolean; showSolutions: boolean;
hasExamEnded: boolean;
selectedModules: Module[]; selectedModules: Module[];
setHasExamEnded: (hasExamEnded: boolean) => void;
setUserSolutions: (userSolutions: UserSolution[]) => void; setUserSolutions: (userSolutions: UserSolution[]) => void;
setExams: (exams: Exam[]) => void; setExams: (exams: Exam[]) => void;
setShowSolutions: (showSolutions: boolean) => void; setShowSolutions: (showSolutions: boolean) => void;
@@ -20,6 +21,7 @@ export const initialState = {
userSolutions: [], userSolutions: [],
showSolutions: false, showSolutions: false,
selectedModules: [], selectedModules: [],
hasExamEnded: false,
}; };
const useExamStore = create<ExamState>((set) => ({ const useExamStore = create<ExamState>((set) => ({
@@ -28,6 +30,7 @@ const useExamStore = create<ExamState>((set) => ({
setExams: (exams: Exam[]) => set(() => ({exams})), setExams: (exams: Exam[]) => set(() => ({exams})),
setShowSolutions: (showSolutions: boolean) => set(() => ({showSolutions})), setShowSolutions: (showSolutions: boolean) => set(() => ({showSolutions})),
setSelectedModules: (modules: Module[]) => set(() => ({selectedModules: modules})), setSelectedModules: (modules: Module[]) => set(() => ({selectedModules: modules})),
setHasExamEnded: (hasExamEnded: boolean) => set(() => ({hasExamEnded})),
reset: () => set(() => initialState), reset: () => set(() => initialState),
})); }));

View File

@@ -1,5 +1,15 @@
import {Module} from "@/interfaces"; import {Module} from "@/interfaces";
import {Exam, ReadingExam, ListeningExam, WritingExam, SpeakingExam} from "@/interfaces/exam"; import {
Exam,
ReadingExam,
ListeningExam,
WritingExam,
SpeakingExam,
Exercise,
UserSolution,
FillBlanksExercise,
MatchSentencesExercise,
} from "@/interfaces/exam";
import axios from "axios"; import axios from "axios";
export const getExamById = async (module: Module, id: string): Promise<Exam | undefined> => { export const getExamById = async (module: Module, id: string): Promise<Exam | undefined> => {
@@ -21,3 +31,37 @@ export const getExamById = async (module: Module, id: string): Promise<Exam | un
return newExam as SpeakingExam; return newExam as SpeakingExam;
} }
}; };
export const defaultUserSolutions = (exercise: Exercise, exam: Exam): UserSolution => {
const defaultSettings = {
exam: exam.id,
exercise: exercise.id,
solutions: [],
module: exam.module,
type: exercise.type,
};
let total = 0;
switch (exercise.type) {
case "fillBlanks":
total = exercise.text.match(/({{\d+}})/g)?.length || 0;
return {...defaultSettings, score: {correct: 0, total, missing: total}};
case "matchSentences":
total = exercise.sentences.length;
return {...defaultSettings, score: {correct: 0, total, missing: total}};
case "multipleChoice":
total = exercise.questions.length;
return {...defaultSettings, score: {correct: 0, total, missing: total}};
case "writeBlanks":
total = exercise.text.match(/({{\d+}})/g)?.length || 0;
return {...defaultSettings, score: {correct: 0, total, missing: total}};
case "writing":
total = 1;
return {...defaultSettings, score: {correct: 0, total, missing: total}};
case "speaking":
total = 1;
return {...defaultSettings, score: {correct: 0, total, missing: total}};
default:
return {...defaultSettings, score: {correct: 0, total: 0, missing: 0}};
}
};

View File

@@ -0,0 +1,5 @@
export const preventNavigation = (navDisabled: Boolean, focusMode: Boolean): Boolean => {
if (navDisabled) return true;
if(focusMode) return true;
return false;
}

View File

@@ -4,18 +4,49 @@ type Type = "academic" | "general";
export const writingReverseMarking: {[key: number]: number} = { export const writingReverseMarking: {[key: number]: number} = {
9: 90, 9: 90,
8.5: 85,
8: 80, 8: 80,
7.5: 75,
7: 70, 7: 70,
6.5: 65,
6: 60, 6: 60,
5.5: 55,
5: 50, 5: 50,
4.5: 45,
4: 40, 4: 40,
3.5: 35,
3: 30, 3: 30,
2.5: 25,
2: 20, 2: 20,
1.5: 15,
1: 10, 1: 10,
0.5: 5,
0: 0, 0: 0,
}; };
const writingMarking: {[key: number]: number} = { export const speakingReverseMarking: {[key: number]: number} = {
9: 90,
8.5: 85,
8: 80,
7.5: 75,
7: 70,
6.5: 65,
6: 60,
5.5: 55,
5: 50,
4.5: 45,
4: 40,
3.5: 35,
3: 30,
2.5: 25,
2: 20,
1.5: 15,
1: 10,
0.5: 5,
0: 0,
};
export const writingMarking: {[key: number]: number} = {
90: 9, 90: 9,
80: 8, 80: 8,
70: 7, 70: 7,
@@ -76,8 +107,8 @@ const moduleMarkings: {[key in Module]: {[key in Type]: {[key: number]: number}}
general: writingMarking, general: writingMarking,
}, },
speaking: { speaking: {
academic: academicMarking, academic: writingMarking,
general: academicMarking, general: writingMarking,
}, },
}; };

View File

@@ -116,6 +116,7 @@ export const getExamsBySession = (stats: Stat[], session: string) => {
export const groupBySession = (stats: Stat[]) => groupBy(stats, "session"); export const groupBySession = (stats: Stat[]) => groupBy(stats, "session");
export const groupByDate = (stats: Stat[]) => groupBy(stats, "date"); export const groupByDate = (stats: Stat[]) => groupBy(stats, "date");
export const groupByModule = (stats: Stat[]) => groupBy(stats, "module");
export const convertToUserSolutions = (stats: Stat[]): UserSolution[] => { export const convertToUserSolutions = (stats: Stat[]): UserSolution[] => {
return stats.map((stat) => ({ return stats.map((stat) => ({

View File

@@ -7,6 +7,9 @@ module.exports = {
mti: { mti: {
orange: {DEFAULT: "#FF6000", dark: "#cc4402", light: "#ff790a", ultralight: "#ffdaa5"}, orange: {DEFAULT: "#FF6000", dark: "#cc4402", light: "#ff790a", ultralight: "#ffdaa5"},
green: {DEFAULT: "#307912", dark: "#2a6014", light: "#3d9f11", ultralight: "#c6edaf"}, green: {DEFAULT: "#307912", dark: "#2a6014", light: "#3d9f11", ultralight: "#c6edaf"},
purple: {DEFAULT: "#6A5FB1", dark: "#6354A1", light: "#7872BF", ultralight: "#D5D9F0"},
red: {DEFAULT: "#BB4747", dark: "#9D3838", light: "#CC5454", ultralight: "#F5D3D3"},
rose: {DEFAULT: "#D6352C", dark: "#B42921", light: "#EB5C54", ultralight: "#FCCFCC"},
blue: {DEFAULT: "#0696ff", dark: "#007ff8", light: "#1eb3ff", ultralight: "#b5edff"}, blue: {DEFAULT: "#0696ff", dark: "#007ff8", light: "#1eb3ff", ultralight: "#b5edff"},
white: {DEFAULT: "#ffffff", alt: "#FDFDFD"}, white: {DEFAULT: "#ffffff", alt: "#FDFDFD"},
gray: { gray: {

5400
yarn.lock

File diff suppressed because it is too large Load Diff