Compare commits
88 Commits
feature/di
...
feature-ge
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7962857a95 | ||
|
|
78c5b7027e | ||
|
|
cd71cf4833 | ||
|
|
93a5bcf40f | ||
|
|
dd0acbea61 | ||
|
|
ef736bc63e | ||
|
|
d9ca0e84a6 | ||
|
|
db54d58bab | ||
|
|
5099721b9b | ||
|
|
2c2fbffd8c | ||
|
|
3fee4292f1 | ||
|
|
7e9e28f134 | ||
|
|
d879f4afab | ||
|
|
d38ca76182 | ||
|
|
77692d270e | ||
|
|
f5c3abb310 | ||
|
|
02260d496c | ||
|
|
581adbb56e | ||
|
|
6ade34d243 | ||
|
|
16ea0b497e | ||
|
|
ea41875e36 | ||
|
|
eae0a4ae4e | ||
|
|
fea788bdc4 | ||
|
|
86c69e5993 | ||
|
|
f01794fed8 | ||
|
|
cc4b38fbbd | ||
|
|
121ac8ba4d | ||
|
|
2c10a203a5 | ||
|
|
6a2fab4f88 | ||
|
|
9637cb6477 | ||
|
|
ce90de1b74 | ||
|
|
49e24865a3 | ||
|
|
dceff807e9 | ||
|
|
3c4dba69db | ||
|
|
3fac92b54d | ||
|
|
139f527fdd | ||
|
|
93cef3d58f | ||
|
|
60b23ce1b5 | ||
|
|
d3a37eed3e | ||
|
|
447cecbf3f | ||
|
|
b2cc706a5e | ||
|
|
9cbb5b93c8 | ||
|
|
747c07f84e | ||
|
|
79ed521703 | ||
|
|
fe4a97ec85 | ||
|
|
b194a9183e | ||
|
|
f369234e8a | ||
|
|
808ec6315b | ||
|
|
d2cf50be68 | ||
|
|
294f00952e | ||
|
|
7beb1c84e7 | ||
|
|
3a7c29de56 | ||
|
|
dd357d991c | ||
|
|
47b1784615 | ||
|
|
d4156c83f4 | ||
|
|
572bc25eed | ||
|
|
e80b163b4a | ||
|
|
87e0610c79 | ||
|
|
52218ff8b8 | ||
|
|
84b0b8ac42 | ||
|
|
989a7449bf | ||
|
|
bc7eaea911 | ||
|
|
f5ec910010 | ||
|
|
2d46bad40f | ||
|
|
65ebdd7dde | ||
|
|
60217e9a66 | ||
|
|
ec3157870e | ||
|
|
9cf4bf7184 | ||
|
|
f5fc85e1a7 | ||
|
|
31f2eb510e | ||
|
|
31e2e56833 | ||
|
|
efaa32cd68 | ||
|
|
b41ee8e2ad | ||
|
|
e055b84688 | ||
|
|
1e286bb65b | ||
|
|
abe986313f | ||
|
|
088b77a66b | ||
|
|
72fc98fccd | ||
|
|
9ce45dfc30 | ||
|
|
e864e16064 | ||
|
|
6fe8a678ea | ||
|
|
b2232df0c7 | ||
|
|
9a7853bd05 | ||
|
|
1e8e95da34 | ||
|
|
4d37bf536a | ||
|
|
d0704e573b | ||
|
|
31dc29b812 | ||
|
|
9ed3672cb6 |
7
.dockerignore
Normal file
7
.dockerignore
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
Dockerfile
|
||||||
|
.dockerignore
|
||||||
|
node_modules
|
||||||
|
npm-debug.log
|
||||||
|
README.md
|
||||||
|
.next
|
||||||
|
.git
|
||||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -36,3 +36,5 @@ yarn-error.log*
|
|||||||
next-env.d.ts
|
next-env.d.ts
|
||||||
|
|
||||||
.env
|
.env
|
||||||
|
.yarn/*
|
||||||
|
.history*
|
||||||
5
.vscode/extensions.json
vendored
Normal file
5
.vscode/extensions.json
vendored
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"recommendations": [
|
||||||
|
"dbaeumer.vscode-eslint"
|
||||||
|
]
|
||||||
|
}
|
||||||
57
Dockerfile
Normal file
57
Dockerfile
Normal 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"]
|
||||||
@@ -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
810
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
11
package.json
11
package.json
@@ -19,10 +19,12 @@
|
|||||||
"axios": "^1.3.5",
|
"axios": "^1.3.5",
|
||||||
"chart.js": "^4.2.1",
|
"chart.js": "^4.2.1",
|
||||||
"clsx": "^1.2.1",
|
"clsx": "^1.2.1",
|
||||||
"daisyui": "^2.50.0",
|
"daisyui": "^3.1.5",
|
||||||
"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",
|
||||||
@@ -34,19 +36,24 @@
|
|||||||
"react-chartjs-2": "^5.2.0",
|
"react-chartjs-2": "^5.2.0",
|
||||||
"react-dom": "18.2.0",
|
"react-dom": "18.2.0",
|
||||||
"react-firebase-hooks": "^5.1.1",
|
"react-firebase-hooks": "^5.1.1",
|
||||||
|
"react-icons": "^4.8.0",
|
||||||
"react-lineto": "^3.3.0",
|
"react-lineto": "^3.3.0",
|
||||||
"react-media-recorder": "^1.6.6",
|
"react-media-recorder": "1.6.5",
|
||||||
"react-player": "^2.12.0",
|
"react-player": "^2.12.0",
|
||||||
"react-string-replace": "^1.1.0",
|
"react-string-replace": "^1.1.0",
|
||||||
"react-toastify": "^9.1.2",
|
"react-toastify": "^9.1.2",
|
||||||
|
"react-xarrows": "^2.0.2",
|
||||||
"swr": "^2.1.3",
|
"swr": "^2.1.3",
|
||||||
"typescript": "4.9.5",
|
"typescript": "4.9.5",
|
||||||
"uuid": "^9.0.0",
|
"uuid": "^9.0.0",
|
||||||
|
"wavesurfer.js": "^6.6.4",
|
||||||
"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",
|
||||||
"@wixc3/react-board": "^2.2.0",
|
"@wixc3/react-board": "^2.2.0",
|
||||||
"autoprefixer": "^10.4.13",
|
"autoprefixer": "^10.4.13",
|
||||||
"postcss": "^8.4.21",
|
"postcss": "^8.4.21",
|
||||||
|
|||||||
BIN
public/defaultAvatar.png
Normal file
BIN
public/defaultAvatar.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 5.2 KiB |
BIN
public/people-talking-tablet.png
Normal file
BIN
public/people-talking-tablet.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 832 KiB |
62
src/components/AbandonPopup.tsx
Normal file
62
src/components/AbandonPopup.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
56
src/components/BlankQuestionsModal.tsx
Normal file
56
src/components/BlankQuestionsModal.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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,14 +29,19 @@ 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 router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
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]));
|
||||||
|
|
||||||
@@ -47,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, 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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,95 +1,120 @@
|
|||||||
import {errorButtonStyle, infoButtonStyle} from "@/constants/buttonStyles";
|
|
||||||
import {FillBlanksExercise} from "@/interfaces/exam";
|
import {FillBlanksExercise} from "@/interfaces/exam";
|
||||||
import {Dialog, Transition} from "@headlessui/react";
|
import useExamStore from "@/stores/examStore";
|
||||||
import {mdiArrowLeft, mdiArrowRight} from "@mdi/js";
|
|
||||||
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 reactStringReplace from "react-string-replace";
|
import reactStringReplace from "react-string-replace";
|
||||||
import {CommonProps} from ".";
|
import {CommonProps} from ".";
|
||||||
|
import Button from "../Low/Button";
|
||||||
|
|
||||||
interface WordsPopoutProps {
|
interface WordsDrawerProps {
|
||||||
words: {word: string; isDisabled: boolean}[];
|
words: {word: string; isDisabled: boolean}[];
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
|
blankId?: string;
|
||||||
|
previouslySelectedWord?: string;
|
||||||
onCancel: () => void;
|
onCancel: () => void;
|
||||||
onAnswer: (answer: string) => void;
|
onAnswer: (answer: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function WordsPopout({words, isOpen, onCancel, onAnswer}: WordsPopoutProps) {
|
function WordsDrawer({words, isOpen, blankId, previouslySelectedWord, onCancel, onAnswer}: WordsDrawerProps) {
|
||||||
|
const [selectedWord, setSelectedWord] = useState<string | undefined>(previouslySelectedWord);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Transition appear show={isOpen} as={Fragment}>
|
<>
|
||||||
<Dialog as="div" className="relative z-10" onClose={onCancel}>
|
<div
|
||||||
<Transition.Child
|
className={clsx(
|
||||||
as={Fragment}
|
"w-full h-full absolute top-0 left-0 bg-gradient-to-t from-mti-black to-transparent z-10",
|
||||||
enter="ease-out duration-300"
|
isOpen ? "visible opacity-10" : "invisible opacity-0",
|
||||||
enterFrom="opacity-0"
|
)}
|
||||||
enterTo="opacity-100"
|
/>
|
||||||
leave="ease-in duration-200"
|
<div
|
||||||
leaveFrom="opacity-100"
|
className={clsx(
|
||||||
leaveTo="opacity-0">
|
"absolute w-full bg-white px-7 py-8 bottom-0 left-0 shadow-2xl rounded-2xl z-20 flex flex-col gap-8 transition-opacity duration-300 ease-in-out",
|
||||||
<div className="fixed inset-0 bg-black bg-opacity-25" />
|
isOpen ? "visible opacity-100" : "invisible opacity-0",
|
||||||
</Transition.Child>
|
)}>
|
||||||
|
<div className="w-full flex gap-2">
|
||||||
<div className="fixed inset-0 overflow-y-auto">
|
<div className="rounded-full w-6 h-6 flex items-center justify-center text-white bg-mti-purple-light">{blankId}</div>
|
||||||
<div className="flex min-h-full items-center justify-center p-4 text-center">
|
<span> Choose the correct word:</span>
|
||||||
<Transition.Child
|
|
||||||
as={Fragment}
|
|
||||||
enter="ease-out duration-300"
|
|
||||||
enterFrom="opacity-0 scale-95"
|
|
||||||
enterTo="opacity-100 scale-100"
|
|
||||||
leave="ease-in duration-200"
|
|
||||||
leaveFrom="opacity-100 scale-100"
|
|
||||||
leaveTo="opacity-0 scale-95">
|
|
||||||
<Dialog.Panel className="w-fit transform overflow-hidden rounded-2xl bg-white p-6 text-left align-middle shadow-xl transition-all flex flex-col">
|
|
||||||
<Dialog.Title as="h3" className="text-lg font-medium leading-6 text-gray-900">
|
|
||||||
List of words
|
|
||||||
</Dialog.Title>
|
|
||||||
<div className="mt-4 grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
|
||||||
{words.map((word) => (
|
|
||||||
<button
|
|
||||||
key={word.word}
|
|
||||||
onClick={() => onAnswer(word.word)}
|
|
||||||
disabled={word.isDisabled}
|
|
||||||
className={clsx("btn sm:btn-wide gap-4 relative text-white", infoButtonStyle)}>
|
|
||||||
{word.word}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-4 self-end">
|
|
||||||
<button onClick={onCancel} className={clsx("btn md:btn-wide gap-4 relative text-white", errorButtonStyle)}>
|
|
||||||
Close
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</Dialog.Panel>
|
|
||||||
</Transition.Child>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</Dialog>
|
<div className="grid grid-cols-6 gap-6">
|
||||||
</Transition>
|
{words.map(({word, isDisabled}) => (
|
||||||
|
<button
|
||||||
|
key={word}
|
||||||
|
onClick={() => setSelectedWord((prev) => (prev === word ? undefined : word))}
|
||||||
|
className={clsx(
|
||||||
|
"rounded-full py-3 text-center transition duration-300 ease-in-out",
|
||||||
|
selectedWord === word ? "text-white bg-mti-purple-light" : "bg-mti-purple-ultralight",
|
||||||
|
!isDisabled && "hover:text-white hover:bg-mti-purple",
|
||||||
|
"disabled:cursor-not-allowed disabled:text-mti-gray-dim",
|
||||||
|
)}
|
||||||
|
disabled={isDisabled}>
|
||||||
|
{word}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between w-full">
|
||||||
|
<Button color="purple" variant="outline" className="max-w-[200px] w-full" onClick={onCancel}>
|
||||||
|
Back
|
||||||
|
</Button>
|
||||||
|
<Button color="purple" className="max-w-[200px] w-full" onClick={() => onAnswer(selectedWord!)} disabled={!selectedWord}>
|
||||||
|
Confirm
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function FillBlanks({id, allowRepetition, type, prompt, solutions, text, words, onNext, onBack}: FillBlanksExercise & CommonProps) {
|
export default function FillBlanks({
|
||||||
const [userSolutions, setUserSolutions] = useState<{id: string; solution: string}[]>([]);
|
id,
|
||||||
|
allowRepetition,
|
||||||
|
type,
|
||||||
|
prompt,
|
||||||
|
solutions,
|
||||||
|
text,
|
||||||
|
words,
|
||||||
|
userSolutions,
|
||||||
|
onNext,
|
||||||
|
onBack,
|
||||||
|
}: FillBlanksExercise & CommonProps) {
|
||||||
|
const [answers, setAnswers] = useState<{id: string; solution: string}[]>(userSolutions);
|
||||||
const [currentBlankId, setCurrentBlankId] = useState<string>();
|
const [currentBlankId, setCurrentBlankId] = useState<string>();
|
||||||
|
const [isDrawerShowing, setIsDrawerShowing] = useState(false);
|
||||||
|
|
||||||
|
const hasExamEnded = useExamStore((state) => state.hasExamEnded);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setTimeout(() => setIsDrawerShowing(!!currentBlankId), 100);
|
||||||
|
}, [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 = userSolutions.filter((x) => solutions.find((y) => x.id === y.id)?.solution === x.solution.toLowerCase() || false).length;
|
const correct = answers.filter((x) => solutions.find((y) => x.id === y.id)?.solution === x.solution.toLowerCase() || false).length;
|
||||||
|
const missing = total - answers.filter((x) => solutions.find((y) => x.id === y.id)).length;
|
||||||
|
|
||||||
return {total, correct};
|
return {total, correct, missing};
|
||||||
};
|
};
|
||||||
|
|
||||||
const renderLines = (line: string) => {
|
const renderLines = (line: string) => {
|
||||||
return (
|
return (
|
||||||
<span>
|
<span className="text-base leading-5">
|
||||||
{reactStringReplace(line, /({{\d+}})/g, (match) => {
|
{reactStringReplace(line, /({{\d+}})/g, (match) => {
|
||||||
const id = match.replaceAll(/[\{\}]/g, "");
|
const id = match.replaceAll(/[\{\}]/g, "");
|
||||||
const userSolution = userSolutions.find((x) => x.id === id);
|
const userSolution = answers.find((x) => x.id === id);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button className="border-2 rounded-xl px-4 text-blue-400 border-blue-400 my-2" onClick={() => setCurrentBlankId(id)}>
|
<button
|
||||||
|
className={clsx(
|
||||||
|
"rounded-full hover:text-white hover:bg-mti-purple transition duration-300 ease-in-out my-1",
|
||||||
|
!userSolution && "w-6 h-6 text-center text-mti-purple-light bg-mti-purple-ultralight",
|
||||||
|
currentBlankId === id && "text-white !bg-mti-purple-light ",
|
||||||
|
userSolution && "px-5 py-2 text-center text-white bg-mti-purple-light",
|
||||||
|
)}
|
||||||
|
onClick={() => setCurrentBlankId(id)}>
|
||||||
{userSolution ? userSolution.solution : id}
|
{userSolution ? userSolution.solution : id}
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
@@ -100,17 +125,21 @@ export default function FillBlanks({id, allowRepetition, type, prompt, solutions
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4 mt-4 h-full mb-20">
|
||||||
<WordsPopout
|
{(!!currentBlankId || isDrawerShowing) && (
|
||||||
words={words.map((word) => ({word, isDisabled: allowRepetition ? false : userSolutions.map((x) => x.solution).includes(word)}))}
|
<WordsDrawer
|
||||||
isOpen={!!currentBlankId}
|
blankId={currentBlankId}
|
||||||
onCancel={() => setCurrentBlankId(undefined)}
|
words={words.map((word) => ({word, isDisabled: allowRepetition ? false : answers.map((x) => x.solution).includes(word)}))}
|
||||||
onAnswer={(solution: string) => {
|
previouslySelectedWord={currentBlankId ? answers.find((x) => x.id === currentBlankId)?.solution : undefined}
|
||||||
setUserSolutions((prev) => [...prev.filter((x) => x.id !== currentBlankId), {id: currentBlankId!, solution}]);
|
isOpen={isDrawerShowing}
|
||||||
setCurrentBlankId(undefined);
|
onCancel={() => setCurrentBlankId(undefined)}
|
||||||
}}
|
onAnswer={(solution: string) => {
|
||||||
/>
|
setAnswers((prev) => [...prev.filter((x) => x.id !== currentBlankId), {id: currentBlankId!, solution}]);
|
||||||
<span className="text-base md:text-lg font-medium text-center px-2 md:px-4 lg:px-48">
|
setCurrentBlankId(undefined);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<span className="text-sm w-full leading-6">
|
||||||
{prompt.split("\\n").map((line, index) => (
|
{prompt.split("\\n").map((line, index) => (
|
||||||
<Fragment key={index}>
|
<Fragment key={index}>
|
||||||
{line}
|
{line}
|
||||||
@@ -118,31 +147,31 @@ export default function FillBlanks({id, allowRepetition, type, prompt, solutions
|
|||||||
</Fragment>
|
</Fragment>
|
||||||
))}
|
))}
|
||||||
</span>
|
</span>
|
||||||
<span>
|
<span className="bg-mti-gray-smoke rounded-xl px-5 py-6">
|
||||||
{text.split("\\n").map((line, index) => (
|
{text.split("\\n").map((line, index) => (
|
||||||
<Fragment key={index}>
|
<p key={index}>
|
||||||
{renderLines(line)}
|
{renderLines(line)}
|
||||||
<br />
|
<br />
|
||||||
</Fragment>
|
</p>
|
||||||
))}
|
))}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="self-end flex flex-col-reverse items-center w-full md:justify-between md:items-start md:flex-row gap-8">
|
<div className="self-end flex justify-between w-full gap-8 absolute bottom-8 left-0 px-8">
|
||||||
<button className={clsx("btn btn-wide gap-4 relative text-white", errorButtonStyle)} onClick={onBack}>
|
<Button
|
||||||
<div className="absolute left-4">
|
color="purple"
|
||||||
<Icon path={mdiArrowLeft} color="white" size={1} />
|
variant="outline"
|
||||||
</div>
|
onClick={() => onBack({exercise: id, solutions: answers, score: calculateScore(), type})}
|
||||||
|
className="max-w-[200px] w-full">
|
||||||
Back
|
Back
|
||||||
</button>
|
</Button>
|
||||||
<button
|
|
||||||
className={clsx("btn btn-wide gap-4 relative text-white", infoButtonStyle)}
|
<Button
|
||||||
onClick={() => onNext({exercise: id, solutions: userSolutions, score: calculateScore(), type})}>
|
color="purple"
|
||||||
|
onClick={() => onNext({exercise: id, solutions: answers, score: calculateScore(), type})}
|
||||||
|
className="max-w-[200px] self-end w-full">
|
||||||
Next
|
Next
|
||||||
<div className="absolute right-4">
|
</Button>
|
||||||
<Icon path={mdiArrowRight} color="white" size={1} />
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -3,35 +3,42 @@ 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 Xarrow from "react-xarrows";
|
||||||
|
import useExamStore from "@/stores/examStore";
|
||||||
|
|
||||||
export default function MatchSentences({id, options, type, prompt, sentences, onNext, onBack}: MatchSentencesExercise & CommonProps) {
|
export default function MatchSentences({id, options, type, prompt, sentences, userSolutions, onNext, onBack}: MatchSentencesExercise & CommonProps) {
|
||||||
const [selectedQuestion, setSelectedQuestion] = useState<string>();
|
const [selectedQuestion, setSelectedQuestion] = useState<string>();
|
||||||
const [userSolutions, setUserSolutions] = useState<{question: string; option: string}[]>([]);
|
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 = userSolutions.filter((x) => sentences.find((y) => y.id === x.question)?.solution === x.option || false).length;
|
const correct = answers.filter((x) => sentences.find((y) => y.id === x.question)?.solution === x.option || false).length;
|
||||||
|
const missing = total - answers.filter((x) => sentences.find((y) => y.id === x.question)).length;
|
||||||
|
|
||||||
return {total, correct};
|
return {total, correct, missing};
|
||||||
};
|
};
|
||||||
|
|
||||||
const selectOption = (option: string) => {
|
const selectOption = (option: string) => {
|
||||||
if (!selectedQuestion) return;
|
if (!selectedQuestion) return;
|
||||||
setUserSolutions((prev) => [...prev.filter((x) => x.question !== selectedQuestion), {question: selectedQuestion, option}]);
|
setAnswers((prev) => [...prev.filter((x) => x.question !== selectedQuestion), {question: selectedQuestion, option}]);
|
||||||
setSelectedQuestion(undefined);
|
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 (
|
||||||
<>
|
<>
|
||||||
<div className="flex flex-col items-center gap-8">
|
<div className="flex flex-col gap-4 mt-4 h-full mb-20">
|
||||||
<span className="text-base md:text-lg font-medium text-center px-2 md:px-4 lg:px-48">
|
<span className="text-sm w-full leading-6">
|
||||||
{prompt.split("\\n").map((line, index) => (
|
{prompt.split("\\n").map((line, index) => (
|
||||||
<Fragment key={index}>
|
<Fragment key={index}>
|
||||||
{line}
|
{line}
|
||||||
@@ -39,74 +46,63 @@ export default function MatchSentences({id, options, type, prompt, sentences, on
|
|||||||
</Fragment>
|
</Fragment>
|
||||||
))}
|
))}
|
||||||
</span>
|
</span>
|
||||||
<div className="grid grid-cols-2 gap-16 place-items-center">
|
<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-1">
|
<div className="flex flex-col gap-4">
|
||||||
{sentences.map(({sentence, id, color}) => (
|
{sentences.map(({sentence, id}) => (
|
||||||
<div
|
<div key={`question_${id}`} className="flex items-center justify-end gap-2 cursor-pointer">
|
||||||
key={`question_${id}`}
|
<span>{sentence} </span>
|
||||||
className="flex items-center justify-end gap-2 cursor-pointer"
|
<button
|
||||||
onClick={() => setSelectedQuestion((prev) => (prev === id ? undefined : id))}>
|
id={id}
|
||||||
<span>
|
onClick={() => setSelectedQuestion((prev) => (prev === id ? undefined : id))}
|
||||||
<span className="font-semibold">{id}.</span> {sentence}{" "}
|
className={clsx(
|
||||||
</span>
|
"bg-mti-purple-ultralight text-mti-purple hover:text-white hover:bg-mti-purple w-8 h-8 rounded-full z-10",
|
||||||
<div
|
"transition duration-300 ease-in-out",
|
||||||
style={{borderColor: color, backgroundColor: selectedQuestion === id ? color : "transparent"}}
|
selectedQuestion === id && "!text-white !bg-mti-purple",
|
||||||
className={clsx("border-2 border-blue-500 w-4 h-4 rounded-full", id)}
|
id,
|
||||||
/>
|
)}>
|
||||||
|
{id}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-4">
|
||||||
{options.map(({sentence, id}) => (
|
{options.map(({sentence, id}) => (
|
||||||
<div
|
<div key={`answer_${id}`} className={clsx("flex items-center justify-start gap-2 cursor-pointer")}>
|
||||||
key={`answer_${id}`}
|
<button
|
||||||
className={clsx("flex items-center justify-start gap-2 cursor-pointer")}
|
id={id}
|
||||||
onClick={() => selectOption(id)}>
|
onClick={() => selectOption(id)}
|
||||||
<div
|
className={clsx(
|
||||||
style={
|
"bg-mti-purple-ultralight text-mti-purple hover:text-white hover:bg-mti-purple w-8 h-8 rounded-full z-10",
|
||||||
userSolutions.find((x) => x.option === id)
|
"transition duration-300 ease-in-out",
|
||||||
? {
|
id,
|
||||||
border: `2px solid ${getSentenceColor(userSolutions.find((x) => x.option === id)!.question)}`,
|
)}>
|
||||||
}
|
{id}
|
||||||
: {}
|
</button>
|
||||||
}
|
<span>{sentence}</span>
|
||||||
className={clsx("border-2 border-green-500 bg-transparent w-4 h-4 rounded-full", id)}
|
|
||||||
/>
|
|
||||||
<span>
|
|
||||||
<span className="font-semibold">{id}.</span> {sentence}{" "}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
{userSolutions.map((solution, index) => (
|
{answers.map((solution, index) => (
|
||||||
<div key={`solution_${index}`} className="absolute">
|
<Xarrow key={index} start={solution.question} end={solution.option} lineColor="#7872BF" showHead={false} />
|
||||||
<LineTo
|
|
||||||
className="rounded-full"
|
|
||||||
from={solution.question}
|
|
||||||
to={solution.option}
|
|
||||||
borderColor={sentences.find((x) => x.id === solution.question)!.color}
|
|
||||||
borderWidth={5}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="self-end flex flex-col-reverse items-center w-full md:justify-between md:items-start md:flex-row gap-8">
|
<div className="self-end flex justify-between w-full gap-8 absolute bottom-8 left-0 px-8">
|
||||||
<button className={clsx("btn btn-wide gap-4 relative text-white", errorButtonStyle)} onClick={onBack}>
|
<Button
|
||||||
<div className="absolute left-4">
|
color="purple"
|
||||||
<Icon path={mdiArrowLeft} color="white" size={1} />
|
variant="outline"
|
||||||
</div>
|
onClick={() => onBack({exercise: id, solutions: answers, score: calculateScore(), type})}
|
||||||
|
className="max-w-[200px] w-full">
|
||||||
Back
|
Back
|
||||||
</button>
|
</Button>
|
||||||
<button
|
|
||||||
className={clsx("btn btn-wide gap-4 relative text-white", infoButtonStyle)}
|
<Button
|
||||||
onClick={() => onNext({exercise: id, solutions: userSolutions, score: calculateScore(), type})}>
|
color="purple"
|
||||||
|
onClick={() => onNext({exercise: id, solutions: answers, score: calculateScore(), type})}
|
||||||
|
className="max-w-[200px] self-end w-full">
|
||||||
Next
|
Next
|
||||||
<div className="absolute right-4">
|
</Button>
|
||||||
<Icon path={mdiArrowRight} color="white" size={1} />
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,71 +1,33 @@
|
|||||||
/* eslint-disable @next/next/no-img-element */
|
/* eslint-disable @next/next/no-img-element */
|
||||||
import {errorButtonStyle, infoButtonStyle} from "@/constants/buttonStyles";
|
|
||||||
import {MultipleChoiceExercise, MultipleChoiceQuestion} from "@/interfaces/exam";
|
import {MultipleChoiceExercise, MultipleChoiceQuestion} from "@/interfaces/exam";
|
||||||
import {mdiArrowLeft, mdiArrowRight, mdiCheck, mdiClose} from "@mdi/js";
|
import useExamStore from "@/stores/examStore";
|
||||||
import Icon from "@mdi/react";
|
|
||||||
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";
|
||||||
|
|
||||||
function Question({
|
function Question({
|
||||||
variant,
|
variant,
|
||||||
prompt,
|
prompt,
|
||||||
solution,
|
|
||||||
options,
|
options,
|
||||||
userSolution,
|
userSolution,
|
||||||
onSelectOption,
|
onSelectOption,
|
||||||
showSolution = false,
|
|
||||||
}: MultipleChoiceQuestion & {userSolution: string | undefined; onSelectOption?: (option: string) => void; showSolution?: boolean}) {
|
}: MultipleChoiceQuestion & {userSolution: string | undefined; onSelectOption?: (option: string) => void; showSolution?: boolean}) {
|
||||||
const optionColor = (option: string) => {
|
|
||||||
if (!showSolution) {
|
|
||||||
return userSolution === option ? "border-blue-400" : "";
|
|
||||||
}
|
|
||||||
|
|
||||||
if (option === solution) {
|
|
||||||
return "border-green-500 text-green-500";
|
|
||||||
}
|
|
||||||
|
|
||||||
return userSolution === option ? "border-red-500 text-red-500" : "";
|
|
||||||
};
|
|
||||||
|
|
||||||
const optionBadge = (option: string) => {
|
|
||||||
if (option === userSolution) {
|
|
||||||
if (solution === option) {
|
|
||||||
return (
|
|
||||||
<div className="badge badge-lg bg-green-500 border-green-500 absolute -top-2 -right-4">
|
|
||||||
<div className="tooltip" data-tip="You have correctly answered!">
|
|
||||||
<Icon path={mdiCheck} color="white" size={0.8} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="badge badge-lg bg-red-500 border-red-500 absolute -top-2 -right-4">
|
|
||||||
<div className="tooltip" data-tip="You have wrongly answered!">
|
|
||||||
<Icon path={mdiClose} color="white" size={0.8} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center gap-4">
|
<div className="flex flex-col gap-10">
|
||||||
<span className="text-center">{prompt}</span>
|
<span className="">{prompt}</span>
|
||||||
<div className="grid grid-rows-4 md:grid-rows-1 md:grid-cols-4 gap-4 place-items-center">
|
<div className="flex justify-between">
|
||||||
{variant === "image" &&
|
{variant === "image" &&
|
||||||
options.map((option) => (
|
options.map((option) => (
|
||||||
<div
|
<div
|
||||||
key={option.id}
|
key={option.id}
|
||||||
onClick={() => (onSelectOption ? onSelectOption(option.id) : null)}
|
onClick={() => (onSelectOption ? onSelectOption(option.id) : null)}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"flex flex-col items-center border-2 p-4 rounded-xl gap-4 cursor-pointer bg-white relative",
|
"flex flex-col items-center border border-mti-gray-platinum p-4 px-8 rounded-xl gap-4 cursor-pointer bg-white relative",
|
||||||
optionColor(option.id),
|
userSolution === option.id && "border-mti-purple-light",
|
||||||
)}>
|
)}>
|
||||||
{showSolution && optionBadge(option.id)}
|
<span className={clsx("text-sm", userSolution !== option.id && "opacity-50")}>{option.id}</span>
|
||||||
<img src={option.src!} alt={`Option ${option.id}`} />
|
<img src={option.src!} alt={`Option ${option.id}`} />
|
||||||
<span>{option.id}</span>
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
{variant === "text" &&
|
{variant === "text" &&
|
||||||
@@ -73,8 +35,11 @@ function Question({
|
|||||||
<div
|
<div
|
||||||
key={option.id}
|
key={option.id}
|
||||||
onClick={() => (onSelectOption ? onSelectOption(option.id) : null)}
|
onClick={() => (onSelectOption ? onSelectOption(option.id) : null)}
|
||||||
className={clsx("flex border-2 p-4 rounded-xl gap-2 cursor-pointer bg-white", optionColor(option.id))}>
|
className={clsx(
|
||||||
<span className="font-bold">{option.id}.</span>
|
"flex border p-4 rounded-xl gap-2 cursor-pointer bg-white text-sm",
|
||||||
|
userSolution === option.id && "border-mti-purple-light",
|
||||||
|
)}>
|
||||||
|
<span className="font-semibold">{option.id}.</span>
|
||||||
<span>{option.text}</span>
|
<span>{option.text}</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -83,25 +48,33 @@ function Question({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function MultipleChoice({id, prompt, type, questions, onNext, onBack}: MultipleChoiceExercise & CommonProps) {
|
export default function MultipleChoice({id, prompt, type, questions, userSolutions, onNext, onBack}: MultipleChoiceExercise & CommonProps) {
|
||||||
const [userSolutions, setUserSolutions] = useState<{question: string; option: string}[]>([]);
|
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];
|
||||||
setUserSolutions((prev) => [...prev.filter((x) => x.question !== question.id), {option, question: question.id}]);
|
setAnswers((prev) => [...prev.filter((x) => x.question !== question.id), {option, question: question.id}]);
|
||||||
};
|
};
|
||||||
|
|
||||||
const calculateScore = () => {
|
const calculateScore = () => {
|
||||||
const total = questions.length;
|
const total = questions.length;
|
||||||
const correct = userSolutions.filter((x) => questions.find((y) => y.id === x.question)?.solution === x.option || false).length;
|
const correct = answers.filter((x) => questions.find((y) => y.id === x.question)?.solution === x.option || false).length;
|
||||||
|
const missing = total - answers.filter((x) => questions.find((y) => y.id === x.question)).length;
|
||||||
|
|
||||||
return {total, correct};
|
return {total, correct, missing};
|
||||||
};
|
};
|
||||||
|
|
||||||
const next = () => {
|
const next = () => {
|
||||||
if (questionIndex === questions.length - 1) {
|
if (questionIndex === questions.length - 1) {
|
||||||
onNext({exercise: id, solutions: userSolutions, score: calculateScore(), type});
|
onNext({exercise: id, solutions: answers, score: calculateScore(), type});
|
||||||
} else {
|
} else {
|
||||||
setQuestionIndex((prev) => prev + 1);
|
setQuestionIndex((prev) => prev + 1);
|
||||||
}
|
}
|
||||||
@@ -109,7 +82,7 @@ export default function MultipleChoice({id, prompt, type, questions, onNext, onB
|
|||||||
|
|
||||||
const back = () => {
|
const back = () => {
|
||||||
if (questionIndex === 0) {
|
if (questionIndex === 0) {
|
||||||
onBack();
|
onBack({exercise: id, solutions: answers, score: calculateScore(), type});
|
||||||
} else {
|
} else {
|
||||||
setQuestionIndex((prev) => prev - 1);
|
setQuestionIndex((prev) => prev - 1);
|
||||||
}
|
}
|
||||||
@@ -117,30 +90,25 @@ export default function MultipleChoice({id, prompt, type, questions, onNext, onB
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="flex flex-col items-center gap-4">
|
<div className="flex flex-col gap-2 mt-4 h-full mb-20 bg-mti-gray-smoke rounded-xl px-16 py-8">
|
||||||
<span className="text-base md:text-lg font-medium text-center px-2 md:px-4 lg:px-48">{prompt}</span>
|
<span className="text-xl font-semibold">{prompt}</span>
|
||||||
{questionIndex < questions.length && (
|
{questionIndex < questions.length && (
|
||||||
<Question
|
<Question
|
||||||
{...questions[questionIndex]}
|
{...questions[questionIndex]}
|
||||||
userSolution={userSolutions.find((x) => questions[questionIndex].id === x.question)?.option}
|
userSolution={answers.find((x) => questions[questionIndex].id === x.question)?.option}
|
||||||
onSelectOption={onSelectOption}
|
onSelectOption={onSelectOption}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="self-end flex flex-col-reverse items-center w-full md:justify-between md:items-start md:flex-row gap-8">
|
<div className="self-end flex justify-between w-full gap-8 absolute bottom-8 left-0 px-8">
|
||||||
<button className={clsx("btn btn-wide gap-4 relative text-white", errorButtonStyle)} onClick={back}>
|
<Button color="purple" variant="outline" onClick={back} className="max-w-[200px] w-full">
|
||||||
<div className="absolute left-4">
|
|
||||||
<Icon path={mdiArrowLeft} color="white" size={1} />
|
|
||||||
</div>
|
|
||||||
Back
|
Back
|
||||||
</button>
|
</Button>
|
||||||
<button className={clsx("btn btn-wide gap-4 relative text-white", infoButtonStyle)} onClick={next}>
|
|
||||||
|
<Button color="purple" onClick={next} className="max-w-[200px] self-end w-full">
|
||||||
Next
|
Next
|
||||||
<div className="absolute right-4">
|
</Button>
|
||||||
<Icon path={mdiArrowRight} color="white" size={1} />
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,52 +1,222 @@
|
|||||||
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 dynamic from "next/dynamic";
|
||||||
|
import Button from "../Low/Button";
|
||||||
|
import useExamStore from "@/stores/examStore";
|
||||||
|
|
||||||
|
const Waveform = dynamic(() => import("../Waveform"), {ssr: false});
|
||||||
|
const ReactMediaRecorder = dynamic(() => import("react-media-recorder").then((mod) => mod.ReactMediaRecorder), {
|
||||||
|
ssr: false,
|
||||||
|
});
|
||||||
|
|
||||||
export default function Speaking({id, title, text, type, prompts, onNext, onBack}: SpeakingExercise & CommonProps) {
|
export default function Speaking({id, title, text, type, prompts, onNext, onBack}: SpeakingExercise & CommonProps) {
|
||||||
|
const [recordingDuration, setRecordingDuration] = useState(0);
|
||||||
|
const [isRecording, setIsRecording] = useState(false);
|
||||||
|
const [mediaBlob, setMediaBlob] = useState<string>();
|
||||||
|
|
||||||
|
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(() => {
|
||||||
|
let recordingInterval: NodeJS.Timer | undefined = undefined;
|
||||||
|
if (isRecording) {
|
||||||
|
recordingInterval = setInterval(() => setRecordingDuration((prev) => prev + 1), 1000);
|
||||||
|
} else if (recordingInterval) {
|
||||||
|
clearInterval(recordingInterval);
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (recordingInterval) clearInterval(recordingInterval);
|
||||||
|
};
|
||||||
|
}, [isRecording]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full w-2/3 items-center justify-center gap-8">
|
<div className="flex flex-col h-full w-full gap-9">
|
||||||
<div className="flex flex-col max-w-2xl gap-4">
|
<div className="flex flex-col w-full gap-14 bg-mti-gray-smoke rounded-xl py-8 pb-12 px-16">
|
||||||
<span className="font-bold">{title}</span>
|
<div className="flex flex-col gap-3">
|
||||||
<span className="font-regular ml-8">
|
<span className="font-semibold">{title}</span>
|
||||||
{text.split("\\n").map((line, index) => (
|
<span className="font-regular">
|
||||||
<Fragment key={index}>
|
{text.split("\\n").map((line, index) => (
|
||||||
<span>{line}</span>
|
<Fragment key={index}>
|
||||||
<br />
|
<span>{line}</span>
|
||||||
</Fragment>
|
<br />
|
||||||
))}
|
</Fragment>
|
||||||
</span>
|
|
||||||
<div className="flex gap-8">
|
|
||||||
<span>You should talk about the following things:</span>
|
|
||||||
<div className="flex flex-col gap-4">
|
|
||||||
{prompts.map((x, index) => (
|
|
||||||
<li className="italic" key={index}>
|
|
||||||
{x}
|
|
||||||
</li>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
{prompts && prompts.length > 0 && (
|
||||||
|
<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>
|
||||||
|
|
||||||
<div className="self-end flex flex-col-reverse items-center w-full md:justify-between md:items-start md:flex-row gap-8">
|
<ReactMediaRecorder
|
||||||
<button className={clsx("btn btn-wide gap-4 relative text-white", errorButtonStyle)} onClick={onBack}>
|
audio
|
||||||
<div className="absolute left-4">
|
onStop={(blob) => setMediaBlob(blob)}
|
||||||
<Icon path={mdiArrowLeft} color="white" size={1} />
|
render={({status, startRecording, stopRecording, pauseRecording, resumeRecording, clearBlobUrl, mediaBlobUrl}) => (
|
||||||
|
<div className="w-full p-4 px-8 bg-transparent border-2 border-mti-gray-platinum rounded-2xl flex-col gap-8 items-center">
|
||||||
|
<p className="text-base font-normal">Record your answer:</p>
|
||||||
|
<div className="flex gap-8 items-center justify-center py-8">
|
||||||
|
{status === "idle" && (
|
||||||
|
<>
|
||||||
|
<div className="w-full h-2 max-w-4xl bg-mti-gray-smoke rounded-full" />
|
||||||
|
{status === "idle" && (
|
||||||
|
<BsMicFill
|
||||||
|
onClick={() => {
|
||||||
|
setRecordingDuration(0);
|
||||||
|
startRecording();
|
||||||
|
setIsRecording(true);
|
||||||
|
}}
|
||||||
|
className="h-5 w-5 text-mti-gray-cool cursor-pointer"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{status === "recording" && (
|
||||||
|
<>
|
||||||
|
<div className="flex gap-4 items-center">
|
||||||
|
<span className="text-xs w-9">
|
||||||
|
{Math.floor(recordingDuration / 60)
|
||||||
|
.toString(10)
|
||||||
|
.padStart(2, "0")}
|
||||||
|
:
|
||||||
|
{Math.floor(recordingDuration % 60)
|
||||||
|
.toString(10)
|
||||||
|
.padStart(2, "0")}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="w-full h-2 max-w-4xl bg-mti-gray-smoke rounded-full" />
|
||||||
|
<div className="flex gap-4 items-center">
|
||||||
|
<BsPauseCircle
|
||||||
|
onClick={() => {
|
||||||
|
setIsRecording(false);
|
||||||
|
pauseRecording();
|
||||||
|
}}
|
||||||
|
className="text-red-500 w-8 h-8 cursor-pointer"
|
||||||
|
/>
|
||||||
|
<BsCheckCircleFill
|
||||||
|
onClick={() => {
|
||||||
|
setIsRecording(false);
|
||||||
|
stopRecording();
|
||||||
|
}}
|
||||||
|
className="text-mti-purple-light w-8 h-8 cursor-pointer"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{status === "paused" && (
|
||||||
|
<>
|
||||||
|
<div className="flex gap-4 items-center">
|
||||||
|
<span className="text-xs w-9">
|
||||||
|
{Math.floor(recordingDuration / 60)
|
||||||
|
.toString(10)
|
||||||
|
.padStart(2, "0")}
|
||||||
|
:
|
||||||
|
{Math.floor(recordingDuration % 60)
|
||||||
|
.toString(10)
|
||||||
|
.padStart(2, "0")}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="w-full h-2 max-w-4xl bg-mti-gray-smoke rounded-full" />
|
||||||
|
<div className="flex gap-4 items-center">
|
||||||
|
<BsPlayCircle
|
||||||
|
onClick={() => {
|
||||||
|
setIsRecording(true);
|
||||||
|
resumeRecording();
|
||||||
|
}}
|
||||||
|
className="text-mti-purple-light w-8 h-8 cursor-pointer"
|
||||||
|
/>
|
||||||
|
<BsCheckCircleFill
|
||||||
|
onClick={() => {
|
||||||
|
setIsRecording(false);
|
||||||
|
stopRecording();
|
||||||
|
}}
|
||||||
|
className="text-mti-purple-light w-8 h-8 cursor-pointer"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{status === "stopped" && mediaBlobUrl && (
|
||||||
|
<>
|
||||||
|
<Waveform audio={mediaBlobUrl} waveColor="#FCDDEC" progressColor="#EF5DA8" />
|
||||||
|
<div className="flex gap-4 items-center">
|
||||||
|
<BsTrashFill
|
||||||
|
className="text-mti-gray-cool cursor-pointer w-5 h-5"
|
||||||
|
onClick={() => {
|
||||||
|
setRecordingDuration(0);
|
||||||
|
clearBlobUrl();
|
||||||
|
setMediaBlob(undefined);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<BsMicFill
|
||||||
|
onClick={() => {
|
||||||
|
clearBlobUrl();
|
||||||
|
setRecordingDuration(0);
|
||||||
|
startRecording();
|
||||||
|
setIsRecording(true);
|
||||||
|
setMediaBlob(undefined);
|
||||||
|
}}
|
||||||
|
className="h-5 w-5 text-mti-gray-cool cursor-pointer"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="self-end flex justify-between w-full gap-8">
|
||||||
|
<Button
|
||||||
|
color="purple"
|
||||||
|
variant="outline"
|
||||||
|
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">
|
||||||
Back
|
Back
|
||||||
</button>
|
</Button>
|
||||||
<button
|
<Button
|
||||||
className={clsx("btn btn-wide gap-4 relative text-white", infoButtonStyle)}
|
color="purple"
|
||||||
onClick={() => onNext({exercise: id, solutions: [], score: {correct: 1, total: 1}, type})}>
|
disabled={!mediaBlob}
|
||||||
|
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">
|
||||||
Next
|
Next
|
||||||
<div className="absolute right-4">
|
</Button>
|
||||||
<Icon path={mdiArrowRight} color="white" size={1} />
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
111
src/components/Exercises/TrueFalse.tsx
Normal file
111
src/components/Exercises/TrueFalse.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,14 +3,17 @@ import {WriteBlanksExercise} 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 {useEffect, useState} from "react";
|
import {Fragment, useEffect, useState} from "react";
|
||||||
import reactStringReplace from "react-string-replace";
|
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 useExamStore from "@/stores/examStore";
|
||||||
|
|
||||||
function Blank({
|
function Blank({
|
||||||
id,
|
id,
|
||||||
maxWords,
|
maxWords,
|
||||||
|
userSolution,
|
||||||
showSolutions = false,
|
showSolutions = false,
|
||||||
setUserSolution,
|
setUserSolution,
|
||||||
}: {
|
}: {
|
||||||
@@ -19,9 +22,9 @@ function Blank({
|
|||||||
userSolution?: string;
|
userSolution?: string;
|
||||||
maxWords: number;
|
maxWords: number;
|
||||||
showSolutions?: boolean;
|
showSolutions?: boolean;
|
||||||
setUserSolution?: (solution: string) => void;
|
setUserSolution: (solution: string) => void;
|
||||||
}) {
|
}) {
|
||||||
const [userInput, setUserInput] = useState("");
|
const [userInput, setUserInput] = useState(userSolution || "");
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const words = userInput.split(" ").filter((x) => x !== "");
|
const words = userInput.split(" ").filter((x) => x !== "");
|
||||||
@@ -33,39 +36,48 @@ function Blank({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<input
|
<input
|
||||||
className={clsx("input border rounded-xl px-2 py-1 bg-white text-blue-400 border-blue-400 my-2")}
|
className="py-2 px-3 rounded-2xl w-48 bg-white focus:outline-none my-2"
|
||||||
placeholder={id}
|
placeholder={id}
|
||||||
onChange={(e) => setUserInput(e.target.value)}
|
onChange={(e) => setUserInput(e.target.value)}
|
||||||
|
onBlur={() => setUserSolution(userInput)}
|
||||||
value={userInput}
|
value={userInput}
|
||||||
contentEditable={showSolutions}
|
contentEditable={showSolutions}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function WriteBlanks({id, prompt, type, maxWords, solutions, text, onNext, onBack}: WriteBlanksExercise & CommonProps) {
|
export default function WriteBlanks({id, prompt, type, maxWords, solutions, userSolutions, text, onNext, onBack}: WriteBlanksExercise & CommonProps) {
|
||||||
const [userSolutions, setUserSolutions] = useState<{id: string; solution: string}[]>([]);
|
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 = userSolutions.filter(
|
const correct = answers.filter(
|
||||||
(x) =>
|
(x) =>
|
||||||
solutions
|
solutions
|
||||||
.find((y) => x.id === y.id)
|
.find((y) => x.id === y.id)
|
||||||
?.solution.map((y) => y.toLowerCase())
|
?.solution.map((y) => y.toLowerCase())
|
||||||
.includes(x.solution.toLowerCase()) || false,
|
.includes(x.solution.toLowerCase()) || false,
|
||||||
).length;
|
).length;
|
||||||
|
const missing = total - answers.filter((x) => solutions.find((y) => x.id === y.id)).length;
|
||||||
|
|
||||||
return {total, correct};
|
return {total, correct, missing};
|
||||||
};
|
};
|
||||||
|
|
||||||
const renderLines = (line: string) => {
|
const renderLines = (line: string) => {
|
||||||
return (
|
return (
|
||||||
<span>
|
<span className="text-base leading-5">
|
||||||
{reactStringReplace(line, /({{\d+}})/g, (match) => {
|
{reactStringReplace(line, /({{\d+}})/g, (match) => {
|
||||||
const id = match.replaceAll(/[\{\}]/g, "");
|
const id = match.replaceAll(/[\{\}]/g, "");
|
||||||
const userSolution = userSolutions.find((x) => x.id === id);
|
const userSolution = answers.find((x) => x.id === id);
|
||||||
const setUserSolution = (solution: string) => {
|
const setUserSolution = (solution: string) => {
|
||||||
setUserSolutions((prev) => [...prev.filter((x) => x.id !== id), {id, solution}]);
|
setAnswers((prev) => [...prev.filter((x) => x.id !== id), {id, solution}]);
|
||||||
};
|
};
|
||||||
|
|
||||||
return <Blank userSolution={userSolution?.solution} maxWords={maxWords} id={id} setUserSolution={setUserSolution} />;
|
return <Blank userSolution={userSolution?.solution} maxWords={maxWords} id={id} setUserSolution={setUserSolution} />;
|
||||||
@@ -76,33 +88,40 @@ export default function WriteBlanks({id, prompt, type, maxWords, solutions, text
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col gap-4 mt-4 h-full mb-20">
|
||||||
<span className="text-lg font-medium text-center px-48">{prompt}</span>
|
<span className="text-sm w-full leading-6">
|
||||||
<span>
|
{prompt.split("\\n").map((line, index) => (
|
||||||
{text.split("\\n").map((line) => (
|
<Fragment key={index}>
|
||||||
<>
|
{line}
|
||||||
|
<br />
|
||||||
|
</Fragment>
|
||||||
|
))}
|
||||||
|
</span>
|
||||||
|
<span className="bg-mti-gray-smoke rounded-xl px-5 py-6">
|
||||||
|
{text.split("\\n").map((line, index) => (
|
||||||
|
<p key={index}>
|
||||||
{renderLines(line)}
|
{renderLines(line)}
|
||||||
<br />
|
<br />
|
||||||
</>
|
</p>
|
||||||
))}
|
))}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="self-end flex flex-col-reverse items-center w-full md:justify-between md:items-start md:flex-row gap-8">
|
<div className="self-end flex justify-between w-full gap-8 absolute bottom-8 left-0 px-8">
|
||||||
<button className={clsx("btn btn-wide gap-4 relative text-white", errorButtonStyle)} onClick={onBack}>
|
<Button
|
||||||
<div className="absolute left-4">
|
color="purple"
|
||||||
<Icon path={mdiArrowLeft} color="white" size={1} />
|
variant="outline"
|
||||||
</div>
|
onClick={() => onBack({exercise: id, solutions: answers, score: calculateScore(), type})}
|
||||||
|
className="max-w-[200px] w-full">
|
||||||
Back
|
Back
|
||||||
</button>
|
</Button>
|
||||||
<button
|
|
||||||
className={clsx("btn btn-wide gap-4 relative text-white", infoButtonStyle)}
|
<Button
|
||||||
onClick={() => onNext({exercise: id, solutions: userSolutions, score: calculateScore(), type})}>
|
color="purple"
|
||||||
|
onClick={() => onNext({exercise: id, solutions: answers, score: calculateScore(), type})}
|
||||||
|
className="max-w-[200px] self-end w-full">
|
||||||
Next
|
Next
|
||||||
<div className="absolute right-4">
|
</Button>
|
||||||
<Icon path={mdiArrowRight} color="white" size={1} />
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,17 +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 {Dialog, Transition} from "@headlessui/react";
|
||||||
|
import useExamStore from "@/stores/examStore";
|
||||||
|
|
||||||
export default function Writing({id, prompt, info, type, wordCounter, attachment, onNext, onBack}: WritingExercise & CommonProps) {
|
export default function Writing({
|
||||||
const [inputText, setInputText] = useState("");
|
id,
|
||||||
|
prompt,
|
||||||
|
prefix,
|
||||||
|
suffix,
|
||||||
|
type,
|
||||||
|
wordCounter,
|
||||||
|
attachment,
|
||||||
|
userSolutions,
|
||||||
|
onNext,
|
||||||
|
onBack,
|
||||||
|
}: WritingExercise & CommonProps) {
|
||||||
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||||
|
const [inputText, setInputText] = useState(userSolutions.length === 1 ? userSolutions[0].solution : "");
|
||||||
const [isSubmitEnabled, setIsSubmitEnabled] = useState(false);
|
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 !== "");
|
||||||
|
|
||||||
@@ -27,59 +45,100 @@ export default function Writing({id, prompt, info, type, wordCounter, attachment
|
|||||||
}, [inputText, wordCounter]);
|
}, [inputText, wordCounter]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full w-2/3 items-center justify-center gap-8">
|
<>
|
||||||
<div className="flex flex-col max-w-2xl gap-2">
|
{attachment && (
|
||||||
<span>{info}</span>
|
<Transition show={isModalOpen} as={Fragment}>
|
||||||
<span className="font-bold ml-8">
|
<Dialog onClose={() => setIsModalOpen(false)} className="relative z-50">
|
||||||
{prompt.split("\\n").map((line, index) => (
|
<Transition.Child
|
||||||
<Fragment key={index}>
|
as={Fragment}
|
||||||
<span>{line}</span>
|
enter="ease-out duration-300"
|
||||||
<br />
|
enterFrom="opacity-0"
|
||||||
</Fragment>
|
enterTo="opacity-100"
|
||||||
))}
|
leave="ease-in duration-200"
|
||||||
</span>
|
leaveFrom="opacity-100"
|
||||||
<span>
|
leaveTo="opacity-0">
|
||||||
You should write {wordCounter.type === "min" ? "at least" : "at most"} {wordCounter.limit} words.
|
<div className="fixed inset-0 bg-black/30" />
|
||||||
</span>
|
</Transition.Child>
|
||||||
{attachment && <img src={attachment} alt="Exercise attachment" />}
|
|
||||||
|
<Transition.Child
|
||||||
|
as={Fragment}
|
||||||
|
enter="ease-out duration-300"
|
||||||
|
enterFrom="opacity-0 scale-95"
|
||||||
|
enterTo="opacity-100 scale-100"
|
||||||
|
leave="ease-in duration-200"
|
||||||
|
leaveFrom="opacity-100 scale-100"
|
||||||
|
leaveTo="opacity-0 scale-95">
|
||||||
|
<div className="fixed inset-0 flex items-center justify-center p-4">
|
||||||
|
<Dialog.Panel className="w-fit h-fit rounded-xl bg-white">
|
||||||
|
<img src={attachment.url} alt={attachment.description} className="max-w-4xl w-full self-center rounded-xl p-4" />
|
||||||
|
</Dialog.Panel>
|
||||||
|
</div>
|
||||||
|
</Transition.Child>
|
||||||
|
</Dialog>
|
||||||
|
</Transition>
|
||||||
|
)}
|
||||||
|
<div className="flex flex-col h-full w-full gap-9 mb-20">
|
||||||
|
<div className="flex flex-col w-full gap-7 bg-mti-gray-smoke rounded-xl py-8 pb-12 px-16">
|
||||||
|
<span>
|
||||||
|
{prefix.split("\\n").map((line) => (
|
||||||
|
<>
|
||||||
|
{line}
|
||||||
|
<br />
|
||||||
|
</>
|
||||||
|
))}
|
||||||
|
</span>
|
||||||
|
<span className="font-semibold">
|
||||||
|
{prompt.split("\\n").map((line, index) => (
|
||||||
|
<Fragment key={index}>
|
||||||
|
<p>{line}</p>
|
||||||
|
<br />
|
||||||
|
</Fragment>
|
||||||
|
))}
|
||||||
|
</span>
|
||||||
|
{attachment && (
|
||||||
|
<img
|
||||||
|
onClick={() => setIsModalOpen(true)}
|
||||||
|
src={attachment.url}
|
||||||
|
alt={attachment.description}
|
||||||
|
className="max-w-md self-center rounded-xl cursor-pointer"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="w-full h-full flex flex-col gap-4">
|
||||||
|
<span>
|
||||||
|
{suffix.split("\\n").map((line) => (
|
||||||
|
<>
|
||||||
|
{line}
|
||||||
|
<br />
|
||||||
|
</>
|
||||||
|
))}
|
||||||
|
</span>
|
||||||
|
<textarea
|
||||||
|
className="w-full h-full min-h-[148px] cursor-text px-7 py-8 input border-2 border-mti-gray-platinum bg-white rounded-3xl"
|
||||||
|
onChange={(e) => setInputText(e.target.value)}
|
||||||
|
value={inputText}
|
||||||
|
placeholder="Write your text here..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<textarea
|
<div className="self-end flex justify-between w-full gap-8 absolute bottom-8 left-0 px-8">
|
||||||
className="w-full h-1/3 cursor-text p-2 input input-bordered bg-white"
|
<Button
|
||||||
onChange={(e) => setInputText(e.target.value)}
|
color="purple"
|
||||||
value={inputText}
|
variant="outline"
|
||||||
placeholder="Write your text here..."
|
onClick={() => onBack({exercise: id, solutions: [{id, solution: inputText}], score: {correct: 1, total: 1, missing: 0}, type})}
|
||||||
/>
|
className="max-w-[200px] self-end w-full">
|
||||||
|
|
||||||
<div className="self-end flex flex-col-reverse items-center w-full md:justify-between md:items-start md:flex-row gap-8">
|
|
||||||
<button className={clsx("btn btn-wide gap-4 relative text-white", errorButtonStyle)} onClick={onBack}>
|
|
||||||
<div className="absolute left-4">
|
|
||||||
<Icon path={mdiArrowLeft} color="white" size={1} />
|
|
||||||
</div>
|
|
||||||
Back
|
Back
|
||||||
</button>
|
</Button>
|
||||||
{!isSubmitEnabled && (
|
<Button
|
||||||
<div className="tooltip" data-tip={`You have not yet reached your minimum word count of ${wordCounter.limit} words!`}>
|
color="purple"
|
||||||
<button
|
disabled={!isSubmitEnabled}
|
||||||
className={clsx("btn btn-wide gap-4 relative text-white", infoButtonStyle)}
|
onClick={() => onNext({exercise: id, solutions: [{id, solution: inputText}], score: {correct: 1, total: 1, missing: 0}, type})}
|
||||||
disabled={!isSubmitEnabled}
|
className="max-w-[200px] self-end w-full">
|
||||||
onClick={() => onNext({exercise: id, solutions: [inputText], score: {correct: 1, total: 1}, type})}>
|
Next
|
||||||
Next
|
</Button>
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{isSubmitEnabled && (
|
|
||||||
<button
|
|
||||||
className={clsx("btn btn-wide gap-4 relative text-white", infoButtonStyle)}
|
|
||||||
disabled={!isSubmitEnabled}
|
|
||||||
onClick={() => onNext({exercise: id, solutions: [inputText], score: {correct: 1, total: 1}, type})}>
|
|
||||||
Next
|
|
||||||
<div className="absolute right-4">
|
|
||||||
<Icon path={mdiArrowRight} color="white" size={1} />
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
MatchSentencesExercise,
|
MatchSentencesExercise,
|
||||||
MultipleChoiceExercise,
|
MultipleChoiceExercise,
|
||||||
SpeakingExercise,
|
SpeakingExercise,
|
||||||
|
TrueFalseExercise,
|
||||||
UserSolution,
|
UserSolution,
|
||||||
WriteBlanksExercise,
|
WriteBlanksExercise,
|
||||||
WritingExercise,
|
WritingExercise,
|
||||||
@@ -14,18 +15,21 @@ 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});
|
||||||
|
|
||||||
export interface CommonProps {
|
export interface CommonProps {
|
||||||
onNext: (userSolutions: UserSolution) => void;
|
onNext: (userSolutions: UserSolution) => void;
|
||||||
onBack: () => void;
|
onBack: (userSolutions: UserSolution) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const renderExercise = (exercise: Exercise, onNext: (userSolutions: UserSolution) => void, onBack: () => void) => {
|
export const renderExercise = (exercise: Exercise, onNext: (userSolutions: UserSolution) => void, onBack: (userSolutions: UserSolution) => void) => {
|
||||||
switch (exercise.type) {
|
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":
|
||||||
|
|||||||
13
src/components/FocusLayer.tsx
Normal file
13
src/components/FocusLayer.tsx
Normal 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}/>
|
||||||
|
);
|
||||||
|
}
|
||||||
34
src/components/High/Layout.tsx
Normal file
34
src/components/High/Layout.tsx
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import {User} from "@/interfaces/user";
|
||||||
|
import clsx from "clsx";
|
||||||
|
import {useRouter} from "next/router";
|
||||||
|
import Navbar from "../Navbar";
|
||||||
|
import Sidebar from "../Sidebar";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
user: User;
|
||||||
|
children: React.ReactNode;
|
||||||
|
className?: string;
|
||||||
|
navDisabled?: boolean;
|
||||||
|
focusMode?: boolean
|
||||||
|
onFocusLayerMouseEnter?: Function;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Layout({user, children, className, navDisabled = false, focusMode = false, onFocusLayerMouseEnter }: Props) {
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="w-full min-h-full h-screen flex flex-col bg-mti-gray-smoke">
|
||||||
|
<Navbar user={user} navDisabled={navDisabled} focusMode={focusMode} onFocusLayerMouseEnter={onFocusLayerMouseEnter} />
|
||||||
|
<div className="h-full w-full flex gap-2">
|
||||||
|
<Sidebar path={router.pathname} navDisabled={navDisabled} focusMode={focusMode} onFocusLayerMouseEnter={onFocusLayerMouseEnter}/>
|
||||||
|
<div
|
||||||
|
className={clsx(
|
||||||
|
"w-5/6 min-h-full h-fit mr-8 bg-white shadow-md rounded-2xl p-12 pb-8 flex flex-col gap-12 relative overflow-hidden mt-2",
|
||||||
|
className,
|
||||||
|
)}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
87
src/components/Low/AudioPlayer.tsx
Normal file
87
src/components/Low/AudioPlayer.tsx
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
import {Module} from "@/interfaces";
|
||||||
|
import {formatTimeInMinutes} from "@/utils/string";
|
||||||
|
import clsx from "clsx";
|
||||||
|
import {useEffect, useRef, useState} from "react";
|
||||||
|
import {BsPauseFill, BsPlayFill} from "react-icons/bs";
|
||||||
|
import ProgressBar from "./ProgressBar";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
src: string;
|
||||||
|
color: "red" | "rose" | "purple" | Module;
|
||||||
|
autoPlay?: boolean;
|
||||||
|
disabled?: boolean;
|
||||||
|
onEnd?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AudioPlayer({src, color, autoPlay = false, disabled = false, onEnd}: Props) {
|
||||||
|
const [isPlaying, setIsPlaying] = useState(false);
|
||||||
|
const [duration, setDuration] = useState(0);
|
||||||
|
const [currentTime, setCurrentTime] = useState(0);
|
||||||
|
|
||||||
|
const audioPlayerRef = useRef<HTMLAudioElement | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (audioPlayerRef && audioPlayerRef.current) {
|
||||||
|
const seconds = Math.floor(audioPlayerRef.current.duration);
|
||||||
|
setDuration(seconds);
|
||||||
|
}
|
||||||
|
}, [audioPlayerRef?.current?.readyState]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let playingInterval: NodeJS.Timer | undefined = undefined;
|
||||||
|
if (isPlaying) {
|
||||||
|
playingInterval = setInterval(() => setCurrentTime((prev) => prev + 1), 1000);
|
||||||
|
} else if (playingInterval) {
|
||||||
|
clearInterval(playingInterval);
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (playingInterval) clearInterval(playingInterval);
|
||||||
|
};
|
||||||
|
}, [isPlaying]);
|
||||||
|
|
||||||
|
const togglePlayPause = () => {
|
||||||
|
const prevValue = isPlaying;
|
||||||
|
setIsPlaying(!prevValue);
|
||||||
|
if (!prevValue) {
|
||||||
|
audioPlayerRef?.current?.play();
|
||||||
|
} else {
|
||||||
|
audioPlayerRef?.current?.pause();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full h-fit flex gap-4 items-center mt-2">
|
||||||
|
{isPlaying && (
|
||||||
|
<BsPauseFill
|
||||||
|
className={clsx("text-mti-gray-cool cursor-pointer w-5 h-5", disabled && "opacity-60 cursor-not-allowed")}
|
||||||
|
onClick={disabled ? undefined : togglePlayPause}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{!isPlaying && (
|
||||||
|
<BsPlayFill
|
||||||
|
className={clsx("text-mti-gray-cool cursor-pointer w-5 h-5", disabled && "opacity-60 cursor-not-allowed")}
|
||||||
|
onClick={disabled ? undefined : togglePlayPause}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<audio
|
||||||
|
src={src}
|
||||||
|
autoPlay={autoPlay}
|
||||||
|
ref={audioPlayerRef}
|
||||||
|
preload="metadata"
|
||||||
|
onEnded={() => {
|
||||||
|
setIsPlaying(false);
|
||||||
|
setCurrentTime(0);
|
||||||
|
if (onEnd) onEnd();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div className="flex flex-col gap-2 w-full relative">
|
||||||
|
<div className="absolute w-full flex justify-between -top-5 text-xs px-1">
|
||||||
|
<span>{formatTimeInMinutes(currentTime)}</span>
|
||||||
|
<span>{formatTimeInMinutes(duration)}</span>
|
||||||
|
</div>
|
||||||
|
<ProgressBar label="" color={color} useColor percentage={(currentTime * 100) / duration} className="h-3 w-full" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
44
src/components/Low/Button.tsx
Normal file
44
src/components/Low/Button.tsx
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import clsx from "clsx";
|
||||||
|
import {ReactNode} from "react";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
children: ReactNode;
|
||||||
|
color?: "rose" | "purple" | "red";
|
||||||
|
variant?: "outline" | "solid";
|
||||||
|
className?: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
onClick?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
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}} = {
|
||||||
|
purple: {
|
||||||
|
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:
|
||||||
|
"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",
|
||||||
|
},
|
||||||
|
red: {
|
||||||
|
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:
|
||||||
|
"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",
|
||||||
|
},
|
||||||
|
rose: {
|
||||||
|
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:
|
||||||
|
"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",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={onClick}
|
||||||
|
className={clsx(
|
||||||
|
"py-4 px-6 rounded-full transition ease-in-out duration-300 disabled:cursor-not-allowed",
|
||||||
|
className,
|
||||||
|
colorClassNames[color][variant],
|
||||||
|
)}
|
||||||
|
disabled={disabled}>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
63
src/components/Low/Input.tsx
Normal file
63
src/components/Low/Input.tsx
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
import {useState} from "react";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
type: "email" | "text" | "password";
|
||||||
|
required?: boolean;
|
||||||
|
label?: string;
|
||||||
|
placeholder?: string;
|
||||||
|
defaultValue?: string;
|
||||||
|
name: string;
|
||||||
|
onChange: (value: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Input({type, label, placeholder, name, required = false, defaultValue, onChange}: Props) {
|
||||||
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
|
|
||||||
|
if (type === "password") {
|
||||||
|
return (
|
||||||
|
<div className="relative flex flex-col gap-3 w-full">
|
||||||
|
{label && (
|
||||||
|
<label className="font-normal text-base text-mti-gray-dim">
|
||||||
|
{label}
|
||||||
|
{required ? " *" : ""}
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
<div className="w-full h-fit relative">
|
||||||
|
<input
|
||||||
|
type={showPassword ? "text" : "password"}
|
||||||
|
name={name}
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
placeholder={placeholder}
|
||||||
|
className="w-full px-8 py-6 text-sm font-normal placeholder:text-mti-gray-cool bg-white rounded-full border border-mti-gray-platinum focus:outline-none"
|
||||||
|
/>
|
||||||
|
<p
|
||||||
|
role="button"
|
||||||
|
onClick={() => setShowPassword((prev) => !prev)}
|
||||||
|
className="text-xs cursor-pointer absolute bottom-1/2 translate-y-1/2 right-8">
|
||||||
|
{showPassword ? "Hide" : "Show"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-3 w-full">
|
||||||
|
{label && (
|
||||||
|
<label className="font-normal text-base text-mti-gray-dim">
|
||||||
|
{label}
|
||||||
|
{required ? " *" : ""}
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
<input
|
||||||
|
type={type}
|
||||||
|
name={name}
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
placeholder={placeholder}
|
||||||
|
className="px-8 py-6 text-sm font-normal placeholder:text-mti-gray-cool bg-white rounded-full border border-mti-gray-platinum focus:outline-none"
|
||||||
|
required={required}
|
||||||
|
defaultValue={defaultValue}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
38
src/components/Low/ProgressBar.tsx
Normal file
38
src/components/Low/ProgressBar.tsx
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import {Module} from "@/interfaces";
|
||||||
|
import clsx from "clsx";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
label: string;
|
||||||
|
percentage: number;
|
||||||
|
color: "red" | "rose" | "purple" | Module;
|
||||||
|
useColor?: boolean;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ProgressBar({label, percentage, color, useColor = false, className}: Props) {
|
||||||
|
const progressColorClass: {[key in typeof color]: string} = {
|
||||||
|
red: "bg-mti-red-light",
|
||||||
|
rose: "bg-mti-rose-light",
|
||||||
|
purple: "bg-mti-purple-light",
|
||||||
|
reading: "bg-ielts-reading",
|
||||||
|
listening: "bg-ielts-listening",
|
||||||
|
writing: "bg-ielts-writing",
|
||||||
|
speaking: "bg-ielts-speaking",
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={clsx(
|
||||||
|
"relative rounded-full overflow-hidden flex items-center justify-center",
|
||||||
|
className,
|
||||||
|
!useColor ? "bg-mti-gray-anti-flash" : progressColorClass[color],
|
||||||
|
useColor && "bg-opacity-20",
|
||||||
|
)}>
|
||||||
|
<div
|
||||||
|
style={{width: `${percentage}%`}}
|
||||||
|
className={clsx("absolute transition-all duration-300 ease-in-out top-0 left-0 h-full overflow-hidden", progressColorClass[color])}
|
||||||
|
/>
|
||||||
|
<span className="z-10 justify-self-center text-white text-sm font-bold">{label}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
100
src/components/Medium/ModuleTitle.tsx
Normal file
100
src/components/Medium/ModuleTitle.tsx
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
import {Module} from "@/interfaces";
|
||||||
|
import useExamStore from "@/stores/examStore";
|
||||||
|
import {moduleLabels} from "@/utils/moduleUtils";
|
||||||
|
import clsx from "clsx";
|
||||||
|
import {motion} from "framer-motion";
|
||||||
|
import {ReactNode, useEffect, useState} from "react";
|
||||||
|
import {BsBook, BsHeadphones, BsMegaphone, BsPen, BsStopwatch} from "react-icons/bs";
|
||||||
|
import ProgressBar from "../Low/ProgressBar";
|
||||||
|
import TimerEndedModal from "../TimerEndedModal";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
minTimer: number;
|
||||||
|
module: Module;
|
||||||
|
label?: string;
|
||||||
|
exerciseIndex: number;
|
||||||
|
totalExercises: number;
|
||||||
|
disableTimer?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ModuleTitle({minTimer, module, label, exerciseIndex, totalExercises, disableTimer = false}: Props) {
|
||||||
|
const [timer, setTimer] = useState(minTimer * 60);
|
||||||
|
const [showModal, setShowModal] = useState(false);
|
||||||
|
const [warningMode, setWarningMode] = useState(false);
|
||||||
|
const setHasExamEnded = useExamStore((state) => state.setHasExamEnded);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!disableTimer) {
|
||||||
|
const timerInterval = setInterval(() => setTimer((prev) => prev - 1), 1000);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
clearInterval(timerInterval);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}, [disableTimer, minTimer]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (timer <= 0) setShowModal(true);
|
||||||
|
}, [timer]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (timer < 300 && !warningMode) setWarningMode(true);
|
||||||
|
}, [timer, warningMode]);
|
||||||
|
|
||||||
|
const moduleIcon: {[key in Module]: ReactNode} = {
|
||||||
|
reading: <BsBook className="text-ielts-reading w-6 h-6" />,
|
||||||
|
listening: <BsHeadphones className="text-ielts-listening w-6 h-6" />,
|
||||||
|
writing: <BsPen className="text-ielts-writing w-6 h-6" />,
|
||||||
|
speaking: <BsMegaphone className="text-ielts-speaking w-6 h-6" />,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<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" />
|
||||||
|
<span className="text-sm font-semibold w-11">
|
||||||
|
{timer > 0 && (
|
||||||
|
<>
|
||||||
|
{Math.floor(timer / 60)
|
||||||
|
.toString(10)
|
||||||
|
.padStart(2, "0")}
|
||||||
|
:
|
||||||
|
{Math.floor(timer % 60)
|
||||||
|
.toString(10)
|
||||||
|
.padStart(2, "0")}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{timer <= 0 && <>00:00</>}
|
||||||
|
</span>
|
||||||
|
</motion.div>
|
||||||
|
<div className="flex gap-6 w-full h-fit items-center mt-5">
|
||||||
|
<div className="w-12 h-12 bg-mti-gray-smoke flex items-center justify-center rounded-lg">{moduleIcon[module]}</div>
|
||||||
|
<div className="flex flex-col gap-3 w-full">
|
||||||
|
<div className="w-full flex justify-between">
|
||||||
|
<span className="text-base font-semibold">
|
||||||
|
{moduleLabels[module]} exam {label && `- ${label}`}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs font-normal self-end text-mti-gray-davy">
|
||||||
|
Question {exerciseIndex}/{totalExercises}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<ProgressBar color={module} label="" percentage={(exerciseIndex * 100) / totalExercises} className="h-2 w-full" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,77 +1,32 @@
|
|||||||
import {Type} from "@/interfaces/user";
|
import {User} from "@/interfaces/user";
|
||||||
import axios from "axios";
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import {useRouter} from "next/router";
|
import {Avatar} from "primereact/avatar";
|
||||||
import {Button} from "primereact/button";
|
import FocusLayer from '@/components/FocusLayer';
|
||||||
import {Menubar} from "primereact/menubar";
|
import { preventNavigation } from "@/utils/navigation.disabled";
|
||||||
import {MenuItem} from "primereact/menuitem";
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
profilePicture: string;
|
user: User;
|
||||||
userType: Type;
|
navDisabled?: boolean;
|
||||||
timer?: number;
|
focusMode?: boolean;
|
||||||
showExamEnd?: boolean;
|
onFocusLayerMouseEnter?: Function;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* eslint-disable @next/next/no-img-element */
|
/* eslint-disable @next/next/no-img-element */
|
||||||
export default function Navbar({profilePicture, userType, timer, showExamEnd = false}: Props) {
|
export default function Navbar({user, navDisabled = false, focusMode = false, onFocusLayerMouseEnter}: Props) {
|
||||||
const router = useRouter();
|
const disableNavigation = preventNavigation(navDisabled, focusMode);
|
||||||
|
|
||||||
const logout = async () => {
|
|
||||||
axios.post("/api/logout").finally(() => {
|
|
||||||
router.push("/login");
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const items: MenuItem[] = [
|
|
||||||
{
|
|
||||||
label: "Home",
|
|
||||||
icon: "pi pi-fw pi-home",
|
|
||||||
url: "/",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Account",
|
|
||||||
icon: "pi pi-fw pi-user",
|
|
||||||
url: "/profile",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Exam",
|
|
||||||
icon: "pi pi-fw pi-plus-circle",
|
|
||||||
url: "/exam",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Users",
|
|
||||||
icon: "pi pi-fw pi-users",
|
|
||||||
items: [
|
|
||||||
...(userType === "student" ? [] : [{label: "List", icon: "pi pi-fw pi-users", url: "/users"}]),
|
|
||||||
{label: "Stats", icon: "pi pi-fw pi-chart-pie", url: "/stats"},
|
|
||||||
{label: "History", icon: "pi pi-fw pi-history", url: "/history"},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Logout",
|
|
||||||
icon: "pi pi-fw pi-power-off",
|
|
||||||
command: logout,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const endTimer = timer && (
|
|
||||||
<span className="pr-2 font-semibold">
|
|
||||||
{Math.floor(timer / 60) < 10 ? "0" : ""}
|
|
||||||
{Math.floor(timer / 60)}:{timer % 60 < 10 ? "0" : ""}
|
|
||||||
{timer % 60}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
|
|
||||||
const endNewExam = (
|
|
||||||
<Link href="/exam" className="pr-2">
|
|
||||||
<Button text label="Exam" severity="secondary" size="small" />
|
|
||||||
</Link>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-neutral-100 z-10 w-full p-2">
|
<header className="w-full bg-transparent py-4 gap-2 flex items-center relative">
|
||||||
<Menubar model={items} end={showExamEnd ? endNewExam : endTimer} />
|
<h1 className="font-bold text-2xl w-1/6 px-8">EnCoach</h1>
|
||||||
</div>
|
<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" />
|
||||||
|
<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 object-cover" />
|
||||||
|
<span className="text-right">{user.name}</span>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
{focusMode && <FocusLayer onFocusLayerMouseEnter={onFocusLayerMouseEnter}/>}
|
||||||
|
</header>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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" />
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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" />
|
||||||
)}
|
)}
|
||||||
|
|||||||
76
src/components/Sidebar.tsx
Normal file
76
src/components/Sidebar.tsx
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
import clsx from "clsx";
|
||||||
|
import {IconType} from "react-icons";
|
||||||
|
import {MdSpaceDashboard} from "react-icons/md";
|
||||||
|
import {BsFileEarmarkText, BsClockHistory, BsPencil, BsGraphUp, BsShield} from "react-icons/bs";
|
||||||
|
import {RiLogoutBoxFill} from "react-icons/ri";
|
||||||
|
import {SlPencil} from "react-icons/sl";
|
||||||
|
import {FaAward} from "react-icons/fa";
|
||||||
|
import Link from "next/link";
|
||||||
|
import {useRouter} from "next/router";
|
||||||
|
import axios from "axios";
|
||||||
|
import FocusLayer from '@/components/FocusLayer';
|
||||||
|
import { preventNavigation } from "@/utils/navigation.disabled";
|
||||||
|
interface Props {
|
||||||
|
path: string;
|
||||||
|
navDisabled?: boolean;
|
||||||
|
focusMode?: boolean;
|
||||||
|
onFocusLayerMouseEnter?: Function;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface NavProps {
|
||||||
|
Icon: IconType;
|
||||||
|
label: string;
|
||||||
|
path: string;
|
||||||
|
keyPath: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Nav = ({Icon, label, path, keyPath, disabled = false}: NavProps) => (
|
||||||
|
<Link
|
||||||
|
href={!disabled ? keyPath : ""}
|
||||||
|
className={clsx(
|
||||||
|
"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-purple-light text-white",
|
||||||
|
)}>
|
||||||
|
<Icon size={20} />
|
||||||
|
<span className="text-lg font-semibold">{label}</span>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
|
||||||
|
export default function Sidebar({path, navDisabled = false, focusMode = false, onFocusLayerMouseEnter}: Props) {
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const logout = async () => {
|
||||||
|
axios.post("/api/logout").finally(() => {
|
||||||
|
setTimeout(() => router.reload(), 500);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const disableNavigation: Boolean = preventNavigation(navDisabled, focusMode);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<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">
|
||||||
|
<Nav disabled={disableNavigation} Icon={MdSpaceDashboard} label="Dashboard" path={path} keyPath="/" />
|
||||||
|
<Nav disabled={disableNavigation} Icon={BsFileEarmarkText} label="Exams" path={path} keyPath="/exam" />
|
||||||
|
<Nav disabled={disableNavigation} Icon={BsPencil} label="Exercises" path={path} keyPath="/exercises" />
|
||||||
|
<Nav disabled={disableNavigation} Icon={BsGraphUp} label="Stats" path={path} keyPath="/stats" />
|
||||||
|
<Nav disabled={disableNavigation} Icon={BsClockHistory} label="Record" path={path} keyPath="/record" />
|
||||||
|
<Nav disabled={disableNavigation} Icon={BsShield} label="Admin" path={path} keyPath="/admin" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
role="button"
|
||||||
|
tabIndex={1}
|
||||||
|
onClick={focusMode ? () => {} : logout}
|
||||||
|
className={clsx(
|
||||||
|
"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",
|
||||||
|
)}>
|
||||||
|
<RiLogoutBoxFill size={20} />
|
||||||
|
<span className="text-lg font-medium">Log Out</span>
|
||||||
|
</div>
|
||||||
|
{focusMode && <FocusLayer onFocusLayerMouseEnter={onFocusLayerMouseEnter} />}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,13 +1,19 @@
|
|||||||
import {errorButtonStyle, infoButtonStyle} from "@/constants/buttonStyles";
|
|
||||||
import {FillBlanksExercise} from "@/interfaces/exam";
|
import {FillBlanksExercise} from "@/interfaces/exam";
|
||||||
import {mdiArrowLeft, mdiArrowRight} from "@mdi/js";
|
|
||||||
import Icon from "@mdi/react";
|
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import reactStringReplace from "react-string-replace";
|
import reactStringReplace from "react-string-replace";
|
||||||
import {CommonProps} from ".";
|
import {CommonProps} from ".";
|
||||||
import {Fragment} from "react";
|
import {Fragment} from "react";
|
||||||
|
import Button from "../Low/Button";
|
||||||
|
|
||||||
|
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};
|
||||||
|
};
|
||||||
|
|
||||||
export default function FillBlanksSolutions({prompt, solutions, text, userSolutions, onNext, onBack}: FillBlanksExercise & CommonProps) {
|
|
||||||
const renderLines = (line: string) => {
|
const renderLines = (line: string) => {
|
||||||
return (
|
return (
|
||||||
<span>
|
<span>
|
||||||
@@ -18,23 +24,45 @@ export default function FillBlanksSolutions({prompt, solutions, text, userSoluti
|
|||||||
|
|
||||||
if (!userSolution) {
|
if (!userSolution) {
|
||||||
return (
|
return (
|
||||||
<>
|
<button
|
||||||
<button className={clsx("border-2 rounded-xl px-4 text-gray-500 border-gray-500 my-2")}>{solution.solution}</button>
|
className={clsx(
|
||||||
</>
|
"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}
|
||||||
|
</button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (userSolution.solution === solution.solution) {
|
if (userSolution.solution === solution.solution) {
|
||||||
return <button className={clsx("border-2 rounded-xl px-4 text-green-500 border-green-500 my-2")}>{solution.solution}</button>;
|
return (
|
||||||
|
<button
|
||||||
|
className={clsx(
|
||||||
|
"rounded-full hover:text-white hover:bg-mti-purple transition duration-300 ease-in-out my-1",
|
||||||
|
userSolution && "px-5 py-2 text-center text-white bg-mti-purple-light",
|
||||||
|
)}>
|
||||||
|
{solution.solution}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (userSolution.solution !== solution.solution) {
|
if (userSolution.solution !== solution.solution) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<button className={clsx("border-2 rounded-xl px-4 text-red-500 border-red-500 mr-1 my-2")}>
|
<button
|
||||||
|
className={clsx(
|
||||||
|
"rounded-full hover:text-white hover:bg-mti-rose transition duration-300 ease-in-out my-1 mr-1",
|
||||||
|
userSolution && "px-5 py-2 text-center text-white bg-mti-rose-light",
|
||||||
|
)}>
|
||||||
{userSolution.solution}
|
{userSolution.solution}
|
||||||
</button>
|
</button>
|
||||||
<button className={clsx("border-2 rounded-xl px-4 text-green-400 border-green-400 my-2")}>{solution.solution}</button>
|
|
||||||
|
<button
|
||||||
|
className={clsx(
|
||||||
|
"rounded-full hover:text-white hover:bg-mti-purple transition duration-300 ease-in-out my-1",
|
||||||
|
userSolution && "px-5 py-2 text-center text-white bg-mti-purple-light",
|
||||||
|
)}>
|
||||||
|
{solution.solution}
|
||||||
|
</button>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -45,8 +73,8 @@ export default function FillBlanksSolutions({prompt, solutions, text, userSoluti
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4 mt-4 h-full mb-20">
|
||||||
<span className="text-base md:text-lg font-medium text-center px-2 md:px-4 lg:px-48">
|
<span className="text-sm w-full leading-6">
|
||||||
{prompt.split("\\n").map((line, index) => (
|
{prompt.split("\\n").map((line, index) => (
|
||||||
<Fragment key={index}>
|
<Fragment key={index}>
|
||||||
{line}
|
{line}
|
||||||
@@ -54,29 +82,45 @@ export default function FillBlanksSolutions({prompt, solutions, text, userSoluti
|
|||||||
</Fragment>
|
</Fragment>
|
||||||
))}
|
))}
|
||||||
</span>
|
</span>
|
||||||
<span>
|
<span className="bg-mti-gray-smoke rounded-xl px-5 py-6">
|
||||||
{text.split("\\n").map((line, index) => (
|
{text.split("\\n").map((line, index) => (
|
||||||
<Fragment key={index}>
|
<p key={index}>
|
||||||
{renderLines(line)}
|
{renderLines(line)}
|
||||||
<br />
|
<br />
|
||||||
</Fragment>
|
</p>
|
||||||
))}
|
))}
|
||||||
</span>
|
</span>
|
||||||
|
<div className="flex gap-4 items-center">
|
||||||
|
<div className="flex gap-2 items-center">
|
||||||
|
<div className="w-4 h-4 rounded-full bg-mti-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>
|
||||||
|
|
||||||
<div className="self-end flex flex-col-reverse items-center w-full md:justify-between md:items-start md:flex-row gap-8">
|
<div className="self-end flex justify-between w-full gap-8 absolute bottom-8 left-0 px-8">
|
||||||
<button className={clsx("btn btn-wide gap-4 relative text-white", errorButtonStyle)} onClick={onBack}>
|
<Button
|
||||||
<div className="absolute left-4">
|
color="purple"
|
||||||
<Icon path={mdiArrowLeft} color="white" size={1} />
|
variant="outline"
|
||||||
</div>
|
onClick={() => onBack({exercise: id, solutions: userSolutions, score: calculateScore(), type})}
|
||||||
|
className="max-w-[200px] w-full">
|
||||||
Back
|
Back
|
||||||
</button>
|
</Button>
|
||||||
<button className={clsx("btn btn-wide gap-4 relative text-white", infoButtonStyle)} onClick={onNext}>
|
|
||||||
|
<Button
|
||||||
|
color="purple"
|
||||||
|
onClick={() => onNext({exercise: id, solutions: userSolutions, score: calculateScore(), type})}
|
||||||
|
className="max-w-[200px] self-end w-full">
|
||||||
Next
|
Next
|
||||||
<div className="absolute right-4">
|
</Button>
|
||||||
<Icon path={mdiArrowRight} color="white" size={1} />
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -6,12 +6,31 @@ import {errorButtonStyle, infoButtonStyle} from "@/constants/buttonStyles";
|
|||||||
import {mdiArrowLeft, mdiArrowRight} from "@mdi/js";
|
import {mdiArrowLeft, mdiArrowRight} from "@mdi/js";
|
||||||
import Icon from "@mdi/react";
|
import Icon from "@mdi/react";
|
||||||
import {Fragment} from "react";
|
import {Fragment} from "react";
|
||||||
|
import Button from "../Low/Button";
|
||||||
|
import Xarrow from "react-xarrows";
|
||||||
|
|
||||||
|
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};
|
||||||
|
};
|
||||||
|
|
||||||
export default function MatchSentencesSolutions({options, prompt, sentences, userSolutions, onNext, onBack}: MatchSentencesExercise & CommonProps) {
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="flex flex-col items-center gap-8">
|
<div className="flex flex-col gap-4 mt-4 h-full mb-20">
|
||||||
<span className="text-base md:text-lg font-medium text-center px-2 md:px-4 lg:px-48">
|
<span className="text-sm w-full leading-6">
|
||||||
{prompt.split("\\n").map((line, index) => (
|
{prompt.split("\\n").map((line, index) => (
|
||||||
<Fragment key={index}>
|
<Fragment key={index}>
|
||||||
{line}
|
{line}
|
||||||
@@ -19,63 +38,84 @@ export default function MatchSentencesSolutions({options, prompt, sentences, use
|
|||||||
</Fragment>
|
</Fragment>
|
||||||
))}
|
))}
|
||||||
</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="grid grid-cols-2 gap-16 place-items-center">
|
<div className="flex flex-col gap-4">
|
||||||
<div className="flex flex-col gap-1">
|
{sentences.map(({sentence, id, solution}) => (
|
||||||
{sentences.map(({sentence, id, color, solution}) => (
|
<div key={`question_${id}`} className="flex items-center justify-end gap-2 cursor-pointer">
|
||||||
<div
|
<span>{sentence} </span>
|
||||||
key={`question_${id}`}
|
<button
|
||||||
className={clsx(
|
id={id}
|
||||||
"flex items-center justify-end gap-2 cursor-pointer",
|
className={clsx(
|
||||||
userSolutions.find((x) => x.question === id)?.option === solution ? "text-green-500" : "text-red-500",
|
"w-8 h-8 rounded-full z-10 text-white",
|
||||||
)}>
|
"transition duration-300 ease-in-out",
|
||||||
<span>
|
!userSolutions.find((x) => x.question === id) && "!bg-mti-red",
|
||||||
<span className="font-semibold">{id}.</span> {sentence}{" "}
|
userSolutions.find((x) => x.question === id)?.option === solution && "bg-mti-purple",
|
||||||
</span>
|
userSolutions.find((x) => x.question === id)?.option !== solution && "bg-mti-rose",
|
||||||
<div style={{borderColor: color}} className={clsx("border-2 border-blue-500 w-4 h-4 rounded-full", id)} />
|
)}>
|
||||||
|
{id}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-4">
|
||||||
{options.map(({sentence, id}) => (
|
{options.map(({sentence, id}) => (
|
||||||
<div key={`answer_${id}`} className={clsx("flex items-center justify-start gap-2 cursor-pointer")}>
|
<div key={`answer_${id}`} className={clsx("flex items-center justify-start gap-2 cursor-pointer")}>
|
||||||
<div
|
<button
|
||||||
style={
|
id={id}
|
||||||
sentences.find((x) => x.solution === id)
|
className={clsx(
|
||||||
? {
|
"bg-mti-purple-ultralight text-mti-purple hover:text-white hover:bg-mti-purple w-8 h-8 rounded-full z-10",
|
||||||
border: `2px solid ${sentences.find((x) => x.solution === id)!.color}`,
|
"transition duration-300 ease-in-out",
|
||||||
}
|
)}>
|
||||||
: {}
|
{id}
|
||||||
}
|
</button>
|
||||||
className={clsx("border-2 border-green-500 bg-transparent w-4 h-4 rounded-full", id)}
|
<span>{sentence}</span>
|
||||||
/>
|
|
||||||
<span>
|
|
||||||
<span className="font-semibold">{id}.</span> {sentence}{" "}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
{sentences.map((sentence, index) => (
|
{sentences.map((sentence, index) => (
|
||||||
<div key={`solution_${index}`} className="absolute">
|
<Xarrow
|
||||||
<LineTo className="rounded-full" from={sentence.id} to={sentence.solution} borderColor={sentence.color} borderWidth={5} />
|
key={index}
|
||||||
</div>
|
start={sentence.id}
|
||||||
|
end={sentence.solution}
|
||||||
|
lineColor={
|
||||||
|
!userSolutions.find((x) => x.question === sentence.id)
|
||||||
|
? "#CC5454"
|
||||||
|
: userSolutions.find((x) => x.question === sentence.id)?.option === sentence.solution
|
||||||
|
? "#7872BF"
|
||||||
|
: "#CC5454"
|
||||||
|
}
|
||||||
|
showHead={false}
|
||||||
|
/>
|
||||||
))}
|
))}
|
||||||
</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>
|
||||||
|
|
||||||
<div className="self-end flex flex-col-reverse items-center w-full md:justify-between md:items-start md:flex-row gap-8">
|
<div className="self-end flex justify-between w-full gap-8 absolute bottom-8 left-0 px-8">
|
||||||
<button className={clsx("btn btn-wide gap-4 relative text-white", errorButtonStyle)} onClick={onBack}>
|
<Button
|
||||||
<div className="absolute left-4">
|
color="purple"
|
||||||
<Icon path={mdiArrowLeft} color="white" size={1} />
|
variant="outline"
|
||||||
</div>
|
onClick={() => onBack({exercise: id, solutions: userSolutions, score: calculateScore(), type})}
|
||||||
|
className="max-w-[200px] w-full">
|
||||||
Back
|
Back
|
||||||
</button>
|
</Button>
|
||||||
<button className={clsx("btn btn-wide gap-4 relative text-white", infoButtonStyle)} onClick={onNext}>
|
|
||||||
|
<Button
|
||||||
|
color="purple"
|
||||||
|
onClick={() => onNext({exercise: id, solutions: userSolutions, score: calculateScore(), type})}
|
||||||
|
className="max-w-[200px] self-end w-full">
|
||||||
Next
|
Next
|
||||||
<div className="absolute right-4">
|
</Button>
|
||||||
<Icon path={mdiArrowRight} color="white" size={1} />
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,11 +1,9 @@
|
|||||||
/* eslint-disable @next/next/no-img-element */
|
/* eslint-disable @next/next/no-img-element */
|
||||||
import {errorButtonStyle, infoButtonStyle} from "@/constants/buttonStyles";
|
|
||||||
import {MultipleChoiceExercise, MultipleChoiceQuestion} from "@/interfaces/exam";
|
import {MultipleChoiceExercise, MultipleChoiceQuestion} from "@/interfaces/exam";
|
||||||
import {mdiArrowLeft, mdiArrowRight, mdiCheck, mdiClose} from "@mdi/js";
|
|
||||||
import Icon from "@mdi/react";
|
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import {useState} from "react";
|
import {useState} from "react";
|
||||||
import {CommonProps} from ".";
|
import {CommonProps} from ".";
|
||||||
|
import Button from "../Low/Button";
|
||||||
|
|
||||||
function Question({
|
function Question({
|
||||||
variant,
|
variant,
|
||||||
@@ -13,41 +11,17 @@ function Question({
|
|||||||
solution,
|
solution,
|
||||||
options,
|
options,
|
||||||
userSolution,
|
userSolution,
|
||||||
onSelectOption,
|
|
||||||
showSolution = false,
|
|
||||||
}: 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 (!showSolution) {
|
if (option === solution && !userSolution) {
|
||||||
return userSolution === option ? "border-blue-400" : "";
|
return "!border-mti-red-light !text-mti-red-light";
|
||||||
}
|
}
|
||||||
|
|
||||||
if (option === solution) {
|
if (option === solution) {
|
||||||
return "border-green-500 text-green-500";
|
return "!border-mti-purple-light !text-mti-purple-light";
|
||||||
}
|
}
|
||||||
|
|
||||||
return userSolution === option ? "border-red-500 text-red-500" : "";
|
return userSolution === option ? "!border-mti-rose-light !text-mti-rose-light" : "";
|
||||||
};
|
|
||||||
|
|
||||||
const optionBadge = (option: string) => {
|
|
||||||
if (option === userSolution) {
|
|
||||||
if (solution === option) {
|
|
||||||
return (
|
|
||||||
<div className="badge badge-lg bg-green-500 border-green-500 absolute -top-2 -right-4">
|
|
||||||
<div className="tooltip" data-tip="You have correctly answered!">
|
|
||||||
<Icon path={mdiCheck} color="white" size={0.8} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="badge badge-lg bg-red-500 border-red-500 absolute -top-2 -right-4">
|
|
||||||
<div className="tooltip" data-tip="You have wrongly answered!">
|
|
||||||
<Icon path={mdiClose} color="white" size={0.8} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -58,24 +32,20 @@ function Question({
|
|||||||
options.map((option) => (
|
options.map((option) => (
|
||||||
<div
|
<div
|
||||||
key={option.id}
|
key={option.id}
|
||||||
onClick={() => (onSelectOption ? onSelectOption(option.id) : null)}
|
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"flex flex-col items-center border-2 p-4 rounded-xl gap-4 cursor-pointer bg-white relative",
|
"flex flex-col items-center border border-mti-gray-platinum p-4 px-8 rounded-xl gap-4 cursor-pointer bg-white relative",
|
||||||
optionColor(option.id),
|
optionColor(option.id),
|
||||||
)}>
|
)}>
|
||||||
{showSolution && optionBadge(option.id)}
|
<span className={clsx("text-sm", solution !== option.id && userSolution !== option.id && "opacity-50")}>{option.id}</span>
|
||||||
<img src={option.src!} alt={`Option ${option.id}`} />
|
<img src={option.src!} alt={`Option ${option.id}`} />
|
||||||
<span>{option.id}</span>
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
{variant === "text" &&
|
{variant === "text" &&
|
||||||
options.map((option) => (
|
options.map((option) => (
|
||||||
<div
|
<div
|
||||||
key={option.id}
|
key={option.id}
|
||||||
onClick={() => (onSelectOption ? onSelectOption(option.id) : null)}
|
className={clsx("flex border p-4 rounded-xl gap-2 cursor-pointer bg-white text-sm", optionColor(option.id))}>
|
||||||
className={clsx("flex border-2 p-4 rounded-xl gap-2 cursor-pointer bg-white", optionColor(option.id))}>
|
<span className="font-semibold">{option.id}.</span>
|
||||||
{showSolution && optionBadge(option.id)}
|
|
||||||
<span className="font-bold">{option.id}.</span>
|
|
||||||
<span>{option.text}</span>
|
<span>{option.text}</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -84,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);
|
||||||
}
|
}
|
||||||
@@ -97,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);
|
||||||
}
|
}
|
||||||
@@ -105,30 +83,40 @@ export default function MultipleChoice({prompt, questions, userSolutions, onNext
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="flex flex-col items-center gap-4">
|
<div className="flex flex-col gap-4 w-full h-full mb-20">
|
||||||
<span className="text-base md:text-lg font-medium text-center px-2 md:px-4 lg:px-48">{prompt}</span>
|
<div className="flex flex-col gap-2 mt-4 h-full bg-mti-gray-smoke rounded-xl px-16 py-8">
|
||||||
{questionIndex < questions.length && (
|
<span className="text-xl font-semibold">{prompt}</span>
|
||||||
<Question
|
{questionIndex < questions.length && (
|
||||||
{...questions[questionIndex]}
|
<Question
|
||||||
userSolution={userSolutions.find((x) => questions[questionIndex].id === x.question)?.option}
|
{...questions[questionIndex]}
|
||||||
showSolution
|
userSolution={userSolutions.find((x) => questions[questionIndex].id === x.question)?.option}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-4 items-center">
|
||||||
|
<div className="flex gap-2 items-center">
|
||||||
|
<div className="w-4 h-4 rounded-full bg-mti-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>
|
||||||
|
|
||||||
<div className="self-end flex flex-col-reverse items-center w-full md:justify-between md:items-start md:flex-row gap-8">
|
<div className="self-end flex justify-between w-full gap-8 absolute bottom-8 left-0 px-8">
|
||||||
<button className={clsx("btn btn-wide gap-4 relative text-white", errorButtonStyle)} onClick={back}>
|
<Button color="purple" variant="outline" onClick={back} className="max-w-[200px] w-full">
|
||||||
<div className="absolute left-4">
|
|
||||||
<Icon path={mdiArrowLeft} color="white" size={1} />
|
|
||||||
</div>
|
|
||||||
Back
|
Back
|
||||||
</button>
|
</Button>
|
||||||
<button className={clsx("btn btn-wide gap-4 relative text-white", infoButtonStyle)} onClick={next}>
|
|
||||||
|
<Button color="purple" onClick={next} className="max-w-[200px] self-end w-full">
|
||||||
Next
|
Next
|
||||||
<div className="absolute right-4">
|
</Button>
|
||||||
<Icon path={mdiArrowRight} color="white" size={1} />
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
104
src/components/Solutions/Speaking.tsx
Normal file
104
src/components/Solutions/Speaking.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
131
src/components/Solutions/TrueFalse.tsx
Normal file
131
src/components/Solutions/TrueFalse.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,10 +3,11 @@ import {WriteBlanksExercise} 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 {useEffect, useState} from "react";
|
import {Fragment, useEffect, useState} from "react";
|
||||||
import reactStringReplace from "react-string-replace";
|
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";
|
||||||
|
|
||||||
function Blank({
|
function Blank({
|
||||||
id,
|
id,
|
||||||
@@ -17,7 +18,7 @@ function Blank({
|
|||||||
setUserSolution,
|
setUserSolution,
|
||||||
}: {
|
}: {
|
||||||
id: string;
|
id: string;
|
||||||
solutions?: string[];
|
solutions: string[];
|
||||||
userSolution?: string;
|
userSolution?: string;
|
||||||
maxWords: number;
|
maxWords: number;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
@@ -34,27 +35,41 @@ function Blank({
|
|||||||
}
|
}
|
||||||
}, [maxWords, userInput, setUserSolution]);
|
}, [maxWords, userInput, setUserSolution]);
|
||||||
|
|
||||||
|
const isUserSolutionCorrect = () => userSolution && solutions.map((x) => x.trim().toLowerCase()).includes(userSolution.trim().toLowerCase());
|
||||||
|
|
||||||
const getSolutionStyling = () => {
|
const getSolutionStyling = () => {
|
||||||
if (solutions && userSolution) {
|
if (!userSolution) {
|
||||||
if (solutions.map((x) => x.trim().toLowerCase()).includes(userSolution.trim().toLowerCase())) return "text-green-500 border-green-500";
|
return "bg-mti-red-ultralight text-mti-red-light";
|
||||||
}
|
}
|
||||||
|
|
||||||
return "text-red-500 border-red-500";
|
return "bg-mti-purple-ultralight text-mti-purple-light";
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<input
|
<span className="inline-flex gap-2">
|
||||||
className={clsx("input border rounded-xl px-2 py-1 bg-white my-2", !solutions && "text-blue-400 border-blue-400", getSolutionStyling())}
|
{userSolution && !isUserSolutionCorrect() && (
|
||||||
placeholder={id}
|
<input
|
||||||
onChange={(e) => setUserInput(e.target.value)}
|
className="py-2 px-3 rounded-2xl w-48 focus:outline-none my-2 bg-mti-rose-ultralight text-mti-rose-light"
|
||||||
value={!solutions ? userInput : solutions.join(" / ")}
|
placeholder={id}
|
||||||
contentEditable={disabled}
|
onChange={(e) => setUserInput(e.target.value)}
|
||||||
/>
|
value={userSolution}
|
||||||
|
contentEditable={disabled}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<input
|
||||||
|
className={clsx("py-2 px-3 rounded-2xl w-48 focus:outline-none my-2", getSolutionStyling())}
|
||||||
|
placeholder={id}
|
||||||
|
onChange={(e) => setUserInput(e.target.value)}
|
||||||
|
value={!solutions ? userInput : solutions.join(" / ")}
|
||||||
|
contentEditable={disabled}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function WriteBlanksSolutions({
|
export default function WriteBlanksSolutions({
|
||||||
id,
|
id,
|
||||||
|
type,
|
||||||
prompt,
|
prompt,
|
||||||
maxWords,
|
maxWords,
|
||||||
solutions,
|
solutions,
|
||||||
@@ -63,9 +78,23 @@ 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>
|
<span className="text-base leading-5">
|
||||||
{reactStringReplace(line, /({{\d+}})/g, (match) => {
|
{reactStringReplace(line, /({{\d+}})/g, (match) => {
|
||||||
const id = match.replaceAll(/[\{\}]/g, "");
|
const id = match.replaceAll(/[\{\}]/g, "");
|
||||||
const userSolution = userSolutions.find((x) => x.id === id);
|
const userSolution = userSolutions.find((x) => x.id === id);
|
||||||
@@ -79,31 +108,54 @@ export default function WriteBlanksSolutions({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col gap-4 mt-4 h-full mb-20">
|
||||||
<span className="text-lg font-medium text-center px-48">{prompt}</span>
|
<span className="text-sm w-full leading-6">
|
||||||
<span>
|
{prompt.split("\\n").map((line, index) => (
|
||||||
{text.split("\\n").map((line) => (
|
<Fragment key={index}>
|
||||||
<>
|
{line}
|
||||||
{renderLines(line)}
|
|
||||||
<br />
|
<br />
|
||||||
</>
|
</Fragment>
|
||||||
))}
|
))}
|
||||||
</span>
|
</span>
|
||||||
|
<span className="bg-mti-gray-smoke rounded-xl px-5 py-6">
|
||||||
|
{text.split("\\n").map((line, index) => (
|
||||||
|
<p key={index}>
|
||||||
|
{renderLines(line)}
|
||||||
|
<br />
|
||||||
|
</p>
|
||||||
|
))}
|
||||||
|
</span>
|
||||||
|
<div className="flex gap-4 items-center">
|
||||||
|
<div className="flex gap-2 items-center">
|
||||||
|
<div className="w-4 h-4 rounded-full bg-mti-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>
|
||||||
|
|
||||||
<div className="self-end flex flex-col-reverse items-center w-full md:justify-between md:items-start md:flex-row gap-8">
|
<div className="self-end flex justify-between w-full gap-8 absolute bottom-8 left-0 px-8">
|
||||||
<button className={clsx("btn btn-wide gap-4 relative text-white", errorButtonStyle)} onClick={onBack}>
|
<Button
|
||||||
<div className="absolute left-4">
|
color="purple"
|
||||||
<Icon path={mdiArrowLeft} color="white" size={1} />
|
variant="outline"
|
||||||
</div>
|
onClick={() => onBack({exercise: id, solutions: userSolutions, score: calculateScore(), type})}
|
||||||
|
className="max-w-[200px] w-full">
|
||||||
Back
|
Back
|
||||||
</button>
|
</Button>
|
||||||
<button className={clsx("btn btn-wide gap-4 relative text-white", infoButtonStyle)} onClick={onNext}>
|
|
||||||
|
<Button
|
||||||
|
color="purple"
|
||||||
|
onClick={() => onNext({exercise: id, solutions: userSolutions, score: calculateScore(), type})}
|
||||||
|
className="max-w-[200px] self-end w-full">
|
||||||
Next
|
Next
|
||||||
<div className="absolute right-4">
|
</Button>
|
||||||
<Icon path={mdiArrowRight} color="white" size={1} />
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
115
src/components/Solutions/Writing.tsx
Normal file
115
src/components/Solutions/Writing.tsx
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
/* eslint-disable @next/next/no-img-element */
|
||||||
|
import {errorButtonStyle, infoButtonStyle} from "@/constants/buttonStyles";
|
||||||
|
import {WritingExercise} from "@/interfaces/exam";
|
||||||
|
import {mdiArrowLeft, mdiArrowRight} from "@mdi/js";
|
||||||
|
import Icon from "@mdi/react";
|
||||||
|
import clsx from "clsx";
|
||||||
|
import {CommonProps} from ".";
|
||||||
|
import {Fragment, useEffect, useState} from "react";
|
||||||
|
import {toast} from "react-toastify";
|
||||||
|
import Button from "../Low/Button";
|
||||||
|
import {Dialog, Transition} from "@headlessui/react";
|
||||||
|
|
||||||
|
export default function Writing({id, type, prompt, attachment, userSolutions, onNext, onBack}: WritingExercise & CommonProps) {
|
||||||
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{attachment && (
|
||||||
|
<Transition show={isModalOpen} as={Fragment}>
|
||||||
|
<Dialog onClose={() => setIsModalOpen(false)} className="relative z-50">
|
||||||
|
<Transition.Child
|
||||||
|
as={Fragment}
|
||||||
|
enter="ease-out duration-300"
|
||||||
|
enterFrom="opacity-0"
|
||||||
|
enterTo="opacity-100"
|
||||||
|
leave="ease-in duration-200"
|
||||||
|
leaveFrom="opacity-100"
|
||||||
|
leaveTo="opacity-0">
|
||||||
|
<div className="fixed inset-0 bg-black/30" />
|
||||||
|
</Transition.Child>
|
||||||
|
|
||||||
|
<Transition.Child
|
||||||
|
as={Fragment}
|
||||||
|
enter="ease-out duration-300"
|
||||||
|
enterFrom="opacity-0 scale-95"
|
||||||
|
enterTo="opacity-100 scale-100"
|
||||||
|
leave="ease-in duration-200"
|
||||||
|
leaveFrom="opacity-100 scale-100"
|
||||||
|
leaveTo="opacity-0 scale-95">
|
||||||
|
<div className="fixed inset-0 flex items-center justify-center p-4">
|
||||||
|
<Dialog.Panel className="w-fit h-fit rounded-xl bg-white">
|
||||||
|
<img src={attachment.url} alt={attachment.description} className="max-w-4xl w-full self-center rounded-xl p-4" />
|
||||||
|
</Dialog.Panel>
|
||||||
|
</div>
|
||||||
|
</Transition.Child>
|
||||||
|
</Dialog>
|
||||||
|
</Transition>
|
||||||
|
)}
|
||||||
|
<div className="flex flex-col h-full w-full gap-8 mb-20">
|
||||||
|
<div className="flex w-full gap-7 bg-mti-gray-smoke rounded-xl py-8 pb-12 px-16">
|
||||||
|
<span className="font-semibold">
|
||||||
|
{prompt.split("\\n").map((line, index) => (
|
||||||
|
<Fragment key={index}>
|
||||||
|
<p>{line}</p>
|
||||||
|
<br />
|
||||||
|
</Fragment>
|
||||||
|
))}
|
||||||
|
</span>
|
||||||
|
{attachment && (
|
||||||
|
<img
|
||||||
|
onClick={() => setIsModalOpen(true)}
|
||||||
|
src={attachment.url}
|
||||||
|
alt={attachment.description}
|
||||||
|
className="max-w-[200px] self-center rounded-xl cursor-pointer"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="w-full h-full flex flex-col gap-8">
|
||||||
|
{userSolutions && (
|
||||||
|
<div className="flex flex-col gap-4 w-full">
|
||||||
|
<span>Your answer:</span>
|
||||||
|
<textarea
|
||||||
|
className="w-full h-full min-h-[320px] cursor-text px-7 py-8 input border-2 border-mti-gray-platinum bg-white rounded-3xl"
|
||||||
|
contentEditable={false}
|
||||||
|
readOnly
|
||||||
|
value={userSolutions[0]!.solution}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{userSolutions && userSolutions.length > 0 && (
|
||||||
|
<div className="flex flex-col gap-4 w-full">
|
||||||
|
<div className="flex gap-4 px-1">
|
||||||
|
{Object.keys(userSolutions[0].evaluation!.task_response).map((key) => (
|
||||||
|
<div className="bg-ielts-writing text-ielts-writing-light rounded-xl px-4 py-2" key={key}>
|
||||||
|
{key}: Level {userSolutions[0].evaluation!.task_response[key]}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="w-full h-full min-h-fit cursor-text px-7 py-8 bg-mti-gray-smoke rounded-3xl">
|
||||||
|
{userSolutions[0].evaluation!.comment}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="self-end flex justify-between w-full gap-8 absolute bottom-8 left-0 px-8">
|
||||||
|
<Button
|
||||||
|
color="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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,25 +1,44 @@
|
|||||||
import {Exercise, FillBlanksExercise, MatchSentencesExercise, MultipleChoiceExercise, WriteBlanksExercise} 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";
|
||||||
|
|
||||||
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":
|
||||||
return <MultipleChoice {...(exercise as MultipleChoiceExercise)} onNext={onNext} onBack={onBack} />;
|
return <MultipleChoice {...(exercise as MultipleChoiceExercise)} onNext={onNext} onBack={onBack} />;
|
||||||
case "writeBlanks":
|
case "writeBlanks":
|
||||||
return <WriteBlanks {...(exercise as WriteBlanksExercise)} onNext={onNext} onBack={onBack} />;
|
return <WriteBlanks {...(exercise as WriteBlanksExercise)} onNext={onNext} onBack={onBack} />;
|
||||||
|
case "writing":
|
||||||
|
return <Writing {...(exercise as WritingExercise)} onNext={onNext} onBack={onBack} />;
|
||||||
|
case "speaking":
|
||||||
|
return <Speaking {...(exercise as SpeakingExercise)} onNext={onNext} onBack={onBack} />;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
49
src/components/TimerEndedModal.tsx
Normal file
49
src/components/TimerEndedModal.tsx
Normal 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'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>
|
||||||
|
);
|
||||||
|
}
|
||||||
72
src/components/Waveform.tsx
Normal file
72
src/components/Waveform.tsx
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
import React, {useEffect, useRef, useState} from "react";
|
||||||
|
import {BsPauseFill, BsPlayFill} from "react-icons/bs";
|
||||||
|
import WaveSurfer from "wavesurfer.js";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
audio: string;
|
||||||
|
waveColor: string;
|
||||||
|
progressColor: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Waveform = ({audio, waveColor, progressColor}: Props) => {
|
||||||
|
const containerRef = useRef(null);
|
||||||
|
const waveSurferRef = useRef<WaveSurfer | null>();
|
||||||
|
const [isPlaying, setIsPlaying] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const waveSurfer = WaveSurfer.create({
|
||||||
|
container: containerRef?.current || "",
|
||||||
|
responsive: true,
|
||||||
|
cursorWidth: 0,
|
||||||
|
height: 24,
|
||||||
|
waveColor,
|
||||||
|
progressColor,
|
||||||
|
barGap: 5,
|
||||||
|
barWidth: 8,
|
||||||
|
barRadius: 4,
|
||||||
|
fillParent: true,
|
||||||
|
hideScrollbar: true,
|
||||||
|
normalize: true,
|
||||||
|
autoCenter: true,
|
||||||
|
ignoreSilenceMode: true,
|
||||||
|
barMinHeight: 4,
|
||||||
|
});
|
||||||
|
waveSurfer.load(audio);
|
||||||
|
|
||||||
|
waveSurfer.on("ready", () => {
|
||||||
|
waveSurferRef.current = waveSurfer;
|
||||||
|
});
|
||||||
|
|
||||||
|
waveSurfer.on("finish", () => setIsPlaying(false));
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
waveSurfer.destroy();
|
||||||
|
};
|
||||||
|
}, [audio, progressColor, waveColor]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{isPlaying && (
|
||||||
|
<BsPauseFill
|
||||||
|
className="text-mti-gray-cool cursor-pointer w-5 h-5"
|
||||||
|
onClick={() => {
|
||||||
|
setIsPlaying((prev) => !prev);
|
||||||
|
waveSurferRef.current?.playPause();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{!isPlaying && (
|
||||||
|
<BsPlayFill
|
||||||
|
className="text-mti-gray-cool cursor-pointer w-5 h-5"
|
||||||
|
onClick={() => {
|
||||||
|
setIsPlaying((prev) => !prev);
|
||||||
|
waveSurferRef.current?.playPause();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<div className="w-full max-w-4xl h-fit" ref={containerRef} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Waveform;
|
||||||
10
src/constants/errors.ts
Normal file
10
src/constants/errors.ts
Normal 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",
|
||||||
|
};
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
import {Module} from "@/interfaces";
|
|
||||||
|
|
||||||
export const BAND_SCORES: {[key in Module]: number[]} = {
|
|
||||||
reading: [0, 2.5, 3, 3.5, 4, 4.5, 5, 5.5, 6, 6.5, 7, 7.5, 8, 8.5, 9],
|
|
||||||
listening: [0, 2.5, 3, 3.5, 4, 4.5, 5, 5.5, 6, 6.5, 7, 7.5, 8, 8.5, 9],
|
|
||||||
writing: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9],
|
|
||||||
speaking: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9],
|
|
||||||
};
|
|
||||||
86
src/constants/ielts.tsx
Normal file
86
src/constants/ielts.tsx
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
import {Module} from "@/interfaces";
|
||||||
|
|
||||||
|
export const MODULES: Module[] = ["reading", "listening", "writing", "speaking"];
|
||||||
|
|
||||||
|
export const BAND_SCORES: {[key in Module]: number[]} = {
|
||||||
|
reading: [0, 2.5, 3, 3.5, 4, 4.5, 5, 5.5, 6, 6.5, 7, 7.5, 8, 8.5, 9],
|
||||||
|
listening: [0, 2.5, 3, 3.5, 4, 4.5, 5, 5.5, 6, 6.5, 7, 7.5, 8, 8.5, 9],
|
||||||
|
writing: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9],
|
||||||
|
speaking: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9],
|
||||||
|
};
|
||||||
|
|
||||||
|
export const LEVEL_TEXT = {
|
||||||
|
excellent:
|
||||||
|
"Congratulations on your exam performance! You achieved an impressive {{level}}, demonstrating excellent mastery of the assessed knowledge.\n\nIf you disagree with the result, you can request a review by a qualified teacher. We are committed to the accuracy and transparency of the results.\n\nPlease contact us for further information. Congratulations again on your outstanding achievement! We are here to support you on your academic journey.",
|
||||||
|
high: "Congratulations on your exam performance! You achieved a commendable {{level}}, demonstrating a good understanding of the assessed knowledge.\n\nIf you have any concerns about the result, you can request a review by a qualified teacher. We are committed to the accuracy and transparency of the results.\n\nPlease contact us for further information. Congratulations again on your achievement! We are here to support you on your academic journey.",
|
||||||
|
medium: "Congratulations on your exam performance! You achieved a {{level}}, demonstrating a satisfactory understanding of the assessed knowledge.\n\nIf you have any concerns about the result, you can request a review by a qualified teacher. We are committed to the accuracy and transparency of the results.\n\nPlease contact us for further information. Congratulations again on your achievement! We are here to support you on your academic journey.",
|
||||||
|
low: "Thank you for taking the exam. You achieved a {{level}}, but unfortunately, it did not meet the required standards.\n\nIf you have any concerns about the result, you can request a review by a qualified teacher. We are committed to the accuracy and transparency of the results.\n\nPlease contact us for further information. We encourage you to continue your studies and wish you the best of luck in your future endeavors.",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const levelText = (level: number) => {
|
||||||
|
if (level === 9) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
Congratulations on your exam performance! You achieved an impressive <span className="font-bold">level {level}</span>, demonstrating
|
||||||
|
excellent mastery of the assessed knowledge.
|
||||||
|
<br />
|
||||||
|
<br />
|
||||||
|
If you disagree with the result, you can request a review by a qualified teacher. We are committed to the accuracy and transparency of
|
||||||
|
the results.
|
||||||
|
<br />
|
||||||
|
<br />
|
||||||
|
Please contact us for further information. Congratulations again on your outstanding achievement! We are here to support you on your
|
||||||
|
academic journey.
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (level >= 6) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
Congratulations on your exam performance! You achieved a commendable <span className="font-bold">level {level}</span>, demonstrating a
|
||||||
|
good understanding of the assessed knowledge.
|
||||||
|
<br />
|
||||||
|
<br />
|
||||||
|
If you have any concerns about the result, you can request a review by a qualified teacher. We are committed to the accuracy and
|
||||||
|
transparency of the results.
|
||||||
|
<br />
|
||||||
|
<br />
|
||||||
|
Please contact us for further information. Congratulations again on your achievement! We are here to support you on your academic
|
||||||
|
journey.
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (level >= 3) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
Congratulations on your exam performance! You achieved a <span className="font-bold">level of {level}</span>, demonstrating a
|
||||||
|
satisfactory understanding of the assessed knowledge.
|
||||||
|
<br />
|
||||||
|
<br />
|
||||||
|
If you have any concerns about the result, you can request a review by a qualified teacher. We are committed to the accuracy and
|
||||||
|
transparency of the results.
|
||||||
|
<br />
|
||||||
|
<br />
|
||||||
|
Please contact us for further information. Congratulations again on your achievement! We are here to support you on your academic
|
||||||
|
journey.
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
Thank you for taking the exam. You achieved a <span className="font-bold">level {level}</span>, but unfortunately, it did not meet the
|
||||||
|
required standards.
|
||||||
|
<br />
|
||||||
|
<br />
|
||||||
|
If you have any concerns about the result, you can request a review by a qualified teacher. We are committed to the accuracy and
|
||||||
|
transparency of the results.
|
||||||
|
<br />
|
||||||
|
<br />
|
||||||
|
Please contact us for further information. We encourage you to continue your studies and wish you the best of luck in your future
|
||||||
|
endeavors.
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,92 +1,202 @@
|
|||||||
import ProfileLevel from "@/components/ProfileLevel";
|
import Button from "@/components/Low/Button";
|
||||||
import {errorButtonStyle, infoButtonStyle} from "@/constants/buttonStyles";
|
import ModuleTitle from "@/components/Medium/ModuleTitle";
|
||||||
|
import {levelText, LEVEL_TEXT} from "@/constants/ielts";
|
||||||
import {Module} from "@/interfaces";
|
import {Module} from "@/interfaces";
|
||||||
import {User} from "@/interfaces/user";
|
import {User} from "@/interfaces/user";
|
||||||
import {ICONS} from "@/resources/modules";
|
import useExamStore from "@/stores/examStore";
|
||||||
import {mdiArrowLeft, mdiArrowRight} from "@mdi/js";
|
import {calculateBandScore} from "@/utils/score";
|
||||||
import Icon from "@mdi/react";
|
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import router from "next/router";
|
import {useRouter} from "next/router";
|
||||||
|
import {Fragment, useEffect, useState} from "react";
|
||||||
|
import {BsArrowCounterclockwise, BsBook, BsEyeFill, BsHeadphones, BsMegaphone, BsPen, BsShareFill} from "react-icons/bs";
|
||||||
|
|
||||||
|
interface Score {
|
||||||
|
module: Module;
|
||||||
|
correct: number;
|
||||||
|
total: number;
|
||||||
|
missing: number;
|
||||||
|
}
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
user: User;
|
user: User;
|
||||||
modules: Module[];
|
modules: Module[];
|
||||||
scores: {
|
scores: Score[];
|
||||||
module?: Module;
|
isLoading: boolean;
|
||||||
correct: number;
|
|
||||||
total: number;
|
|
||||||
}[];
|
|
||||||
onViewResults: () => void;
|
onViewResults: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Finish({user, scores, modules, onViewResults}: Props) {
|
export default function Finish({user, scores, modules, isLoading, onViewResults}: Props) {
|
||||||
const renderModuleScore = (module: Module) => {
|
const [selectedModule, setSelectedModule] = useState(modules[0]);
|
||||||
const moduleScores = scores.filter((x) => x.module === module);
|
const [selectedScore, setSelectedScore] = useState<Score>(scores.find((x) => x.module === modules[0])!);
|
||||||
if (moduleScores.length === 0) {
|
|
||||||
return <>0</>;
|
|
||||||
}
|
|
||||||
|
|
||||||
return moduleScores.map((x, index) => (
|
const exams = useExamStore((state) => state.exams);
|
||||||
<span key={index}>
|
|
||||||
{x.correct} / {x.total}
|
|
||||||
</span>
|
|
||||||
));
|
|
||||||
};
|
|
||||||
|
|
||||||
const renderModuleTotal = (module: Module) => {
|
useEffect(() => setSelectedScore(scores.find((x) => x.module === selectedModule)!), [scores, selectedModule]);
|
||||||
const moduleScores = scores.filter((x) => x.module === module);
|
|
||||||
if (moduleScores.length === 0) {
|
|
||||||
return <>0</>;
|
|
||||||
}
|
|
||||||
|
|
||||||
const total = moduleScores.reduce((accumulator, current) => accumulator + current.total, 0);
|
const moduleColors: {[key in Module]: {progress: string; inner: string}} = {
|
||||||
const correct = moduleScores.reduce((accumulator, current) => accumulator + current.correct, 0);
|
reading: {
|
||||||
|
progress: "text-ielts-reading",
|
||||||
return (
|
inner: "bg-ielts-reading-light",
|
||||||
<span>
|
},
|
||||||
{correct} / {total} | {Math.floor((correct / total) * 100)}%
|
listening: {
|
||||||
</span>
|
progress: "text-ielts-listening",
|
||||||
);
|
inner: "bg-ielts-listening-light",
|
||||||
|
},
|
||||||
|
writing: {
|
||||||
|
progress: "text-ielts-writing",
|
||||||
|
inner: "bg-ielts-writing-light",
|
||||||
|
},
|
||||||
|
speaking: {
|
||||||
|
progress: "text-ielts-speaking",
|
||||||
|
inner: "bg-ielts-speaking-light",
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full h-full relative">
|
<>
|
||||||
<section className="h-full w-full flex flex-col items-center justify-center gap-4">
|
<div className="w-full min-h-full h-fit flex flex-col items-center justify-between gap-8">
|
||||||
<ProfileLevel user={user} className="h-1/2" />
|
<ModuleTitle
|
||||||
<div className="h-2/3 w-2/3 flex flex-col items-center gap-4">
|
module={selectedModule}
|
||||||
<div className="rounded-xl p-2 md:p-4 items-center flex lg:justify-center gap-2 md:gap-4 max-w-[100%] overflow-auto">
|
totalExercises={exams.find((x) => x.module === selectedModule)!.exercises.length}
|
||||||
{modules.map((module) => (
|
exerciseIndex={exams.find((x) => x.module === selectedModule)!.exercises.length}
|
||||||
<div
|
minTimer={exams.find((x) => x.module === selectedModule)!.minTimer}
|
||||||
className={`flex flex-col gap-12 min-w-[176px] items-center justify-center border-2 border-ielts-${module} bg-ielts-${module}-transparent py-4 px-2 rounded-xl text-white font-semibold`}
|
disableTimer
|
||||||
key={module}>
|
/>
|
||||||
<div className="flex flex-col items-center">
|
<div className="flex gap-4 self-start">
|
||||||
<Icon path={ICONS[module]} color="white" size={2} />
|
{modules.includes("reading") && (
|
||||||
<span>{module.toUpperCase()}</span>
|
<div
|
||||||
</div>
|
onClick={() => setSelectedModule("reading")}
|
||||||
<div className="flex flex-col">{renderModuleScore(module)}</div>
|
className={clsx(
|
||||||
<div>TOTAL: {renderModuleTotal(module)}</div>
|
"flex gap-2 items-center rounded-xl p-4 cursor-pointer hover:shadow-lg transition duration-300 ease-in-out hover:bg-ielts-reading hover:text-white",
|
||||||
</div>
|
selectedModule === "reading" ? "bg-ielts-reading text-white" : "bg-mti-gray-smoke text-ielts-reading",
|
||||||
))}
|
)}>
|
||||||
</div>
|
<BsBook className="w-6 h-6" />
|
||||||
<div className="w-full flex flex-col gap-4 lg:gap-0 md:flex-row justify-center items-center lg:justify-between">
|
<span className="font-semibold">Reading</span>
|
||||||
<Link href="/">
|
</div>
|
||||||
<button className={clsx("btn btn-wide gap-4 relative text-white", errorButtonStyle)}>
|
)}
|
||||||
<div className="absolute left-4">
|
{modules.includes("listening") && (
|
||||||
<Icon path={mdiArrowLeft} color="white" size={1} />
|
<div
|
||||||
</div>
|
onClick={() => setSelectedModule("listening")}
|
||||||
Go Home
|
className={clsx(
|
||||||
</button>
|
"flex gap-2 items-center rounded-xl p-4 cursor-pointer hover:shadow-lg transition duration-300 ease-in-out hover:bg-ielts-listening hover:text-white",
|
||||||
</Link>
|
selectedModule === "listening" ? "bg-ielts-listening text-white" : "bg-mti-gray-smoke text-ielts-listening",
|
||||||
<button className={clsx("btn btn-wide gap-4 relative text-white", infoButtonStyle)} onClick={onViewResults}>
|
)}>
|
||||||
View Solutions
|
<BsHeadphones className="w-6 h-6" />
|
||||||
<div className="absolute right-4">
|
<span className="font-semibold">Listening</span>
|
||||||
<Icon path={mdiArrowRight} color="white" size={1} />
|
</div>
|
||||||
</div>
|
)}
|
||||||
</button>
|
{modules.includes("writing") && (
|
||||||
</div>
|
<div
|
||||||
|
onClick={() => setSelectedModule("writing")}
|
||||||
|
className={clsx(
|
||||||
|
"flex gap-2 items-center rounded-xl p-4 cursor-pointer hover:shadow-lg transition duration-300 ease-in-out hover:bg-ielts-writing hover:text-white",
|
||||||
|
selectedModule === "writing" ? "bg-ielts-writing text-white" : "bg-mti-gray-smoke text-ielts-writing",
|
||||||
|
)}>
|
||||||
|
<BsPen className="w-6 h-6" />
|
||||||
|
<span className="font-semibold">Writing</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{modules.includes("speaking") && (
|
||||||
|
<div
|
||||||
|
onClick={() => setSelectedModule("speaking")}
|
||||||
|
className={clsx(
|
||||||
|
"flex gap-2 items-center rounded-xl p-4 cursor-pointer hover:shadow-lg transition duration-300 ease-in-out hover:bg-ielts-speaking hover:text-white",
|
||||||
|
selectedModule === "speaking" ? "bg-ielts-speaking text-white" : "bg-mti-gray-smoke text-ielts-speaking",
|
||||||
|
)}>
|
||||||
|
<BsMegaphone className="w-6 h-6" />
|
||||||
|
<span className="font-semibold">Speaking</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
{isLoading && (
|
||||||
</div>
|
<div className="w-fit h-fit absolute top-1/2 -translate-y-1/2 left-1/2 -translate-x-1/2 animate-pulse flex flex-col gap-12 items-center">
|
||||||
|
<span className={clsx("loading loading-infinity w-32", moduleColors[selectedModule].progress)} />
|
||||||
|
<span className={clsx("font-bold text-2xl", moduleColors[selectedModule].progress)}>Evaluating your answers...</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!isLoading && (
|
||||||
|
<div className="w-full flex gap-9 mt-32 items-center justify-between">
|
||||||
|
<span className="max-w-3xl">
|
||||||
|
{levelText(calculateBandScore(selectedScore.correct, selectedScore.total, selectedModule, user.focus))}
|
||||||
|
</span>
|
||||||
|
<div className="flex gap-9 px-16">
|
||||||
|
<div
|
||||||
|
className={clsx("radial-progress overflow-hidden", moduleColors[selectedModule].progress)}
|
||||||
|
style={
|
||||||
|
{"--value": (selectedScore.correct / selectedScore.total) * 100, "--thickness": "12px", "--size": "13rem"} as any
|
||||||
|
}>
|
||||||
|
<div
|
||||||
|
className={clsx(
|
||||||
|
"w-48 h-48 rounded-full flex flex-col items-center justify-center",
|
||||||
|
moduleColors[selectedModule].inner,
|
||||||
|
)}>
|
||||||
|
<span className="text-xl">Level</span>
|
||||||
|
<span className="text-3xl font-bold">
|
||||||
|
{calculateBandScore(selectedScore.correct, selectedScore.total, selectedModule, user.focus)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-5">
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<div className="w-3 h-3 bg-mti-red-light rounded-full mt-1" />
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="text-mti-red-light">
|
||||||
|
{(((selectedScore.total - selectedScore.missing) / selectedScore.total) * 100).toFixed(0)}%
|
||||||
|
</span>
|
||||||
|
<span className="text-lg">Completion</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<div className="w-3 h-3 bg-mti-purple-light rounded-full mt-1" />
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="text-mti-purple-light">{selectedScore.correct.toString().padStart(2, "0")}</span>
|
||||||
|
<span className="text-lg">Correct</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<div className="w-3 h-3 bg-mti-rose-light rounded-full mt-1" />
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="text-mti-rose-light">
|
||||||
|
{(selectedScore.total - selectedScore.correct).toString().padStart(2, "0")}
|
||||||
|
</span>
|
||||||
|
<span className="text-lg">Wrong</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!isLoading && (
|
||||||
|
<div className="self-end flex justify-between w-full gap-8 absolute bottom-8 left-0 px-8">
|
||||||
|
<div className="flex gap-8">
|
||||||
|
<div className="w-fit flex flex-col items-center gap-1 cursor-pointer">
|
||||||
|
<button
|
||||||
|
onClick={() => window.location.reload()}
|
||||||
|
className="w-11 h-11 rounded-full bg-mti-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" />
|
||||||
|
</button>
|
||||||
|
<span>Play Again</span>
|
||||||
|
</div>
|
||||||
|
<div className="w-fit flex flex-col items-center gap-1 cursor-pointer">
|
||||||
|
<button
|
||||||
|
onClick={onViewResults}
|
||||||
|
className="w-11 h-11 rounded-full bg-mti-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" />
|
||||||
|
</button>
|
||||||
|
<span>Review Answers</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Link href="/" className="max-w-[200px] w-full self-end">
|
||||||
|
<Button color="purple" className="max-w-[200px] self-end w-full">
|
||||||
|
Dashboard
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,17 @@
|
|||||||
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";
|
||||||
import {infoButtonStyle} from "@/constants/buttonStyles";
|
import {infoButtonStyle} from "@/constants/buttonStyles";
|
||||||
import {renderExercise} from "@/components/Exercises";
|
import {renderExercise} from "@/components/Exercises";
|
||||||
import {renderSolution} from "@/components/Solutions";
|
import {renderSolution} from "@/components/Solutions";
|
||||||
|
import ModuleTitle from "@/components/Medium/ModuleTitle";
|
||||||
|
import AudioPlayer from "@/components/Low/AudioPlayer";
|
||||||
|
import Button from "@/components/Low/Button";
|
||||||
|
import BlankQuestionsModal from "@/components/BlankQuestionsModal";
|
||||||
|
import useExamStore from "@/stores/examStore";
|
||||||
|
import {defaultUserSolutions} from "@/utils/exams";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
exam: ListeningExam;
|
exam: ListeningExam;
|
||||||
@@ -16,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})),
|
||||||
@@ -37,61 +75,70 @@ export default function Listening({exam, showSolutions = false, onFinish}: Props
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const previousExercise = () => {
|
const previousExercise = (solution?: UserSolution) => {
|
||||||
|
if (solution) {
|
||||||
|
setUserSolutions((prev) => [...prev.filter((x) => x.exercise !== solution.exercise), solution]);
|
||||||
|
}
|
||||||
|
|
||||||
setExerciseIndex((prev) => prev - 1);
|
setExerciseIndex((prev) => prev - 1);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const getExercise = () => {
|
||||||
|
const exercise = exam.exercises[exerciseIndex];
|
||||||
|
return {
|
||||||
|
...exercise,
|
||||||
|
userSolutions: userSolutions.find((x) => x.exercise === exercise.id)?.solutions || [],
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
const renderAudioPlayer = () => (
|
const renderAudioPlayer = () => (
|
||||||
<>
|
<div className="flex flex-col gap-8 w-full bg-mti-gray-seasalt rounded-xl py-8 px-16">
|
||||||
{exerciseIndex === -1 && (
|
<div className="flex flex-col w-full gap-2">
|
||||||
<div className="flex flex-col">
|
<h4 className="text-xl font-semibold">Please listen to the following audio attentively.</h4>
|
||||||
<span className="text-lg font-semibold">Please listen to the following audio attentively.</span>
|
<span className="text-base">
|
||||||
{exam.audio.repeatableTimes > 0 ? (
|
{exam.audio.repeatableTimes > 0
|
||||||
<span className="self-center text-sm">
|
? `You will only be allowed to listen to the audio ${exam.audio.repeatableTimes - timesListened} time(s).`
|
||||||
You will only be allowed to listen to the audio {exam.audio.repeatableTimes} time(s).
|
: "You may listen to the audio as many times as you would like."}
|
||||||
</span>
|
</span>
|
||||||
) : (
|
|
||||||
<span className="self-center text-sm">You may listen to the audio as many times as you would like.</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="rounded-xl flex flex-col gap-4 items-center w-full overflow-auto">
|
|
||||||
{exam.audio.repeatableTimes > 0 && (
|
|
||||||
<>{exam.audio.repeatableTimes <= timesListened && <span>You are no longer allowed to listen to the audio again.</span>}</>
|
|
||||||
)}
|
|
||||||
{exam.audio.repeatableTimes > 0 && timesListened < exam.audio.repeatableTimes && (
|
|
||||||
<audio preload="auto" controls autoPlay onPlay={() => setTimesListened((prev) => prev + 1)}>
|
|
||||||
<source src={exam.audio.source} type="audio/mpeg" />
|
|
||||||
Your browser does not support the audio element
|
|
||||||
</audio>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</>
|
<div className="rounded-xl flex flex-col gap-4 items-center w-full h-fit">
|
||||||
|
<AudioPlayer
|
||||||
|
src={exam.audio.source}
|
||||||
|
color="listening"
|
||||||
|
onEnd={() => setTimesListened((prev) => prev + 1)}
|
||||||
|
disabled={timesListened === exam.audio.repeatableTimes}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="w-full relative flex flex-col gap-8 items-center justify-center p-8 px-16 overflow-hidden">
|
<BlankQuestionsModal isOpen={showBlankModal} onClose={confirmFinishModule} />
|
||||||
{renderAudioPlayer()}
|
<div className="flex flex-col h-full w-full gap-8 justify-between">
|
||||||
|
<ModuleTitle
|
||||||
|
exerciseIndex={exerciseIndex + 1}
|
||||||
|
minTimer={exam.minTimer}
|
||||||
|
module="listening"
|
||||||
|
totalExercises={exam.exercises.length}
|
||||||
|
disableTimer={showSolutions}
|
||||||
|
/>
|
||||||
|
{exerciseIndex === -1 && renderAudioPlayer()}
|
||||||
{exerciseIndex > -1 &&
|
{exerciseIndex > -1 &&
|
||||||
exerciseIndex < exam.exercises.length &&
|
exerciseIndex < exam.exercises.length &&
|
||||||
!showSolutions &&
|
!showSolutions &&
|
||||||
renderExercise(exam.exercises[exerciseIndex], nextExercise, previousExercise)}
|
renderExercise(getExercise(), nextExercise, previousExercise)}
|
||||||
{exerciseIndex > -1 &&
|
{exerciseIndex > -1 &&
|
||||||
exerciseIndex < exam.exercises.length &&
|
exerciseIndex < exam.exercises.length &&
|
||||||
showSolutions &&
|
showSolutions &&
|
||||||
renderSolution(exam.exercises[exerciseIndex], nextExercise, previousExercise)}
|
renderSolution(exam.exercises[exerciseIndex], nextExercise, previousExercise)}
|
||||||
{exerciseIndex === -1 && (
|
|
||||||
<button
|
|
||||||
className={clsx("btn md:btn-wide w-full gap-4 relative text-white self-end", infoButtonStyle)}
|
|
||||||
onClick={() => nextExercise()}>
|
|
||||||
Next
|
|
||||||
<div className="absolute right-4">
|
|
||||||
<Icon path={mdiArrowRight} color="white" size={1} />
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{exerciseIndex === -1 && (
|
||||||
|
<Button color="purple" onClick={() => nextExercise()} className="max-w-[200px] self-end w-full justify-self-end">
|
||||||
|
Start now
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,11 +4,20 @@ import Icon from "@mdi/react";
|
|||||||
import {mdiArrowRight, mdiNotebook} from "@mdi/js";
|
import {mdiArrowRight, mdiNotebook} from "@mdi/js";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import {infoButtonStyle} from "@/constants/buttonStyles";
|
import {infoButtonStyle} from "@/constants/buttonStyles";
|
||||||
|
import {convertCamelCaseToReadable} from "@/utils/string";
|
||||||
import {Dialog, Transition} from "@headlessui/react";
|
import {Dialog, Transition} from "@headlessui/react";
|
||||||
import {renderExercise} from "@/components/Exercises";
|
import {renderExercise} from "@/components/Exercises";
|
||||||
import {renderSolution} from "@/components/Solutions";
|
import {renderSolution} from "@/components/Solutions";
|
||||||
import {Panel} from "primereact/panel";
|
import {Panel} from "primereact/panel";
|
||||||
import {Steps} from "primereact/steps";
|
import {Steps} from "primereact/steps";
|
||||||
|
import {BsAlarm, BsBook, BsClock, BsStopwatch} from "react-icons/bs";
|
||||||
|
import ProgressBar from "@/components/Low/ProgressBar";
|
||||||
|
import ModuleTitle from "@/components/Medium/ModuleTitle";
|
||||||
|
import {Divider} from "primereact/divider";
|
||||||
|
import Button from "@/components/Low/Button";
|
||||||
|
import BlankQuestionsModal from "@/components/BlankQuestionsModal";
|
||||||
|
import useExamStore from "@/stores/examStore";
|
||||||
|
import {defaultUserSolutions} from "@/utils/exams";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
exam: ReadingExam;
|
exam: ReadingExam;
|
||||||
@@ -41,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}
|
||||||
@@ -56,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>
|
||||||
@@ -76,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})),
|
||||||
@@ -97,68 +135,79 @@ export default function Reading({exam, showSolutions = false, onFinish}: Props)
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const previousExercise = () => {
|
const previousExercise = (solution?: UserSolution) => {
|
||||||
|
if (solution) {
|
||||||
|
setUserSolutions((prev) => [...prev.filter((x) => x.exercise !== solution.exercise), solution]);
|
||||||
|
}
|
||||||
|
|
||||||
setExerciseIndex((prev) => prev - 1);
|
setExerciseIndex((prev) => prev - 1);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const getExercise = () => {
|
||||||
|
const exercise = exam.exercises[exerciseIndex];
|
||||||
|
return {
|
||||||
|
...exercise,
|
||||||
|
userSolutions: userSolutions.find((x) => x.exercise === exercise.id)?.solutions || [],
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
const renderText = () => (
|
const renderText = () => (
|
||||||
<div className="flex flex-col gap-4 w-full">
|
<div className="flex flex-col gap-6 w-full bg-mti-gray-seasalt rounded-xl py-8 px-16">
|
||||||
<div className="flex flex-col w-full gap-2">
|
<div className="flex flex-col w-full gap-2">
|
||||||
<span className="text-base md:text-lg font-semibold">
|
<h4 className="text-xl font-semibold">
|
||||||
Please read the following excerpt attentively, you will then be asked questions about the text you've read.
|
Please read the following excerpt attentively, you will then be asked questions about the text you've read.
|
||||||
</span>
|
</h4>
|
||||||
<span className="self-end text-sm">You will be allowed to read the text while doing the exercises</span>
|
<span className="text-base">You will be allowed to read the text while doing the exercises</span>
|
||||||
</div>
|
</div>
|
||||||
<Panel header={exam.text.title}>
|
<div className="flex flex-col gap-2 w-full">
|
||||||
<p className="overflow-auto">
|
<h3 className="text-xl font-semibold">{exam.text.title}</h3>
|
||||||
|
<div className="border border-mti-gray-dim w-full rounded-full opacity-10" />
|
||||||
|
<span className="overflow-auto">
|
||||||
{exam.text.content.split("\\n").map((line, index) => (
|
{exam.text.content.split("\\n").map((line, index) => (
|
||||||
<p key={index}>{line}</p>
|
<p key={index}>{line}</p>
|
||||||
))}
|
))}
|
||||||
</p>
|
</span>
|
||||||
</Panel>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<TextModal {...exam.text} isOpen={showTextModal} onClose={() => setShowTextModal(false)} />
|
<div className="flex flex-col h-full w-full gap-8">
|
||||||
<div className="w-full h-full relative flex flex-col gap-8 items-center justify-center p-2 md:p-8 px-4 md:px-16 overflow-hidden">
|
<BlankQuestionsModal isOpen={showBlankModal} onClose={confirmFinishModule} />
|
||||||
|
<TextModal {...exam.text} isOpen={showTextModal} onClose={() => setShowTextModal(false)} />
|
||||||
|
<ModuleTitle
|
||||||
|
minTimer={exam.minTimer}
|
||||||
|
exerciseIndex={exerciseIndex + 1}
|
||||||
|
module="reading"
|
||||||
|
totalExercises={exam.exercises.length}
|
||||||
|
disableTimer={showSolutions}
|
||||||
|
label={exerciseIndex === -1 ? undefined : convertCamelCaseToReadable(exam.exercises[exerciseIndex].type)}
|
||||||
|
/>
|
||||||
{exerciseIndex === -1 && renderText()}
|
{exerciseIndex === -1 && renderText()}
|
||||||
{exerciseIndex > -1 &&
|
{exerciseIndex > -1 &&
|
||||||
exerciseIndex < exam.exercises.length &&
|
exerciseIndex < exam.exercises.length &&
|
||||||
!showSolutions &&
|
!showSolutions &&
|
||||||
renderExercise(exam.exercises[exerciseIndex], nextExercise, previousExercise)}
|
renderExercise(getExercise(), nextExercise, previousExercise)}
|
||||||
{exerciseIndex > -1 &&
|
{exerciseIndex > -1 &&
|
||||||
exerciseIndex < exam.exercises.length &&
|
exerciseIndex < exam.exercises.length &&
|
||||||
showSolutions &&
|
showSolutions &&
|
||||||
renderSolution(exam.exercises[exerciseIndex], nextExercise, previousExercise)}
|
renderSolution(exam.exercises[exerciseIndex], nextExercise, previousExercise)}
|
||||||
<div
|
{exerciseIndex > -1 && exerciseIndex < exam.exercises.length && (
|
||||||
className={clsx("flex gap-8", exerciseIndex > -1 ? "w-full justify-center md:justify-between" : "self-end w-full md:w-fit flex")}>
|
<Button
|
||||||
{exerciseIndex > -1 && (
|
color="purple"
|
||||||
<button
|
variant="outline"
|
||||||
className={clsx(
|
onClick={() => setShowTextModal(true)}
|
||||||
"btn btn-wide gap-4 relative text-white",
|
className="max-w-[200px] self-end w-full absolute bottom-[31px] right-64">
|
||||||
"border-2 border-ielts-reading hover:bg-ielts-reading hover:border-ielts-reading bg-ielts-reading-transparent",
|
Read text
|
||||||
)}
|
</Button>
|
||||||
onClick={() => setShowTextModal(true)}>
|
)}
|
||||||
Read Text
|
|
||||||
<div className="absolute right-4">
|
|
||||||
<Icon path={mdiNotebook} color="white" size={1} />
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
{exerciseIndex === -1 && (
|
|
||||||
<button
|
|
||||||
className={clsx("btn w-full md:btn-wide gap-4 relative text-white self-end", infoButtonStyle)}
|
|
||||||
onClick={() => nextExercise()}>
|
|
||||||
Next
|
|
||||||
<div className="absolute right-4">
|
|
||||||
<Icon path={mdiArrowRight} color="white" size={1} />
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
{exerciseIndex === -1 && (
|
||||||
|
<Button color="purple" onClick={() => nextExercise()} className="max-w-[200px] self-end w-full">
|
||||||
|
Start now
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,24 +1,25 @@
|
|||||||
/* eslint-disable @next/next/no-img-element */
|
/* eslint-disable @next/next/no-img-element */
|
||||||
import Icon from "@mdi/react";
|
import {useState} from "react";
|
||||||
import {mdiAccountVoice, mdiArrowLeft, mdiArrowRight, mdiBookOpen, mdiHeadphones, mdiPen} from "@mdi/js";
|
|
||||||
import {useEffect, useState} from "react";
|
|
||||||
import {Module} from "@/interfaces";
|
import {Module} from "@/interfaces";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import {useRouter} from "next/router";
|
|
||||||
import {errorButtonStyle, infoButtonStyle} from "@/constants/buttonStyles";
|
|
||||||
import ProfileLevel from "@/components/ProfileLevel";
|
|
||||||
import {User} from "@/interfaces/user";
|
import {User} from "@/interfaces/user";
|
||||||
import useExamStore from "@/stores/examStore";
|
import ProgressBar from "@/components/Low/ProgressBar";
|
||||||
|
import {BsBook, BsCheckCircle, BsHeadphones, BsMegaphone, BsPen} from "react-icons/bs";
|
||||||
|
import {totalExamsByModule} from "@/utils/stats";
|
||||||
|
import useStats from "@/hooks/useStats";
|
||||||
|
import Button from "@/components/Low/Button";
|
||||||
|
import {calculateAverageLevel} from "@/utils/score";
|
||||||
|
import {sortByModuleName} from "@/utils/moduleUtils";
|
||||||
|
|
||||||
interface Props {
|
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 router = useRouter();
|
|
||||||
|
|
||||||
const toggleModule = (module: Module) => {
|
const toggleModule = (module: Module) => {
|
||||||
const modules = selectedModules.filter((x) => x !== module);
|
const modules = selectedModules.filter((x) => x !== module);
|
||||||
@@ -27,82 +28,168 @@ export default function Selection({user, onStart}: Props) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="w-full h-full relative">
|
<div className="w-full h-full relative flex flex-col gap-16">
|
||||||
<section className="h-full w-full flex flex-col items-center justify-center gap-8">
|
<section className="w-full flex gap-8">
|
||||||
<ProfileLevel user={user} className="h-1/2" />
|
<img src={user.profilePicture} alt={user.name} className="aspect-square h-64 rounded-3xl drop-shadow-xl object-cover" />
|
||||||
<div className="h-1/2 flex flex-col gap-8">
|
<div className="flex flex-col gap-4 py-4 w-full">
|
||||||
<div className="h-1/2 items-center flex flex-col lg:flex-row gap-8">
|
<div className="flex justify-between w-full gap-8">
|
||||||
<div
|
<div className="flex flex-col gap-2 py-2">
|
||||||
role="button"
|
<h1 className="font-bold text-4xl">{user.name}</h1>
|
||||||
tabIndex={0}
|
<h6 className="font-normal text-base text-mti-gray-taupe capitalize">{user.type}</h6>
|
||||||
onClick={() => toggleModule("reading")}
|
|
||||||
className={clsx(
|
|
||||||
"flex flex-col gap-2 items-center justify-center",
|
|
||||||
"border-ielts-reading hover:bg-ielts-reading text-white",
|
|
||||||
"border-2 rounded-xl p-4 h-fit w-48 cursor-pointer",
|
|
||||||
selectedModules.includes("reading") ? "bg-ielts-reading " : "bg-ielts-reading-transparent ",
|
|
||||||
)}>
|
|
||||||
<Icon path={mdiBookOpen} color="white" size={3} />
|
|
||||||
<span>Reading</span>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
role="button"
|
|
||||||
tabIndex={0}
|
|
||||||
onClick={() => toggleModule("listening")}
|
|
||||||
className={clsx(
|
|
||||||
"flex flex-col gap-2 items-center justify-center",
|
|
||||||
"border-ielts-listening hover:bg-ielts-listening text-white",
|
|
||||||
"border-2 rounded-xl p-4 h-fit w-48 cursor-pointer",
|
|
||||||
selectedModules.includes("listening") ? "bg-ielts-listening " : "bg-ielts-listening-transparent ",
|
|
||||||
)}>
|
|
||||||
<Icon path={mdiHeadphones} color="white" size={3} />
|
|
||||||
<span>Listening</span>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
role="button"
|
|
||||||
tabIndex={0}
|
|
||||||
onClick={() => toggleModule("writing")}
|
|
||||||
className={clsx(
|
|
||||||
"flex flex-col gap-2 items-center justify-center",
|
|
||||||
"border-ielts-writing hover:bg-ielts-writing text-white",
|
|
||||||
"border-2 rounded-xl p-4 h-fit w-48 cursor-pointer",
|
|
||||||
selectedModules.includes("writing") ? "bg-ielts-writing " : "bg-ielts-writing-transparent ",
|
|
||||||
)}>
|
|
||||||
<Icon path={mdiPen} color="white" size={3} />
|
|
||||||
<span>Writing</span>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
role="button"
|
|
||||||
tabIndex={0}
|
|
||||||
onClick={() => toggleModule("speaking")}
|
|
||||||
className={clsx(
|
|
||||||
"flex flex-col gap-2 items-center justify-center",
|
|
||||||
"border-ielts-speaking hover:bg-ielts-speaking text-white",
|
|
||||||
"border-2 rounded-xl p-4 h-fit w-48 cursor-pointer",
|
|
||||||
selectedModules.includes("speaking") ? "bg-ielts-speaking " : "bg-ielts-speaking-transparent ",
|
|
||||||
)}>
|
|
||||||
<Icon path={mdiAccountVoice} color="white" size={3} />
|
|
||||||
<span>Speaking</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
<ProgressBar
|
||||||
|
label={`Level ${calculateAverageLevel(user.levels).toFixed(1)}`}
|
||||||
|
percentage={100}
|
||||||
|
color="purple"
|
||||||
|
className="max-w-xs w-32 self-end h-10"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="w-full flex flex-col gap-4 md:flex-row justify-between">
|
<ProgressBar
|
||||||
<button onClick={() => router.push("/")} className={clsx("btn btn-wide gap-4 relative text-white", errorButtonStyle)}>
|
label=""
|
||||||
<div className="absolute left-4">
|
percentage={Math.round((calculateAverageLevel(user.levels) * 100) / calculateAverageLevel(user.desiredLevels))}
|
||||||
<Icon path={mdiArrowLeft} color="white" size={1} />
|
color="red"
|
||||||
|
className="w-full h-3 drop-shadow-lg"
|
||||||
|
/>
|
||||||
|
<div className="flex justify-between w-full mt-8">
|
||||||
|
<div className="flex gap-4 items-center">
|
||||||
|
<div className="w-16 h-16 border border-mti-gray-platinum bg-mti-gray-smoke flex items-center justify-center rounded-xl">
|
||||||
|
<BsBook className="text-ielts-reading w-8 h-8" />
|
||||||
</div>
|
</div>
|
||||||
Back
|
<div className="flex flex-col">
|
||||||
</button>
|
<span className="font-bold text-xl">{totalExamsByModule(stats, "reading")}</span>
|
||||||
<button
|
<span className="font-normal text-base text-mti-gray-dim">Reading</span>
|
||||||
className={clsx("btn btn-wide gap-4 relative text-white", infoButtonStyle)}
|
|
||||||
onClick={() => onStart(selectedModules)}>
|
|
||||||
Start
|
|
||||||
<div className="absolute right-4">
|
|
||||||
<Icon path={mdiArrowRight} color="white" size={1} />
|
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</div>
|
||||||
|
<div className="flex gap-4 items-center">
|
||||||
|
<div className="w-16 h-16 border border-mti-gray-platinum bg-mti-gray-smoke flex items-center justify-center rounded-xl">
|
||||||
|
<BsHeadphones className="text-ielts-listening w-8 h-8" />
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="font-bold text-xl">{totalExamsByModule(stats, "listening")}</span>
|
||||||
|
<span className="font-normal text-base text-mti-gray-dim">Listening</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-4 items-center">
|
||||||
|
<div className="w-16 h-16 border border-mti-gray-platinum bg-mti-gray-smoke flex items-center justify-center rounded-xl">
|
||||||
|
<BsPen className="text-ielts-writing w-8 h-8" />
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="font-bold text-xl">{totalExamsByModule(stats, "writing")}</span>
|
||||||
|
<span className="font-normal text-base text-mti-gray-dim">Writing</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-4 items-center">
|
||||||
|
<div className="w-16 h-16 border border-mti-gray-platinum bg-mti-gray-smoke flex items-center justify-center rounded-xl">
|
||||||
|
<BsMegaphone className="text-ielts-speaking w-8 h-8" />
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="font-bold text-xl">{totalExamsByModule(stats, "speaking")}</span>
|
||||||
|
<span className="font-normal text-base text-mti-gray-dim">Speaking</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
<section className="flex flex-col gap-3">
|
||||||
|
<span className="font-bold text-lg">About Exams</span>
|
||||||
|
<span className="text-mti-gray-taupe">
|
||||||
|
This comprehensive test will assess your proficiency in reading, listening, writing, and speaking English. Be prepared to dive
|
||||||
|
into a variety of interesting and challenging topics while showcasing your ability to communicate effectively in English.
|
||||||
|
Master the vocabulary, grammar, and interpretation skills required to succeed in this high-level exam. Are you ready to
|
||||||
|
demonstrate your mastery of the English language to the world?
|
||||||
|
</span>
|
||||||
|
</section>
|
||||||
|
<section className="w-full flex justify-between gap-8 mt-8">
|
||||||
|
<div
|
||||||
|
onClick={!disableSelection ? () => toggleModule("reading") : undefined}
|
||||||
|
className={clsx(
|
||||||
|
"relative w-fit max-w-xs flex flex-col items-center bg-mti-white-alt transition duration-300 ease-in-out border p-5 rounded-xl gap-2 pt-12 cursor-pointer",
|
||||||
|
selectedModules.includes("reading") || 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">
|
||||||
|
<BsBook className="text-white w-7 h-7" />
|
||||||
|
</div>
|
||||||
|
<span className="font-semibold">Reading:</span>
|
||||||
|
<p className="text-center text-xs">
|
||||||
|
Expand your vocabulary, improve your reading comprehension and improve your ability to interpret texts in English.
|
||||||
|
</p>
|
||||||
|
{!selectedModules.includes("reading") && !disableSelection && (
|
||||||
|
<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
|
||||||
|
onClick={!disableSelection ? () => toggleModule("listening") : undefined}
|
||||||
|
className={clsx(
|
||||||
|
"relative w-fit max-w-xs flex flex-col items-center bg-mti-white-alt transition duration-300 ease-in-out border p-5 rounded-xl gap-2 pt-12 cursor-pointer",
|
||||||
|
selectedModules.includes("listening") || 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">
|
||||||
|
<BsHeadphones className="text-white w-7 h-7" />
|
||||||
|
</div>
|
||||||
|
<span className="font-semibold">Listening:</span>
|
||||||
|
<p className="text-center text-xs">
|
||||||
|
Improve your ability to follow conversations in English and your ability to understand different accents and intonations.
|
||||||
|
</p>
|
||||||
|
{!selectedModules.includes("listening") && !disableSelection && (
|
||||||
|
<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
|
||||||
|
onClick={!disableSelection ? () => toggleModule("writing") : undefined}
|
||||||
|
className={clsx(
|
||||||
|
"relative w-fit max-w-xs flex flex-col items-center bg-mti-white-alt transition duration-300 ease-in-out border p-5 rounded-xl gap-2 pt-12 cursor-pointer",
|
||||||
|
selectedModules.includes("writing") || 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">
|
||||||
|
<BsPen className="text-white w-7 h-7" />
|
||||||
|
</div>
|
||||||
|
<span className="font-semibold">Writing:</span>
|
||||||
|
<p className="text-center text-xs">
|
||||||
|
Allow you to practice writing in a variety of formats, from simple paragraphs to complex essays.
|
||||||
|
</p>
|
||||||
|
{!selectedModules.includes("writing") && !disableSelection && (
|
||||||
|
<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
|
||||||
|
onClick={!disableSelection ? () => toggleModule("speaking") : undefined}
|
||||||
|
className={clsx(
|
||||||
|
"relative w-fit max-w-xs flex flex-col items-center bg-mti-white-alt transition duration-300 ease-in-out border p-5 rounded-xl gap-2 pt-12 cursor-pointer",
|
||||||
|
selectedModules.includes("speaking") || 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">
|
||||||
|
<BsMegaphone className="text-white w-7 h-7" />
|
||||||
|
</div>
|
||||||
|
<span className="font-semibold">Speaking:</span>
|
||||||
|
<p className="text-center text-xs">
|
||||||
|
You'll have access to interactive dialogs, pronunciation exercises and speech recordings.
|
||||||
|
</p>
|
||||||
|
{!selectedModules.includes("speaking") && !disableSelection && (
|
||||||
|
<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>
|
||||||
|
</section>
|
||||||
|
<Button
|
||||||
|
onClick={() =>
|
||||||
|
onStart(!disableSelection ? selectedModules.sort(sortByModuleName) : ["reading", "listening", "writing", "speaking"])
|
||||||
|
}
|
||||||
|
color="purple"
|
||||||
|
className="px-12 w-full max-w-xs self-end"
|
||||||
|
disabled={selectedModules.length === 0 && !disableSelection}>
|
||||||
|
Start Exam
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
import {renderExercise} from "@/components/Exercises";
|
import {renderExercise} from "@/components/Exercises";
|
||||||
|
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";
|
||||||
@@ -16,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) {
|
||||||
@@ -28,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})),
|
||||||
@@ -37,28 +45,43 @@ export default function Speaking({exam, showSolutions = false, onFinish}: Props)
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const previousExercise = () => {
|
const previousExercise = (solution?: UserSolution) => {
|
||||||
setExerciseIndex((prev) => prev - 1);
|
if (solution) {
|
||||||
|
setUserSolutions((prev) => [...prev.filter((x) => x.exercise !== solution.exercise), solution]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (exerciseIndex > 0) {
|
||||||
|
setExerciseIndex((prev) => prev - 1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getExercise = () => {
|
||||||
|
const exercise = exam.exercises[exerciseIndex];
|
||||||
|
return {
|
||||||
|
...exercise,
|
||||||
|
userSolutions: userSolutions.find((x) => x.exercise === exercise.id)?.solutions || [],
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full h-full relative flex flex-col gap-8 items-center justify-center p-8 px-16 overflow-hidden">
|
<>
|
||||||
{exerciseIndex > -1 &&
|
<div className="flex flex-col h-full w-full gap-8 items-center">
|
||||||
exerciseIndex < exam.exercises.length &&
|
<ModuleTitle
|
||||||
!showSolutions &&
|
minTimer={exam.minTimer}
|
||||||
renderExercise(exam.exercises[exerciseIndex], nextExercise, previousExercise)}
|
exerciseIndex={exerciseIndex + 1}
|
||||||
{exerciseIndex > -1 &&
|
module="speaking"
|
||||||
exerciseIndex < exam.exercises.length &&
|
totalExercises={exam.exercises.length}
|
||||||
showSolutions &&
|
disableTimer={showSolutions}
|
||||||
renderSolution(exam.exercises[exerciseIndex], nextExercise, previousExercise)}
|
/>
|
||||||
{exerciseIndex === -1 && (
|
{exerciseIndex > -1 &&
|
||||||
<button className={clsx("btn btn-wide gap-4 relative text-white self-end", infoButtonStyle)} onClick={() => nextExercise()}>
|
exerciseIndex < exam.exercises.length &&
|
||||||
Next
|
!showSolutions &&
|
||||||
<div className="absolute right-4">
|
renderExercise(getExercise(), nextExercise, previousExercise)}
|
||||||
<Icon path={mdiArrowRight} color="white" size={1} />
|
{exerciseIndex > -1 &&
|
||||||
</div>
|
exerciseIndex < exam.exercises.length &&
|
||||||
</button>
|
showSolutions &&
|
||||||
)}
|
renderSolution(exam.exercises[exerciseIndex], nextExercise, previousExercise)}
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
import {renderExercise} from "@/components/Exercises";
|
import {renderExercise} from "@/components/Exercises";
|
||||||
|
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";
|
||||||
@@ -16,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) {
|
||||||
@@ -28,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})),
|
||||||
@@ -37,28 +46,43 @@ export default function Writing({exam, showSolutions = false, onFinish}: Props)
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const previousExercise = () => {
|
const previousExercise = (solution?: UserSolution) => {
|
||||||
setExerciseIndex((prev) => prev - 1);
|
if (solution) {
|
||||||
|
setUserSolutions((prev) => [...prev.filter((x) => x.exercise !== solution.exercise), solution]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (exerciseIndex > 0) {
|
||||||
|
setExerciseIndex((prev) => prev - 1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getExercise = () => {
|
||||||
|
const exercise = exam.exercises[exerciseIndex];
|
||||||
|
return {
|
||||||
|
...exercise,
|
||||||
|
userSolutions: userSolutions.find((x) => x.exercise === exercise.id)?.solutions || [],
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full h-full relative flex flex-col gap-8 items-center justify-center p-8 px-16 overflow-hidden">
|
<>
|
||||||
{exerciseIndex > -1 &&
|
<div className="flex flex-col h-full w-full gap-8 items-center">
|
||||||
exerciseIndex < exam.exercises.length &&
|
<ModuleTitle
|
||||||
!showSolutions &&
|
minTimer={exam.minTimer}
|
||||||
renderExercise(exam.exercises[exerciseIndex], nextExercise, previousExercise)}
|
exerciseIndex={exerciseIndex + 1}
|
||||||
{exerciseIndex > -1 &&
|
module="writing"
|
||||||
exerciseIndex < exam.exercises.length &&
|
totalExercises={exam.exercises.length}
|
||||||
showSolutions &&
|
disableTimer={showSolutions}
|
||||||
renderSolution(exam.exercises[exerciseIndex], nextExercise, previousExercise)}
|
/>
|
||||||
{exerciseIndex === -1 && (
|
{exerciseIndex > -1 &&
|
||||||
<button className={clsx("btn btn-wide gap-4 relative text-white self-end", infoButtonStyle)} onClick={() => nextExercise()}>
|
exerciseIndex < exam.exercises.length &&
|
||||||
Next
|
!showSolutions &&
|
||||||
<div className="absolute right-4">
|
renderExercise(getExercise(), nextExercise, previousExercise)}
|
||||||
<Icon path={mdiArrowRight} color="white" size={1} />
|
{exerciseIndex > -1 &&
|
||||||
</div>
|
exerciseIndex < exam.exercises.length &&
|
||||||
</button>
|
showSolutions &&
|
||||||
)}
|
renderSolution(exam.exercises[exerciseIndex], nextExercise, previousExercise)}
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ export interface UserSolution {
|
|||||||
score: {
|
score: {
|
||||||
correct: number;
|
correct: number;
|
||||||
total: number;
|
total: number;
|
||||||
|
missing: number;
|
||||||
};
|
};
|
||||||
exercise: string;
|
exercise: string;
|
||||||
}
|
}
|
||||||
@@ -62,22 +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 Evaluation {
|
||||||
|
comment: string;
|
||||||
|
overall: 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?: string; //* The url for an image to work as an attachment to show the user
|
attachment?: {
|
||||||
|
url: string;
|
||||||
|
description: string;
|
||||||
|
}; //* The url for an image to work as an attachment to show the user
|
||||||
userSolutions: {
|
userSolutions: {
|
||||||
id: string;
|
id: string;
|
||||||
solution: string;
|
solution: string;
|
||||||
|
evaluation?: Evaluation;
|
||||||
}[];
|
}[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -90,6 +102,7 @@ export interface SpeakingExercise {
|
|||||||
userSolutions: {
|
userSolutions: {
|
||||||
id: string;
|
id: string;
|
||||||
solution: string;
|
solution: string;
|
||||||
|
evaluation?: Evaluation;
|
||||||
}[];
|
}[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -110,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
|
||||||
|
|||||||
@@ -9,7 +9,9 @@ export interface User {
|
|||||||
isFirstLogin: boolean;
|
isFirstLogin: boolean;
|
||||||
focus: "academic" | "general";
|
focus: "academic" | "general";
|
||||||
levels: {[key in Module]: number};
|
levels: {[key in Module]: number};
|
||||||
|
desiredLevels: {[key in Module]: number};
|
||||||
type: Type;
|
type: Type;
|
||||||
|
bio: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Stat {
|
export interface Stat {
|
||||||
@@ -24,6 +26,7 @@ export interface Stat {
|
|||||||
score: {
|
score: {
|
||||||
correct: number;
|
correct: number;
|
||||||
total: number;
|
total: number;
|
||||||
|
missing: number;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
1
src/lib/formidable-serverless.d.ts
vendored
Normal file
1
src/lib/formidable-serverless.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
declare module "formidable-serverless";
|
||||||
@@ -5,7 +5,18 @@ import type {AppProps} from "next/app";
|
|||||||
import "primereact/resources/themes/lara-light-indigo/theme.css";
|
import "primereact/resources/themes/lara-light-indigo/theme.css";
|
||||||
import "primereact/resources/primereact.min.css";
|
import "primereact/resources/primereact.min.css";
|
||||||
import "primeicons/primeicons.css";
|
import "primeicons/primeicons.css";
|
||||||
|
import {useRouter} from "next/router";
|
||||||
|
import {useEffect} from "react";
|
||||||
|
import useExamStore from "@/stores/examStore";
|
||||||
|
|
||||||
export default function App({Component, pageProps}: AppProps) {
|
export default function App({Component, pageProps}: AppProps) {
|
||||||
|
const reset = useExamStore((state) => state.reset);
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (router.pathname !== "/exercises") reset();
|
||||||
|
}, [router.pathname, reset]);
|
||||||
|
|
||||||
return <Component {...pageProps} />;
|
return <Component {...pageProps} />;
|
||||||
}
|
}
|
||||||
|
|||||||
61
src/pages/admin.tsx
Normal file
61
src/pages/admin.tsx
Normal 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>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
48
src/pages/api/evaluate/speaking.ts
Normal file
48
src/pages/api/evaluate/speaking.ts
Normal 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,
|
||||||
|
},
|
||||||
|
};
|
||||||
28
src/pages/api/evaluate/writing.ts
Normal file
28
src/pages/api/evaluate/writing.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
|
||||||
|
import type {NextApiRequest, NextApiResponse} from "next";
|
||||||
|
import {getFirestore, doc, getDoc} from "firebase/firestore";
|
||||||
|
import {withIronSessionApiRoute} from "iron-session/next";
|
||||||
|
import {sessionOptions} from "@/lib/session";
|
||||||
|
import axios from "axios";
|
||||||
|
|
||||||
|
interface Body {
|
||||||
|
question: string;
|
||||||
|
answer: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default withIronSessionApiRoute(handler, sessionOptions);
|
||||||
|
|
||||||
|
async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||||
|
if (!req.session.user) {
|
||||||
|
res.status(401).json({ok: false});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const 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);
|
||||||
|
}
|
||||||
@@ -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
56
src/pages/api/register.ts
Normal 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
26
src/pages/api/speaking.ts
Normal 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);
|
||||||
|
}
|
||||||
100
src/pages/api/stats/update.ts
Normal file
100
src/pages/api/stats/update.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()) {
|
||||||
|
|||||||
@@ -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",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|||||||
347
src/pages/exam.tsx
Normal file
347
src/pages/exam.tsx
Normal file
@@ -0,0 +1,347 @@
|
|||||||
|
/* eslint-disable @next/next/no-img-element */
|
||||||
|
import Head from "next/head";
|
||||||
|
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 Layout from "@/components/High/Layout";
|
||||||
|
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 setHasExamEnded = useExamStore((state) => state.setHasExamEnded);
|
||||||
|
|
||||||
|
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, !x.userSolutions ? {userSolutions: userSolutions.find((y) => x.id === y.exercise)?.solutions} : x.userSolutions),
|
||||||
|
);
|
||||||
|
|
||||||
|
return Object.assign(exam, exercises);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onFinish = (solutions: UserSolution[]) => {
|
||||||
|
const solutionIds = solutions.map((x) => x.exercise);
|
||||||
|
|
||||||
|
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} disableSelection />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (moduleIndex >= selectedModules.length) {
|
||||||
|
return (
|
||||||
|
<Finish
|
||||||
|
isLoading={isEvaluationLoading}
|
||||||
|
user={user!}
|
||||||
|
modules={selectedModules}
|
||||||
|
onViewResults={() => {
|
||||||
|
setShowSolutions(true);
|
||||||
|
setModuleIndex(0);
|
||||||
|
setExam(exams[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>Exam | IELTS GPT</title>
|
||||||
|
<meta
|
||||||
|
name="description"
|
||||||
|
content="A training platform for the IELTS exam provided by the Muscat Training Institute and developed by eCrop."
|
||||||
|
/>
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<link rel="icon" href="/favicon.ico" />
|
||||||
|
</Head>
|
||||||
|
<ToastContainer />
|
||||||
|
{user && (
|
||||||
|
<Layout
|
||||||
|
user={user}
|
||||||
|
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>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,218 +0,0 @@
|
|||||||
/* 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, WritingExam} 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";
|
|
||||||
|
|
||||||
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 [timer, setTimer] = useState(-1);
|
|
||||||
|
|
||||||
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 {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(() => {
|
|
||||||
(async () => {
|
|
||||||
if (selectedModules.length > 0 && exams.length === 0 && moduleIndex >= selectedModules.length && !hasBeenUploaded) {
|
|
||||||
const newStats: Stat[] = userSolutions.map((solution) => ({
|
|
||||||
...solution,
|
|
||||||
session: sessionId,
|
|
||||||
exam: solution.exam!,
|
|
||||||
module: solution.module!,
|
|
||||||
user: user?.id || "",
|
|
||||||
date: new Date().getTime(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
axios
|
|
||||||
.post<{ok: boolean}>("/api/stats", newStats)
|
|
||||||
.then((response) => setHasBeenUploaded(response.data.ok))
|
|
||||||
.catch(() => setHasBeenUploaded(false));
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [selectedModules, moduleIndex, hasBeenUploaded]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (exam) {
|
|
||||||
setTimer(exam.minTimer * 60);
|
|
||||||
const timerInterval = setInterval(() => setTimer((prev) => (prev && prev > 0 ? prev - 1 : 0)), 1000);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
clearInterval(timerInterval);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}, [exam]);
|
|
||||||
|
|
||||||
const getExam = async (module: Module): Promise<Exam | undefined> => {
|
|
||||||
const examRequest = await axios<Exam[]>(`/api/exam/${module}`);
|
|
||||||
if (examRequest.status !== 200) {
|
|
||||||
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 updateExamWithUserSolutions = (exam: Exam): Exam => {
|
|
||||||
const exercises = exam.exercises.map((x) =>
|
|
||||||
Object.assign(x, !x.userSolutions ? {userSolutions: userSolutions.find((y) => x.id === y.exercise)?.solutions} : x.userSolutions),
|
|
||||||
);
|
|
||||||
|
|
||||||
return Object.assign(exam, exercises);
|
|
||||||
};
|
|
||||||
|
|
||||||
const onFinish = (solutions: UserSolution[]) => {
|
|
||||||
const solutionIds = solutions.map((x) => x.exercise);
|
|
||||||
|
|
||||||
setUserSolutions([...userSolutions.filter((x) => !solutionIds.includes(x.exercise)), ...solutions]);
|
|
||||||
setModuleIndex((prev) => prev + 1);
|
|
||||||
};
|
|
||||||
|
|
||||||
const renderScreen = () => {
|
|
||||||
if (selectedModules.length === 0) {
|
|
||||||
return <Selection user={user!} onStart={setSelectedModules} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (moduleIndex >= selectedModules.length) {
|
|
||||||
return (
|
|
||||||
<Finish
|
|
||||||
user={user!}
|
|
||||||
modules={selectedModules}
|
|
||||||
onViewResults={() => {
|
|
||||||
setShowSolutions(true);
|
|
||||||
setModuleIndex(0);
|
|
||||||
setExam(exams[0]);
|
|
||||||
}}
|
|
||||||
scores={userSolutions.map((x) => ({...x.score, module: x.module}))}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
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" && showSolutions) {
|
|
||||||
setModuleIndex((prev) => prev + 1);
|
|
||||||
return <></>;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (exam && exam.module === "writing") {
|
|
||||||
return <Writing exam={exam} onFinish={onFinish} showSolutions={showSolutions} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (exam && exam.module === "speaking" && showSolutions) {
|
|
||||||
setModuleIndex((prev) => prev + 1);
|
|
||||||
return <></>;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (exam && exam.module === "speaking") {
|
|
||||||
return <Speaking exam={exam} onFinish={onFinish} showSolutions={showSolutions} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
return <>Loading...</>;
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Head>
|
|
||||||
<title>Exam | IELTS GPT</title>
|
|
||||||
<meta
|
|
||||||
name="description"
|
|
||||||
content="A training platform for the IELTS exam provided by the Muscat Training Institute and developed by eCrop."
|
|
||||||
/>
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
||||||
<link rel="icon" href="/favicon.ico" />
|
|
||||||
</Head>
|
|
||||||
<ToastContainer />
|
|
||||||
{user && (
|
|
||||||
<main className="w-full h-full min-h-[100vh] flex flex-col items-center bg-neutral-100 text-black pb-4 gap-4">
|
|
||||||
<Navbar userType={user.type} profilePicture={user.profilePicture} timer={exam ? timer : undefined} />
|
|
||||||
{renderScreen()}
|
|
||||||
</main>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
349
src/pages/exercises.tsx
Normal file
349
src/pages/exercises.tsx
Normal 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>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,154 +0,0 @@
|
|||||||
/* eslint-disable @next/next/no-img-element */
|
|
||||||
import Head from "next/head";
|
|
||||||
import SingleDatasetChart from "@/components/UserResultChart";
|
|
||||||
import Navbar from "@/components/Navbar";
|
|
||||||
import ProfileCard from "@/components/ProfileCard";
|
|
||||||
import {withIronSessionSsr} from "iron-session/next";
|
|
||||||
import {sessionOptions} from "@/lib/session";
|
|
||||||
import {Stat, User} from "@/interfaces/user";
|
|
||||||
import {useEffect, useState} from "react";
|
|
||||||
import useStats from "@/hooks/useStats";
|
|
||||||
import {averageScore, convertToUserSolutions, formatModuleTotalStats, groupByDate, groupBySession, totalExams} from "@/utils/stats";
|
|
||||||
import {Divider} from "primereact/divider";
|
|
||||||
import useUser from "@/hooks/useUser";
|
|
||||||
import {Timeline} from "primereact/timeline";
|
|
||||||
import moment from "moment";
|
|
||||||
import {AutoComplete} from "primereact/autocomplete";
|
|
||||||
import useUsers from "@/hooks/useUsers";
|
|
||||||
import {Dropdown} from "primereact/dropdown";
|
|
||||||
import useExamStore from "@/stores/examStore";
|
|
||||||
import {Exam, ListeningExam, ReadingExam, SpeakingExam, WritingExam} from "@/interfaces/exam";
|
|
||||||
import {Module} from "@/interfaces";
|
|
||||||
import axios from "axios";
|
|
||||||
import {toast} from "react-toastify";
|
|
||||||
import {useRouter} from "next/router";
|
|
||||||
import Icon from "@mdi/react";
|
|
||||||
import {mdiArrowRight, mdiChevronRight} from "@mdi/js";
|
|
||||||
import {uniqBy} from "lodash";
|
|
||||||
import {getExamById} from "@/utils/exams";
|
|
||||||
import {sortByModule} from "@/utils/moduleUtils";
|
|
||||||
|
|
||||||
export const getServerSideProps = withIronSessionSsr(({req, res}) => {
|
|
||||||
const user = req.session.user;
|
|
||||||
|
|
||||||
if (!user) {
|
|
||||||
res.setHeader("location", "/login");
|
|
||||||
res.statusCode = 302;
|
|
||||||
res.end();
|
|
||||||
return {
|
|
||||||
props: {
|
|
||||||
user: null,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
props: {user: req.session.user},
|
|
||||||
};
|
|
||||||
}, sessionOptions);
|
|
||||||
|
|
||||||
export default function History({user}: {user: User}) {
|
|
||||||
const [selectedUser, setSelectedUser] = useState<User>(user);
|
|
||||||
const [groupedStats, setGroupedStats] = useState<{[key: string]: Stat[]}>();
|
|
||||||
|
|
||||||
const {users, isLoading: isUsersLoading} = useUsers();
|
|
||||||
const {stats, isLoading: isStatsLoading} = useStats(selectedUser?.id);
|
|
||||||
|
|
||||||
const setExams = useExamStore((state) => state.setExams);
|
|
||||||
const setShowSolutions = useExamStore((state) => state.setShowSolutions);
|
|
||||||
const setUserSolutions = useExamStore((state) => state.setUserSolutions);
|
|
||||||
const setSelectedModules = useExamStore((state) => state.setSelectedModules);
|
|
||||||
|
|
||||||
const router = useRouter();
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (stats && !isStatsLoading) {
|
|
||||||
setGroupedStats(groupByDate(stats));
|
|
||||||
}
|
|
||||||
}, [stats, isStatsLoading]);
|
|
||||||
|
|
||||||
const formatTimestamp = (timestamp: string) => {
|
|
||||||
const date = moment(parseInt(timestamp));
|
|
||||||
const formatter = "YYYY/MM/DD - HH:mm";
|
|
||||||
|
|
||||||
return date.format(formatter);
|
|
||||||
};
|
|
||||||
|
|
||||||
const customContent = (timestamp: string) => {
|
|
||||||
if (!groupedStats) return <></>;
|
|
||||||
|
|
||||||
const dateStats = groupedStats[timestamp];
|
|
||||||
const correct = dateStats.reduce((accumulator, current) => accumulator + current.score.correct, 0);
|
|
||||||
const total = dateStats.reduce((accumulator, current) => accumulator + current.score.total, 0);
|
|
||||||
|
|
||||||
const selectExam = () => {
|
|
||||||
const examPromises = uniqBy(dateStats, "exam").map((stat) => getExamById(stat.module, stat.exam));
|
|
||||||
|
|
||||||
Promise.all(examPromises).then((exams) => {
|
|
||||||
if (exams.every((x) => !!x)) {
|
|
||||||
setUserSolutions(convertToUserSolutions(dateStats));
|
|
||||||
setShowSolutions(true);
|
|
||||||
setExams(exams.map((x) => x!).sort(sortByModule));
|
|
||||||
setSelectedModules(
|
|
||||||
exams
|
|
||||||
.map((x) => x!)
|
|
||||||
.sort(sortByModule)
|
|
||||||
.map((x) => x!.module),
|
|
||||||
);
|
|
||||||
router.push("/exam");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
<span>{formatTimestamp(timestamp)}</span>
|
|
||||||
<div
|
|
||||||
className="bg-white p-4 rounded-xl mb-4 flex justify-between items-center drop-shadow-lg cursor-pointer hover:bg-neutral-100 hover:drop-shadow-xl focus:bg-neutral-100 focus:drop-shadow-xl transition ease-in-out duration-300"
|
|
||||||
onClick={selectExam}
|
|
||||||
role="button">
|
|
||||||
<div className="flex flex-col gap-2 ">
|
|
||||||
<span>
|
|
||||||
Modules:{" "}
|
|
||||||
{formatModuleTotalStats(dateStats)
|
|
||||||
.filter((x) => x.value > 0)
|
|
||||||
.map((x) => x.label)
|
|
||||||
.join(", ")}
|
|
||||||
</span>
|
|
||||||
<span>
|
|
||||||
Score: {correct}/{total} | {((correct / total) * 100).toFixed(2)}%
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<Icon path={mdiChevronRight} color="black" size={1} className="cursor-pointer" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Head>
|
|
||||||
<title>IELTS GPT | Muscat Training Institute</title>
|
|
||||||
<meta
|
|
||||||
name="description"
|
|
||||||
content="A training platform for the IELTS exam provided by the Muscat Training Institute and developed by eCrop."
|
|
||||||
/>
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
||||||
<link rel="icon" href="/favicon.ico" />
|
|
||||||
</Head>
|
|
||||||
<main className="w-full h-full min-h-[100vh] flex flex-col bg-neutral-100 text-black">
|
|
||||||
<Navbar userType={user.type} profilePicture={user.profilePicture} />
|
|
||||||
<div className="w-fit self-center">
|
|
||||||
{!isUsersLoading && user.type !== "student" && (
|
|
||||||
<Dropdown value={selectedUser} options={users} optionLabel="name" onChange={(e) => setSelectedUser(e.target.value)} />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="w-2/3 h-full p-4 relative flex flex-col gap-8">
|
|
||||||
{groupedStats && !isStatsLoading && (
|
|
||||||
<Timeline value={Object.keys(groupedStats).sort((a, b) => parseInt(b) - parseInt(a))} content={customContent} />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,18 +1,22 @@
|
|||||||
/* 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 Navbar from "@/components/Navbar";
|
||||||
import ProfileCard from "@/components/ProfileCard";
|
import {BsFileEarmarkText, BsPencil, BsStar, BsBook, BsHeadphones, BsPen, BsMegaphone} from "react-icons/bs";
|
||||||
import {withIronSessionSsr} from "iron-session/next";
|
import {withIronSessionSsr} from "iron-session/next";
|
||||||
import {sessionOptions} from "@/lib/session";
|
import {sessionOptions} from "@/lib/session";
|
||||||
import {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, formatModuleTotalStats, totalExams} from "@/utils/stats";
|
import {averageScore, totalExams} from "@/utils/stats";
|
||||||
import {Divider} from "primereact/divider";
|
|
||||||
import useUser from "@/hooks/useUser";
|
import useUser from "@/hooks/useUser";
|
||||||
|
import Sidebar from "@/components/Sidebar";
|
||||||
import Diagnostic from "@/components/Diagnostic";
|
import Diagnostic from "@/components/Diagnostic";
|
||||||
import {ToastContainer} from "react-toastify";
|
import {ToastContainer} from "react-toastify";
|
||||||
|
import {capitalize} from "lodash";
|
||||||
|
import {Module} from "@/interfaces";
|
||||||
|
import ProgressBar from "@/components/Low/ProgressBar";
|
||||||
|
import Layout from "@/components/High/Layout";
|
||||||
|
import {calculateAverageLevel} from "@/utils/score";
|
||||||
|
import 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;
|
||||||
@@ -34,15 +38,10 @@ export const getServerSideProps = withIronSessionSsr(({req, res}) => {
|
|||||||
}, sessionOptions);
|
}, sessionOptions);
|
||||||
|
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
const [showEndExam, setShowEndExam] = useState(false);
|
|
||||||
const [windowWidth, setWindowWidth] = useState(0);
|
|
||||||
const [showDiagnostics, setShowDiagnostics] = useState(false);
|
const [showDiagnostics, setShowDiagnostics] = useState(false);
|
||||||
|
|
||||||
const {stats, isLoading} = useStats();
|
|
||||||
const {user} = useUser({redirectTo: "/login"});
|
const {user} = useUser({redirectTo: "/login"});
|
||||||
|
const {stats} = useStats(user?.id);
|
||||||
|
|
||||||
useEffect(() => setShowEndExam(window.innerWidth <= 960), []);
|
|
||||||
useEffect(() => setWindowWidth(window.innerWidth), []);
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (user) setShowDiagnostics(user.isFirstLogin);
|
if (user) setShowDiagnostics(user.isFirstLogin);
|
||||||
}, [user]);
|
}, [user]);
|
||||||
@@ -59,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 text-black">
|
<Layout user={user} navDisabled>
|
||||||
<Diagnostic user={user} onFinish={() => setShowDiagnostics(false)} />
|
<Diagnostic user={user} onFinish={() => setShowDiagnostics(false)} />
|
||||||
</main>
|
</Layout>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -79,34 +78,155 @@ export default function Home() {
|
|||||||
</Head>
|
</Head>
|
||||||
<ToastContainer />
|
<ToastContainer />
|
||||||
{user && (
|
{user && (
|
||||||
<main className="w-full h-full min-h-[100vh] flex flex-col items-center bg-neutral-100 text-black">
|
<Layout user={user}>
|
||||||
<Navbar userType={user.type} profilePicture={user.profilePicture} showExamEnd={showEndExam} />
|
<section className="w-full flex gap-8">
|
||||||
<div className="w-full h-full p-4 relative flex flex-col gap-8">
|
<img src={user.profilePicture} alt={user.name} className="aspect-square h-64 rounded-3xl drop-shadow-xl object-cover" />
|
||||||
<section className="h-full w-full flex lg:gap-8 flex-col lg:flex-row justify-center md:justify-start md:items-start">
|
<div className="flex flex-col gap-4 py-4 w-full">
|
||||||
<section className="w-full h-full flex items-center">
|
<div className="flex justify-between w-full gap-8">
|
||||||
<ProfileCard user={user} className="text-black self-start" />
|
<div className="flex flex-col gap-2 py-2">
|
||||||
</section>
|
<h1 className="font-bold text-4xl">{user.name}</h1>
|
||||||
{windowWidth <= 960 && <Divider />}
|
<h6 className="font-normal text-base text-mti-gray-taupe">{capitalize(user.type)}</h6>
|
||||||
<div className="flex flex-col w-full gap-4">
|
</div>
|
||||||
<span className="font-bold text-2xl">Statistics</span>
|
<ProgressBar
|
||||||
{!isLoading && stats && (
|
label={`Level ${calculateAverageLevel(user.levels).toFixed(1)}`}
|
||||||
<div className="text-neutral-600 flex flex-wrap gap-2 md:gap-4 w-full justify-between md:justify-start">
|
percentage={100}
|
||||||
<div className="bg-white p-4 rounded-xl drop-shadow-xl flex flex-col gap-2 md:gap-4 w-full">
|
color="purple"
|
||||||
<span className="font-bold text-xl">Exams: {totalExams(stats)}</span>
|
className="max-w-xs w-32 self-end h-10"
|
||||||
<span className="font-bold text-xl">Exercises: {stats.length}</span>
|
/>
|
||||||
<span className="font-bold text-xl">Average Score: {averageScore(stats)}%</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
<ProgressBar
|
||||||
{!isLoading && stats && (
|
label=""
|
||||||
<section className="w-full lg:w-1/3 h-full flex items-center justify-center">
|
percentage={Math.round((calculateAverageLevel(user.levels) * 100) / calculateAverageLevel(user.desiredLevels))}
|
||||||
<SingleDatasetChart type="polarArea" data={formatModuleTotalStats(stats)} title="Exams per Module" />
|
color="red"
|
||||||
</section>
|
className="w-full h-3 drop-shadow-lg"
|
||||||
)}
|
/>
|
||||||
</div>
|
<div className="flex justify-between w-full mt-8">
|
||||||
</main>
|
<div className="flex gap-4 items-center">
|
||||||
|
<div className="w-16 h-16 border border-mti-gray-platinum bg-mti-gray-smoke flex items-center justify-center rounded-xl">
|
||||||
|
<BsFileEarmarkText className="w-8 h-8 text-mti-red-light" />
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="font-bold text-xl">{totalExams(stats)}</span>
|
||||||
|
<span className="font-normal text-base text-mti-gray-dim">Exams</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-4 items-center">
|
||||||
|
<div className="w-16 h-16 border border-mti-gray-platinum bg-mti-gray-smoke flex items-center justify-center rounded-xl">
|
||||||
|
<BsPencil className="w-8 h-8 text-mti-red-light" />
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="font-bold text-xl">{stats.length}</span>
|
||||||
|
<span className="font-normal text-base text-mti-gray-dim">Exercises</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-4 items-center">
|
||||||
|
<div className="w-16 h-16 border border-mti-gray-platinum bg-mti-gray-smoke flex items-center justify-center rounded-xl">
|
||||||
|
<BsStar className="w-8 h-8 text-mti-red-light" />
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<section className="flex flex-col gap-3">
|
||||||
|
<span className="font-bold text-lg">Bio</span>
|
||||||
|
<span className="text-mti-gray-taupe">
|
||||||
|
{user.bio || "Your bio will appear here, you can change it by clicking on your name in the top right corner."}
|
||||||
|
</span>
|
||||||
|
</section>
|
||||||
|
<section className="flex flex-col gap-3">
|
||||||
|
<span className="font-bold text-lg">Score History</span>
|
||||||
|
<div className="grid grid-cols-2 gap-6">
|
||||||
|
<div className="border border-mti-gray-anti-flash rounded-xl flex flex-col gap-2 p-4">
|
||||||
|
<div className="flex gap-3 items-center">
|
||||||
|
<div className="w-12 h-12 bg-mti-gray-smoke flex items-center justify-center rounded-xl">
|
||||||
|
<BsBook className="text-ielts-reading w-5 h-5" />
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between w-full">
|
||||||
|
<span className="font-extrabold text-sm">Reading</span>
|
||||||
|
<span className="text-sm font-normal text-mti-gray-dim">
|
||||||
|
Level {user.levels.reading} / Level {user.desiredLevels.reading}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="pl-14">
|
||||||
|
<ProgressBar
|
||||||
|
color="red"
|
||||||
|
label=""
|
||||||
|
percentage={Math.round((user.levels.reading * 100) / user.desiredLevels.reading)}
|
||||||
|
className="w-full h-2"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="border border-mti-gray-anti-flash rounded-xl flex flex-col gap-2 p-4">
|
||||||
|
<div className="flex gap-3 items-center">
|
||||||
|
<div className="w-12 h-12 bg-mti-gray-smoke flex items-center justify-center rounded-xl">
|
||||||
|
<BsPen className="text-ielts-writing w-5 h-5" />
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between w-full">
|
||||||
|
<span className="font-extrabold text-sm">Writing</span>
|
||||||
|
<span className="text-sm font-normal text-mti-gray-dim">
|
||||||
|
Level {user.levels.writing} / Level {user.desiredLevels.writing}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="pl-14">
|
||||||
|
<ProgressBar
|
||||||
|
color="red"
|
||||||
|
label=""
|
||||||
|
percentage={Math.round((user.levels.writing * 100) / user.desiredLevels.writing)}
|
||||||
|
className="w-full h-2"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="border border-mti-gray-anti-flash rounded-xl flex flex-col gap-2 p-4">
|
||||||
|
<div className="flex gap-3 items-center">
|
||||||
|
<div className="w-12 h-12 bg-mti-gray-smoke flex items-center justify-center rounded-xl">
|
||||||
|
<BsHeadphones className="text-ielts-listening w-5 h-5" />
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between w-full">
|
||||||
|
<span className="font-extrabold text-sm">Listening</span>
|
||||||
|
<span className="text-sm font-normal text-mti-gray-dim">
|
||||||
|
Level {user.levels.listening} / Level {user.desiredLevels.listening}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="pl-14">
|
||||||
|
<ProgressBar
|
||||||
|
color="red"
|
||||||
|
label=""
|
||||||
|
percentage={Math.round((user.levels.listening * 100) / user.desiredLevels.listening)}
|
||||||
|
className="w-full h-2"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="border border-mti-gray-anti-flash rounded-xl flex flex-col gap-2 p-4">
|
||||||
|
<div className="flex gap-3 items-center">
|
||||||
|
<div className="w-12 h-12 bg-mti-gray-smoke flex items-center justify-center rounded-xl">
|
||||||
|
<BsMegaphone className="text-ielts-speaking w-5 h-5" />
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between w-full">
|
||||||
|
<span className="font-extrabold text-sm">Speaking</span>
|
||||||
|
<span className="text-sm font-normal text-mti-gray-dim">
|
||||||
|
Level {user.levels.speaking} / Level {user.desiredLevels.speaking}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="pl-14">
|
||||||
|
<ProgressBar
|
||||||
|
color="red"
|
||||||
|
label=""
|
||||||
|
percentage={Math.round((user.levels.speaking * 100) / user.desiredLevels.speaking)}
|
||||||
|
className="w-full h-2"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</Layout>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,16 +1,21 @@
|
|||||||
|
/* eslint-disable @next/next/no-img-element */
|
||||||
import {User} from "@/interfaces/user";
|
import {User} from "@/interfaces/user";
|
||||||
import {toast, ToastContainer} from "react-toastify";
|
import {toast, ToastContainer} from "react-toastify";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import {FormEvent, useState} from "react";
|
import {FormEvent, useState} from "react";
|
||||||
import Head from "next/head";
|
import Head from "next/head";
|
||||||
import useUser from "@/hooks/useUser";
|
import useUser from "@/hooks/useUser";
|
||||||
import {InputText} from "primereact/inputtext";
|
import {Divider} from "primereact/divider";
|
||||||
import {Button} from "primereact/button";
|
import Button from "@/components/Low/Button";
|
||||||
import {Password} from "primereact/password";
|
import {BsArrowRepeat, BsCheck} from "react-icons/bs";
|
||||||
|
import Link from "next/link";
|
||||||
|
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 [rememberPassword, setRememberPassword] = useState(false);
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
const {mutateUser} = useUser({
|
const {mutateUser} = useUser({
|
||||||
@@ -46,38 +51,52 @@ export default function Login() {
|
|||||||
<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 justify-center bg-neutral-100">
|
<main className="w-full h-[100vh] flex bg-white text-black">
|
||||||
<ToastContainer />
|
<ToastContainer />
|
||||||
<form className="p-4 rounded-xl bg-white drop-shadow-xl flex flex-col gap-4" onSubmit={login}>
|
<section className="h-full w-fit min-w-fit relative">
|
||||||
<div className="p-inputgroup">
|
<div className="absolute h-full w-full bg-mti-rose-light z-10 bg-opacity-50" />
|
||||||
<span className="p-inputgroup-addon">
|
<img src="/people-talking-tablet.png" alt="People smiling looking at a tablet" className="h-full aspect-auto" />
|
||||||
<i className="pi pi-inbox"></i>
|
</section>
|
||||||
</span>
|
<section className="h-full w-full flex flex-col items-center justify-center gap-2">
|
||||||
<InputText
|
<div className="flex flex-col gap-2 items-center">
|
||||||
placeholder="E-mail..."
|
<h1 className="font-bold text-4xl">Login to your account</h1>
|
||||||
type="email"
|
<p className="self-start text-base font-normal text-mti-gray-cool">with your registered Email Address</p>
|
||||||
required
|
|
||||||
disabled={isLoading}
|
|
||||||
onChange={(e) => setEmail(e.target.value)}
|
|
||||||
autoComplete="username"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="p-inputgroup">
|
<Divider className="max-w-md" />
|
||||||
<span className="p-inputgroup-addon">
|
<form className="flex flex-col items-center gap-6 w-1/2" onSubmit={login}>
|
||||||
<i className="pi pi-star"></i>
|
<Input type="email" name="email" onChange={(e) => setEmail(e)} placeholder="Enter email address" />
|
||||||
</span>
|
<Input type="password" name="password" onChange={(e) => setPassword(e)} placeholder="Password" />
|
||||||
<Password
|
<div className="flex justify-between w-full px-4">
|
||||||
placeholder="Password..."
|
<div className="flex gap-3 text-mti-gray-dim text-xs cursor-pointer" onClick={() => setRememberPassword((prev) => !prev)}>
|
||||||
type="password"
|
<input type="checkbox" className="hidden" />
|
||||||
required
|
<div
|
||||||
feedback={false}
|
className={clsx(
|
||||||
disabled={isLoading}
|
"w-4 h-4 rounded-sm flex items-center justify-center border border-mti-purple-light bg-white",
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
"transition duration-300 ease-in-out",
|
||||||
autoComplete="current-password"
|
rememberPassword && "!bg-mti-purple-light ",
|
||||||
/>
|
)}>
|
||||||
</div>
|
<BsCheck color="white" className="w-full h-full" />
|
||||||
<Button loading={isLoading} iconPos="right" label="Login" icon="pi pi-check" />
|
</div>
|
||||||
</form>
|
<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 && (
|
||||||
|
<div className="flex items-center justify-center">
|
||||||
|
<BsArrowRepeat className="text-white animate-spin" size={25} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
<span className="text-mti-gray-cool text-sm font-normal mt-8">
|
||||||
|
Don't have an account?{" "}
|
||||||
|
<Link className="text-mti-purple-light" href="/register">
|
||||||
|
Sign up
|
||||||
|
</Link>
|
||||||
|
</span>
|
||||||
|
</section>
|
||||||
</main>
|
</main>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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,14 +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 />
|
||||||
<Navbar userType={user.type} profilePicture={user.profilePicture} />
|
{user && (
|
||||||
<div className="w-full h-full flex flex-col items-center justify-center p-4 relative">
|
<Layout user={user}>
|
||||||
<section className="bg-white drop-shadow-xl p-4 rounded-xl w-96 flex flex-col items-center">
|
<section className="w-full flex flex-col gap-8 px-4 py-8">
|
||||||
<Avatar image={user.profilePicture} size="xlarge" shape="circle" />
|
<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>
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
280
src/pages/record.tsx
Normal file
280
src/pages/record.tsx
Normal file
@@ -0,0 +1,280 @@
|
|||||||
|
/* eslint-disable @next/next/no-img-element */
|
||||||
|
import Head from "next/head";
|
||||||
|
import {withIronSessionSsr} from "iron-session/next";
|
||||||
|
import {sessionOptions} from "@/lib/session";
|
||||||
|
import {Stat, User} from "@/interfaces/user";
|
||||||
|
import {useEffect, useState} from "react";
|
||||||
|
import useStats from "@/hooks/useStats";
|
||||||
|
import {convertToUserSolutions, groupByDate} from "@/utils/stats";
|
||||||
|
import moment from "moment";
|
||||||
|
import useUsers from "@/hooks/useUsers";
|
||||||
|
import useExamStore from "@/stores/examStore";
|
||||||
|
import {Module} from "@/interfaces";
|
||||||
|
import {ToastContainer} from "react-toastify";
|
||||||
|
import {useRouter} from "next/router";
|
||||||
|
import {uniqBy} from "lodash";
|
||||||
|
import {getExamById} from "@/utils/exams";
|
||||||
|
import {sortByModule} from "@/utils/moduleUtils";
|
||||||
|
import Layout from "@/components/High/Layout";
|
||||||
|
import clsx from "clsx";
|
||||||
|
import {calculateBandScore} from "@/utils/score";
|
||||||
|
import {BsBook, BsHeadphones, BsMegaphone, BsPen} from "react-icons/bs";
|
||||||
|
|
||||||
|
export const getServerSideProps = withIronSessionSsr(({req, res}) => {
|
||||||
|
const user = req.session.user;
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
res.setHeader("location", "/login");
|
||||||
|
res.statusCode = 302;
|
||||||
|
res.end();
|
||||||
|
return {
|
||||||
|
props: {
|
||||||
|
user: null,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
props: {user: req.session.user},
|
||||||
|
};
|
||||||
|
}, sessionOptions);
|
||||||
|
|
||||||
|
export default function History({user}: {user: User}) {
|
||||||
|
const [selectedUser, setSelectedUser] = useState<User>(user);
|
||||||
|
const [groupedStats, setGroupedStats] = useState<{[key: string]: Stat[]}>();
|
||||||
|
const [filter, setFilter] = useState<"months" | "weeks" | "days">();
|
||||||
|
|
||||||
|
const {users, isLoading: isUsersLoading} = useUsers();
|
||||||
|
const {stats, isLoading: isStatsLoading} = useStats(selectedUser?.id);
|
||||||
|
|
||||||
|
const setExams = useExamStore((state) => state.setExams);
|
||||||
|
const setShowSolutions = useExamStore((state) => state.setShowSolutions);
|
||||||
|
const setUserSolutions = useExamStore((state) => state.setUserSolutions);
|
||||||
|
const setSelectedModules = useExamStore((state) => state.setSelectedModules);
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (stats && !isStatsLoading) {
|
||||||
|
setGroupedStats(groupByDate(stats));
|
||||||
|
}
|
||||||
|
}, [stats, isStatsLoading]);
|
||||||
|
|
||||||
|
const 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 date = moment(parseInt(timestamp));
|
||||||
|
const formatter = "YYYY/MM/DD - HH:mm";
|
||||||
|
|
||||||
|
return date.format(formatter);
|
||||||
|
};
|
||||||
|
|
||||||
|
const aggregateScoresByModule = (stats: Stat[]): {module: Module; total: number; missing: number; correct: number}[] => {
|
||||||
|
const scores: {[key in Module]: {total: number; missing: number; correct: number}} = {
|
||||||
|
reading: {
|
||||||
|
total: 0,
|
||||||
|
correct: 0,
|
||||||
|
missing: 0,
|
||||||
|
},
|
||||||
|
listening: {
|
||||||
|
total: 0,
|
||||||
|
correct: 0,
|
||||||
|
missing: 0,
|
||||||
|
},
|
||||||
|
writing: {
|
||||||
|
total: 0,
|
||||||
|
correct: 0,
|
||||||
|
missing: 0,
|
||||||
|
},
|
||||||
|
speaking: {
|
||||||
|
total: 0,
|
||||||
|
correct: 0,
|
||||||
|
missing: 0,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
stats.forEach((x) => {
|
||||||
|
scores[x.module!] = {
|
||||||
|
total: scores[x.module!].total + x.score.total,
|
||||||
|
correct: scores[x.module!].correct + x.score.correct,
|
||||||
|
missing: scores[x.module!].missing + x.score.missing,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return Object.keys(scores)
|
||||||
|
.filter((x) => scores[x as Module].total > 0)
|
||||||
|
.map((x) => ({module: x as Module, ...scores[x as Module]}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const customContent = (timestamp: string) => {
|
||||||
|
if (!groupedStats) return <></>;
|
||||||
|
|
||||||
|
const dateStats = groupedStats[timestamp];
|
||||||
|
const correct = dateStats.reduce((accumulator, current) => accumulator + current.score.correct, 0);
|
||||||
|
const total = dateStats.reduce((accumulator, current) => accumulator + current.score.total, 0);
|
||||||
|
const aggregatedScores = aggregateScoresByModule(dateStats).filter((x) => x.total > 0);
|
||||||
|
|
||||||
|
const aggregatedLevels = aggregatedScores.map((x) => ({
|
||||||
|
module: x.module,
|
||||||
|
level: calculateBandScore(x.correct, x.total, x.module, user.focus),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const selectExam = () => {
|
||||||
|
const examPromises = uniqBy(dateStats, "exam").map((stat) => getExamById(stat.module, stat.exam));
|
||||||
|
|
||||||
|
Promise.all(examPromises).then((exams) => {
|
||||||
|
if (exams.every((x) => !!x)) {
|
||||||
|
setUserSolutions(convertToUserSolutions(dateStats));
|
||||||
|
setShowSolutions(true);
|
||||||
|
setExams(exams.map((x) => x!).sort(sortByModule));
|
||||||
|
setSelectedModules(
|
||||||
|
exams
|
||||||
|
.map((x) => x!)
|
||||||
|
.sort(sortByModule)
|
||||||
|
.map((x) => x!.module),
|
||||||
|
);
|
||||||
|
router.push("/exercises");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={timestamp}
|
||||||
|
className={clsx(
|
||||||
|
"flex flex-col gap-4 border border-mti-gray-platinum p-4 cursor-pointer rounded-xl transition ease-in-out duration-300",
|
||||||
|
correct / total >= 0.7 && "hover:border-mti-purple",
|
||||||
|
correct / total >= 0.3 && correct / total < 0.7 && "hover:border-mti-red",
|
||||||
|
correct / total < 0.3 && "hover:border-mti-rose",
|
||||||
|
)}
|
||||||
|
onClick={selectExam}
|
||||||
|
role="button">
|
||||||
|
<div className="w-full flex justify-between items-center">
|
||||||
|
<span className="font-medium">{formatTimestamp(timestamp)}</span>
|
||||||
|
<span
|
||||||
|
className={clsx(
|
||||||
|
correct / total >= 0.7 && "text-mti-purple",
|
||||||
|
correct / total >= 0.3 && correct / total < 0.7 && "text-mti-red",
|
||||||
|
correct / total < 0.3 && "text-mti-rose",
|
||||||
|
)}>
|
||||||
|
Level{" "}
|
||||||
|
{(aggregatedLevels.reduce((accumulator, current) => accumulator + current.level, 0) / aggregatedLevels.length).toFixed(1)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-4 gap-4 place-items-center w-full">
|
||||||
|
{aggregatedLevels.map(({module, level}) => (
|
||||||
|
<div
|
||||||
|
key={module}
|
||||||
|
className={clsx(
|
||||||
|
"flex gap-2 items-center w-fit text-white px-4 py-2 rounded-xl",
|
||||||
|
module === "reading" && "bg-ielts-reading",
|
||||||
|
module === "listening" && "bg-ielts-listening",
|
||||||
|
module === "writing" && "bg-ielts-writing",
|
||||||
|
module === "speaking" && "bg-ielts-speaking",
|
||||||
|
)}>
|
||||||
|
{module === "reading" && <BsBook className="w-4 h-4" />}
|
||||||
|
{module === "listening" && <BsHeadphones className="w-4 h-4" />}
|
||||||
|
{module === "writing" && <BsPen className="w-4 h-4" />}
|
||||||
|
{module === "speaking" && <BsMegaphone className="w-4 h-4" />}
|
||||||
|
<span className="text-sm">{level.toFixed(1)}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Head>
|
||||||
|
<title>IELTS GPT | Muscat Training Institute</title>
|
||||||
|
<meta
|
||||||
|
name="description"
|
||||||
|
content="A training platform for the IELTS exam provided by the Muscat Training Institute and developed by eCrop."
|
||||||
|
/>
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<link rel="icon" href="/favicon.ico" />
|
||||||
|
</Head>
|
||||||
|
<ToastContainer />
|
||||||
|
{user && (
|
||||||
|
<Layout user={user}>
|
||||||
|
<div className="w-full flex justify-between items-center">
|
||||||
|
<div className="w-fit">
|
||||||
|
{!isUsersLoading && user.type !== "student" && (
|
||||||
|
<>
|
||||||
|
<select
|
||||||
|
className="select w-full max-w-xs bg-white border border-mti-gray-platinum outline-none font-normal text-base"
|
||||||
|
onChange={(e) => setSelectedUser(users.find((x) => x.id === e.target.value)!)}>
|
||||||
|
{users.map((x) => (
|
||||||
|
<option key={x.id} selected={selectedUser.id === x.id} value={x.id}>
|
||||||
|
{x.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<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>
|
||||||
|
{groupedStats && Object.keys(groupedStats).length > 0 && !isStatsLoading && (
|
||||||
|
<div className="grid grid-cols-3 w-full gap-6">
|
||||||
|
{Object.keys(filterStatsByDate(groupedStats))
|
||||||
|
.sort((a, b) => parseInt(b) - parseInt(a))
|
||||||
|
.map(customContent)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{groupedStats && Object.keys(groupedStats).length === 0 && !isStatsLoading && (
|
||||||
|
<span className="font-semibold ml-1">No record to display...</span>
|
||||||
|
)}
|
||||||
|
</Layout>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
91
src/pages/register.tsx
Normal file
91
src/pages/register.tsx
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
/* eslint-disable @next/next/no-img-element */
|
||||||
|
import {toast, ToastContainer} from "react-toastify";
|
||||||
|
import {useState} from "react";
|
||||||
|
import Head from "next/head";
|
||||||
|
import useUser from "@/hooks/useUser";
|
||||||
|
import Button from "@/components/Low/Button";
|
||||||
|
import {BsArrowRepeat} from "react-icons/bs";
|
||||||
|
import Link from "next/link";
|
||||||
|
import Input from "@/components/Low/Input";
|
||||||
|
import axios from "axios";
|
||||||
|
|
||||||
|
export default function Register() {
|
||||||
|
const [name, setName] = useState("");
|
||||||
|
const [email, setEmail] = useState("");
|
||||||
|
const [password, setPassword] = useState("");
|
||||||
|
const [confirmPassword, setConfirmPassword] = useState("");
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
|
const {mutateUser} = useUser({
|
||||||
|
redirectTo: "/",
|
||||||
|
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 (
|
||||||
|
<>
|
||||||
|
<Head>
|
||||||
|
<title>Register | IELTS GPT</title>
|
||||||
|
<meta name="description" content="Generated by create next app" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<link rel="icon" href="/favicon.ico" />
|
||||||
|
</Head>
|
||||||
|
<main className="w-full h-[100vh] flex bg-white text-black">
|
||||||
|
<ToastContainer />
|
||||||
|
<section className="h-full w-fit min-w-fit relative">
|
||||||
|
<div className="absolute h-full w-full bg-mti-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" />
|
||||||
|
</section>
|
||||||
|
<section className="h-full w-full flex flex-col items-center justify-center gap-12">
|
||||||
|
<h1 className="font-bold text-4xl">Create new account</h1>
|
||||||
|
<form className="flex flex-col items-center gap-6 w-1/2" onSubmit={register}>
|
||||||
|
<Input type="text" name="name" onChange={(e) => setName(e)} placeholder="Enter your name" required />
|
||||||
|
<Input type="email" name="email" onChange={(e) => setEmail(e)} placeholder="Enter email address" required />
|
||||||
|
<Input type="password" name="password" onChange={(e) => setPassword(e)} placeholder="Enter your password" required />
|
||||||
|
<Input
|
||||||
|
type="password"
|
||||||
|
name="confirmPassword"
|
||||||
|
onChange={(e) => setConfirmPassword(e)}
|
||||||
|
placeholder="Confirm your password"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<Button className="mt-8 w-full" color="purple" disabled={isLoading}>
|
||||||
|
{!isLoading && "Create account"}
|
||||||
|
{isLoading && (
|
||||||
|
<div className="flex items-center justify-center">
|
||||||
|
<BsArrowRepeat className="text-white animate-spin" size={25} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
<Link className="text-mti-purple-light text-sm font-normal" href="/login">
|
||||||
|
Sign in instead
|
||||||
|
</Link>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,17 +1,24 @@
|
|||||||
import Navbar from "@/components/Navbar";
|
/* eslint-disable @next/next/no-img-element */
|
||||||
import SingleDatasetChart from "@/components/UserResultChart";
|
|
||||||
import useStats from "@/hooks/useStats";
|
|
||||||
import useUsers from "@/hooks/useUsers";
|
|
||||||
import {User} from "@/interfaces/user";
|
|
||||||
import {sessionOptions} from "@/lib/session";
|
|
||||||
import {formatExerciseAverageScoreStats, formatExerciseTotalStats, formatModuleAverageScoreStats, formatModuleTotalStats} from "@/utils/stats";
|
|
||||||
import {withIronSessionSsr} from "iron-session/next";
|
|
||||||
import Head from "next/head";
|
import Head from "next/head";
|
||||||
import {AutoComplete} from "primereact/autocomplete";
|
import Navbar from "@/components/Navbar";
|
||||||
import {Divider} from "primereact/divider";
|
import {BsFileEarmarkText, BsPencil, BsStar, BsBook, BsHeadphones, BsPen, BsMegaphone} from "react-icons/bs";
|
||||||
|
import {ArcElement} from "chart.js";
|
||||||
|
import {withIronSessionSsr} from "iron-session/next";
|
||||||
|
import {sessionOptions} from "@/lib/session";
|
||||||
import {useEffect, useState} from "react";
|
import {useEffect, useState} from "react";
|
||||||
import chartColors from "@/constants/chartColors.json";
|
import useStats from "@/hooks/useStats";
|
||||||
import {shuffle} from "lodash";
|
import {averageScore, totalExams, totalExamsByModule, groupBySession} from "@/utils/stats";
|
||||||
|
import useUser from "@/hooks/useUser";
|
||||||
|
import Sidebar from "@/components/Sidebar";
|
||||||
|
import Diagnostic from "@/components/Diagnostic";
|
||||||
|
import {ToastContainer} from "react-toastify";
|
||||||
|
import {capitalize} from "lodash";
|
||||||
|
import {Module} from "@/interfaces";
|
||||||
|
import ProgressBar from "@/components/Low/ProgressBar";
|
||||||
|
import Layout from "@/components/High/Layout";
|
||||||
|
import {calculateAverageLevel} from "@/utils/score";
|
||||||
|
import {MODULE_ARRAY} from "@/utils/moduleUtils";
|
||||||
|
import {Chart} from "react-chartjs-2";
|
||||||
|
|
||||||
export const getServerSideProps = withIronSessionSsr(({req, res}) => {
|
export const getServerSideProps = withIronSessionSsr(({req, res}) => {
|
||||||
const user = req.session.user;
|
const user = req.session.user;
|
||||||
@@ -32,22 +39,25 @@ export const getServerSideProps = withIronSessionSsr(({req, res}) => {
|
|||||||
};
|
};
|
||||||
}, sessionOptions);
|
}, sessionOptions);
|
||||||
|
|
||||||
export default function Stats({user}: {user: User}) {
|
export default function Stats() {
|
||||||
const [autocompleteValue, setAutocompleteValue] = useState(user.name);
|
const {user} = useUser({redirectTo: "/login"});
|
||||||
const [selectedUser, setSelectedUser] = useState<User>();
|
const {stats} = useStats(user?.id);
|
||||||
const [items, setItems] = useState<User[]>([]);
|
|
||||||
|
|
||||||
const {users, isLoading} = useUsers();
|
const totalExamsData = {
|
||||||
const {stats, isLoading: isStatsLoading} = useStats(selectedUser?.id);
|
labels: MODULE_ARRAY.map((x) => capitalize(x)),
|
||||||
|
datasets: [
|
||||||
const search = (event: {query: string}) => {
|
{
|
||||||
setItems(event.query ? users.filter((x) => x.name.startsWith(event.query)) : users);
|
label: "Total exams",
|
||||||
|
data: MODULE_ARRAY.map((x) => totalExamsByModule(stats, x)),
|
||||||
|
backgroundColor: ["#1EB3FF", "#FF790A", "#3D9F11", "#EF5DA8"],
|
||||||
|
},
|
||||||
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Head>
|
<Head>
|
||||||
<title>IELTS GPT | Stats</title>
|
<title>Stats | IELTS GPT</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."
|
||||||
@@ -55,86 +65,133 @@ export default function Stats({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-full min-h-[100vh] flex flex-col items-center bg-neutral-100 text-neutral-600">
|
<ToastContainer />
|
||||||
<Navbar userType={user.type} profilePicture={user.profilePicture} />
|
{user && (
|
||||||
<div className="w-full h-full flex flex-col items-center justify-center p-4 relative gap-8">
|
<Layout user={user}>
|
||||||
{!isLoading && user.type !== "student" && (
|
<section className="w-full flex gap-8">
|
||||||
<AutoComplete
|
<img src={user.profilePicture} alt={user.name} className="aspect-square h-64 rounded-3xl drop-shadow-xl object-cover" />
|
||||||
value={autocompleteValue}
|
<div className="flex flex-col gap-4 py-4 w-full">
|
||||||
suggestions={items}
|
<div className="flex justify-between w-full gap-8">
|
||||||
field="name"
|
<div className="flex flex-col gap-2 py-2">
|
||||||
onChange={(e) => setAutocompleteValue(e.target.value)}
|
<h1 className="font-bold text-4xl">{user.name}</h1>
|
||||||
completeMethod={search}
|
<h6 className="font-normal text-base text-mti-gray-taupe">{capitalize(user.type)}</h6>
|
||||||
onSelect={(e) => setSelectedUser(e.value)}
|
</div>
|
||||||
dropdown
|
<ProgressBar
|
||||||
/>
|
label={`Level ${calculateAverageLevel(user.levels).toFixed(1)}`}
|
||||||
)}
|
percentage={100}
|
||||||
|
color="purple"
|
||||||
<section className="flex flex-col gap-2 md:gap-4 w-full">
|
className="max-w-xs w-32 self-end h-10"
|
||||||
<div className="flex flex-col">
|
/>
|
||||||
<span className="font-semibold">Module Statistics</span>
|
</div>
|
||||||
<Divider />
|
<ProgressBar
|
||||||
</div>
|
label=""
|
||||||
<div className="flex flex-col md:grid md:grid-cols-3 w-full gap-4">
|
percentage={Math.round((calculateAverageLevel(user.levels) * 100) / calculateAverageLevel(user.desiredLevels))}
|
||||||
{!isStatsLoading && stats && (
|
color="red"
|
||||||
<>
|
className="w-full h-3 drop-shadow-lg"
|
||||||
<div className="max-w-lg">
|
/>
|
||||||
<SingleDatasetChart
|
<div className="flex justify-between w-full mt-8">
|
||||||
type="polarArea"
|
<div className="flex gap-4 items-center">
|
||||||
data={formatModuleTotalStats(stats)}
|
<div className="w-16 h-16 border border-mti-gray-platinum bg-mti-gray-smoke flex items-center justify-center rounded-xl">
|
||||||
title="Exams per Module"
|
<BsFileEarmarkText className="w-8 h-8 text-mti-red-light" />
|
||||||
label="Total"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="max-w-lg">
|
<div className="flex flex-col">
|
||||||
<SingleDatasetChart
|
<span className="font-bold text-xl">{Object.keys(groupBySession(stats)).length}</span>
|
||||||
type="polarArea"
|
<span className="font-normal text-base text-mti-gray-dim">Exams</span>
|
||||||
data={formatModuleAverageScoreStats(stats)}
|
|
||||||
title="Average Score per Module"
|
|
||||||
label="Score (in %)"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="max-w-lg">
|
</div>
|
||||||
<SingleDatasetChart type="polarArea" data={formatModuleTotalStats(stats)} title="Total exams" />
|
<div className="flex gap-4 items-center">
|
||||||
|
<div className="w-16 h-16 border border-mti-gray-platinum bg-mti-gray-smoke flex items-center justify-center rounded-xl">
|
||||||
|
<BsPencil className="w-8 h-8 text-mti-red-light" />
|
||||||
</div>
|
</div>
|
||||||
</>
|
<div className="flex flex-col">
|
||||||
)}
|
<span className="font-bold text-xl">{stats.length}</span>
|
||||||
|
<span className="font-normal text-base text-mti-gray-dim">Exercises</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-4 items-center">
|
||||||
|
<div className="w-16 h-16 border border-mti-gray-platinum bg-mti-gray-smoke flex items-center justify-center rounded-xl">
|
||||||
|
<BsStar className="w-8 h-8 text-mti-red-light" />
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="font-bold text-xl">{averageScore(stats)}%</span>
|
||||||
|
<span className="font-normal text-base text-mti-gray-dim">Average Score</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
<section className="flex flex-col gap-3">
|
||||||
<section className="flex flex-col gap-2 md:gap-4 w-full">
|
<span className="font-semi text-lg">Module Statistics</span>
|
||||||
<div className="flex flex-col">
|
<div className="flex gap-4 justify-between">
|
||||||
<span className="font-semibold">Exam Statistics</span>
|
<div className="flex flex-col gap-12 border w-full max-w-xs border-mti-gray-platinum p-4 pb-12 rounded-xl">
|
||||||
<Divider />
|
<span className="text-sm font-bold">Exams per Module</span>
|
||||||
</div>
|
<div className="flex flex-col gap-4">
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
<div className="flex flex-col md:grid md:grid-cols-3 w-full gap-4">
|
<div className="flex justify-between items-end">
|
||||||
{!isStatsLoading && stats && (
|
<span className="text-xs">
|
||||||
<>
|
<span className="font-medium">{totalExamsByModule(stats, "reading")}</span> of{" "}
|
||||||
<div className="max-w-lg">
|
<span className="font-medium">{Object.keys(groupBySession(stats)).length}</span>
|
||||||
<SingleDatasetChart
|
</span>
|
||||||
type="polarArea"
|
<span className="text-xs">Reading</span>
|
||||||
data={formatExerciseTotalStats(stats)}
|
</div>
|
||||||
title="Exercises per Type"
|
<ProgressBar
|
||||||
label="Total"
|
color="reading"
|
||||||
colors={chartColors}
|
percentage={(totalExamsByModule(stats, "reading") * 100) / Object.keys(groupBySession(stats)).length}
|
||||||
|
label=""
|
||||||
|
className="h-1"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="max-w-lg">
|
<div className="flex flex-col gap-2">
|
||||||
<SingleDatasetChart
|
<div className="flex justify-between items-end">
|
||||||
type="polarArea"
|
<span className="text-xs">
|
||||||
data={formatExerciseAverageScoreStats(stats)}
|
<span className="font-medium">{totalExamsByModule(stats, "listening")}</span> of{" "}
|
||||||
title="Average Score by Exercise"
|
<span className="font-medium">{Object.keys(groupBySession(stats)).length}</span>
|
||||||
label="Average"
|
</span>
|
||||||
colors={chartColors}
|
<span className="text-xs">Listening</span>
|
||||||
|
</div>
|
||||||
|
<ProgressBar
|
||||||
|
color="listening"
|
||||||
|
percentage={(totalExamsByModule(stats, "listening") * 100) / Object.keys(groupBySession(stats)).length}
|
||||||
|
label=""
|
||||||
|
className="h-1"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</>
|
<div className="flex flex-col gap-2">
|
||||||
)}
|
<div className="flex justify-between items-end">
|
||||||
|
<span className="text-xs">
|
||||||
|
<span className="font-medium">{totalExamsByModule(stats, "writing")}</span> of{" "}
|
||||||
|
<span className="font-medium">{Object.keys(groupBySession(stats)).length}</span>
|
||||||
|
</span>
|
||||||
|
<span className="text-xs">Writing</span>
|
||||||
|
</div>
|
||||||
|
<ProgressBar
|
||||||
|
color="writing"
|
||||||
|
percentage={(totalExamsByModule(stats, "writing") * 100) / Object.keys(groupBySession(stats)).length}
|
||||||
|
label=""
|
||||||
|
className="h-1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<div className="flex justify-between items-end">
|
||||||
|
<span className="text-xs">
|
||||||
|
<span className="font-medium">{totalExamsByModule(stats, "speaking")}</span> of{" "}
|
||||||
|
<span className="font-medium">{Object.keys(groupBySession(stats)).length}</span>
|
||||||
|
</span>
|
||||||
|
<span className="text-xs">Speaking</span>
|
||||||
|
</div>
|
||||||
|
<ProgressBar
|
||||||
|
color="speaking"
|
||||||
|
percentage={(totalExamsByModule(stats, "speaking") * 100) / Object.keys(groupBySession(stats)).length}
|
||||||
|
label=""
|
||||||
|
className="h-1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</Layout>
|
||||||
</main>
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
190
src/pages/test.tsx
Normal file
190
src/pages/test.tsx
Normal file
@@ -0,0 +1,190 @@
|
|||||||
|
/* eslint-disable @next/next/no-img-element */
|
||||||
|
|
||||||
|
import Head from "next/head";
|
||||||
|
import Navbar from "@/components/Navbar";
|
||||||
|
import {ToastContainer} from "react-toastify";
|
||||||
|
import {withIronSessionSsr} from "iron-session/next";
|
||||||
|
import {sessionOptions} from "@/lib/session";
|
||||||
|
import useUser from "@/hooks/useUser";
|
||||||
|
import Sidebar from "@/components/Sidebar";
|
||||||
|
import dynamic from "next/dynamic";
|
||||||
|
import {BsCheckCircleFill, BsMicFill, BsPauseCircle, BsPauseFill, BsPlayCircle, BsPlayFill, BsTrashFill} from "react-icons/bs";
|
||||||
|
import {useEffect, useState} from "react";
|
||||||
|
import Layout from "@/components/High/Layout";
|
||||||
|
|
||||||
|
const Waveform = dynamic(() => import("../components/Waveform"), {ssr: false});
|
||||||
|
const ReactMediaRecorder = dynamic(() => import("react-media-recorder").then((mod) => mod.ReactMediaRecorder), {
|
||||||
|
ssr: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const getServerSideProps = withIronSessionSsr(({req, res}) => {
|
||||||
|
const user = req.session.user;
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
res.setHeader("location", "/login");
|
||||||
|
res.statusCode = 302;
|
||||||
|
res.end();
|
||||||
|
return {
|
||||||
|
props: {
|
||||||
|
user: null,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
props: {user: req.session.user},
|
||||||
|
};
|
||||||
|
}, sessionOptions);
|
||||||
|
|
||||||
|
export default function Page() {
|
||||||
|
const {user} = useUser({redirectTo: "/login"});
|
||||||
|
const [recordingDuration, setRecordingDuration] = useState(0);
|
||||||
|
const [isRecording, setIsRecording] = useState(false);
|
||||||
|
const [mediaBlob, setMediaBlob] = useState<string>();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let recordingInterval: NodeJS.Timer | undefined = undefined;
|
||||||
|
if (isRecording) {
|
||||||
|
recordingInterval = setInterval(() => setRecordingDuration((prev) => prev + 1), 1000);
|
||||||
|
} else if (recordingInterval) {
|
||||||
|
clearInterval(recordingInterval);
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (recordingInterval) clearInterval(recordingInterval);
|
||||||
|
};
|
||||||
|
}, [isRecording]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Head>
|
||||||
|
<title>Exam | IELTS GPT</title>
|
||||||
|
<meta
|
||||||
|
name="description"
|
||||||
|
content="A training platform for the IELTS exam provided by the Muscat Training Institute and developed by eCrop."
|
||||||
|
/>
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<link rel="icon" href="/favicon.ico" />
|
||||||
|
</Head>
|
||||||
|
<ToastContainer />
|
||||||
|
{user && (
|
||||||
|
<Layout user={user}>
|
||||||
|
<ReactMediaRecorder
|
||||||
|
audio
|
||||||
|
onStop={(blob) => setMediaBlob(blob)}
|
||||||
|
render={({status, startRecording, stopRecording, pauseRecording, resumeRecording, clearBlobUrl, mediaBlobUrl}) => (
|
||||||
|
<div className="w-full p-4 px-8 bg-transparent border-2 border-mti-gray-platinum rounded-2xl flex-col gap-8 items-center">
|
||||||
|
<p className="text-base font-normal">Record your answer:</p>
|
||||||
|
<div className="flex gap-8 items-center justify-center py-8">
|
||||||
|
{status === "idle" && (
|
||||||
|
<>
|
||||||
|
<div className="w-full h-2 max-w-4xl bg-mti-gray-smoke rounded-full" />
|
||||||
|
{status === "idle" && (
|
||||||
|
<BsMicFill
|
||||||
|
onClick={() => {
|
||||||
|
setRecordingDuration(0);
|
||||||
|
startRecording();
|
||||||
|
setIsRecording(true);
|
||||||
|
}}
|
||||||
|
className="h-5 w-5 text-mti-gray-cool cursor-pointer"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{status === "recording" && (
|
||||||
|
<>
|
||||||
|
<div className="flex gap-4 items-center">
|
||||||
|
<span className="text-xs w-9">
|
||||||
|
{Math.round(recordingDuration / 60)
|
||||||
|
.toString(10)
|
||||||
|
.padStart(2, "0")}
|
||||||
|
:
|
||||||
|
{Math.round(recordingDuration % 60)
|
||||||
|
.toString(10)
|
||||||
|
.padStart(2, "0")}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="w-full h-2 max-w-4xl bg-mti-gray-smoke rounded-full" />
|
||||||
|
<div className="flex gap-4 items-center">
|
||||||
|
<BsPauseCircle
|
||||||
|
onClick={() => {
|
||||||
|
setIsRecording(false);
|
||||||
|
pauseRecording();
|
||||||
|
}}
|
||||||
|
className="text-red-500 w-8 h-8 cursor-pointer"
|
||||||
|
/>
|
||||||
|
<BsCheckCircleFill
|
||||||
|
onClick={() => {
|
||||||
|
setIsRecording(false);
|
||||||
|
stopRecording();
|
||||||
|
}}
|
||||||
|
className="text-mti-purple-light w-8 h-8 cursor-pointer"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{status === "paused" && (
|
||||||
|
<>
|
||||||
|
<div className="flex gap-4 items-center">
|
||||||
|
<span className="text-xs w-9">
|
||||||
|
{Math.floor(recordingDuration / 60)
|
||||||
|
.toString(10)
|
||||||
|
.padStart(2, "0")}
|
||||||
|
:
|
||||||
|
{Math.floor(recordingDuration % 60)
|
||||||
|
.toString(10)
|
||||||
|
.padStart(2, "0")}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="w-full h-2 max-w-4xl bg-mti-gray-smoke rounded-full" />
|
||||||
|
<div className="flex gap-4 items-center">
|
||||||
|
<BsPlayCircle
|
||||||
|
onClick={() => {
|
||||||
|
setIsRecording(true);
|
||||||
|
resumeRecording();
|
||||||
|
}}
|
||||||
|
className="text-mti-purple-light w-8 h-8 cursor-pointer"
|
||||||
|
/>
|
||||||
|
<BsCheckCircleFill
|
||||||
|
onClick={() => {
|
||||||
|
setIsRecording(false);
|
||||||
|
stopRecording();
|
||||||
|
}}
|
||||||
|
className="text-mti-purple-light w-8 h-8 cursor-pointer"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{status === "stopped" && mediaBlobUrl && (
|
||||||
|
<>
|
||||||
|
<Waveform audio={mediaBlobUrl} waveColor="#FCDDEC" progressColor="#EF5DA8" />
|
||||||
|
<div className="flex gap-4 items-center">
|
||||||
|
<BsTrashFill
|
||||||
|
className="text-mti-gray-cool cursor-pointer w-5 h-5"
|
||||||
|
onClick={() => {
|
||||||
|
setRecordingDuration(0);
|
||||||
|
clearBlobUrl();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<BsMicFill
|
||||||
|
onClick={() => {
|
||||||
|
clearBlobUrl();
|
||||||
|
setRecordingDuration(0);
|
||||||
|
startRecording();
|
||||||
|
setIsRecording(true);
|
||||||
|
}}
|
||||||
|
className="h-5 w-5 text-mti-gray-cool cursor-pointer"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</Layout>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,7 +2,6 @@ import {withIronSessionSsr} from "iron-session/next";
|
|||||||
import {sessionOptions} from "@/lib/session";
|
import {sessionOptions} from "@/lib/session";
|
||||||
import {Type, User} from "@/interfaces/user";
|
import {Type, User} from "@/interfaces/user";
|
||||||
import Head from "next/head";
|
import Head from "next/head";
|
||||||
import Navbar from "@/components/Navbar";
|
|
||||||
import {Avatar} from "primereact/avatar";
|
import {Avatar} from "primereact/avatar";
|
||||||
import {useEffect, useState} from "react";
|
import {useEffect, useState} from "react";
|
||||||
import {FilterMatchMode, FilterOperator} from "primereact/api";
|
import {FilterMatchMode, FilterOperator} from "primereact/api";
|
||||||
@@ -67,7 +66,6 @@ export default function Users({user}: {user: User}) {
|
|||||||
<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 bg-neutral-100">
|
<main className="w-full h-full min-h-[100vh] flex flex-col items-center bg-neutral-100">
|
||||||
<Navbar userType={user.type} profilePicture={user.profilePicture} />
|
|
||||||
<div className="w-full h-full flex flex-col items-center justify-center p-4 relative">
|
<div className="w-full h-full flex flex-col items-center justify-center p-4 relative">
|
||||||
<DataTable
|
<DataTable
|
||||||
dataKey="id"
|
dataKey="id"
|
||||||
|
|||||||
@@ -1,28 +1,37 @@
|
|||||||
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;
|
||||||
setSelectedModules: (modules: Module[]) => void;
|
setSelectedModules: (modules: Module[]) => void;
|
||||||
|
reset: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const useExamStore = create<ExamState>((set) => ({
|
export const initialState = {
|
||||||
exams: [],
|
exams: [],
|
||||||
userSolutions: [],
|
userSolutions: [],
|
||||||
showSolutions: false,
|
showSolutions: false,
|
||||||
selectedModules: [],
|
selectedModules: [],
|
||||||
|
hasExamEnded: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
const useExamStore = create<ExamState>((set) => ({
|
||||||
|
...initialState,
|
||||||
setUserSolutions: (userSolutions: UserSolution[]) => set(() => ({userSolutions})),
|
setUserSolutions: (userSolutions: UserSolution[]) => set(() => ({userSolutions})),
|
||||||
setExams: (exams: Exam[]) => set(() => ({exams})),
|
setExams: (exams: Exam[]) => set(() => ({exams})),
|
||||||
setShowSolutions: (showSolutions: boolean) => set(() => ({showSolutions})),
|
setShowSolutions: (showSolutions: boolean) => set(() => ({showSolutions})),
|
||||||
setSelectedModules: (modules: Module[]) => set(() => ({selectedModules: modules})),
|
setSelectedModules: (modules: Module[]) => set(() => ({selectedModules: modules})),
|
||||||
|
setHasExamEnded: (hasExamEnded: boolean) => set(() => ({hasExamEnded})),
|
||||||
|
reset: () => set(() => initialState),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
export default useExamStore;
|
export default useExamStore;
|
||||||
|
|||||||
@@ -8,9 +8,9 @@
|
|||||||
--font-mono: ui-monospace, Menlo, Monaco, "Cascadia Mono", "Segoe UI Mono", "Roboto Mono", "Oxygen Mono", "Ubuntu Monospace", "Source Code Pro",
|
--font-mono: ui-monospace, Menlo, Monaco, "Cascadia Mono", "Segoe UI Mono", "Roboto Mono", "Oxygen Mono", "Ubuntu Monospace", "Source Code Pro",
|
||||||
"Fira Mono", "Droid Sans Mono", "Courier New", monospace;
|
"Fira Mono", "Droid Sans Mono", "Courier New", monospace;
|
||||||
|
|
||||||
--foreground-rgb: 0, 0, 0;
|
--foreground-rgb: 53, 51, 56;
|
||||||
--background-start-rgb: 214, 219, 220;
|
--background-start-rgb: 245, 245, 245;
|
||||||
--background-end-rgb: 255, 255, 255;
|
--background-end-rgb: 245, 245, 245;
|
||||||
|
|
||||||
--primary-glow: conic-gradient(from 180deg at 50% 50%, #16abff33 0deg, #0885ff33 55deg, #54d6ff33 120deg, #0071ff33 160deg, transparent 360deg);
|
--primary-glow: conic-gradient(from 180deg at 50% 50%, #16abff33 0deg, #0885ff33 55deg, #54d6ff33 120deg, #0071ff33 160deg, transparent 360deg);
|
||||||
--secondary-glow: radial-gradient(rgba(255, 255, 255, 1), rgba(255, 255, 255, 0));
|
--secondary-glow: radial-gradient(rgba(255, 255, 255, 1), rgba(255, 255, 255, 0));
|
||||||
@@ -25,26 +25,6 @@
|
|||||||
--card-border-rgb: 131, 134, 135;
|
--card-border-rgb: 131, 134, 135;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (prefers-color-scheme: dark) {
|
|
||||||
:root {
|
|
||||||
--foreground-rgb: 255, 255, 255;
|
|
||||||
--background-start-rgb: 0, 0, 0;
|
|
||||||
--background-end-rgb: 0, 0, 0;
|
|
||||||
|
|
||||||
--primary-glow: radial-gradient(rgba(1, 65, 255, 0.4), rgba(1, 65, 255, 0));
|
|
||||||
--secondary-glow: linear-gradient(to bottom right, rgba(1, 65, 255, 0), rgba(1, 65, 255, 0), rgba(1, 65, 255, 0.3));
|
|
||||||
|
|
||||||
--tile-start-rgb: 2, 13, 46;
|
|
||||||
--tile-end-rgb: 2, 5, 19;
|
|
||||||
--tile-border: conic-gradient(#ffffff80, #ffffff40, #ffffff30, #ffffff20, #ffffff10, #ffffff10, #ffffff80);
|
|
||||||
|
|
||||||
--callout-rgb: 20, 20, 20;
|
|
||||||
--callout-border-rgb: 108, 108, 108;
|
|
||||||
--card-rgb: 100, 100, 100;
|
|
||||||
--card-border-rgb: 200, 200, 200;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
* {
|
* {
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
@@ -53,8 +33,11 @@
|
|||||||
|
|
||||||
html,
|
html,
|
||||||
body {
|
body {
|
||||||
|
min-height: 100vh !important;
|
||||||
|
height: 100%;
|
||||||
max-width: 100vw;
|
max-width: 100vw;
|
||||||
overflow-x: hidden;
|
overflow-x: hidden;
|
||||||
|
font-family: "Open Sans", system-ui, -apple-system, "Helvetica Neue", sans-serif;
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
@@ -66,9 +49,3 @@ a {
|
|||||||
color: inherit;
|
color: inherit;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (prefers-color-scheme: dark) {
|
|
||||||
html {
|
|
||||||
color-scheme: dark;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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}};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import {Module} from "@/interfaces";
|
import {Module} from "@/interfaces";
|
||||||
|
|
||||||
const MODULE_ARRAY: Module[] = ["reading", "listening", "writing", "speaking"];
|
export const MODULE_ARRAY: Module[] = ["reading", "listening", "writing", "speaking"];
|
||||||
|
|
||||||
export const moduleLabels: {[key in Module]: string} = {
|
export const moduleLabels: {[key in Module]: string} = {
|
||||||
listening: "Listening",
|
listening: "Listening",
|
||||||
@@ -12,3 +12,7 @@ export const moduleLabels: {[key in Module]: string} = {
|
|||||||
export const sortByModule = (a: {module: Module}, b: {module: Module}) => {
|
export const sortByModule = (a: {module: Module}, b: {module: Module}) => {
|
||||||
return MODULE_ARRAY.findIndex((x) => a.module === x) - MODULE_ARRAY.findIndex((x) => b.module === x);
|
return MODULE_ARRAY.findIndex((x) => a.module === x) - MODULE_ARRAY.findIndex((x) => b.module === x);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const sortByModuleName = (a: string, b: string) => {
|
||||||
|
return MODULE_ARRAY.findIndex((x) => a === x) - MODULE_ARRAY.findIndex((x) => b === x);
|
||||||
|
};
|
||||||
|
|||||||
5
src/utils/navigation.disabled.ts
Normal file
5
src/utils/navigation.disabled.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
export const preventNavigation = (navDisabled: Boolean, focusMode: Boolean): Boolean => {
|
||||||
|
if (navDisabled) return true;
|
||||||
|
if(focusMode) return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
132
src/utils/score.ts
Normal file
132
src/utils/score.ts
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
import {Module} from "@/interfaces";
|
||||||
|
|
||||||
|
type Type = "academic" | "general";
|
||||||
|
|
||||||
|
export const writingReverseMarking: {[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 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,
|
||||||
|
80: 8,
|
||||||
|
70: 7,
|
||||||
|
60: 6,
|
||||||
|
50: 5,
|
||||||
|
40: 4,
|
||||||
|
30: 3,
|
||||||
|
20: 2,
|
||||||
|
10: 1,
|
||||||
|
0: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
const readingGeneralMarking: {[key: number]: number} = {
|
||||||
|
100: 9,
|
||||||
|
97.5: 8.5,
|
||||||
|
92.5: 8,
|
||||||
|
90: 7.5,
|
||||||
|
85: 7,
|
||||||
|
80: 6.5,
|
||||||
|
75: 6,
|
||||||
|
67.5: 5.5,
|
||||||
|
57.5: 5,
|
||||||
|
45.5: 4.5,
|
||||||
|
37.5: 4,
|
||||||
|
30: 3.5,
|
||||||
|
22.5: 3,
|
||||||
|
15: 2.5,
|
||||||
|
};
|
||||||
|
|
||||||
|
const academicMarking: {[key: number]: number} = {
|
||||||
|
97.5: 9,
|
||||||
|
92.5: 8.5,
|
||||||
|
87.5: 8,
|
||||||
|
80: 7.5,
|
||||||
|
75: 7,
|
||||||
|
65: 6.5,
|
||||||
|
57.5: 6,
|
||||||
|
45: 5.5,
|
||||||
|
40: 5,
|
||||||
|
32.5: 4.5,
|
||||||
|
25: 4,
|
||||||
|
20: 3.5,
|
||||||
|
15: 3,
|
||||||
|
10: 2.5,
|
||||||
|
};
|
||||||
|
|
||||||
|
const moduleMarkings: {[key in Module]: {[key in Type]: {[key: number]: number}}} = {
|
||||||
|
reading: {
|
||||||
|
academic: academicMarking,
|
||||||
|
general: readingGeneralMarking,
|
||||||
|
},
|
||||||
|
listening: {
|
||||||
|
academic: academicMarking,
|
||||||
|
general: academicMarking,
|
||||||
|
},
|
||||||
|
writing: {
|
||||||
|
academic: writingMarking,
|
||||||
|
general: writingMarking,
|
||||||
|
},
|
||||||
|
speaking: {
|
||||||
|
academic: writingMarking,
|
||||||
|
general: writingMarking,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const calculateBandScore = (correct: number, total: number, module: Module, type: Type) => {
|
||||||
|
const marking = moduleMarkings[module][type];
|
||||||
|
const percentage = (correct * 100) / total;
|
||||||
|
|
||||||
|
for (const value of Object.keys(marking)
|
||||||
|
.map((x) => parseFloat(x))
|
||||||
|
.sort((a, b) => b - a)) {
|
||||||
|
if (percentage >= value) {
|
||||||
|
return marking[value];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const calculateAverageLevel = (levels: {[key in Module]: number}) => {
|
||||||
|
return Object.keys(levels).reduce((accumulator, current) => levels[current as Module] + accumulator, 0) / 4;
|
||||||
|
};
|
||||||
@@ -2,6 +2,7 @@ import {Stat} from "@/interfaces/user";
|
|||||||
import {capitalize, groupBy} from "lodash";
|
import {capitalize, groupBy} from "lodash";
|
||||||
import {convertCamelCaseToReadable} from "@/utils/string";
|
import {convertCamelCaseToReadable} from "@/utils/string";
|
||||||
import {UserSolution} from "@/interfaces/exam";
|
import {UserSolution} from "@/interfaces/exam";
|
||||||
|
import {Module} from "@/interfaces";
|
||||||
|
|
||||||
export const totalExams = (stats: Stat[]): number => {
|
export const totalExams = (stats: Stat[]): number => {
|
||||||
const moduleStats = formatModuleTotalStats(stats);
|
const moduleStats = formatModuleTotalStats(stats);
|
||||||
@@ -35,6 +36,22 @@ export const formatModuleTotalStats = (stats: Stat[]): {label: string; value: nu
|
|||||||
}));
|
}));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const totalExamsByModule = (stats: Stat[], module: Module): number => {
|
||||||
|
const moduleSessions: {[key: string]: string[]} = {};
|
||||||
|
|
||||||
|
stats.forEach((stat) => {
|
||||||
|
if (stat.module in moduleSessions) {
|
||||||
|
if (!moduleSessions[stat.module].includes(stat.session)) {
|
||||||
|
moduleSessions[stat.module] = [...moduleSessions[stat.module], stat.session];
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
moduleSessions[stat.module] = [stat.session];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return moduleSessions[module]?.length || 0;
|
||||||
|
};
|
||||||
|
|
||||||
export const formatModuleAverageScoreStats = (stats: Stat[]): {label: string; value: number}[] => {
|
export const formatModuleAverageScoreStats = (stats: Stat[]): {label: string; value: number}[] => {
|
||||||
const moduleScores: {[key: string]: {correct: number; total: number}} = {};
|
const moduleScores: {[key: string]: {correct: number; total: number}} = {};
|
||||||
|
|
||||||
@@ -99,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) => ({
|
||||||
|
|||||||
@@ -7,3 +7,15 @@ export function convertCamelCaseToReadable(camelCaseString: string): string {
|
|||||||
|
|
||||||
return readableString;
|
return readableString;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function formatTimeInMinutes(time: number) {
|
||||||
|
if (time === 0) {
|
||||||
|
return "00:00";
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${Math.floor(time / 60)
|
||||||
|
.toString(10)
|
||||||
|
.padStart(2, "0")}:${Math.floor(time % 60)
|
||||||
|
.toString(10)
|
||||||
|
.padStart(2, "0")}`;
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,11 +4,31 @@ module.exports = {
|
|||||||
theme: {
|
theme: {
|
||||||
extend: {
|
extend: {
|
||||||
colors: {
|
colors: {
|
||||||
|
mti: {
|
||||||
|
orange: {DEFAULT: "#FF6000", dark: "#cc4402", light: "#ff790a", ultralight: "#ffdaa5"},
|
||||||
|
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"},
|
||||||
|
white: {DEFAULT: "#ffffff", alt: "#FDFDFD"},
|
||||||
|
gray: {
|
||||||
|
seasalt: "#F9F9F9",
|
||||||
|
smoke: "#F5F5F5",
|
||||||
|
taupe: "#898492",
|
||||||
|
dim: "#696F79",
|
||||||
|
cool: "#8692A6",
|
||||||
|
platinum: "#DBDBDB",
|
||||||
|
"anti-flash": "#EAEBEC",
|
||||||
|
davy: "#595959",
|
||||||
|
},
|
||||||
|
black: "#353338",
|
||||||
|
},
|
||||||
ielts: {
|
ielts: {
|
||||||
reading: {DEFAULT: "#FF6384", transparent: "rgba(255, 99, 132, 0.5)"},
|
reading: {DEFAULT: "#1EB3FF", light: "#F0F9FF", transparent: "rgba(255, 99, 132, 0.5)"},
|
||||||
listening: {DEFAULT: "#36A2EB", transparent: "rgba(54, 162, 235, 0.5)"},
|
listening: {DEFAULT: "#FF790A", light: "#FFF1E5", transparent: "rgba(54, 162, 235, 0.5)"},
|
||||||
writing: {DEFAULT: "#FFCE56", transparent: "rgba(255, 206, 86, 0.5)"},
|
writing: {DEFAULT: "#3D9F11", light: "#E8FCDF", transparent: "rgba(255, 206, 86, 0.5)"},
|
||||||
speaking: {DEFAULT: "#4bc0c0", transparent: "rgba(75, 192, 192, 0.5)"},
|
speaking: {DEFAULT: "#EF5DA8", light: "#FEF6FA", transparent: "rgba(75, 192, 192, 0.5)"},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user