Compare commits
36 Commits
task/desig
...
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 |
7
.dockerignore
Normal file
7
.dockerignore
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
Dockerfile
|
||||||
|
.dockerignore
|
||||||
|
node_modules
|
||||||
|
npm-debug.log
|
||||||
|
README.md
|
||||||
|
.next
|
||||||
|
.git
|
||||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -37,3 +37,4 @@ next-env.d.ts
|
|||||||
|
|
||||||
.env
|
.env
|
||||||
.yarn/*
|
.yarn/*
|
||||||
|
.history*
|
||||||
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
@@ -23,6 +23,8 @@
|
|||||||
"eslint": "8.33.0",
|
"eslint": "8.33.0",
|
||||||
"eslint-config-next": "13.1.6",
|
"eslint-config-next": "13.1.6",
|
||||||
"firebase": "9.19.1",
|
"firebase": "9.19.1",
|
||||||
|
"formidable": "^3.5.0",
|
||||||
|
"formidable-serverless": "^1.1.1",
|
||||||
"framer-motion": "^9.0.2",
|
"framer-motion": "^9.0.2",
|
||||||
"iron-session": "^6.3.1",
|
"iron-session": "^6.3.1",
|
||||||
"lodash": "^4.17.21",
|
"lodash": "^4.17.21",
|
||||||
@@ -48,6 +50,7 @@
|
|||||||
"zustand": "^4.3.6"
|
"zustand": "^4.3.6"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@types/formidable": "^3.4.0",
|
||||||
"@types/lodash": "^4.14.191",
|
"@types/lodash": "^4.14.191",
|
||||||
"@types/uuid": "^9.0.1",
|
"@types/uuid": "^9.0.1",
|
||||||
"@types/wavesurfer.js": "^6.0.6",
|
"@types/wavesurfer.js": "^6.0.6",
|
||||||
|
|||||||
BIN
public/defaultAvatar.png
Normal file
BIN
public/defaultAvatar.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 5.2 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,8 +29,7 @@ const DIAGNOSTIC_EXAMS = [
|
|||||||
|
|
||||||
export default function Diagnostic({onFinish}: Props) {
|
export default function Diagnostic({onFinish}: Props) {
|
||||||
const [focus, setFocus] = useState<"academic" | "general">();
|
const [focus, setFocus] = useState<"academic" | "general">();
|
||||||
const [isInsert, setIsInsert] = useState(false);
|
const [levels, setLevels] = useState({reading: -1, listening: -1, writing: -1, speaking: -1});
|
||||||
const [levels, setLevels] = useState({reading: 0, listening: 0, writing: 0, speaking: 0});
|
|
||||||
const [desiredLevels, setDesiredLevels] = useState({reading: 9, listening: 9, writing: 9, speaking: 9});
|
const [desiredLevels, setDesiredLevels] = useState({reading: 9, listening: 9, writing: 9, speaking: 9});
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -34,6 +37,11 @@ export default function Diagnostic({onFinish}: Props) {
|
|||||||
const setExams = useExamStore((state) => state.setExams);
|
const setExams = useExamStore((state) => state.setExams);
|
||||||
const setSelectedModules = useExamStore((state) => state.setSelectedModules);
|
const setSelectedModules = useExamStore((state) => state.setSelectedModules);
|
||||||
|
|
||||||
|
const isNextDisabled = () => {
|
||||||
|
if (!focus) return true;
|
||||||
|
return Object.values(levels).includes(-1);
|
||||||
|
};
|
||||||
|
|
||||||
const selectExam = () => {
|
const selectExam = () => {
|
||||||
const examPromises = DIAGNOSTIC_EXAMS.map((exam) => getExamById(exam[0] as Module, exam[1]));
|
const examPromises = DIAGNOSTIC_EXAMS.map((exam) => getExamById(exam[0] as Module, exam[1]));
|
||||||
|
|
||||||
@@ -48,72 +56,172 @@ export default function Diagnostic({onFinish}: Props) {
|
|||||||
|
|
||||||
const updateUser = (callback: () => void) => {
|
const updateUser = (callback: () => void) => {
|
||||||
axios
|
axios
|
||||||
.patch("/api/users/update", {focus, levels, desiredLevels, isFirstLogin: false})
|
.patch("/api/users/update", {
|
||||||
|
focus,
|
||||||
|
levels: Object.values(levels).includes(-1) ? {reading: -1, listening: -1, writing: -1, speaking: -1} : levels,
|
||||||
|
desiredLevels,
|
||||||
|
isFirstLogin: false,
|
||||||
|
})
|
||||||
.then(callback)
|
.then(callback)
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
toast.error("Something went wrong, please try again later!", {toastId: "user-update-error"});
|
toast.error("Something went wrong, please try again later!", {toastId: "user-update-error"});
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!focus) {
|
|
||||||
return (
|
|
||||||
<div className="bg-white p-16 rounded-2xl flex flex-col items-center justify-center gap-8 h-96 relative shadow-md">
|
|
||||||
<h2 className="absolute top-8 font-semibold text-xl">What is your focus?</h2>
|
|
||||||
<div className="flex flex-col gap-4 justify-self-stretch">
|
|
||||||
<button onClick={() => setFocus("academic")} className={clsx("btn btn-wide gap-4 relative text-white", infoButtonStyle)}>
|
|
||||||
Academic
|
|
||||||
</button>
|
|
||||||
<button onClick={() => setFocus("general")} className={clsx("btn btn-wide gap-4 relative text-white", infoButtonStyle)}>
|
|
||||||
General
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isInsert) {
|
|
||||||
return (
|
|
||||||
<div className="bg-white p-16 rounded-2xl flex flex-col items-center justify-center gap-8 shadow-md">
|
|
||||||
<h2 className="font-semibold text-xl">What is your level?</h2>
|
|
||||||
<div className="flex w-full flex-col gap-4 justify-self-stretch">
|
|
||||||
{Object.keys(levels).map((module) => (
|
|
||||||
<div key={module} className="flex items-center gap-4 justify-between">
|
|
||||||
<span className="font-medium text-lg">{capitalize(module)}</span>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
className={clsx(
|
|
||||||
"input input-bordered bg-white w-24",
|
|
||||||
!BAND_SCORES[module as Module].includes(levels[module as keyof typeof levels]) && "input-error",
|
|
||||||
)}
|
|
||||||
value={levels[module as keyof typeof levels]}
|
|
||||||
min={0}
|
|
||||||
max={9}
|
|
||||||
step={0.5}
|
|
||||||
onChange={(e) => setLevels((prev) => ({...prev, [module]: parseFloat(e.target.value)}))}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={() => updateUser(onFinish)}
|
|
||||||
className={clsx("btn btn-wide gap-4 relative text-white", infoButtonStyle)}
|
|
||||||
disabled={!Object.keys(levels).every((module) => BAND_SCORES[module as Module].includes(levels[module as keyof typeof levels]))}>
|
|
||||||
Next
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-white p-16 rounded-2xl flex flex-col items-center justify-center gap-8 h-96 relative shadow-md">
|
<div className="flex flex-col items-center justify-center gap-12 w-full">
|
||||||
<h2 className="absolute top-8 font-semibold text-xl">What is your current IELTS level?</h2>
|
<div className="flex flex-col items-center justify-center gap-8 w-full">
|
||||||
<div className="flex flex-col gap-4">
|
<h2 className="font-semibold text-xl">What is your current focus?</h2>
|
||||||
<button onClick={() => setIsInsert(true)} className={clsx("btn btn-wide gap-4 relative text-white", infoButtonStyle)}>
|
<div className="flex flex-col gap-16 w-full">
|
||||||
Insert my IELTS level
|
<div className="grid grid-cols-2 gap-y-4 gap-x-16">
|
||||||
</button>
|
<button
|
||||||
<button onClick={() => updateUser(selectExam)} className={clsx("btn btn-wide gap-4 relative text-white", infoButtonStyle)}>
|
onClick={() => setFocus("academic")}
|
||||||
Perform a Diagnosis Test
|
className={clsx(
|
||||||
</button>
|
"w-full border border-mti-gray-platinum rounded-full px-6 py-4 flex justify-center items-center gap-12 bg-white",
|
||||||
|
"hover:bg-mti-purple-light hover:text-white",
|
||||||
|
focus === "academic" && "!bg-mti-purple-light !text-white",
|
||||||
|
"transition duration-300 ease-in-out",
|
||||||
|
)}>
|
||||||
|
Academic
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setFocus("general")}
|
||||||
|
className={clsx(
|
||||||
|
"w-full border border-mti-gray-platinum rounded-full px-6 py-4 flex justify-center items-center gap-12 bg-white",
|
||||||
|
"hover:bg-mti-purple-light hover:text-white",
|
||||||
|
focus === "general" && "!bg-mti-purple-light !text-white",
|
||||||
|
"transition duration-300 ease-in-out",
|
||||||
|
)}>
|
||||||
|
General
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col items-center justify-center gap-8 w-full">
|
||||||
|
<h2 className="font-semibold text-xl">What is your current IELTS level?</h2>
|
||||||
|
<div className="flex flex-col gap-16 w-full">
|
||||||
|
<div className="grid grid-cols-2 gap-y-4 gap-x-16">
|
||||||
|
<div className="w-full flex flex-col gap-3.5 relative">
|
||||||
|
<span className="text-sm text-mti-gray-dim">
|
||||||
|
<span className="font-bold">Reading</span> level
|
||||||
|
</span>
|
||||||
|
<Menu>
|
||||||
|
<Menu.Button className="w-full border border-mti-gray-platinum rounded-full px-6 py-4 flex justify-between items-center gap-12 bg-white">
|
||||||
|
<BsBook className="text-ielts-reading" size={34} />
|
||||||
|
<span className="text-mti-gray-cool text-sm">
|
||||||
|
{levels.reading === -1 ? "Select your reading level" : `Level ${levels.reading}`}
|
||||||
|
</span>
|
||||||
|
<BsChevronDown className="text-mti-gray-cool" size={12} />
|
||||||
|
</Menu.Button>
|
||||||
|
<Menu.Items className="absolute origin-top top-full bg-white flex flex-col items-center w-full z-20 drop-shadow-lg rounded-2xl overflow-hidden">
|
||||||
|
{Object.values(writingMarking).map((x) => (
|
||||||
|
<Menu.Item key={x}>
|
||||||
|
<span
|
||||||
|
onClick={() => setLevels((prev) => ({...prev, reading: x}))}
|
||||||
|
className="w-full py-4 text-center cursor-pointer bg-white hover:bg-mti-gray-platinum transition ease-in-out duration-300">
|
||||||
|
Level {x}
|
||||||
|
</span>
|
||||||
|
</Menu.Item>
|
||||||
|
))}
|
||||||
|
</Menu.Items>
|
||||||
|
</Menu>
|
||||||
|
</div>
|
||||||
|
<div className="w-full flex flex-col gap-3.5 relative">
|
||||||
|
<span className="text-sm text-mti-gray-dim">
|
||||||
|
<span className="font-bold">Listening</span> level
|
||||||
|
</span>
|
||||||
|
<Menu>
|
||||||
|
<Menu.Button className="w-full border border-mti-gray-platinum rounded-full px-6 py-4 flex justify-between items-center gap-12 bg-white">
|
||||||
|
<BsHeadphones className="text-ielts-listening" size={34} />
|
||||||
|
<span className="text-mti-gray-cool text-sm">
|
||||||
|
{levels.listening === -1 ? "Select your listening level" : `Level ${levels.listening}`}
|
||||||
|
</span>
|
||||||
|
<BsChevronDown className="text-mti-gray-cool" size={12} />
|
||||||
|
</Menu.Button>
|
||||||
|
<Menu.Items className="absolute origin-top top-full bg-white flex flex-col items-center w-full z-20 drop-shadow-lg rounded-2xl overflow-hidden">
|
||||||
|
{Object.values(writingMarking).map((x) => (
|
||||||
|
<Menu.Item key={x}>
|
||||||
|
<span
|
||||||
|
onClick={() => setLevels((prev) => ({...prev, listening: x}))}
|
||||||
|
className="w-full py-5 text-center cursor-pointer bg-white hover:bg-mti-gray-platinum transition ease-in-out duration-300">
|
||||||
|
Level {x}
|
||||||
|
</span>
|
||||||
|
</Menu.Item>
|
||||||
|
))}
|
||||||
|
</Menu.Items>
|
||||||
|
</Menu>
|
||||||
|
</div>
|
||||||
|
<div className="w-full flex flex-col gap-3.5 relative">
|
||||||
|
<span className="text-sm text-mti-gray-dim">
|
||||||
|
<span className="font-bold">Writing</span> level
|
||||||
|
</span>
|
||||||
|
<Menu>
|
||||||
|
<Menu.Button className="w-full border border-mti-gray-platinum rounded-full px-6 py-4 flex justify-between items-center gap-12 bg-white">
|
||||||
|
<BsPen className="text-ielts-writing" size={34} />
|
||||||
|
<span className="text-mti-gray-cool text-sm">
|
||||||
|
{levels.writing === -1 ? "Select your writing level" : `Level ${levels.writing}`}
|
||||||
|
</span>
|
||||||
|
<BsChevronDown className="text-mti-gray-cool" size={12} />
|
||||||
|
</Menu.Button>
|
||||||
|
<Menu.Items className="absolute origin-top top-full bg-white flex flex-col items-center w-full z-20 drop-shadow-lg rounded-2xl overflow-hidden">
|
||||||
|
{Object.values(writingMarking).map((x) => (
|
||||||
|
<Menu.Item key={x}>
|
||||||
|
<span
|
||||||
|
onClick={() => setLevels((prev) => ({...prev, writing: x}))}
|
||||||
|
className="w-full py-5 text-center cursor-pointer bg-white hover:bg-mti-gray-platinum transition ease-in-out duration-300">
|
||||||
|
Level {x}
|
||||||
|
</span>
|
||||||
|
</Menu.Item>
|
||||||
|
))}
|
||||||
|
</Menu.Items>
|
||||||
|
</Menu>
|
||||||
|
</div>
|
||||||
|
<div className="w-full flex flex-col gap-3.5 relative">
|
||||||
|
<span className="text-sm text-mti-gray-dim">
|
||||||
|
<span className="font-bold">Speaking</span> level
|
||||||
|
</span>
|
||||||
|
<Menu>
|
||||||
|
<Menu.Button className="w-full border border-mti-gray-platinum rounded-full px-6 py-4 flex justify-between items-center gap-12 bg-white">
|
||||||
|
<BsMegaphone className="text-ielts-speaking" size={34} />
|
||||||
|
<span className="text-mti-gray-cool text-sm">
|
||||||
|
{levels.speaking === -1 ? "Select your speaking level" : `Level ${levels.speaking}`}
|
||||||
|
</span>
|
||||||
|
<BsChevronDown className="text-mti-gray-cool" size={12} />
|
||||||
|
</Menu.Button>
|
||||||
|
<Menu.Items className="absolute origin-top top-full bg-white flex flex-col items-center w-full z-20 drop-shadow-lg rounded-2xl">
|
||||||
|
{Object.values(writingMarking).map((x) => (
|
||||||
|
<Menu.Item key={x}>
|
||||||
|
<span
|
||||||
|
onClick={() => setLevels((prev) => ({...prev, speaking: x}))}
|
||||||
|
className="w-full py-5 text-center cursor-pointer bg-white hover:bg-mti-gray-platinum transition ease-in-out duration-300">
|
||||||
|
Level {x}
|
||||||
|
</span>
|
||||||
|
</Menu.Item>
|
||||||
|
))}
|
||||||
|
</Menu.Items>
|
||||||
|
</Menu>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="self-end flex justify-between w-full gap-8 absolute bottom-8 left-0 px-8">
|
||||||
|
<Button
|
||||||
|
onClick={() => updateUser(selectExam)}
|
||||||
|
color="purple"
|
||||||
|
variant="outline"
|
||||||
|
className="group flex items-center justify-center gap-6 relative max-w-[400px] w-full"
|
||||||
|
disabled={!focus}>
|
||||||
|
<BsQuestionSquare
|
||||||
|
className="text-mti-purple-light group-hover:text-white transition duration-300 ease-in-out"
|
||||||
|
size={20}
|
||||||
|
onClick={() => updateUser(selectExam)}
|
||||||
|
/>
|
||||||
|
<span onClick={() => updateUser(selectExam)}>Perform diagnostic test instead</span>
|
||||||
|
</Button>
|
||||||
|
<Button color="purple" className="max-w-[400px] w-full" onClick={() => updateUser(onFinish)} disabled={isNextDisabled()}>
|
||||||
|
Next Step
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import {FillBlanksExercise} from "@/interfaces/exam";
|
import {FillBlanksExercise} from "@/interfaces/exam";
|
||||||
|
import useExamStore from "@/stores/examStore";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import {Fragment, useEffect, useState} from "react";
|
import {Fragment, useEffect, useState} from "react";
|
||||||
import reactStringReplace from "react-string-replace";
|
import reactStringReplace from "react-string-replace";
|
||||||
@@ -31,7 +32,7 @@ function WordsDrawer({words, isOpen, blankId, previouslySelectedWord, onCancel,
|
|||||||
isOpen ? "visible opacity-100" : "invisible opacity-0",
|
isOpen ? "visible opacity-100" : "invisible opacity-0",
|
||||||
)}>
|
)}>
|
||||||
<div className="w-full flex gap-2">
|
<div className="w-full flex gap-2">
|
||||||
<div className="rounded-full w-6 h-6 flex items-center justify-center text-white bg-mti-green-light">{blankId}</div>
|
<div className="rounded-full w-6 h-6 flex items-center justify-center text-white bg-mti-purple-light">{blankId}</div>
|
||||||
<span> Choose the correct word:</span>
|
<span> Choose the correct word:</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-6 gap-6">
|
<div className="grid grid-cols-6 gap-6">
|
||||||
@@ -41,8 +42,8 @@ function WordsDrawer({words, isOpen, blankId, previouslySelectedWord, onCancel,
|
|||||||
onClick={() => setSelectedWord((prev) => (prev === word ? undefined : word))}
|
onClick={() => setSelectedWord((prev) => (prev === word ? undefined : word))}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"rounded-full py-3 text-center transition duration-300 ease-in-out",
|
"rounded-full py-3 text-center transition duration-300 ease-in-out",
|
||||||
selectedWord === word ? "text-white bg-mti-green-light" : "bg-mti-green-ultralight",
|
selectedWord === word ? "text-white bg-mti-purple-light" : "bg-mti-purple-ultralight",
|
||||||
!isDisabled && "hover:text-white hover:bg-mti-green",
|
!isDisabled && "hover:text-white hover:bg-mti-purple",
|
||||||
"disabled:cursor-not-allowed disabled:text-mti-gray-dim",
|
"disabled:cursor-not-allowed disabled:text-mti-gray-dim",
|
||||||
)}
|
)}
|
||||||
disabled={isDisabled}>
|
disabled={isDisabled}>
|
||||||
@@ -51,10 +52,10 @@ function WordsDrawer({words, isOpen, blankId, previouslySelectedWord, onCancel,
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between w-full">
|
<div className="flex justify-between w-full">
|
||||||
<Button color="green" variant="outline" className="max-w-[200px] w-full" onClick={onCancel}>
|
<Button color="purple" variant="outline" className="max-w-[200px] w-full" onClick={onCancel}>
|
||||||
Back
|
Back
|
||||||
</Button>
|
</Button>
|
||||||
<Button color="green" className="max-w-[200px] w-full" onClick={() => onAnswer(selectedWord!)} disabled={!selectedWord}>
|
<Button color="purple" className="max-w-[200px] w-full" onClick={() => onAnswer(selectedWord!)} disabled={!selectedWord}>
|
||||||
Confirm
|
Confirm
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -79,10 +80,17 @@ export default function FillBlanks({
|
|||||||
const [currentBlankId, setCurrentBlankId] = useState<string>();
|
const [currentBlankId, setCurrentBlankId] = useState<string>();
|
||||||
const [isDrawerShowing, setIsDrawerShowing] = useState(false);
|
const [isDrawerShowing, setIsDrawerShowing] = useState(false);
|
||||||
|
|
||||||
|
const hasExamEnded = useExamStore((state) => state.hasExamEnded);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setTimeout(() => setIsDrawerShowing(!!currentBlankId), 100);
|
setTimeout(() => setIsDrawerShowing(!!currentBlankId), 100);
|
||||||
}, [currentBlankId]);
|
}, [currentBlankId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (hasExamEnded) onNext({exercise: id, solutions: answers, score: calculateScore(), type});
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [hasExamEnded]);
|
||||||
|
|
||||||
const calculateScore = () => {
|
const calculateScore = () => {
|
||||||
const total = text.match(/({{\d+}})/g)?.length || 0;
|
const total = text.match(/({{\d+}})/g)?.length || 0;
|
||||||
const correct = answers.filter((x) => solutions.find((y) => x.id === y.id)?.solution === x.solution.toLowerCase() || false).length;
|
const correct = answers.filter((x) => solutions.find((y) => x.id === y.id)?.solution === x.solution.toLowerCase() || false).length;
|
||||||
@@ -101,10 +109,10 @@ export default function FillBlanks({
|
|||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"rounded-full hover:text-white hover:bg-mti-green transition duration-300 ease-in-out my-1",
|
"rounded-full hover:text-white hover:bg-mti-purple transition duration-300 ease-in-out my-1",
|
||||||
!userSolution && "w-6 h-6 text-center text-mti-green-light bg-mti-green-ultralight",
|
!userSolution && "w-6 h-6 text-center text-mti-purple-light bg-mti-purple-ultralight",
|
||||||
currentBlankId === id && "text-white !bg-mti-green-light ",
|
currentBlankId === id && "text-white !bg-mti-purple-light ",
|
||||||
userSolution && "px-5 py-2 text-center text-white bg-mti-green-light",
|
userSolution && "px-5 py-2 text-center text-white bg-mti-purple-light",
|
||||||
)}
|
)}
|
||||||
onClick={() => setCurrentBlankId(id)}>
|
onClick={() => setCurrentBlankId(id)}>
|
||||||
{userSolution ? userSolution.solution : id}
|
{userSolution ? userSolution.solution : id}
|
||||||
@@ -151,7 +159,7 @@ export default function FillBlanks({
|
|||||||
|
|
||||||
<div className="self-end flex justify-between w-full gap-8 absolute bottom-8 left-0 px-8">
|
<div className="self-end flex justify-between w-full gap-8 absolute bottom-8 left-0 px-8">
|
||||||
<Button
|
<Button
|
||||||
color="green"
|
color="purple"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() => onBack({exercise: id, solutions: answers, score: calculateScore(), type})}
|
onClick={() => onBack({exercise: id, solutions: answers, score: calculateScore(), type})}
|
||||||
className="max-w-[200px] w-full">
|
className="max-w-[200px] w-full">
|
||||||
@@ -159,7 +167,7 @@ export default function FillBlanks({
|
|||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
color="green"
|
color="purple"
|
||||||
onClick={() => onNext({exercise: id, solutions: answers, score: calculateScore(), type})}
|
onClick={() => onNext({exercise: id, solutions: answers, score: calculateScore(), type})}
|
||||||
className="max-w-[200px] self-end w-full">
|
className="max-w-[200px] self-end w-full">
|
||||||
Next
|
Next
|
||||||
|
|||||||
@@ -3,16 +3,19 @@ import {MatchSentencesExercise} from "@/interfaces/exam";
|
|||||||
import {mdiArrowLeft, mdiArrowRight} from "@mdi/js";
|
import {mdiArrowLeft, mdiArrowRight} from "@mdi/js";
|
||||||
import Icon from "@mdi/react";
|
import Icon from "@mdi/react";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import {Fragment, useState} from "react";
|
import {Fragment, useEffect, useState} from "react";
|
||||||
import LineTo from "react-lineto";
|
import LineTo from "react-lineto";
|
||||||
import {CommonProps} from ".";
|
import {CommonProps} from ".";
|
||||||
import Button from "../Low/Button";
|
import Button from "../Low/Button";
|
||||||
import Xarrow from "react-xarrows";
|
import Xarrow from "react-xarrows";
|
||||||
|
import useExamStore from "@/stores/examStore";
|
||||||
|
|
||||||
export default function MatchSentences({id, options, type, prompt, sentences, userSolutions, onNext, onBack}: MatchSentencesExercise & CommonProps) {
|
export default function MatchSentences({id, options, type, prompt, sentences, userSolutions, onNext, onBack}: MatchSentencesExercise & CommonProps) {
|
||||||
const [selectedQuestion, setSelectedQuestion] = useState<string>();
|
const [selectedQuestion, setSelectedQuestion] = useState<string>();
|
||||||
const [answers, setAnswers] = useState<{question: string; option: string}[]>(userSolutions);
|
const [answers, setAnswers] = useState<{question: string; option: string}[]>(userSolutions);
|
||||||
|
|
||||||
|
const hasExamEnded = useExamStore((state) => state.hasExamEnded);
|
||||||
|
|
||||||
const calculateScore = () => {
|
const calculateScore = () => {
|
||||||
const total = sentences.length;
|
const total = sentences.length;
|
||||||
const correct = answers.filter((x) => sentences.find((y) => y.id === x.question)?.solution === x.option || false).length;
|
const correct = answers.filter((x) => sentences.find((y) => y.id === x.question)?.solution === x.option || false).length;
|
||||||
@@ -27,9 +30,10 @@ export default function MatchSentences({id, options, type, prompt, sentences, us
|
|||||||
setSelectedQuestion(undefined);
|
setSelectedQuestion(undefined);
|
||||||
};
|
};
|
||||||
|
|
||||||
const getSentenceColor = (id: string) => {
|
useEffect(() => {
|
||||||
return sentences.find((x) => x.id === id)?.color || "";
|
if (hasExamEnded) onNext({exercise: id, solutions: answers, score: calculateScore(), type});
|
||||||
};
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [hasExamEnded]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -44,16 +48,16 @@ export default function MatchSentences({id, options, type, prompt, sentences, us
|
|||||||
</span>
|
</span>
|
||||||
<div className="flex gap-8 w-full items-center justify-between bg-mti-gray-smoke rounded-xl px-24 py-6">
|
<div className="flex gap-8 w-full items-center justify-between bg-mti-gray-smoke rounded-xl px-24 py-6">
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
{sentences.map(({sentence, id, color}) => (
|
{sentences.map(({sentence, id}) => (
|
||||||
<div key={`question_${id}`} className="flex items-center justify-end gap-2 cursor-pointer">
|
<div key={`question_${id}`} className="flex items-center justify-end gap-2 cursor-pointer">
|
||||||
<span>{sentence} </span>
|
<span>{sentence} </span>
|
||||||
<button
|
<button
|
||||||
id={id}
|
id={id}
|
||||||
onClick={() => setSelectedQuestion((prev) => (prev === id ? undefined : id))}
|
onClick={() => setSelectedQuestion((prev) => (prev === id ? undefined : id))}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"bg-mti-green-ultralight text-mti-green hover:text-white hover:bg-mti-green w-8 h-8 rounded-full z-10",
|
"bg-mti-purple-ultralight text-mti-purple hover:text-white hover:bg-mti-purple w-8 h-8 rounded-full z-10",
|
||||||
"transition duration-300 ease-in-out",
|
"transition duration-300 ease-in-out",
|
||||||
selectedQuestion === id && "!text-white !bg-mti-green",
|
selectedQuestion === id && "!text-white !bg-mti-purple",
|
||||||
id,
|
id,
|
||||||
)}>
|
)}>
|
||||||
{id}
|
{id}
|
||||||
@@ -68,7 +72,7 @@ export default function MatchSentences({id, options, type, prompt, sentences, us
|
|||||||
id={id}
|
id={id}
|
||||||
onClick={() => selectOption(id)}
|
onClick={() => selectOption(id)}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"bg-mti-green-ultralight text-mti-green hover:text-white hover:bg-mti-green w-8 h-8 rounded-full z-10",
|
"bg-mti-purple-ultralight text-mti-purple hover:text-white hover:bg-mti-purple w-8 h-8 rounded-full z-10",
|
||||||
"transition duration-300 ease-in-out",
|
"transition duration-300 ease-in-out",
|
||||||
id,
|
id,
|
||||||
)}>
|
)}>
|
||||||
@@ -79,14 +83,14 @@ export default function MatchSentences({id, options, type, prompt, sentences, us
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
{answers.map((solution, index) => (
|
{answers.map((solution, index) => (
|
||||||
<Xarrow key={index} start={solution.question} end={solution.option} lineColor="#307912" showHead={false} />
|
<Xarrow key={index} start={solution.question} end={solution.option} lineColor="#7872BF" showHead={false} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="self-end flex justify-between w-full gap-8 absolute bottom-8 left-0 px-8">
|
<div className="self-end flex justify-between w-full gap-8 absolute bottom-8 left-0 px-8">
|
||||||
<Button
|
<Button
|
||||||
color="green"
|
color="purple"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() => onBack({exercise: id, solutions: answers, score: calculateScore(), type})}
|
onClick={() => onBack({exercise: id, solutions: answers, score: calculateScore(), type})}
|
||||||
className="max-w-[200px] w-full">
|
className="max-w-[200px] w-full">
|
||||||
@@ -94,7 +98,7 @@ export default function MatchSentences({id, options, type, prompt, sentences, us
|
|||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
color="green"
|
color="purple"
|
||||||
onClick={() => onNext({exercise: id, solutions: answers, score: calculateScore(), type})}
|
onClick={() => onNext({exercise: id, solutions: answers, score: calculateScore(), type})}
|
||||||
className="max-w-[200px] self-end w-full">
|
className="max-w-[200px] self-end w-full">
|
||||||
Next
|
Next
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
/* eslint-disable @next/next/no-img-element */
|
/* eslint-disable @next/next/no-img-element */
|
||||||
import {MultipleChoiceExercise, MultipleChoiceQuestion} from "@/interfaces/exam";
|
import {MultipleChoiceExercise, MultipleChoiceQuestion} from "@/interfaces/exam";
|
||||||
|
import useExamStore from "@/stores/examStore";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import {useState} from "react";
|
import {useEffect, useState} from "react";
|
||||||
import {CommonProps} from ".";
|
import {CommonProps} from ".";
|
||||||
import Button from "../Low/Button";
|
import Button from "../Low/Button";
|
||||||
|
|
||||||
@@ -23,7 +24,7 @@ function Question({
|
|||||||
onClick={() => (onSelectOption ? onSelectOption(option.id) : null)}
|
onClick={() => (onSelectOption ? onSelectOption(option.id) : null)}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"flex flex-col items-center border border-mti-gray-platinum p-4 px-8 rounded-xl gap-4 cursor-pointer bg-white relative",
|
"flex flex-col items-center border border-mti-gray-platinum p-4 px-8 rounded-xl gap-4 cursor-pointer bg-white relative",
|
||||||
userSolution === option.id && "border-mti-green-light",
|
userSolution === option.id && "border-mti-purple-light",
|
||||||
)}>
|
)}>
|
||||||
<span className={clsx("text-sm", userSolution !== option.id && "opacity-50")}>{option.id}</span>
|
<span className={clsx("text-sm", userSolution !== option.id && "opacity-50")}>{option.id}</span>
|
||||||
<img src={option.src!} alt={`Option ${option.id}`} />
|
<img src={option.src!} alt={`Option ${option.id}`} />
|
||||||
@@ -36,7 +37,7 @@ function Question({
|
|||||||
onClick={() => (onSelectOption ? onSelectOption(option.id) : null)}
|
onClick={() => (onSelectOption ? onSelectOption(option.id) : null)}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"flex border p-4 rounded-xl gap-2 cursor-pointer bg-white text-sm",
|
"flex border p-4 rounded-xl gap-2 cursor-pointer bg-white text-sm",
|
||||||
userSolution === option.id && "border-mti-green-light",
|
userSolution === option.id && "border-mti-purple-light",
|
||||||
)}>
|
)}>
|
||||||
<span className="font-semibold">{option.id}.</span>
|
<span className="font-semibold">{option.id}.</span>
|
||||||
<span>{option.text}</span>
|
<span>{option.text}</span>
|
||||||
@@ -51,6 +52,13 @@ export default function MultipleChoice({id, prompt, type, questions, userSolutio
|
|||||||
const [answers, setAnswers] = useState<{question: string; option: string}[]>(userSolutions);
|
const [answers, setAnswers] = useState<{question: string; option: string}[]>(userSolutions);
|
||||||
const [questionIndex, setQuestionIndex] = useState(0);
|
const [questionIndex, setQuestionIndex] = useState(0);
|
||||||
|
|
||||||
|
const hasExamEnded = useExamStore((state) => state.hasExamEnded);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (hasExamEnded) onNext({exercise: id, solutions: answers, score: calculateScore(), type});
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [hasExamEnded]);
|
||||||
|
|
||||||
const onSelectOption = (option: string) => {
|
const onSelectOption = (option: string) => {
|
||||||
const question = questions[questionIndex];
|
const question = questions[questionIndex];
|
||||||
setAnswers((prev) => [...prev.filter((x) => x.question !== question.id), {option, question: question.id}]);
|
setAnswers((prev) => [...prev.filter((x) => x.question !== question.id), {option, question: question.id}]);
|
||||||
@@ -94,11 +102,11 @@ export default function MultipleChoice({id, prompt, type, questions, userSolutio
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="self-end flex justify-between w-full gap-8 absolute bottom-8 left-0 px-8">
|
<div className="self-end flex justify-between w-full gap-8 absolute bottom-8 left-0 px-8">
|
||||||
<Button color="green" variant="outline" onClick={back} className="max-w-[200px] w-full">
|
<Button color="purple" variant="outline" onClick={back} className="max-w-[200px] w-full">
|
||||||
Back
|
Back
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<Button color="green" onClick={next} className="max-w-[200px] self-end w-full">
|
<Button color="purple" onClick={next} className="max-w-[200px] self-end w-full">
|
||||||
Next
|
Next
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,14 +1,10 @@
|
|||||||
import {errorButtonStyle, infoButtonStyle} from "@/constants/buttonStyles";
|
import {SpeakingExercise} from "@/interfaces/exam";
|
||||||
import {SpeakingExercise, WritingExercise} from "@/interfaces/exam";
|
|
||||||
import {mdiArrowLeft, mdiArrowRight} from "@mdi/js";
|
|
||||||
import Icon from "@mdi/react";
|
|
||||||
import clsx from "clsx";
|
|
||||||
import {CommonProps} from ".";
|
import {CommonProps} from ".";
|
||||||
import {Fragment, useEffect, useState} from "react";
|
import {Fragment, useEffect, useState} from "react";
|
||||||
import {toast} from "react-toastify";
|
|
||||||
import {BsCheckCircleFill, BsMicFill, BsPauseCircle, BsPlayCircle, BsTrashFill} from "react-icons/bs";
|
import {BsCheckCircleFill, BsMicFill, BsPauseCircle, BsPlayCircle, BsTrashFill} from "react-icons/bs";
|
||||||
import dynamic from "next/dynamic";
|
import dynamic from "next/dynamic";
|
||||||
import Button from "../Low/Button";
|
import Button from "../Low/Button";
|
||||||
|
import useExamStore from "@/stores/examStore";
|
||||||
|
|
||||||
const Waveform = dynamic(() => import("../Waveform"), {ssr: false});
|
const Waveform = dynamic(() => import("../Waveform"), {ssr: false});
|
||||||
const ReactMediaRecorder = dynamic(() => import("react-media-recorder").then((mod) => mod.ReactMediaRecorder), {
|
const ReactMediaRecorder = dynamic(() => import("react-media-recorder").then((mod) => mod.ReactMediaRecorder), {
|
||||||
@@ -20,6 +16,20 @@ export default function Speaking({id, title, text, type, prompts, onNext, onBack
|
|||||||
const [isRecording, setIsRecording] = useState(false);
|
const [isRecording, setIsRecording] = useState(false);
|
||||||
const [mediaBlob, setMediaBlob] = useState<string>();
|
const [mediaBlob, setMediaBlob] = useState<string>();
|
||||||
|
|
||||||
|
const hasExamEnded = useExamStore((state) => state.hasExamEnded);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (hasExamEnded) {
|
||||||
|
onNext({
|
||||||
|
exercise: id,
|
||||||
|
solutions: mediaBlob ? [{id, solution: mediaBlob}] : [],
|
||||||
|
score: {correct: 1, total: 1, missing: 0},
|
||||||
|
type,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [hasExamEnded]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let recordingInterval: NodeJS.Timer | undefined = undefined;
|
let recordingInterval: NodeJS.Timer | undefined = undefined;
|
||||||
if (isRecording) {
|
if (isRecording) {
|
||||||
@@ -47,16 +57,18 @@ export default function Speaking({id, title, text, type, prompts, onNext, onBack
|
|||||||
))}
|
))}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-4">
|
{prompts && prompts.length > 0 && (
|
||||||
<span className="font-bold">You should talk about the following things:</span>
|
<div className="flex flex-col gap-4">
|
||||||
<div className="flex flex-col gap-1 ml-4">
|
<span className="font-bold">You should talk about the following things:</span>
|
||||||
{prompts.map((x, index) => (
|
<div className="flex flex-col gap-1 ml-4">
|
||||||
<li className="italic" key={index}>
|
{prompts.map((x, index) => (
|
||||||
{x}
|
<li className="italic" key={index}>
|
||||||
</li>
|
{x}
|
||||||
))}
|
</li>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ReactMediaRecorder
|
<ReactMediaRecorder
|
||||||
@@ -85,11 +97,11 @@ export default function Speaking({id, title, text, type, prompts, onNext, onBack
|
|||||||
<>
|
<>
|
||||||
<div className="flex gap-4 items-center">
|
<div className="flex gap-4 items-center">
|
||||||
<span className="text-xs w-9">
|
<span className="text-xs w-9">
|
||||||
{Math.round(recordingDuration / 60)
|
{Math.floor(recordingDuration / 60)
|
||||||
.toString(10)
|
.toString(10)
|
||||||
.padStart(2, "0")}
|
.padStart(2, "0")}
|
||||||
:
|
:
|
||||||
{Math.round(recordingDuration % 60)
|
{Math.floor(recordingDuration % 60)
|
||||||
.toString(10)
|
.toString(10)
|
||||||
.padStart(2, "0")}
|
.padStart(2, "0")}
|
||||||
</span>
|
</span>
|
||||||
@@ -108,7 +120,7 @@ export default function Speaking({id, title, text, type, prompts, onNext, onBack
|
|||||||
setIsRecording(false);
|
setIsRecording(false);
|
||||||
stopRecording();
|
stopRecording();
|
||||||
}}
|
}}
|
||||||
className="text-mti-green-light w-8 h-8 cursor-pointer"
|
className="text-mti-purple-light w-8 h-8 cursor-pointer"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
@@ -133,14 +145,14 @@ export default function Speaking({id, title, text, type, prompts, onNext, onBack
|
|||||||
setIsRecording(true);
|
setIsRecording(true);
|
||||||
resumeRecording();
|
resumeRecording();
|
||||||
}}
|
}}
|
||||||
className="text-mti-green-light w-8 h-8 cursor-pointer"
|
className="text-mti-purple-light w-8 h-8 cursor-pointer"
|
||||||
/>
|
/>
|
||||||
<BsCheckCircleFill
|
<BsCheckCircleFill
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setIsRecording(false);
|
setIsRecording(false);
|
||||||
stopRecording();
|
stopRecording();
|
||||||
}}
|
}}
|
||||||
className="text-mti-green-light w-8 h-8 cursor-pointer"
|
className="text-mti-purple-light w-8 h-8 cursor-pointer"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
@@ -178,16 +190,30 @@ export default function Speaking({id, title, text, type, prompts, onNext, onBack
|
|||||||
|
|
||||||
<div className="self-end flex justify-between w-full gap-8">
|
<div className="self-end flex justify-between w-full gap-8">
|
||||||
<Button
|
<Button
|
||||||
color="green"
|
color="purple"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() => onBack({exercise: id, solutions: [], score: {correct: 1, total: 1, missing: 0}, type})}
|
onClick={() =>
|
||||||
|
onBack({
|
||||||
|
exercise: id,
|
||||||
|
solutions: mediaBlob ? [{id, solution: mediaBlob}] : [],
|
||||||
|
score: {correct: 1, total: 1, missing: 0},
|
||||||
|
type,
|
||||||
|
})
|
||||||
|
}
|
||||||
className="max-w-[200px] self-end w-full">
|
className="max-w-[200px] self-end w-full">
|
||||||
Back
|
Back
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
color="green"
|
color="purple"
|
||||||
disabled={!mediaBlob}
|
disabled={!mediaBlob}
|
||||||
onClick={() => onNext({exercise: id, solutions: [], score: {correct: 1, total: 1, missing: 0}, type})}
|
onClick={() =>
|
||||||
|
onNext({
|
||||||
|
exercise: id,
|
||||||
|
solutions: mediaBlob ? [{id, solution: mediaBlob}] : [],
|
||||||
|
score: {correct: 1, total: 1, missing: 0},
|
||||||
|
type,
|
||||||
|
})
|
||||||
|
}
|
||||||
className="max-w-[200px] self-end w-full">
|
className="max-w-[200px] self-end w-full">
|
||||||
Next
|
Next
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ import reactStringReplace from "react-string-replace";
|
|||||||
import {CommonProps} from ".";
|
import {CommonProps} from ".";
|
||||||
import {toast} from "react-toastify";
|
import {toast} from "react-toastify";
|
||||||
import Button from "../Low/Button";
|
import Button from "../Low/Button";
|
||||||
|
import useExamStore from "@/stores/examStore";
|
||||||
|
|
||||||
function Blank({
|
function Blank({
|
||||||
id,
|
id,
|
||||||
@@ -48,6 +49,13 @@ function Blank({
|
|||||||
export default function WriteBlanks({id, prompt, type, maxWords, solutions, userSolutions, text, onNext, onBack}: WriteBlanksExercise & CommonProps) {
|
export default function WriteBlanks({id, prompt, type, maxWords, solutions, userSolutions, text, onNext, onBack}: WriteBlanksExercise & CommonProps) {
|
||||||
const [answers, setAnswers] = useState<{id: string; solution: string}[]>(userSolutions);
|
const [answers, setAnswers] = useState<{id: string; solution: string}[]>(userSolutions);
|
||||||
|
|
||||||
|
const hasExamEnded = useExamStore((state) => state.hasExamEnded);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (hasExamEnded) onNext({exercise: id, solutions: answers, score: calculateScore(), type});
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [hasExamEnded]);
|
||||||
|
|
||||||
const calculateScore = () => {
|
const calculateScore = () => {
|
||||||
const total = text.match(/({{\d+}})/g)?.length || 0;
|
const total = text.match(/({{\d+}})/g)?.length || 0;
|
||||||
const correct = answers.filter(
|
const correct = answers.filter(
|
||||||
@@ -101,7 +109,7 @@ export default function WriteBlanks({id, prompt, type, maxWords, solutions, user
|
|||||||
|
|
||||||
<div className="self-end flex justify-between w-full gap-8 absolute bottom-8 left-0 px-8">
|
<div className="self-end flex justify-between w-full gap-8 absolute bottom-8 left-0 px-8">
|
||||||
<Button
|
<Button
|
||||||
color="green"
|
color="purple"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() => onBack({exercise: id, solutions: answers, score: calculateScore(), type})}
|
onClick={() => onBack({exercise: id, solutions: answers, score: calculateScore(), type})}
|
||||||
className="max-w-[200px] w-full">
|
className="max-w-[200px] w-full">
|
||||||
@@ -109,7 +117,7 @@ export default function WriteBlanks({id, prompt, type, maxWords, solutions, user
|
|||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
color="green"
|
color="purple"
|
||||||
onClick={() => onNext({exercise: id, solutions: answers, score: calculateScore(), type})}
|
onClick={() => onNext({exercise: id, solutions: answers, score: calculateScore(), type})}
|
||||||
className="max-w-[200px] self-end w-full">
|
className="max-w-[200px] self-end w-full">
|
||||||
Next
|
Next
|
||||||
|
|||||||
@@ -1,20 +1,35 @@
|
|||||||
/* eslint-disable @next/next/no-img-element */
|
/* eslint-disable @next/next/no-img-element */
|
||||||
import {errorButtonStyle, infoButtonStyle} from "@/constants/buttonStyles";
|
|
||||||
import {WritingExercise} from "@/interfaces/exam";
|
import {WritingExercise} from "@/interfaces/exam";
|
||||||
import {mdiArrowLeft, mdiArrowRight} from "@mdi/js";
|
|
||||||
import Icon from "@mdi/react";
|
|
||||||
import clsx from "clsx";
|
|
||||||
import {CommonProps} from ".";
|
import {CommonProps} from ".";
|
||||||
import {Fragment, useEffect, useState} from "react";
|
import {Fragment, useEffect, useState} from "react";
|
||||||
import {toast} from "react-toastify";
|
import {toast} from "react-toastify";
|
||||||
import Button from "../Low/Button";
|
import Button from "../Low/Button";
|
||||||
import {Dialog, Transition} from "@headlessui/react";
|
import {Dialog, Transition} from "@headlessui/react";
|
||||||
|
import useExamStore from "@/stores/examStore";
|
||||||
|
|
||||||
export default function Writing({id, prompt, info, type, wordCounter, attachment, userSolutions, onNext, onBack}: WritingExercise & CommonProps) {
|
export default function Writing({
|
||||||
|
id,
|
||||||
|
prompt,
|
||||||
|
prefix,
|
||||||
|
suffix,
|
||||||
|
type,
|
||||||
|
wordCounter,
|
||||||
|
attachment,
|
||||||
|
userSolutions,
|
||||||
|
onNext,
|
||||||
|
onBack,
|
||||||
|
}: WritingExercise & CommonProps) {
|
||||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||||
const [inputText, setInputText] = useState(userSolutions.length === 1 ? userSolutions[0].solution : "");
|
const [inputText, setInputText] = useState(userSolutions.length === 1 ? userSolutions[0].solution : "");
|
||||||
const [isSubmitEnabled, setIsSubmitEnabled] = useState(false);
|
const [isSubmitEnabled, setIsSubmitEnabled] = useState(false);
|
||||||
|
|
||||||
|
const hasExamEnded = useExamStore((state) => state.hasExamEnded);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (hasExamEnded) onNext({exercise: id, solutions: [{id, solution: inputText}], score: {correct: 1, total: 1, missing: 0}, type});
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [hasExamEnded]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const words = inputText.split(" ").filter((x) => x !== "");
|
const words = inputText.split(" ").filter((x) => x !== "");
|
||||||
|
|
||||||
@@ -64,7 +79,14 @@ export default function Writing({id, prompt, info, type, wordCounter, attachment
|
|||||||
)}
|
)}
|
||||||
<div className="flex flex-col h-full w-full gap-9 mb-20">
|
<div className="flex flex-col h-full w-full gap-9 mb-20">
|
||||||
<div className="flex flex-col w-full gap-7 bg-mti-gray-smoke rounded-xl py-8 pb-12 px-16">
|
<div className="flex flex-col w-full gap-7 bg-mti-gray-smoke rounded-xl py-8 pb-12 px-16">
|
||||||
<span>{info}</span>
|
<span>
|
||||||
|
{prefix.split("\\n").map((line) => (
|
||||||
|
<>
|
||||||
|
{line}
|
||||||
|
<br />
|
||||||
|
</>
|
||||||
|
))}
|
||||||
|
</span>
|
||||||
<span className="font-semibold">
|
<span className="font-semibold">
|
||||||
{prompt.split("\\n").map((line, index) => (
|
{prompt.split("\\n").map((line, index) => (
|
||||||
<Fragment key={index}>
|
<Fragment key={index}>
|
||||||
@@ -85,7 +107,12 @@ export default function Writing({id, prompt, info, type, wordCounter, attachment
|
|||||||
|
|
||||||
<div className="w-full h-full flex flex-col gap-4">
|
<div className="w-full h-full flex flex-col gap-4">
|
||||||
<span>
|
<span>
|
||||||
You should write {wordCounter.type === "min" ? "at least" : "at most"} {wordCounter.limit} words.
|
{suffix.split("\\n").map((line) => (
|
||||||
|
<>
|
||||||
|
{line}
|
||||||
|
<br />
|
||||||
|
</>
|
||||||
|
))}
|
||||||
</span>
|
</span>
|
||||||
<textarea
|
<textarea
|
||||||
className="w-full h-full min-h-[148px] cursor-text px-7 py-8 input border-2 border-mti-gray-platinum bg-white rounded-3xl"
|
className="w-full h-full min-h-[148px] cursor-text px-7 py-8 input border-2 border-mti-gray-platinum bg-white rounded-3xl"
|
||||||
@@ -98,14 +125,14 @@ export default function Writing({id, prompt, info, type, wordCounter, attachment
|
|||||||
|
|
||||||
<div className="self-end flex justify-between w-full gap-8 absolute bottom-8 left-0 px-8">
|
<div className="self-end flex justify-between w-full gap-8 absolute bottom-8 left-0 px-8">
|
||||||
<Button
|
<Button
|
||||||
color="green"
|
color="purple"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() => onBack({exercise: id, solutions: [inputText], score: {correct: 1, total: 1, missing: 0}, type})}
|
onClick={() => onBack({exercise: id, solutions: [{id, solution: inputText}], score: {correct: 1, total: 1, missing: 0}, type})}
|
||||||
className="max-w-[200px] self-end w-full">
|
className="max-w-[200px] self-end w-full">
|
||||||
Back
|
Back
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
color="green"
|
color="purple"
|
||||||
disabled={!isSubmitEnabled}
|
disabled={!isSubmitEnabled}
|
||||||
onClick={() => onNext({exercise: id, solutions: [{id, solution: inputText}], score: {correct: 1, total: 1, missing: 0}, type})}
|
onClick={() => onNext({exercise: id, solutions: [{id, solution: inputText}], score: {correct: 1, total: 1, missing: 0}, type})}
|
||||||
className="max-w-[200px] self-end w-full">
|
className="max-w-[200px] self-end w-full">
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
MatchSentencesExercise,
|
MatchSentencesExercise,
|
||||||
MultipleChoiceExercise,
|
MultipleChoiceExercise,
|
||||||
SpeakingExercise,
|
SpeakingExercise,
|
||||||
|
TrueFalseExercise,
|
||||||
UserSolution,
|
UserSolution,
|
||||||
WriteBlanksExercise,
|
WriteBlanksExercise,
|
||||||
WritingExercise,
|
WritingExercise,
|
||||||
@@ -14,6 +15,7 @@ import MultipleChoice from "./MultipleChoice";
|
|||||||
import WriteBlanks from "./WriteBlanks";
|
import WriteBlanks from "./WriteBlanks";
|
||||||
import Writing from "./Writing";
|
import Writing from "./Writing";
|
||||||
import Speaking from "./Speaking";
|
import Speaking from "./Speaking";
|
||||||
|
import TrueFalse from "./TrueFalse";
|
||||||
|
|
||||||
const MatchSentences = dynamic(() => import("@/components/Exercises/MatchSentences"), {ssr: false});
|
const MatchSentences = dynamic(() => import("@/components/Exercises/MatchSentences"), {ssr: false});
|
||||||
|
|
||||||
@@ -26,6 +28,8 @@ export const renderExercise = (exercise: Exercise, onNext: (userSolutions: UserS
|
|||||||
switch (exercise.type) {
|
switch (exercise.type) {
|
||||||
case "fillBlanks":
|
case "fillBlanks":
|
||||||
return <FillBlanks {...(exercise as FillBlanksExercise)} onNext={onNext} onBack={onBack} />;
|
return <FillBlanks {...(exercise as FillBlanksExercise)} onNext={onNext} onBack={onBack} />;
|
||||||
|
case "trueFalse":
|
||||||
|
return <TrueFalse {...(exercise as TrueFalseExercise)} onNext={onNext} onBack={onBack} />;
|
||||||
case "matchSentences":
|
case "matchSentences":
|
||||||
return <MatchSentences {...(exercise as MatchSentencesExercise)} onNext={onNext} onBack={onBack} />;
|
return <MatchSentences {...(exercise as MatchSentencesExercise)} onNext={onNext} onBack={onBack} />;
|
||||||
case "multipleChoice":
|
case "multipleChoice":
|
||||||
|
|||||||
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}/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -8,19 +8,22 @@ interface Props {
|
|||||||
user: User;
|
user: User;
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
className?: string;
|
className?: string;
|
||||||
|
navDisabled?: boolean;
|
||||||
|
focusMode?: boolean
|
||||||
|
onFocusLayerMouseEnter?: Function;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Layout({user, children, className}: Props) {
|
export default function Layout({user, children, className, navDisabled = false, focusMode = false, onFocusLayerMouseEnter }: Props) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="w-full min-h-full h-screen flex flex-col bg-mti-gray-smoke">
|
<main className="w-full min-h-full h-screen flex flex-col bg-mti-gray-smoke">
|
||||||
<Navbar user={user} />
|
<Navbar user={user} navDisabled={navDisabled} focusMode={focusMode} onFocusLayerMouseEnter={onFocusLayerMouseEnter} />
|
||||||
<div className="h-full w-full flex py-4 pb-8 gap-2">
|
<div className="h-full w-full flex gap-2">
|
||||||
<Sidebar path={router.pathname} />
|
<Sidebar path={router.pathname} navDisabled={navDisabled} focusMode={focusMode} onFocusLayerMouseEnter={onFocusLayerMouseEnter}/>
|
||||||
<div
|
<div
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"w-5/6 min-h-full h-fit mr-8 bg-white shadow-md rounded-2xl p-12 pb-8 flex flex-col gap-12 relative overflow-hidden",
|
"w-5/6 min-h-full h-fit mr-8 bg-white shadow-md rounded-2xl p-12 pb-8 flex flex-col gap-12 relative overflow-hidden mt-2",
|
||||||
className,
|
className,
|
||||||
)}>
|
)}>
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import ProgressBar from "./ProgressBar";
|
|||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
src: string;
|
src: string;
|
||||||
color: "blue" | "orange" | "green" | Module;
|
color: "red" | "rose" | "purple" | Module;
|
||||||
autoPlay?: boolean;
|
autoPlay?: boolean;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
onEnd?: () => void;
|
onEnd?: () => void;
|
||||||
|
|||||||
@@ -3,29 +3,29 @@ import {ReactNode} from "react";
|
|||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
color?: "orange" | "green" | "blue";
|
color?: "rose" | "purple" | "red";
|
||||||
variant?: "outline" | "solid";
|
variant?: "outline" | "solid";
|
||||||
className?: string;
|
className?: string;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
onClick?: () => void;
|
onClick?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Button({color = "green", variant = "solid", disabled = false, className, children, onClick}: Props) {
|
export default function Button({color = "purple", variant = "solid", disabled = false, className, children, onClick}: Props) {
|
||||||
const colorClassNames: {[key in typeof color]: {[key in typeof variant]: string}} = {
|
const colorClassNames: {[key in typeof color]: {[key in typeof variant]: string}} = {
|
||||||
green: {
|
purple: {
|
||||||
solid: "bg-mti-green-light text-white hover:bg-mti-green disabled:text-mti-green disabled:bg-mti-green-ultralight selection:bg-mti-green-dark",
|
solid: "bg-mti-purple-light text-white border border-mti-purple-light hover:bg-mti-purple disabled:text-mti-purple disabled:bg-mti-purple-ultralight selection:bg-mti-purple-dark",
|
||||||
outline:
|
outline:
|
||||||
"bg-transparent text-mti-green-light border border-mti-green-light hover:bg-mti-green-light disabled:text-mti-green disabled:bg-mti-green-ultralight selection:bg-mti-green-dark hover:text-white selection:text-white",
|
"bg-transparent text-mti-purple-light border border-mti-purple-light hover:bg-mti-purple-light disabled:text-mti-purple disabled:bg-mti-purple-ultralight disabled:border-none selection:bg-mti-purple-dark hover:text-white selection:text-white",
|
||||||
},
|
},
|
||||||
blue: {
|
red: {
|
||||||
solid: "bg-mti-blue-light text-white hover:bg-mti-blue disabled:text-mti-blue disabled:bg-mti-blue-ultralight selection:bg-mti-blue-dark",
|
solid: "bg-mti-red-light text-white border border-mti-red-light hover:bg-mti-red disabled:text-mti-red disabled:bg-mti-red-ultralight selection:bg-mti-red-dark",
|
||||||
outline:
|
outline:
|
||||||
"bg-transparent text-mti-blue-light border border-mti-blue-light hover:bg-mti-blue-light disabled:text-mti-blue disabled:bg-mti-blue-ultralight selection:bg-mti-blue-dark hover:text-white selection:text-white",
|
"bg-transparent text-mti-red-light border border-mti-red-light hover:bg-mti-red-light disabled:text-mti-red disabled:bg-mti-red-ultralight disabled:border-none selection:bg-mti-red-dark hover:text-white selection:text-white",
|
||||||
},
|
},
|
||||||
orange: {
|
rose: {
|
||||||
solid: "bg-mti-orange-light text-white hover:bg-mti-orange disabled:text-mti-orange disabled:bg-mti-orange-ultralight selection:bg-mti-orange-dark",
|
solid: "bg-mti-rose-light text-white border border-mti-rose-light hover:bg-mti-rose disabled:text-mti-rose disabled:bg-mti-rose-ultralight selection:bg-mti-rose-dark",
|
||||||
outline:
|
outline:
|
||||||
"bg-transparent text-mti-orange-light border border-mti-orange-light hover:bg-mti-orange-light disabled:text-mti-orange disabled:bg-mti-orange-ultralight selection:bg-mti-orange-dark hover:text-white selection:text-white",
|
"bg-transparent text-mti-rose-light border border-mti-rose-light hover:bg-mti-rose-light disabled:text-mti-rose disabled:bg-mti-rose-ultralight disabled:border-none selection:bg-mti-rose-dark hover:text-white selection:text-white",
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -5,11 +5,12 @@ interface Props {
|
|||||||
required?: boolean;
|
required?: boolean;
|
||||||
label?: string;
|
label?: string;
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
|
defaultValue?: string;
|
||||||
name: string;
|
name: string;
|
||||||
onChange: (value: string) => void;
|
onChange: (value: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Input({type, label, placeholder, name, required = false, onChange}: Props) {
|
export default function Input({type, label, placeholder, name, required = false, defaultValue, onChange}: Props) {
|
||||||
const [showPassword, setShowPassword] = useState(false);
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
|
|
||||||
if (type === "password") {
|
if (type === "password") {
|
||||||
@@ -55,6 +56,7 @@ export default function Input({type, label, placeholder, name, required = false,
|
|||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
className="px-8 py-6 text-sm font-normal placeholder:text-mti-gray-cool bg-white rounded-full border border-mti-gray-platinum focus:outline-none"
|
className="px-8 py-6 text-sm font-normal placeholder:text-mti-gray-cool bg-white rounded-full border border-mti-gray-platinum focus:outline-none"
|
||||||
required={required}
|
required={required}
|
||||||
|
defaultValue={defaultValue}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -4,16 +4,16 @@ import clsx from "clsx";
|
|||||||
interface Props {
|
interface Props {
|
||||||
label: string;
|
label: string;
|
||||||
percentage: number;
|
percentage: number;
|
||||||
color: "blue" | "orange" | "green" | Module;
|
color: "red" | "rose" | "purple" | Module;
|
||||||
useColor?: boolean;
|
useColor?: boolean;
|
||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ProgressBar({label, percentage, color, useColor = false, className}: Props) {
|
export default function ProgressBar({label, percentage, color, useColor = false, className}: Props) {
|
||||||
const progressColorClass: {[key in typeof color]: string} = {
|
const progressColorClass: {[key in typeof color]: string} = {
|
||||||
blue: "bg-mti-blue-light",
|
red: "bg-mti-red-light",
|
||||||
orange: "bg-mti-orange-light",
|
rose: "bg-mti-rose-light",
|
||||||
green: "bg-mti-green-light",
|
purple: "bg-mti-purple-light",
|
||||||
reading: "bg-ielts-reading",
|
reading: "bg-ielts-reading",
|
||||||
listening: "bg-ielts-listening",
|
listening: "bg-ielts-listening",
|
||||||
writing: "bg-ielts-writing",
|
writing: "bg-ielts-writing",
|
||||||
|
|||||||
@@ -1,8 +1,12 @@
|
|||||||
import {Module} from "@/interfaces";
|
import {Module} from "@/interfaces";
|
||||||
|
import useExamStore from "@/stores/examStore";
|
||||||
import {moduleLabels} from "@/utils/moduleUtils";
|
import {moduleLabels} from "@/utils/moduleUtils";
|
||||||
|
import clsx from "clsx";
|
||||||
|
import {motion} from "framer-motion";
|
||||||
import {ReactNode, useEffect, useState} from "react";
|
import {ReactNode, useEffect, useState} from "react";
|
||||||
import {BsBook, BsHeadphones, BsMegaphone, BsPen, BsStopwatch} from "react-icons/bs";
|
import {BsBook, BsHeadphones, BsMegaphone, BsPen, BsStopwatch} from "react-icons/bs";
|
||||||
import ProgressBar from "../Low/ProgressBar";
|
import ProgressBar from "../Low/ProgressBar";
|
||||||
|
import TimerEndedModal from "../TimerEndedModal";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
minTimer: number;
|
minTimer: number;
|
||||||
@@ -15,6 +19,9 @@ interface Props {
|
|||||||
|
|
||||||
export default function ModuleTitle({minTimer, module, label, exerciseIndex, totalExercises, disableTimer = false}: Props) {
|
export default function ModuleTitle({minTimer, module, label, exerciseIndex, totalExercises, disableTimer = false}: Props) {
|
||||||
const [timer, setTimer] = useState(minTimer * 60);
|
const [timer, setTimer] = useState(minTimer * 60);
|
||||||
|
const [showModal, setShowModal] = useState(false);
|
||||||
|
const [warningMode, setWarningMode] = useState(false);
|
||||||
|
const setHasExamEnded = useExamStore((state) => state.setHasExamEnded);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!disableTimer) {
|
if (!disableTimer) {
|
||||||
@@ -26,6 +33,14 @@ export default function ModuleTitle({minTimer, module, label, exerciseIndex, tot
|
|||||||
}
|
}
|
||||||
}, [disableTimer, minTimer]);
|
}, [disableTimer, minTimer]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (timer <= 0) setShowModal(true);
|
||||||
|
}, [timer]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (timer < 300 && !warningMode) setWarningMode(true);
|
||||||
|
}, [timer, warningMode]);
|
||||||
|
|
||||||
const moduleIcon: {[key in Module]: ReactNode} = {
|
const moduleIcon: {[key in Module]: ReactNode} = {
|
||||||
reading: <BsBook className="text-ielts-reading w-6 h-6" />,
|
reading: <BsBook className="text-ielts-reading w-6 h-6" />,
|
||||||
listening: <BsHeadphones className="text-ielts-listening w-6 h-6" />,
|
listening: <BsHeadphones className="text-ielts-listening w-6 h-6" />,
|
||||||
@@ -35,7 +50,21 @@ export default function ModuleTitle({minTimer, module, label, exerciseIndex, tot
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="absolute top-4 right-6 bg-mti-gray-seasalt px-3 py-2 flex items-center gap-2 rounded-full text-mti-gray-davy">
|
<TimerEndedModal
|
||||||
|
isOpen={showModal}
|
||||||
|
onClose={() => {
|
||||||
|
setHasExamEnded(true);
|
||||||
|
setShowModal(false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<motion.div
|
||||||
|
className={clsx(
|
||||||
|
"absolute top-4 right-6 bg-mti-gray-seasalt px-3 py-2 flex items-center gap-2 rounded-full text-mti-gray-davy",
|
||||||
|
warningMode && !disableTimer && "bg-mti-red-light text-mti-gray-seasalt",
|
||||||
|
)}
|
||||||
|
initial={{scale: warningMode && !disableTimer ? 0.8 : 1}}
|
||||||
|
animate={{scale: warningMode && !disableTimer ? 1.1 : 1}}
|
||||||
|
transition={{repeat: Infinity, repeatType: "reverse", duration: 0.5, ease: "easeInOut"}}>
|
||||||
<BsStopwatch className="w-4 h-4" />
|
<BsStopwatch className="w-4 h-4" />
|
||||||
<span className="text-sm font-semibold w-11">
|
<span className="text-sm font-semibold w-11">
|
||||||
{timer > 0 && (
|
{timer > 0 && (
|
||||||
@@ -51,7 +80,7 @@ export default function ModuleTitle({minTimer, module, label, exerciseIndex, tot
|
|||||||
)}
|
)}
|
||||||
{timer <= 0 && <>00:00</>}
|
{timer <= 0 && <>00:00</>}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</motion.div>
|
||||||
<div className="flex gap-6 w-full h-fit items-center mt-5">
|
<div className="flex gap-6 w-full h-fit items-center mt-5">
|
||||||
<div className="w-12 h-12 bg-mti-gray-smoke flex items-center justify-center rounded-lg">{moduleIcon[module]}</div>
|
<div className="w-12 h-12 bg-mti-gray-smoke flex items-center justify-center rounded-lg">{moduleIcon[module]}</div>
|
||||||
<div className="flex flex-col gap-3 w-full">
|
<div className="flex flex-col gap-3 w-full">
|
||||||
|
|||||||
@@ -1,22 +1,32 @@
|
|||||||
import {User} from "@/interfaces/user";
|
import {User} from "@/interfaces/user";
|
||||||
|
import Link from "next/link";
|
||||||
import {Avatar} from "primereact/avatar";
|
import {Avatar} from "primereact/avatar";
|
||||||
|
import FocusLayer from '@/components/FocusLayer';
|
||||||
|
import { preventNavigation } from "@/utils/navigation.disabled";
|
||||||
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
user: User;
|
user: User;
|
||||||
|
navDisabled?: boolean;
|
||||||
|
focusMode?: boolean;
|
||||||
|
onFocusLayerMouseEnter?: Function;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* eslint-disable @next/next/no-img-element */
|
/* eslint-disable @next/next/no-img-element */
|
||||||
export default function Navbar({user}: Props) {
|
export default function Navbar({user, navDisabled = false, focusMode = false, onFocusLayerMouseEnter}: Props) {
|
||||||
|
const disableNavigation = preventNavigation(navDisabled, focusMode);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header className="w-full bg-transparent py-4 gap-2 flex items-center">
|
<header className="w-full bg-transparent py-4 gap-2 flex items-center relative">
|
||||||
<h1 className="font-bold text-2xl w-1/6 px-8">eCrop</h1>
|
<h1 className="font-bold text-2xl w-1/6 px-8">EnCoach</h1>
|
||||||
<div className="flex justify-between w-5/6 mr-8">
|
<div className="flex justify-between w-5/6 mr-8">
|
||||||
<input type="text" placeholder="Search..." className="rounded-full py-4 px-6 border border-mti-gray-platinum outline-none" />
|
<input type="text" placeholder="Search..." className="rounded-full py-4 px-6 border border-mti-gray-platinum outline-none" />
|
||||||
<div className="flex gap-3 items-center justify-end">
|
<Link href={disableNavigation ? "" : "/profile"} className="flex gap-3 items-center justify-end">
|
||||||
<img src={user.profilePicture} alt={user.name} className="w-10 h-10 rounded-full" />
|
<img src={user.profilePicture} alt={user.name} className="w-10 h-10 rounded-full object-cover" />
|
||||||
<span className="text-right">{user.name}</span>
|
<span className="text-right">{user.name}</span>
|
||||||
</div>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
{focusMode && <FocusLayer onFocusLayerMouseEnter={onFocusLayerMouseEnter}/>}
|
||||||
</header>
|
</header>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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" />
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,16 +1,20 @@
|
|||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import {IconType} from "react-icons";
|
import {IconType} from "react-icons";
|
||||||
import {MdSpaceDashboard} from "react-icons/md";
|
import {MdSpaceDashboard} from "react-icons/md";
|
||||||
import {BsFileEarmarkText, BsClockHistory, BsPencil, BsGraphUp} from "react-icons/bs";
|
import {BsFileEarmarkText, BsClockHistory, BsPencil, BsGraphUp, BsShield} from "react-icons/bs";
|
||||||
import {RiLogoutBoxFill} from "react-icons/ri";
|
import {RiLogoutBoxFill} from "react-icons/ri";
|
||||||
import {SlPencil} from "react-icons/sl";
|
import {SlPencil} from "react-icons/sl";
|
||||||
import {FaAward} from "react-icons/fa";
|
import {FaAward} from "react-icons/fa";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import {useRouter} from "next/router";
|
import {useRouter} from "next/router";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
|
import FocusLayer from '@/components/FocusLayer';
|
||||||
|
import { preventNavigation } from "@/utils/navigation.disabled";
|
||||||
interface Props {
|
interface Props {
|
||||||
path: string;
|
path: string;
|
||||||
|
navDisabled?: boolean;
|
||||||
|
focusMode?: boolean;
|
||||||
|
onFocusLayerMouseEnter?: Function;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface NavProps {
|
interface NavProps {
|
||||||
@@ -18,50 +22,55 @@ interface NavProps {
|
|||||||
label: string;
|
label: string;
|
||||||
path: string;
|
path: string;
|
||||||
keyPath: string;
|
keyPath: string;
|
||||||
|
disabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const Nav = ({Icon, label, path, keyPath}: NavProps) => (
|
const Nav = ({Icon, label, path, keyPath, disabled = false}: NavProps) => (
|
||||||
<Link
|
<Link
|
||||||
href={keyPath}
|
href={!disabled ? keyPath : ""}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"p-4 px-8 rounded-full flex gap-4 items-center cursor-pointer text-gray-500 hover:bg-mti-green-light hover:text-white transition duration-300 ease-in-out",
|
"p-4 px-8 rounded-full flex gap-4 items-center cursor-pointer text-gray-500 hover:bg-mti-purple-light hover:text-white transition duration-300 ease-in-out",
|
||||||
path === keyPath && "bg-mti-green-light text-white",
|
path === keyPath && "bg-mti-purple-light text-white",
|
||||||
)}>
|
)}>
|
||||||
<Icon size={20} />
|
<Icon size={20} />
|
||||||
<span className="text-lg font-semibold">{label}</span>
|
<span className="text-lg font-semibold">{label}</span>
|
||||||
</Link>
|
</Link>
|
||||||
);
|
);
|
||||||
|
|
||||||
export default function Sidebar({path}: Props) {
|
export default function Sidebar({path, navDisabled = false, focusMode = false, onFocusLayerMouseEnter}: Props) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
const logout = async () => {
|
const logout = async () => {
|
||||||
axios.post("/api/logout").finally(() => {
|
axios.post("/api/logout").finally(() => {
|
||||||
router.push("/login");
|
setTimeout(() => router.reload(), 500);
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const disableNavigation: Boolean = preventNavigation(navDisabled, focusMode);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="h-full flex bg-transparent flex-col justify-between w-1/6 px-4">
|
<section className="h-full flex bg-transparent flex-col justify-between w-1/6 px-4 py-4 pb-8 relative">
|
||||||
<div className="flex flex-col gap-3">
|
<div className="flex flex-col gap-3">
|
||||||
<Nav Icon={MdSpaceDashboard} label="Dashboard" path={path} keyPath="/" />
|
<Nav disabled={disableNavigation} Icon={MdSpaceDashboard} label="Dashboard" path={path} keyPath="/" />
|
||||||
<Nav Icon={BsFileEarmarkText} label="Exams" path={path} keyPath="/exam" />
|
<Nav disabled={disableNavigation} Icon={BsFileEarmarkText} label="Exams" path={path} keyPath="/exam" />
|
||||||
<Nav Icon={BsPencil} label="Exercises" path={path} keyPath="/#" />
|
<Nav disabled={disableNavigation} Icon={BsPencil} label="Exercises" path={path} keyPath="/exercises" />
|
||||||
<Nav Icon={BsGraphUp} label="Stats" path={path} keyPath="/stats" />
|
<Nav disabled={disableNavigation} Icon={BsGraphUp} label="Stats" path={path} keyPath="/stats" />
|
||||||
<Nav Icon={BsClockHistory} label="Record" path={path} keyPath="/record" />
|
<Nav disabled={disableNavigation} Icon={BsClockHistory} label="Record" path={path} keyPath="/record" />
|
||||||
|
<Nav disabled={disableNavigation} Icon={BsShield} label="Admin" path={path} keyPath="/admin" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
role="button"
|
role="button"
|
||||||
tabIndex={1}
|
tabIndex={1}
|
||||||
onClick={logout}
|
onClick={focusMode ? () => {} : logout}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"p-4 px-8 rounded-full flex gap-4 items-center cursor-pointer text-black hover:text-mti-orange transition duration-300 ease-in-out",
|
"p-4 px-8 rounded-full flex gap-4 items-center cursor-pointer text-black hover:text-mti-rose transition duration-300 ease-in-out",
|
||||||
"absolute bottom-8",
|
"absolute bottom-8",
|
||||||
)}>
|
)}>
|
||||||
<RiLogoutBoxFill size={20} />
|
<RiLogoutBoxFill size={20} />
|
||||||
<span className="text-lg font-medium">Log Out</span>
|
<span className="text-lg font-medium">Log Out</span>
|
||||||
</div>
|
</div>
|
||||||
|
{focusMode && <FocusLayer onFocusLayerMouseEnter={onFocusLayerMouseEnter} />}
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,15 @@ import {CommonProps} from ".";
|
|||||||
import {Fragment} from "react";
|
import {Fragment} from "react";
|
||||||
import Button from "../Low/Button";
|
import Button from "../Low/Button";
|
||||||
|
|
||||||
export default function FillBlanksSolutions({prompt, solutions, text, userSolutions, onNext, onBack}: FillBlanksExercise & CommonProps) {
|
export default function FillBlanksSolutions({id, type, prompt, solutions, text, userSolutions, onNext, onBack}: FillBlanksExercise & CommonProps) {
|
||||||
|
const calculateScore = () => {
|
||||||
|
const total = text.match(/({{\d+}})/g)?.length || 0;
|
||||||
|
const correct = userSolutions.filter((x) => solutions.find((y) => x.id === y.id)?.solution === x.solution.toLowerCase() || false).length;
|
||||||
|
const missing = total - userSolutions.filter((x) => solutions.find((y) => x.id === y.id)).length;
|
||||||
|
|
||||||
|
return {total, correct, missing};
|
||||||
|
};
|
||||||
|
|
||||||
const renderLines = (line: string) => {
|
const renderLines = (line: string) => {
|
||||||
return (
|
return (
|
||||||
<span>
|
<span>
|
||||||
@@ -18,7 +26,7 @@ export default function FillBlanksSolutions({prompt, solutions, text, userSoluti
|
|||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"rounded-full hover:text-white hover:bg-mti-blue transition duration-300 ease-in-out my-1 px-5 py-2 text-center text-white bg-mti-blue-light",
|
"rounded-full hover:text-white hover:bg-mti-red transition duration-300 ease-in-out my-1 px-5 py-2 text-center text-white bg-mti-red-light",
|
||||||
)}>
|
)}>
|
||||||
{solution.solution}
|
{solution.solution}
|
||||||
</button>
|
</button>
|
||||||
@@ -29,8 +37,8 @@ export default function FillBlanksSolutions({prompt, solutions, text, userSoluti
|
|||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"rounded-full hover:text-white hover:bg-mti-green transition duration-300 ease-in-out my-1",
|
"rounded-full hover:text-white hover:bg-mti-purple transition duration-300 ease-in-out my-1",
|
||||||
userSolution && "px-5 py-2 text-center text-white bg-mti-green-light",
|
userSolution && "px-5 py-2 text-center text-white bg-mti-purple-light",
|
||||||
)}>
|
)}>
|
||||||
{solution.solution}
|
{solution.solution}
|
||||||
</button>
|
</button>
|
||||||
@@ -42,16 +50,16 @@ export default function FillBlanksSolutions({prompt, solutions, text, userSoluti
|
|||||||
<>
|
<>
|
||||||
<button
|
<button
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"rounded-full hover:text-white hover:bg-mti-orange transition duration-300 ease-in-out my-1 mr-1",
|
"rounded-full hover:text-white hover:bg-mti-rose transition duration-300 ease-in-out my-1 mr-1",
|
||||||
userSolution && "px-5 py-2 text-center text-white bg-mti-orange-light",
|
userSolution && "px-5 py-2 text-center text-white bg-mti-rose-light",
|
||||||
)}>
|
)}>
|
||||||
{userSolution.solution}
|
{userSolution.solution}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"rounded-full hover:text-white hover:bg-mti-green transition duration-300 ease-in-out my-1",
|
"rounded-full hover:text-white hover:bg-mti-purple transition duration-300 ease-in-out my-1",
|
||||||
userSolution && "px-5 py-2 text-center text-white bg-mti-green-light",
|
userSolution && "px-5 py-2 text-center text-white bg-mti-purple-light",
|
||||||
)}>
|
)}>
|
||||||
{solution.solution}
|
{solution.solution}
|
||||||
</button>
|
</button>
|
||||||
@@ -84,26 +92,33 @@ export default function FillBlanksSolutions({prompt, solutions, text, userSoluti
|
|||||||
</span>
|
</span>
|
||||||
<div className="flex gap-4 items-center">
|
<div className="flex gap-4 items-center">
|
||||||
<div className="flex gap-2 items-center">
|
<div className="flex gap-2 items-center">
|
||||||
<div className="w-4 h-4 rounded-full bg-mti-green" />
|
<div className="w-4 h-4 rounded-full bg-mti-purple" />
|
||||||
Correct
|
Correct
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2 items-center">
|
<div className="flex gap-2 items-center">
|
||||||
<div className="w-4 h-4 rounded-full bg-mti-blue" />
|
<div className="w-4 h-4 rounded-full bg-mti-red" />
|
||||||
Unanswered
|
Unanswered
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2 items-center">
|
<div className="flex gap-2 items-center">
|
||||||
<div className="w-4 h-4 rounded-full bg-mti-orange" />
|
<div className="w-4 h-4 rounded-full bg-mti-rose" />
|
||||||
Wrong
|
Wrong
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="self-end flex justify-between w-full gap-8 absolute bottom-8 left-0 px-8">
|
<div className="self-end flex justify-between w-full gap-8 absolute bottom-8 left-0 px-8">
|
||||||
<Button color="green" variant="outline" onClick={onBack} className="max-w-[200px] w-full">
|
<Button
|
||||||
|
color="purple"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => onBack({exercise: id, solutions: userSolutions, score: calculateScore(), type})}
|
||||||
|
className="max-w-[200px] w-full">
|
||||||
Back
|
Back
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<Button color="green" onClick={() => onNext()} className="max-w-[200px] self-end w-full">
|
<Button
|
||||||
|
color="purple"
|
||||||
|
onClick={() => onNext({exercise: id, solutions: userSolutions, score: calculateScore(), type})}
|
||||||
|
className="max-w-[200px] self-end w-full">
|
||||||
Next
|
Next
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -9,7 +9,24 @@ import {Fragment} from "react";
|
|||||||
import Button from "../Low/Button";
|
import Button from "../Low/Button";
|
||||||
import Xarrow from "react-xarrows";
|
import Xarrow from "react-xarrows";
|
||||||
|
|
||||||
export default function MatchSentencesSolutions({options, prompt, sentences, userSolutions, onNext, onBack}: MatchSentencesExercise & CommonProps) {
|
export default function MatchSentencesSolutions({
|
||||||
|
id,
|
||||||
|
type,
|
||||||
|
options,
|
||||||
|
prompt,
|
||||||
|
sentences,
|
||||||
|
userSolutions,
|
||||||
|
onNext,
|
||||||
|
onBack,
|
||||||
|
}: MatchSentencesExercise & CommonProps) {
|
||||||
|
const calculateScore = () => {
|
||||||
|
const total = sentences.length;
|
||||||
|
const correct = userSolutions.filter((x) => sentences.find((y) => y.id === x.question)?.solution === x.option || false).length;
|
||||||
|
const missing = total - userSolutions.filter((x) => sentences.find((y) => y.id === x.question)).length;
|
||||||
|
|
||||||
|
return {total, correct, missing};
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="flex flex-col gap-4 mt-4 h-full mb-20">
|
<div className="flex flex-col gap-4 mt-4 h-full mb-20">
|
||||||
@@ -31,9 +48,9 @@ export default function MatchSentencesSolutions({options, prompt, sentences, use
|
|||||||
className={clsx(
|
className={clsx(
|
||||||
"w-8 h-8 rounded-full z-10 text-white",
|
"w-8 h-8 rounded-full z-10 text-white",
|
||||||
"transition duration-300 ease-in-out",
|
"transition duration-300 ease-in-out",
|
||||||
!userSolutions.find((x) => x.question === id) && "!bg-mti-blue",
|
!userSolutions.find((x) => x.question === id) && "!bg-mti-red",
|
||||||
userSolutions.find((x) => x.question === id)?.option === solution && "bg-mti-green",
|
userSolutions.find((x) => x.question === id)?.option === solution && "bg-mti-purple",
|
||||||
userSolutions.find((x) => x.question === id)?.option !== solution && "bg-mti-orange",
|
userSolutions.find((x) => x.question === id)?.option !== solution && "bg-mti-rose",
|
||||||
)}>
|
)}>
|
||||||
{id}
|
{id}
|
||||||
</button>
|
</button>
|
||||||
@@ -46,7 +63,7 @@ export default function MatchSentencesSolutions({options, prompt, sentences, use
|
|||||||
<button
|
<button
|
||||||
id={id}
|
id={id}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"bg-mti-green-ultralight text-mti-green hover:text-white hover:bg-mti-green w-8 h-8 rounded-full z-10",
|
"bg-mti-purple-ultralight text-mti-purple hover:text-white hover:bg-mti-purple w-8 h-8 rounded-full z-10",
|
||||||
"transition duration-300 ease-in-out",
|
"transition duration-300 ease-in-out",
|
||||||
)}>
|
)}>
|
||||||
{id}
|
{id}
|
||||||
@@ -62,10 +79,10 @@ export default function MatchSentencesSolutions({options, prompt, sentences, use
|
|||||||
end={sentence.solution}
|
end={sentence.solution}
|
||||||
lineColor={
|
lineColor={
|
||||||
!userSolutions.find((x) => x.question === sentence.id)
|
!userSolutions.find((x) => x.question === sentence.id)
|
||||||
? "#0696ff"
|
? "#CC5454"
|
||||||
: userSolutions.find((x) => x.question === sentence.id)?.option === sentence.solution
|
: userSolutions.find((x) => x.question === sentence.id)?.option === sentence.solution
|
||||||
? "#307912"
|
? "#7872BF"
|
||||||
: "#FF6000"
|
: "#CC5454"
|
||||||
}
|
}
|
||||||
showHead={false}
|
showHead={false}
|
||||||
/>
|
/>
|
||||||
@@ -73,23 +90,30 @@ export default function MatchSentencesSolutions({options, prompt, sentences, use
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex gap-4 items-center">
|
<div className="flex gap-4 items-center">
|
||||||
<div className="flex gap-2 items-center">
|
<div className="flex gap-2 items-center">
|
||||||
<div className="w-4 h-4 rounded-full bg-mti-green" /> Correct
|
<div className="w-4 h-4 rounded-full bg-mti-purple" /> Correct
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2 items-center">
|
<div className="flex gap-2 items-center">
|
||||||
<div className="w-4 h-4 rounded-full bg-mti-blue" /> Unanswered
|
<div className="w-4 h-4 rounded-full bg-mti-red" /> Unanswered
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2 items-center">
|
<div className="flex gap-2 items-center">
|
||||||
<div className="w-4 h-4 rounded-full bg-mti-orange" /> Wrong
|
<div className="w-4 h-4 rounded-full bg-mti-rose" /> Wrong
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="self-end flex justify-between w-full gap-8 absolute bottom-8 left-0 px-8">
|
<div className="self-end flex justify-between w-full gap-8 absolute bottom-8 left-0 px-8">
|
||||||
<Button color="green" variant="outline" onClick={() => onBack()} className="max-w-[200px] w-full">
|
<Button
|
||||||
|
color="purple"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => onBack({exercise: id, solutions: userSolutions, score: calculateScore(), type})}
|
||||||
|
className="max-w-[200px] w-full">
|
||||||
Back
|
Back
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<Button color="green" onClick={() => onNext()} className="max-w-[200px] self-end w-full">
|
<Button
|
||||||
|
color="purple"
|
||||||
|
onClick={() => onNext({exercise: id, solutions: userSolutions, score: calculateScore(), type})}
|
||||||
|
className="max-w-[200px] self-end w-full">
|
||||||
Next
|
Next
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -14,14 +14,14 @@ function Question({
|
|||||||
}: MultipleChoiceQuestion & {userSolution: string | undefined; onSelectOption?: (option: string) => void; showSolution?: boolean}) {
|
}: MultipleChoiceQuestion & {userSolution: string | undefined; onSelectOption?: (option: string) => void; showSolution?: boolean}) {
|
||||||
const optionColor = (option: string) => {
|
const optionColor = (option: string) => {
|
||||||
if (option === solution && !userSolution) {
|
if (option === solution && !userSolution) {
|
||||||
return "!border-mti-blue-light !text-mti-blue-light";
|
return "!border-mti-red-light !text-mti-red-light";
|
||||||
}
|
}
|
||||||
|
|
||||||
if (option === solution) {
|
if (option === solution) {
|
||||||
return "!border-mti-green-light !text-mti-green-light";
|
return "!border-mti-purple-light !text-mti-purple-light";
|
||||||
}
|
}
|
||||||
|
|
||||||
return userSolution === option ? "!border-mti-orange-light !text-mti-orange-light" : "";
|
return userSolution === option ? "!border-mti-rose-light !text-mti-rose-light" : "";
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -54,12 +54,20 @@ function Question({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function MultipleChoice({prompt, questions, userSolutions, onNext, onBack}: MultipleChoiceExercise & CommonProps) {
|
export default function MultipleChoice({id, type, prompt, questions, userSolutions, onNext, onBack}: MultipleChoiceExercise & CommonProps) {
|
||||||
const [questionIndex, setQuestionIndex] = useState(0);
|
const [questionIndex, setQuestionIndex] = useState(0);
|
||||||
|
|
||||||
|
const calculateScore = () => {
|
||||||
|
const total = questions.length;
|
||||||
|
const correct = userSolutions.filter((x) => questions.find((y) => y.id === x.question)?.solution === x.option || false).length;
|
||||||
|
const missing = total - userSolutions.filter((x) => questions.find((y) => y.id === x.question)).length;
|
||||||
|
|
||||||
|
return {total, correct, missing};
|
||||||
|
};
|
||||||
|
|
||||||
const next = () => {
|
const next = () => {
|
||||||
if (questionIndex === questions.length - 1) {
|
if (questionIndex === questions.length - 1) {
|
||||||
onNext();
|
onNext({exercise: id, solutions: userSolutions, score: calculateScore(), type});
|
||||||
} else {
|
} else {
|
||||||
setQuestionIndex((prev) => prev + 1);
|
setQuestionIndex((prev) => prev + 1);
|
||||||
}
|
}
|
||||||
@@ -67,7 +75,7 @@ export default function MultipleChoice({prompt, questions, userSolutions, onNext
|
|||||||
|
|
||||||
const back = () => {
|
const back = () => {
|
||||||
if (questionIndex === 0) {
|
if (questionIndex === 0) {
|
||||||
onBack();
|
onBack({exercise: id, solutions: userSolutions, score: calculateScore(), type});
|
||||||
} else {
|
} else {
|
||||||
setQuestionIndex((prev) => prev - 1);
|
setQuestionIndex((prev) => prev - 1);
|
||||||
}
|
}
|
||||||
@@ -87,26 +95,26 @@ export default function MultipleChoice({prompt, questions, userSolutions, onNext
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex gap-4 items-center">
|
<div className="flex gap-4 items-center">
|
||||||
<div className="flex gap-2 items-center">
|
<div className="flex gap-2 items-center">
|
||||||
<div className="w-4 h-4 rounded-full bg-mti-green" />
|
<div className="w-4 h-4 rounded-full bg-mti-purple" />
|
||||||
Correct
|
Correct
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2 items-center">
|
<div className="flex gap-2 items-center">
|
||||||
<div className="w-4 h-4 rounded-full bg-mti-blue" />
|
<div className="w-4 h-4 rounded-full bg-mti-red" />
|
||||||
Unanswered
|
Unanswered
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2 items-center">
|
<div className="flex gap-2 items-center">
|
||||||
<div className="w-4 h-4 rounded-full bg-mti-orange" />
|
<div className="w-4 h-4 rounded-full bg-mti-rose" />
|
||||||
Wrong
|
Wrong
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="self-end flex justify-between w-full gap-8 absolute bottom-8 left-0 px-8">
|
<div className="self-end flex justify-between w-full gap-8 absolute bottom-8 left-0 px-8">
|
||||||
<Button color="green" variant="outline" onClick={back} className="max-w-[200px] w-full">
|
<Button color="purple" variant="outline" onClick={back} className="max-w-[200px] w-full">
|
||||||
Back
|
Back
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<Button color="green" onClick={next} className="max-w-[200px] self-end w-full">
|
<Button color="purple" onClick={next} className="max-w-[200px] self-end w-full">
|
||||||
Next
|
Next
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -39,17 +39,17 @@ function Blank({
|
|||||||
|
|
||||||
const getSolutionStyling = () => {
|
const getSolutionStyling = () => {
|
||||||
if (!userSolution) {
|
if (!userSolution) {
|
||||||
return "bg-mti-blue-ultralight text-mti-blue-light";
|
return "bg-mti-red-ultralight text-mti-red-light";
|
||||||
}
|
}
|
||||||
|
|
||||||
return "bg-mti-green-ultralight text-mti-green-light";
|
return "bg-mti-purple-ultralight text-mti-purple-light";
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<span className="inline-flex gap-2">
|
<span className="inline-flex gap-2">
|
||||||
{userSolution && !isUserSolutionCorrect() && (
|
{userSolution && !isUserSolutionCorrect() && (
|
||||||
<input
|
<input
|
||||||
className="py-2 px-3 rounded-2xl w-48 focus:outline-none my-2 bg-mti-orange-ultralight text-mti-orange-light"
|
className="py-2 px-3 rounded-2xl w-48 focus:outline-none my-2 bg-mti-rose-ultralight text-mti-rose-light"
|
||||||
placeholder={id}
|
placeholder={id}
|
||||||
onChange={(e) => setUserInput(e.target.value)}
|
onChange={(e) => setUserInput(e.target.value)}
|
||||||
value={userSolution}
|
value={userSolution}
|
||||||
@@ -69,6 +69,7 @@ function Blank({
|
|||||||
|
|
||||||
export default function WriteBlanksSolutions({
|
export default function WriteBlanksSolutions({
|
||||||
id,
|
id,
|
||||||
|
type,
|
||||||
prompt,
|
prompt,
|
||||||
maxWords,
|
maxWords,
|
||||||
solutions,
|
solutions,
|
||||||
@@ -77,6 +78,20 @@ export default function WriteBlanksSolutions({
|
|||||||
onNext,
|
onNext,
|
||||||
onBack,
|
onBack,
|
||||||
}: WriteBlanksExercise & CommonProps) {
|
}: WriteBlanksExercise & CommonProps) {
|
||||||
|
const calculateScore = () => {
|
||||||
|
const total = text.match(/({{\d+}})/g)?.length || 0;
|
||||||
|
const correct = userSolutions.filter(
|
||||||
|
(x) =>
|
||||||
|
solutions
|
||||||
|
.find((y) => x.id === y.id)
|
||||||
|
?.solution.map((y) => y.toLowerCase())
|
||||||
|
.includes(x.solution.toLowerCase()) || false,
|
||||||
|
).length;
|
||||||
|
const missing = total - userSolutions.filter((x) => solutions.find((y) => x.id === y.id)).length;
|
||||||
|
|
||||||
|
return {total, correct, missing};
|
||||||
|
};
|
||||||
|
|
||||||
const renderLines = (line: string) => {
|
const renderLines = (line: string) => {
|
||||||
return (
|
return (
|
||||||
<span className="text-base leading-5">
|
<span className="text-base leading-5">
|
||||||
@@ -112,26 +127,33 @@ export default function WriteBlanksSolutions({
|
|||||||
</span>
|
</span>
|
||||||
<div className="flex gap-4 items-center">
|
<div className="flex gap-4 items-center">
|
||||||
<div className="flex gap-2 items-center">
|
<div className="flex gap-2 items-center">
|
||||||
<div className="w-4 h-4 rounded-full bg-mti-green" />
|
<div className="w-4 h-4 rounded-full bg-mti-purple" />
|
||||||
Correct
|
Correct
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2 items-center">
|
<div className="flex gap-2 items-center">
|
||||||
<div className="w-4 h-4 rounded-full bg-mti-blue" />
|
<div className="w-4 h-4 rounded-full bg-mti-red" />
|
||||||
Unanswered
|
Unanswered
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2 items-center">
|
<div className="flex gap-2 items-center">
|
||||||
<div className="w-4 h-4 rounded-full bg-mti-orange" />
|
<div className="w-4 h-4 rounded-full bg-mti-rose" />
|
||||||
Wrong
|
Wrong
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="self-end flex justify-between w-full gap-8 absolute bottom-8 left-0 px-8">
|
<div className="self-end flex justify-between w-full gap-8 absolute bottom-8 left-0 px-8">
|
||||||
<Button color="green" variant="outline" onClick={() => onBack()} className="max-w-[200px] w-full">
|
<Button
|
||||||
|
color="purple"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => onBack({exercise: id, solutions: userSolutions, score: calculateScore(), type})}
|
||||||
|
className="max-w-[200px] w-full">
|
||||||
Back
|
Back
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<Button color="green" onClick={() => onNext()} className="max-w-[200px] self-end w-full">
|
<Button
|
||||||
|
color="purple"
|
||||||
|
onClick={() => onNext({exercise: id, solutions: userSolutions, score: calculateScore(), type})}
|
||||||
|
className="max-w-[200px] self-end w-full">
|
||||||
Next
|
Next
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import {toast} from "react-toastify";
|
|||||||
import Button from "../Low/Button";
|
import Button from "../Low/Button";
|
||||||
import {Dialog, Transition} from "@headlessui/react";
|
import {Dialog, Transition} from "@headlessui/react";
|
||||||
|
|
||||||
export default function Writing({id, prompt, info, attachment, userSolutions, onNext, onBack}: WritingExercise & CommonProps) {
|
export default function Writing({id, type, prompt, attachment, userSolutions, onNext, onBack}: WritingExercise & CommonProps) {
|
||||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -96,11 +96,17 @@ export default function Writing({id, prompt, info, attachment, userSolutions, on
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="self-end flex justify-between w-full gap-8 absolute bottom-8 left-0 px-8">
|
<div className="self-end flex justify-between w-full gap-8 absolute bottom-8 left-0 px-8">
|
||||||
<Button color="green" variant="outline" onClick={onBack} className="max-w-[200px] w-full">
|
<Button
|
||||||
|
color="purple"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => onBack({exercise: id, solutions: userSolutions, score: {correct: 1, total: 1, missing: 0}, type})}
|
||||||
|
className="max-w-[200px] self-end w-full">
|
||||||
Back
|
Back
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button
|
||||||
<Button color="green" onClick={() => onNext()} className="max-w-[200px] self-end w-full">
|
color="purple"
|
||||||
|
onClick={() => onNext({exercise: id, solutions: userSolutions, score: {correct: 1, total: 1, missing: 0}, type})}
|
||||||
|
className="max-w-[200px] self-end w-full">
|
||||||
Next
|
Next
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,21 +1,35 @@
|
|||||||
import {Exercise, FillBlanksExercise, MatchSentencesExercise, MultipleChoiceExercise, WriteBlanksExercise, WritingExercise} from "@/interfaces/exam";
|
import {
|
||||||
|
Exercise,
|
||||||
|
FillBlanksExercise,
|
||||||
|
MatchSentencesExercise,
|
||||||
|
MultipleChoiceExercise,
|
||||||
|
SpeakingExercise,
|
||||||
|
TrueFalseExercise,
|
||||||
|
UserSolution,
|
||||||
|
WriteBlanksExercise,
|
||||||
|
WritingExercise,
|
||||||
|
} from "@/interfaces/exam";
|
||||||
import dynamic from "next/dynamic";
|
import dynamic from "next/dynamic";
|
||||||
import FillBlanks from "./FillBlanks";
|
import FillBlanks from "./FillBlanks";
|
||||||
import MultipleChoice from "./MultipleChoice";
|
import MultipleChoice from "./MultipleChoice";
|
||||||
|
import Speaking from "./Speaking";
|
||||||
|
import TrueFalseSolution from "./TrueFalse";
|
||||||
import WriteBlanks from "./WriteBlanks";
|
import WriteBlanks from "./WriteBlanks";
|
||||||
import Writing from "./Writing";
|
import Writing from "./Writing";
|
||||||
|
|
||||||
const MatchSentences = dynamic(() => import("@/components/Solutions/MatchSentences"), {ssr: false});
|
const MatchSentences = dynamic(() => import("@/components/Solutions/MatchSentences"), {ssr: false});
|
||||||
|
|
||||||
export interface CommonProps {
|
export interface CommonProps {
|
||||||
onNext: () => void;
|
onNext: (userSolutions: UserSolution) => void;
|
||||||
onBack: () => void;
|
onBack: (userSolutions: UserSolution) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const renderSolution = (exercise: Exercise, onNext: () => void, onBack: () => void) => {
|
export const renderSolution = (exercise: Exercise, onNext: () => void, onBack: () => void) => {
|
||||||
switch (exercise.type) {
|
switch (exercise.type) {
|
||||||
case "fillBlanks":
|
case "fillBlanks":
|
||||||
return <FillBlanks {...(exercise as FillBlanksExercise)} onNext={onNext} onBack={onBack} />;
|
return <FillBlanks {...(exercise as FillBlanksExercise)} onNext={onNext} onBack={onBack} />;
|
||||||
|
case "trueFalse":
|
||||||
|
return <TrueFalseSolution {...(exercise as TrueFalseExercise)} onNext={onNext} onBack={onBack} />;
|
||||||
case "matchSentences":
|
case "matchSentences":
|
||||||
return <MatchSentences {...(exercise as MatchSentencesExercise)} onNext={onNext} onBack={onBack} />;
|
return <MatchSentences {...(exercise as MatchSentencesExercise)} onNext={onNext} onBack={onBack} />;
|
||||||
case "multipleChoice":
|
case "multipleChoice":
|
||||||
@@ -24,5 +38,7 @@ export const renderSolution = (exercise: Exercise, onNext: () => void, onBack: (
|
|||||||
return <WriteBlanks {...(exercise as WriteBlanksExercise)} onNext={onNext} onBack={onBack} />;
|
return <WriteBlanks {...(exercise as WriteBlanksExercise)} onNext={onNext} onBack={onBack} />;
|
||||||
case "writing":
|
case "writing":
|
||||||
return <Writing {...(exercise as WritingExercise)} onNext={onNext} onBack={onBack} />;
|
return <Writing {...(exercise as WritingExercise)} onNext={onNext} onBack={onBack} />;
|
||||||
|
case "speaking":
|
||||||
|
return <Speaking {...(exercise as SpeakingExercise)} onNext={onNext} onBack={onBack} />;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
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,5 +1,7 @@
|
|||||||
import {Module} from "@/interfaces";
|
import {Module} from "@/interfaces";
|
||||||
|
|
||||||
|
export const MODULES: Module[] = ["reading", "listening", "writing", "speaking"];
|
||||||
|
|
||||||
export const BAND_SCORES: {[key in Module]: number[]} = {
|
export const BAND_SCORES: {[key in Module]: number[]} = {
|
||||||
reading: [0, 2.5, 3, 3.5, 4, 4.5, 5, 5.5, 6, 6.5, 7, 7.5, 8, 8.5, 9],
|
reading: [0, 2.5, 3, 3.5, 4, 4.5, 5, 5.5, 6, 6.5, 7, 7.5, 8, 8.5, 9],
|
||||||
listening: [0, 2.5, 3, 3.5, 4, 4.5, 5, 5.5, 6, 6.5, 7, 7.5, 8, 8.5, 9],
|
listening: [0, 2.5, 3, 3.5, 4, 4.5, 5, 5.5, 6, 6.5, 7, 7.5, 8, 8.5, 9],
|
||||||
|
|||||||
@@ -139,25 +139,25 @@ export default function Finish({user, scores, modules, isLoading, onViewResults}
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-5">
|
<div className="flex flex-col gap-5">
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<div className="w-3 h-3 bg-mti-blue-light rounded-full mt-1" />
|
<div className="w-3 h-3 bg-mti-red-light rounded-full mt-1" />
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
<span className="text-mti-blue-light">
|
<span className="text-mti-red-light">
|
||||||
{(((selectedScore.total - selectedScore.missing) / selectedScore.total) * 100).toFixed(0)}%
|
{(((selectedScore.total - selectedScore.missing) / selectedScore.total) * 100).toFixed(0)}%
|
||||||
</span>
|
</span>
|
||||||
<span className="text-lg">Completion</span>
|
<span className="text-lg">Completion</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<div className="w-3 h-3 bg-mti-green-light rounded-full mt-1" />
|
<div className="w-3 h-3 bg-mti-purple-light rounded-full mt-1" />
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
<span className="text-mti-green-light">{selectedScore.correct.toString().padStart(2, "0")}</span>
|
<span className="text-mti-purple-light">{selectedScore.correct.toString().padStart(2, "0")}</span>
|
||||||
<span className="text-lg">Correct</span>
|
<span className="text-lg">Correct</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<div className="w-3 h-3 bg-mti-orange-light rounded-full mt-1" />
|
<div className="w-3 h-3 bg-mti-rose-light rounded-full mt-1" />
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
<span className="text-mti-orange-light">
|
<span className="text-mti-rose-light">
|
||||||
{(selectedScore.total - selectedScore.correct).toString().padStart(2, "0")}
|
{(selectedScore.total - selectedScore.correct).toString().padStart(2, "0")}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-lg">Wrong</span>
|
<span className="text-lg">Wrong</span>
|
||||||
@@ -175,7 +175,7 @@ export default function Finish({user, scores, modules, isLoading, onViewResults}
|
|||||||
<div className="w-fit flex flex-col items-center gap-1 cursor-pointer">
|
<div className="w-fit flex flex-col items-center gap-1 cursor-pointer">
|
||||||
<button
|
<button
|
||||||
onClick={() => window.location.reload()}
|
onClick={() => window.location.reload()}
|
||||||
className="w-11 h-11 rounded-full bg-mti-green-light hover:bg-mti-green flex items-center justify-center transition duration-300 ease-in-out">
|
className="w-11 h-11 rounded-full bg-mti-purple-light hover:bg-mti-purple flex items-center justify-center transition duration-300 ease-in-out">
|
||||||
<BsArrowCounterclockwise className="text-white w-7 h-7" />
|
<BsArrowCounterclockwise className="text-white w-7 h-7" />
|
||||||
</button>
|
</button>
|
||||||
<span>Play Again</span>
|
<span>Play Again</span>
|
||||||
@@ -183,7 +183,7 @@ export default function Finish({user, scores, modules, isLoading, onViewResults}
|
|||||||
<div className="w-fit flex flex-col items-center gap-1 cursor-pointer">
|
<div className="w-fit flex flex-col items-center gap-1 cursor-pointer">
|
||||||
<button
|
<button
|
||||||
onClick={onViewResults}
|
onClick={onViewResults}
|
||||||
className="w-11 h-11 rounded-full bg-mti-green-light hover:bg-mti-green flex items-center justify-center transition duration-300 ease-in-out">
|
className="w-11 h-11 rounded-full bg-mti-purple-light hover:bg-mti-purple flex items-center justify-center transition duration-300 ease-in-out">
|
||||||
<BsEyeFill className="text-white w-7 h-7" />
|
<BsEyeFill className="text-white w-7 h-7" />
|
||||||
</button>
|
</button>
|
||||||
<span>Review Answers</span>
|
<span>Review Answers</span>
|
||||||
@@ -191,7 +191,7 @@ export default function Finish({user, scores, modules, isLoading, onViewResults}
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Link href="/" className="max-w-[200px] w-full self-end">
|
<Link href="/" className="max-w-[200px] w-full self-end">
|
||||||
<Button color="green" className="max-w-[200px] self-end w-full">
|
<Button color="purple" className="max-w-[200px] self-end w-full">
|
||||||
Dashboard
|
Dashboard
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import {ListeningExam, UserSolution} from "@/interfaces/exam";
|
import {ListeningExam, UserSolution} from "@/interfaces/exam";
|
||||||
import {useState} from "react";
|
import {useEffect, useState} from "react";
|
||||||
import Icon from "@mdi/react";
|
import Icon from "@mdi/react";
|
||||||
import {mdiArrowRight} from "@mdi/js";
|
import {mdiArrowRight} from "@mdi/js";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
@@ -9,6 +9,9 @@ import {renderSolution} from "@/components/Solutions";
|
|||||||
import ModuleTitle from "@/components/Medium/ModuleTitle";
|
import ModuleTitle from "@/components/Medium/ModuleTitle";
|
||||||
import AudioPlayer from "@/components/Low/AudioPlayer";
|
import AudioPlayer from "@/components/Low/AudioPlayer";
|
||||||
import Button from "@/components/Low/Button";
|
import Button from "@/components/Low/Button";
|
||||||
|
import BlankQuestionsModal from "@/components/BlankQuestionsModal";
|
||||||
|
import useExamStore from "@/stores/examStore";
|
||||||
|
import {defaultUserSolutions} from "@/utils/exams";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
exam: ListeningExam;
|
exam: ListeningExam;
|
||||||
@@ -19,18 +22,50 @@ interface Props {
|
|||||||
export default function Listening({exam, showSolutions = false, onFinish}: Props) {
|
export default function Listening({exam, showSolutions = false, onFinish}: Props) {
|
||||||
const [exerciseIndex, setExerciseIndex] = useState(-1);
|
const [exerciseIndex, setExerciseIndex] = useState(-1);
|
||||||
const [timesListened, setTimesListened] = useState(0);
|
const [timesListened, setTimesListened] = useState(0);
|
||||||
const [userSolutions, setUserSolutions] = useState<UserSolution[]>([]);
|
const [userSolutions, setUserSolutions] = useState<UserSolution[]>(exam.exercises.map((x) => defaultUserSolutions(x, exam)));
|
||||||
|
const [showBlankModal, setShowBlankModal] = useState(false);
|
||||||
|
|
||||||
|
const [hasExamEnded, setHasExamEnded] = useExamStore((state) => [state.hasExamEnded, state.setHasExamEnded]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (hasExamEnded && exerciseIndex === -1) {
|
||||||
|
setExerciseIndex((prev) => prev + 1);
|
||||||
|
}
|
||||||
|
}, [hasExamEnded, exerciseIndex]);
|
||||||
|
|
||||||
|
const confirmFinishModule = (keepGoing?: boolean) => {
|
||||||
|
if (!keepGoing) {
|
||||||
|
setShowBlankModal(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
onFinish(userSolutions.map((x) => ({...x, module: "listening", exam: exam.id})));
|
||||||
|
};
|
||||||
|
|
||||||
const nextExercise = (solution?: UserSolution) => {
|
const nextExercise = (solution?: UserSolution) => {
|
||||||
if (solution) {
|
if (solution) {
|
||||||
setUserSolutions((prev) => [...prev.filter((x) => x.exercise !== solution.exercise), solution]);
|
setUserSolutions((prev) => [...prev.filter((x) => x.exercise !== solution.exercise), solution]);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (exerciseIndex + 1 < exam.exercises.length) {
|
if (exerciseIndex + 1 < exam.exercises.length && !hasExamEnded) {
|
||||||
setExerciseIndex((prev) => prev + 1);
|
setExerciseIndex((prev) => prev + 1);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
solution &&
|
||||||
|
![...userSolutions.filter((x) => x.exercise !== solution?.exercise).map((x) => x.score.missing), solution?.score.missing].every(
|
||||||
|
(x) => x === 0,
|
||||||
|
) &&
|
||||||
|
!showSolutions &&
|
||||||
|
!hasExamEnded
|
||||||
|
) {
|
||||||
|
setShowBlankModal(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setHasExamEnded(false);
|
||||||
|
|
||||||
if (solution) {
|
if (solution) {
|
||||||
onFinish(
|
onFinish(
|
||||||
[...userSolutions.filter((x) => x.exercise !== solution.exercise), solution].map((x) => ({...x, module: "listening", exam: exam.id})),
|
[...userSolutions.filter((x) => x.exercise !== solution.exercise), solution].map((x) => ({...x, module: "listening", exam: exam.id})),
|
||||||
@@ -79,6 +114,7 @@ export default function Listening({exam, showSolutions = false, onFinish}: Props
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
<BlankQuestionsModal isOpen={showBlankModal} onClose={confirmFinishModule} />
|
||||||
<div className="flex flex-col h-full w-full gap-8 justify-between">
|
<div className="flex flex-col h-full w-full gap-8 justify-between">
|
||||||
<ModuleTitle
|
<ModuleTitle
|
||||||
exerciseIndex={exerciseIndex + 1}
|
exerciseIndex={exerciseIndex + 1}
|
||||||
@@ -99,7 +135,7 @@ export default function Listening({exam, showSolutions = false, onFinish}: Props
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{exerciseIndex === -1 && (
|
{exerciseIndex === -1 && (
|
||||||
<Button color="green" onClick={() => nextExercise()} className="max-w-[200px] self-end w-full justify-self-end">
|
<Button color="purple" onClick={() => nextExercise()} className="max-w-[200px] self-end w-full justify-self-end">
|
||||||
Start now
|
Start now
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -15,6 +15,9 @@ import ProgressBar from "@/components/Low/ProgressBar";
|
|||||||
import ModuleTitle from "@/components/Medium/ModuleTitle";
|
import ModuleTitle from "@/components/Medium/ModuleTitle";
|
||||||
import {Divider} from "primereact/divider";
|
import {Divider} from "primereact/divider";
|
||||||
import Button from "@/components/Low/Button";
|
import Button from "@/components/Low/Button";
|
||||||
|
import BlankQuestionsModal from "@/components/BlankQuestionsModal";
|
||||||
|
import useExamStore from "@/stores/examStore";
|
||||||
|
import {defaultUserSolutions} from "@/utils/exams";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
exam: ReadingExam;
|
exam: ReadingExam;
|
||||||
@@ -47,12 +50,12 @@ function TextModal({isOpen, title, content, onClose}: {isOpen: boolean; title: s
|
|||||||
leave="ease-in duration-200"
|
leave="ease-in duration-200"
|
||||||
leaveFrom="opacity-100 scale-100"
|
leaveFrom="opacity-100 scale-100"
|
||||||
leaveTo="opacity-0 scale-95">
|
leaveTo="opacity-0 scale-95">
|
||||||
<Dialog.Panel className="w-full max-w-4xl transform rounded-2xl bg-white p-6 text-left align-middle shadow-xl transition-all">
|
<Dialog.Panel className="w-full relative max-w-4xl transform rounded-2xl bg-white p-6 text-left align-middle shadow-xl transition-all">
|
||||||
<Dialog.Title as="h3" className="text-lg font-medium leading-6 text-gray-900">
|
<Dialog.Title as="h3" className="text-lg font-medium leading-6 text-gray-900">
|
||||||
{title}
|
{title}
|
||||||
</Dialog.Title>
|
</Dialog.Title>
|
||||||
<div className="mt-2 overflow-auto">
|
<div className="mt-2 overflow-auto mb-28">
|
||||||
<p className="text-sm text-gray-500">
|
<p className="text-sm">
|
||||||
{content.split("\\n").map((line, index) => (
|
{content.split("\\n").map((line, index) => (
|
||||||
<Fragment key={index}>
|
<Fragment key={index}>
|
||||||
{line}
|
{line}
|
||||||
@@ -62,13 +65,10 @@ function TextModal({isOpen, title, content, onClose}: {isOpen: boolean; title: s
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-4">
|
<div className="absolute bottom-8 right-8 max-w-[200px] self-end w-full">
|
||||||
<button
|
<Button color="purple" variant="outline" className="max-w-[200px] self-end w-full" onClick={onClose}>
|
||||||
type="button"
|
Close
|
||||||
className="inline-flex justify-center rounded-md border border-transparent bg-blue-100 px-4 py-2 text-sm font-medium text-blue-900 hover:bg-blue-200 focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2"
|
</Button>
|
||||||
onClick={onClose}>
|
|
||||||
Got it, thanks!
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</Dialog.Panel>
|
</Dialog.Panel>
|
||||||
</Transition.Child>
|
</Transition.Child>
|
||||||
@@ -82,18 +82,50 @@ function TextModal({isOpen, title, content, onClose}: {isOpen: boolean; title: s
|
|||||||
export default function Reading({exam, showSolutions = false, onFinish}: Props) {
|
export default function Reading({exam, showSolutions = false, onFinish}: Props) {
|
||||||
const [exerciseIndex, setExerciseIndex] = useState(-1);
|
const [exerciseIndex, setExerciseIndex] = useState(-1);
|
||||||
const [showTextModal, setShowTextModal] = useState(false);
|
const [showTextModal, setShowTextModal] = useState(false);
|
||||||
const [userSolutions, setUserSolutions] = useState<UserSolution[]>([]);
|
const [userSolutions, setUserSolutions] = useState<UserSolution[]>(exam.exercises.map((x) => defaultUserSolutions(x, exam)));
|
||||||
|
const [showBlankModal, setShowBlankModal] = useState(false);
|
||||||
|
|
||||||
|
const [hasExamEnded, setHasExamEnded] = useExamStore((state) => [state.hasExamEnded, state.setHasExamEnded]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (hasExamEnded && exerciseIndex === -1) {
|
||||||
|
setExerciseIndex((prev) => prev + 1);
|
||||||
|
}
|
||||||
|
}, [hasExamEnded, exerciseIndex]);
|
||||||
|
|
||||||
|
const confirmFinishModule = (keepGoing?: boolean) => {
|
||||||
|
if (!keepGoing) {
|
||||||
|
setShowBlankModal(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
onFinish(userSolutions.map((x) => ({...x, module: "reading", exam: exam.id})));
|
||||||
|
};
|
||||||
|
|
||||||
const nextExercise = (solution?: UserSolution) => {
|
const nextExercise = (solution?: UserSolution) => {
|
||||||
if (solution) {
|
if (solution) {
|
||||||
setUserSolutions((prev) => [...prev.filter((x) => x.exercise !== solution.exercise), solution]);
|
setUserSolutions((prev) => [...prev.filter((x) => x.exercise !== solution.exercise), solution]);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (exerciseIndex + 1 < exam.exercises.length) {
|
if (exerciseIndex + 1 < exam.exercises.length && !hasExamEnded) {
|
||||||
setExerciseIndex((prev) => prev + 1);
|
setExerciseIndex((prev) => prev + 1);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
solution &&
|
||||||
|
![...userSolutions.filter((x) => x.exercise !== solution?.exercise).map((x) => x.score.missing), solution?.score.missing].every(
|
||||||
|
(x) => x === 0,
|
||||||
|
) &&
|
||||||
|
!showSolutions &&
|
||||||
|
!hasExamEnded
|
||||||
|
) {
|
||||||
|
setShowBlankModal(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setHasExamEnded(false);
|
||||||
|
|
||||||
if (solution) {
|
if (solution) {
|
||||||
onFinish(
|
onFinish(
|
||||||
[...userSolutions.filter((x) => x.exercise !== solution.exercise), solution].map((x) => ({...x, module: "reading", exam: exam.id})),
|
[...userSolutions.filter((x) => x.exercise !== solution.exercise), solution].map((x) => ({...x, module: "reading", exam: exam.id})),
|
||||||
@@ -142,6 +174,7 @@ export default function Reading({exam, showSolutions = false, onFinish}: Props)
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="flex flex-col h-full w-full gap-8">
|
<div className="flex flex-col h-full w-full gap-8">
|
||||||
|
<BlankQuestionsModal isOpen={showBlankModal} onClose={confirmFinishModule} />
|
||||||
<TextModal {...exam.text} isOpen={showTextModal} onClose={() => setShowTextModal(false)} />
|
<TextModal {...exam.text} isOpen={showTextModal} onClose={() => setShowTextModal(false)} />
|
||||||
<ModuleTitle
|
<ModuleTitle
|
||||||
minTimer={exam.minTimer}
|
minTimer={exam.minTimer}
|
||||||
@@ -160,9 +193,18 @@ export default function Reading({exam, showSolutions = false, onFinish}: Props)
|
|||||||
exerciseIndex < exam.exercises.length &&
|
exerciseIndex < exam.exercises.length &&
|
||||||
showSolutions &&
|
showSolutions &&
|
||||||
renderSolution(exam.exercises[exerciseIndex], nextExercise, previousExercise)}
|
renderSolution(exam.exercises[exerciseIndex], nextExercise, previousExercise)}
|
||||||
|
{exerciseIndex > -1 && exerciseIndex < exam.exercises.length && (
|
||||||
|
<Button
|
||||||
|
color="purple"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setShowTextModal(true)}
|
||||||
|
className="max-w-[200px] self-end w-full absolute bottom-[31px] right-64">
|
||||||
|
Read text
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{exerciseIndex === -1 && (
|
{exerciseIndex === -1 && (
|
||||||
<Button color="green" onClick={() => nextExercise()} className="max-w-[200px] self-end w-full">
|
<Button color="purple" onClick={() => nextExercise()} className="max-w-[200px] self-end w-full">
|
||||||
Start now
|
Start now
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -14,9 +14,10 @@ import {sortByModuleName} from "@/utils/moduleUtils";
|
|||||||
interface Props {
|
interface Props {
|
||||||
user: User;
|
user: User;
|
||||||
onStart: (modules: Module[]) => void;
|
onStart: (modules: Module[]) => void;
|
||||||
|
disableSelection?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Selection({user, onStart}: Props) {
|
export default function Selection({user, onStart, disableSelection = false}: Props) {
|
||||||
const [selectedModules, setSelectedModules] = useState<Module[]>([]);
|
const [selectedModules, setSelectedModules] = useState<Module[]>([]);
|
||||||
const {stats} = useStats(user?.id);
|
const {stats} = useStats(user?.id);
|
||||||
|
|
||||||
@@ -29,7 +30,7 @@ export default function Selection({user, onStart}: Props) {
|
|||||||
<>
|
<>
|
||||||
<div className="w-full h-full relative flex flex-col gap-16">
|
<div className="w-full h-full relative flex flex-col gap-16">
|
||||||
<section className="w-full flex gap-8">
|
<section className="w-full flex gap-8">
|
||||||
<img src={user.profilePicture} alt={user.name} className="aspect-square h-64 rounded-3xl drop-shadow-xl" />
|
<img src={user.profilePicture} alt={user.name} className="aspect-square h-64 rounded-3xl drop-shadow-xl object-cover" />
|
||||||
<div className="flex flex-col gap-4 py-4 w-full">
|
<div className="flex flex-col gap-4 py-4 w-full">
|
||||||
<div className="flex justify-between w-full gap-8">
|
<div className="flex justify-between w-full gap-8">
|
||||||
<div className="flex flex-col gap-2 py-2">
|
<div className="flex flex-col gap-2 py-2">
|
||||||
@@ -39,14 +40,14 @@ export default function Selection({user, onStart}: Props) {
|
|||||||
<ProgressBar
|
<ProgressBar
|
||||||
label={`Level ${calculateAverageLevel(user.levels).toFixed(1)}`}
|
label={`Level ${calculateAverageLevel(user.levels).toFixed(1)}`}
|
||||||
percentage={100}
|
percentage={100}
|
||||||
color="blue"
|
color="purple"
|
||||||
className="max-w-xs w-32 self-end h-10"
|
className="max-w-xs w-32 self-end h-10"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<ProgressBar
|
<ProgressBar
|
||||||
label=""
|
label=""
|
||||||
percentage={Math.round((calculateAverageLevel(user.levels) * 100) / calculateAverageLevel(user.desiredLevels))}
|
percentage={Math.round((calculateAverageLevel(user.levels) * 100) / calculateAverageLevel(user.desiredLevels))}
|
||||||
color="blue"
|
color="red"
|
||||||
className="w-full h-3 drop-shadow-lg"
|
className="w-full h-3 drop-shadow-lg"
|
||||||
/>
|
/>
|
||||||
<div className="flex justify-between w-full mt-8">
|
<div className="flex justify-between w-full mt-8">
|
||||||
@@ -100,10 +101,10 @@ export default function Selection({user, onStart}: Props) {
|
|||||||
</section>
|
</section>
|
||||||
<section className="w-full flex justify-between gap-8 mt-8">
|
<section className="w-full flex justify-between gap-8 mt-8">
|
||||||
<div
|
<div
|
||||||
onClick={() => toggleModule("reading")}
|
onClick={!disableSelection ? () => toggleModule("reading") : undefined}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"relative w-fit max-w-xs flex flex-col items-center bg-mti-white-alt transition duration-300 ease-in-out border p-5 rounded-xl gap-2 pt-12 cursor-pointer",
|
"relative w-fit max-w-xs flex flex-col items-center bg-mti-white-alt transition duration-300 ease-in-out border p-5 rounded-xl gap-2 pt-12 cursor-pointer",
|
||||||
selectedModules.includes("reading") ? "border-mti-green-light" : "border-mti-gray-platinum",
|
selectedModules.includes("reading") || disableSelection ? "border-mti-purple-light" : "border-mti-gray-platinum",
|
||||||
)}>
|
)}>
|
||||||
<div className="absolute w-16 h-16 flex items-center justify-center rounded-full bg-ielts-reading top-0 -translate-y-1/2">
|
<div className="absolute w-16 h-16 flex items-center justify-center rounded-full bg-ielts-reading top-0 -translate-y-1/2">
|
||||||
<BsBook className="text-white w-7 h-7" />
|
<BsBook className="text-white w-7 h-7" />
|
||||||
@@ -112,14 +113,18 @@ export default function Selection({user, onStart}: Props) {
|
|||||||
<p className="text-center text-xs">
|
<p className="text-center text-xs">
|
||||||
Expand your vocabulary, improve your reading comprehension and improve your ability to interpret texts in English.
|
Expand your vocabulary, improve your reading comprehension and improve your ability to interpret texts in English.
|
||||||
</p>
|
</p>
|
||||||
{!selectedModules.includes("reading") && <div className="border border-mti-gray-platinum w-8 h-8 rounded-full mt-4" />}
|
{!selectedModules.includes("reading") && !disableSelection && (
|
||||||
{selectedModules.includes("reading") && <BsCheckCircle className="mt-4 text-mti-green-light w-8 h-8" />}
|
<div className="border border-mti-gray-platinum w-8 h-8 rounded-full mt-4" />
|
||||||
|
)}
|
||||||
|
{(selectedModules.includes("reading") || disableSelection) && (
|
||||||
|
<BsCheckCircle className="mt-4 text-mti-purple-light w-8 h-8" />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
onClick={() => toggleModule("listening")}
|
onClick={!disableSelection ? () => toggleModule("listening") : undefined}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"relative w-fit max-w-xs flex flex-col items-center bg-mti-white-alt transition duration-300 ease-in-out border p-5 rounded-xl gap-2 pt-12 cursor-pointer",
|
"relative w-fit max-w-xs flex flex-col items-center bg-mti-white-alt transition duration-300 ease-in-out border p-5 rounded-xl gap-2 pt-12 cursor-pointer",
|
||||||
selectedModules.includes("listening") ? "border-mti-green-light" : "border-mti-gray-platinum",
|
selectedModules.includes("listening") || disableSelection ? "border-mti-purple-light" : "border-mti-gray-platinum",
|
||||||
)}>
|
)}>
|
||||||
<div className="absolute w-16 h-16 flex items-center justify-center rounded-full bg-ielts-listening top-0 -translate-y-1/2">
|
<div className="absolute w-16 h-16 flex items-center justify-center rounded-full bg-ielts-listening top-0 -translate-y-1/2">
|
||||||
<BsHeadphones className="text-white w-7 h-7" />
|
<BsHeadphones className="text-white w-7 h-7" />
|
||||||
@@ -128,14 +133,18 @@ export default function Selection({user, onStart}: Props) {
|
|||||||
<p className="text-center text-xs">
|
<p className="text-center text-xs">
|
||||||
Improve your ability to follow conversations in English and your ability to understand different accents and intonations.
|
Improve your ability to follow conversations in English and your ability to understand different accents and intonations.
|
||||||
</p>
|
</p>
|
||||||
{!selectedModules.includes("listening") && <div className="border border-mti-gray-platinum w-8 h-8 rounded-full mt-4" />}
|
{!selectedModules.includes("listening") && !disableSelection && (
|
||||||
{selectedModules.includes("listening") && <BsCheckCircle className="mt-4 text-mti-green-light w-8 h-8" />}
|
<div className="border border-mti-gray-platinum w-8 h-8 rounded-full mt-4" />
|
||||||
|
)}
|
||||||
|
{(selectedModules.includes("listening") || disableSelection) && (
|
||||||
|
<BsCheckCircle className="mt-4 text-mti-purple-light w-8 h-8" />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
onClick={() => toggleModule("writing")}
|
onClick={!disableSelection ? () => toggleModule("writing") : undefined}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"relative w-fit max-w-xs flex flex-col items-center bg-mti-white-alt transition duration-300 ease-in-out border p-5 rounded-xl gap-2 pt-12 cursor-pointer",
|
"relative w-fit max-w-xs flex flex-col items-center bg-mti-white-alt transition duration-300 ease-in-out border p-5 rounded-xl gap-2 pt-12 cursor-pointer",
|
||||||
selectedModules.includes("writing") ? "border-mti-green-light" : "border-mti-gray-platinum",
|
selectedModules.includes("writing") || disableSelection ? "border-mti-purple-light" : "border-mti-gray-platinum",
|
||||||
)}>
|
)}>
|
||||||
<div className="absolute w-16 h-16 flex items-center justify-center rounded-full bg-ielts-writing top-0 -translate-y-1/2">
|
<div className="absolute w-16 h-16 flex items-center justify-center rounded-full bg-ielts-writing top-0 -translate-y-1/2">
|
||||||
<BsPen className="text-white w-7 h-7" />
|
<BsPen className="text-white w-7 h-7" />
|
||||||
@@ -144,14 +153,18 @@ export default function Selection({user, onStart}: Props) {
|
|||||||
<p className="text-center text-xs">
|
<p className="text-center text-xs">
|
||||||
Allow you to practice writing in a variety of formats, from simple paragraphs to complex essays.
|
Allow you to practice writing in a variety of formats, from simple paragraphs to complex essays.
|
||||||
</p>
|
</p>
|
||||||
{!selectedModules.includes("writing") && <div className="border border-mti-gray-platinum w-8 h-8 rounded-full mt-4" />}
|
{!selectedModules.includes("writing") && !disableSelection && (
|
||||||
{selectedModules.includes("writing") && <BsCheckCircle className="mt-4 text-mti-green-light w-8 h-8" />}
|
<div className="border border-mti-gray-platinum w-8 h-8 rounded-full mt-4" />
|
||||||
|
)}
|
||||||
|
{(selectedModules.includes("writing") || disableSelection) && (
|
||||||
|
<BsCheckCircle className="mt-4 text-mti-purple-light w-8 h-8" />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
onClick={() => toggleModule("speaking")}
|
onClick={!disableSelection ? () => toggleModule("speaking") : undefined}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"relative w-fit max-w-xs flex flex-col items-center bg-mti-white-alt transition duration-300 ease-in-out border p-5 rounded-xl gap-2 pt-12 cursor-pointer",
|
"relative w-fit max-w-xs flex flex-col items-center bg-mti-white-alt transition duration-300 ease-in-out border p-5 rounded-xl gap-2 pt-12 cursor-pointer",
|
||||||
selectedModules.includes("speaking") ? "border-mti-green-light" : "border-mti-gray-platinum",
|
selectedModules.includes("speaking") || disableSelection ? "border-mti-purple-light" : "border-mti-gray-platinum",
|
||||||
)}>
|
)}>
|
||||||
<div className="absolute w-16 h-16 flex items-center justify-center rounded-full bg-ielts-speaking top-0 -translate-y-1/2">
|
<div className="absolute w-16 h-16 flex items-center justify-center rounded-full bg-ielts-speaking top-0 -translate-y-1/2">
|
||||||
<BsMegaphone className="text-white w-7 h-7" />
|
<BsMegaphone className="text-white w-7 h-7" />
|
||||||
@@ -160,15 +173,21 @@ export default function Selection({user, onStart}: Props) {
|
|||||||
<p className="text-center text-xs">
|
<p className="text-center text-xs">
|
||||||
You'll have access to interactive dialogs, pronunciation exercises and speech recordings.
|
You'll have access to interactive dialogs, pronunciation exercises and speech recordings.
|
||||||
</p>
|
</p>
|
||||||
{!selectedModules.includes("speaking") && <div className="border border-mti-gray-platinum w-8 h-8 rounded-full mt-4" />}
|
{!selectedModules.includes("speaking") && !disableSelection && (
|
||||||
{selectedModules.includes("speaking") && <BsCheckCircle className="mt-4 text-mti-green-light w-8 h-8" />}
|
<div className="border border-mti-gray-platinum w-8 h-8 rounded-full mt-4" />
|
||||||
|
)}
|
||||||
|
{(selectedModules.includes("speaking") || disableSelection) && (
|
||||||
|
<BsCheckCircle className="mt-4 text-mti-purple-light w-8 h-8" />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
<Button
|
<Button
|
||||||
onClick={() => onStart(selectedModules.sort(sortByModuleName))}
|
onClick={() =>
|
||||||
color="green"
|
onStart(!disableSelection ? selectedModules.sort(sortByModuleName) : ["reading", "listening", "writing", "speaking"])
|
||||||
|
}
|
||||||
|
color="purple"
|
||||||
className="px-12 w-full max-w-xs self-end"
|
className="px-12 w-full max-w-xs self-end"
|
||||||
disabled={selectedModules.length === 0}>
|
disabled={selectedModules.length === 0 && !disableSelection}>
|
||||||
Start Exam
|
Start Exam
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import ModuleTitle from "@/components/Medium/ModuleTitle";
|
|||||||
import {renderSolution} from "@/components/Solutions";
|
import {renderSolution} from "@/components/Solutions";
|
||||||
import {infoButtonStyle} from "@/constants/buttonStyles";
|
import {infoButtonStyle} from "@/constants/buttonStyles";
|
||||||
import {UserSolution, SpeakingExam} from "@/interfaces/exam";
|
import {UserSolution, SpeakingExam} from "@/interfaces/exam";
|
||||||
|
import useExamStore from "@/stores/examStore";
|
||||||
|
import {defaultUserSolutions} from "@/utils/exams";
|
||||||
import {mdiArrowRight} from "@mdi/js";
|
import {mdiArrowRight} from "@mdi/js";
|
||||||
import Icon from "@mdi/react";
|
import Icon from "@mdi/react";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
@@ -17,7 +19,8 @@ interface Props {
|
|||||||
|
|
||||||
export default function Speaking({exam, showSolutions = false, onFinish}: Props) {
|
export default function Speaking({exam, showSolutions = false, onFinish}: Props) {
|
||||||
const [exerciseIndex, setExerciseIndex] = useState(0);
|
const [exerciseIndex, setExerciseIndex] = useState(0);
|
||||||
const [userSolutions, setUserSolutions] = useState<UserSolution[]>([]);
|
const [userSolutions, setUserSolutions] = useState<UserSolution[]>(exam.exercises.map((x) => defaultUserSolutions(x, exam)));
|
||||||
|
const [hasExamEnded, setHasExamEnded] = useExamStore((state) => [state.hasExamEnded, state.setHasExamEnded]);
|
||||||
|
|
||||||
const nextExercise = (solution?: UserSolution) => {
|
const nextExercise = (solution?: UserSolution) => {
|
||||||
if (solution) {
|
if (solution) {
|
||||||
@@ -29,6 +32,10 @@ export default function Speaking({exam, showSolutions = false, onFinish}: Props)
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (exerciseIndex >= exam.exercises.length) return;
|
||||||
|
|
||||||
|
setHasExamEnded(false);
|
||||||
|
|
||||||
if (solution) {
|
if (solution) {
|
||||||
onFinish(
|
onFinish(
|
||||||
[...userSolutions.filter((x) => x.exercise !== solution.exercise), solution].map((x) => ({...x, module: "speaking", exam: exam.id})),
|
[...userSolutions.filter((x) => x.exercise !== solution.exercise), solution].map((x) => ({...x, module: "speaking", exam: exam.id})),
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import ModuleTitle from "@/components/Medium/ModuleTitle";
|
|||||||
import {renderSolution} from "@/components/Solutions";
|
import {renderSolution} from "@/components/Solutions";
|
||||||
import {infoButtonStyle} from "@/constants/buttonStyles";
|
import {infoButtonStyle} from "@/constants/buttonStyles";
|
||||||
import {UserSolution, WritingExam} from "@/interfaces/exam";
|
import {UserSolution, WritingExam} from "@/interfaces/exam";
|
||||||
|
import useExamStore from "@/stores/examStore";
|
||||||
|
import {defaultUserSolutions} from "@/utils/exams";
|
||||||
import {mdiArrowRight} from "@mdi/js";
|
import {mdiArrowRight} from "@mdi/js";
|
||||||
import Icon from "@mdi/react";
|
import Icon from "@mdi/react";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
@@ -17,7 +19,9 @@ interface Props {
|
|||||||
|
|
||||||
export default function Writing({exam, showSolutions = false, onFinish}: Props) {
|
export default function Writing({exam, showSolutions = false, onFinish}: Props) {
|
||||||
const [exerciseIndex, setExerciseIndex] = useState(0);
|
const [exerciseIndex, setExerciseIndex] = useState(0);
|
||||||
const [userSolutions, setUserSolutions] = useState<UserSolution[]>([]);
|
const [userSolutions, setUserSolutions] = useState<UserSolution[]>(exam.exercises.map((x) => defaultUserSolutions(x, exam)));
|
||||||
|
|
||||||
|
const [hasExamEnded, setHasExamEnded] = useExamStore((state) => [state.hasExamEnded, state.setHasExamEnded]);
|
||||||
|
|
||||||
const nextExercise = (solution?: UserSolution) => {
|
const nextExercise = (solution?: UserSolution) => {
|
||||||
if (solution) {
|
if (solution) {
|
||||||
@@ -29,6 +33,10 @@ export default function Writing({exam, showSolutions = false, onFinish}: Props)
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (exerciseIndex >= exam.exercises.length) return;
|
||||||
|
|
||||||
|
setHasExamEnded(false);
|
||||||
|
|
||||||
if (solution) {
|
if (solution) {
|
||||||
onFinish(
|
onFinish(
|
||||||
[...userSolutions.filter((x) => x.exercise !== solution.exercise), solution].map((x) => ({...x, module: "writing", exam: exam.id})),
|
[...userSolutions.filter((x) => x.exercise !== solution.exercise), solution].map((x) => ({...x, module: "writing", exam: exam.id})),
|
||||||
|
|||||||
@@ -63,33 +63,33 @@ export interface SpeakingExam {
|
|||||||
|
|
||||||
export type Exercise =
|
export type Exercise =
|
||||||
| FillBlanksExercise
|
| FillBlanksExercise
|
||||||
|
| TrueFalseExercise
|
||||||
| MatchSentencesExercise
|
| MatchSentencesExercise
|
||||||
| MultipleChoiceExercise
|
| MultipleChoiceExercise
|
||||||
| WriteBlanksExercise
|
| WriteBlanksExercise
|
||||||
| WritingExercise
|
| WritingExercise
|
||||||
| SpeakingExercise;
|
| SpeakingExercise;
|
||||||
|
|
||||||
export interface WritingEvaluation {
|
export interface Evaluation {
|
||||||
comment: string;
|
comment: string;
|
||||||
overall: number;
|
overall: number;
|
||||||
task_response: {[key: string]: number};
|
task_response: {[key: string]: number};
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface WritingExercise {
|
export interface WritingExercise {
|
||||||
id: string;
|
id: string;
|
||||||
type: "writing";
|
type: "writing";
|
||||||
info: string; //* The information about the task, like the amount of time they should spend on it
|
prefix: string; //* The information about the task, like the amount of time they should spend on it
|
||||||
|
suffix: string;
|
||||||
prompt: string; //* The context given to the user containing what they should write about
|
prompt: string; //* The context given to the user containing what they should write about
|
||||||
wordCounter: WordCounter; //* The minimum or maximum amount of words that should be written
|
wordCounter: WordCounter; //* The minimum or maximum amount of words that should be written
|
||||||
attachment?: {
|
attachment?: {
|
||||||
url: string;
|
url: string;
|
||||||
description: string;
|
description: string;
|
||||||
}; //* The url for an image to work as an attachment to show the user
|
}; //* The url for an image to work as an attachment to show the user
|
||||||
evaluation?: WritingEvaluation;
|
|
||||||
userSolutions: {
|
userSolutions: {
|
||||||
id: string;
|
id: string;
|
||||||
solution: string;
|
solution: string;
|
||||||
evaluation?: WritingEvaluation;
|
evaluation?: Evaluation;
|
||||||
}[];
|
}[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -102,6 +102,7 @@ export interface SpeakingExercise {
|
|||||||
userSolutions: {
|
userSolutions: {
|
||||||
id: string;
|
id: string;
|
||||||
solution: string;
|
solution: string;
|
||||||
|
evaluation?: Evaluation;
|
||||||
}[];
|
}[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -122,6 +123,20 @@ export interface FillBlanksExercise {
|
|||||||
}[];
|
}[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface TrueFalseExercise {
|
||||||
|
type: "trueFalse";
|
||||||
|
id: string;
|
||||||
|
prompt: string; // *EXAMPLE: "Select the appropriate option."
|
||||||
|
questions: TrueFalseQuestion[];
|
||||||
|
userSolutions: {id: string; solution: "true" | "false" | "not_given"}[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TrueFalseQuestion {
|
||||||
|
id: string; // *EXAMPLE: "1"
|
||||||
|
prompt: string; // *EXAMPLE: "What does her briefcase look like?"
|
||||||
|
solution: "true" | "false" | "not_given"; // *EXAMPLE: "True"
|
||||||
|
}
|
||||||
|
|
||||||
export interface WriteBlanksExercise {
|
export interface WriteBlanksExercise {
|
||||||
prompt: string; // *EXAMPLE: "Complete the notes below by writing NO MORE THAN THREE WORDS in the spaces provided."
|
prompt: string; // *EXAMPLE: "Complete the notes below by writing NO MORE THAN THREE WORDS in the spaces provided."
|
||||||
maxWords: number; // *EXAMPLE: 3 - The maximum amount of words allowed per blank, 0 for unlimited
|
maxWords: number; // *EXAMPLE: 3 - The maximum amount of words allowed per blank, 0 for unlimited
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ export interface User {
|
|||||||
levels: {[key in Module]: number};
|
levels: {[key in Module]: number};
|
||||||
desiredLevels: {[key in Module]: number};
|
desiredLevels: {[key in Module]: number};
|
||||||
type: Type;
|
type: Type;
|
||||||
|
bio: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Stat {
|
export interface Stat {
|
||||||
|
|||||||
1
src/lib/formidable-serverless.d.ts
vendored
Normal file
1
src/lib/formidable-serverless.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
declare module "formidable-serverless";
|
||||||
@@ -15,9 +15,7 @@ export default function App({Component, pageProps}: AppProps) {
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (router.pathname !== "/exam") {
|
if (router.pathname !== "/exercises") reset();
|
||||||
reset();
|
|
||||||
}
|
|
||||||
}, [router.pathname, reset]);
|
}, [router.pathname, reset]);
|
||||||
|
|
||||||
return <Component {...pageProps} />;
|
return <Component {...pageProps} />;
|
||||||
|
|||||||
61
src/pages/admin.tsx
Normal file
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,
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -18,19 +18,11 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const {module} = req.query as {module: string};
|
const backendRequest = await axios.post(`${process.env.BACKEND_URL}/writing_task2`, req.body as Body, {
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${process.env.BACKEND_JWT}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
if (module === "writing") {
|
res.status(backendRequest.status).json(backendRequest.data);
|
||||||
const backendRequest = await axios.post(`${process.env.BACKEND_URL}/writing_task2`, req.body as Body, {
|
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${process.env.BACKEND_JWT}`,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
res.status(backendRequest.status).json(backendRequest.data);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
res.status(404).json({ok: false});
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
@@ -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",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|||||||
@@ -1,12 +1,21 @@
|
|||||||
/* eslint-disable @next/next/no-img-element */
|
/* eslint-disable @next/next/no-img-element */
|
||||||
import Head from "next/head";
|
import Head from "next/head";
|
||||||
import Navbar from "@/components/Navbar";
|
|
||||||
import {useEffect, useState} from "react";
|
import {useEffect, useState} from "react";
|
||||||
import {Module} from "@/interfaces";
|
import {Module} from "@/interfaces";
|
||||||
|
|
||||||
import Selection from "@/exams/Selection";
|
import Selection from "@/exams/Selection";
|
||||||
import Reading from "@/exams/Reading";
|
import Reading from "@/exams/Reading";
|
||||||
import {Exam, ListeningExam, ReadingExam, SpeakingExam, UserSolution, WritingEvaluation, WritingExam, WritingExercise} from "@/interfaces/exam";
|
import {
|
||||||
|
Exam,
|
||||||
|
ListeningExam,
|
||||||
|
ReadingExam,
|
||||||
|
SpeakingExam,
|
||||||
|
UserSolution,
|
||||||
|
Evaluation,
|
||||||
|
WritingExam,
|
||||||
|
WritingExercise,
|
||||||
|
SpeakingExercise,
|
||||||
|
} from "@/interfaces/exam";
|
||||||
import Listening from "@/exams/Listening";
|
import Listening from "@/exams/Listening";
|
||||||
import Writing from "@/exams/Writing";
|
import Writing from "@/exams/Writing";
|
||||||
import {ToastContainer, toast} from "react-toastify";
|
import {ToastContainer, toast} from "react-toastify";
|
||||||
@@ -19,10 +28,9 @@ import Speaking from "@/exams/Speaking";
|
|||||||
import {v4 as uuidv4} from "uuid";
|
import {v4 as uuidv4} from "uuid";
|
||||||
import useUser from "@/hooks/useUser";
|
import useUser from "@/hooks/useUser";
|
||||||
import useExamStore from "@/stores/examStore";
|
import useExamStore from "@/stores/examStore";
|
||||||
import Sidebar from "@/components/Sidebar";
|
|
||||||
import Layout from "@/components/High/Layout";
|
import Layout from "@/components/High/Layout";
|
||||||
import {sortByModule} from "@/utils/moduleUtils";
|
import {speakingReverseMarking, writingReverseMarking} from "@/utils/score";
|
||||||
import {writingReverseMarking} from "@/utils/score";
|
import AbandonPopup from "@/components/AbandonPopup";
|
||||||
|
|
||||||
export const getServerSideProps = withIronSessionSsr(({req, res}) => {
|
export const getServerSideProps = withIronSessionSsr(({req, res}) => {
|
||||||
const user = req.session.user;
|
const user = req.session.user;
|
||||||
@@ -54,6 +62,8 @@ export default function Page() {
|
|||||||
const [userSolutions, setUserSolutions] = useExamStore((state) => [state.userSolutions, state.setUserSolutions]);
|
const [userSolutions, setUserSolutions] = useExamStore((state) => [state.userSolutions, state.setUserSolutions]);
|
||||||
const [showSolutions, setShowSolutions] = useExamStore((state) => [state.showSolutions, state.setShowSolutions]);
|
const [showSolutions, setShowSolutions] = useExamStore((state) => [state.showSolutions, state.setShowSolutions]);
|
||||||
const [selectedModules, setSelectedModules] = useExamStore((state) => [state.selectedModules, state.setSelectedModules]);
|
const [selectedModules, setSelectedModules] = useExamStore((state) => [state.selectedModules, state.setSelectedModules]);
|
||||||
|
const [showAbandonPopup, setShowAbandonPopup] = useState(false);
|
||||||
|
const setHasExamEnded = useExamStore((state) => state.setHasExamEnded);
|
||||||
|
|
||||||
const {user} = useUser({redirectTo: "/login"});
|
const {user} = useUser({redirectTo: "/login"});
|
||||||
|
|
||||||
@@ -122,11 +132,47 @@ export default function Page() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const evaluateSpeakingAnswer = async (examId: string, exerciseId: string, solution: UserSolution) => {
|
||||||
|
const speakingExam = exams.find((x) => x.id === examId)!;
|
||||||
|
const exercise = speakingExam.exercises.find((x) => x.id === exerciseId)! as SpeakingExercise;
|
||||||
|
|
||||||
|
const blobResponse = await axios.get(solution.solutions[0].solution.trim(), {responseType: "arraybuffer"});
|
||||||
|
const audioBlob = Buffer.from(blobResponse.data, "binary");
|
||||||
|
const audioFile = new File([audioBlob], "audio.wav", {type: "audio/wav"});
|
||||||
|
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append("audio", audioFile, "audio.wav");
|
||||||
|
formData.append("question", `${exercise.text.replaceAll("\n", "")} You should talk about: ${exercise.prompts.join(", ")}`);
|
||||||
|
|
||||||
|
const config = {
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "audio/mp3",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await axios.post("/api/evaluate/speaking", formData, config);
|
||||||
|
|
||||||
|
if (response.status === 200) {
|
||||||
|
setUserSolutions([
|
||||||
|
...userSolutions.filter((x) => x.exercise !== exerciseId),
|
||||||
|
{
|
||||||
|
...solution,
|
||||||
|
score: {
|
||||||
|
correct: speakingReverseMarking[response.data.overall] || 0,
|
||||||
|
missing: 0,
|
||||||
|
total: 100,
|
||||||
|
},
|
||||||
|
solutions: [{id: exerciseId, solution: response.data.fullPath, evaluation: response.data}],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const evaluateWritingAnswer = async (examId: string, exerciseId: string, solution: UserSolution) => {
|
const evaluateWritingAnswer = async (examId: string, exerciseId: string, solution: UserSolution) => {
|
||||||
const writingExam = exams.find((x) => x.id === examId)!;
|
const writingExam = exams.find((x) => x.id === examId)!;
|
||||||
const exercise = writingExam.exercises.find((x) => x.id === exerciseId)! as WritingExercise;
|
const exercise = writingExam.exercises.find((x) => x.id === exerciseId)! as WritingExercise;
|
||||||
|
|
||||||
const response = await axios.post<WritingEvaluation>("/api/exam/writing/evaluate", {
|
const response = await axios.post<Evaluation>("/api/evaluate/writing", {
|
||||||
question: `${exercise.prompt} ${exercise.attachment ? exercise.attachment.description : ""}`.replaceAll("\n", ""),
|
question: `${exercise.prompt} ${exercise.attachment ? exercise.attachment.description : ""}`.replaceAll("\n", ""),
|
||||||
answer: solution.solutions[0].solution.trim().replaceAll("\n", " "),
|
answer: solution.solutions[0].solution.trim().replaceAll("\n", " "),
|
||||||
});
|
});
|
||||||
@@ -137,7 +183,7 @@ export default function Page() {
|
|||||||
{
|
{
|
||||||
...solution,
|
...solution,
|
||||||
score: {
|
score: {
|
||||||
correct: writingReverseMarking[response.data.overall],
|
correct: writingReverseMarking[response.data.overall] || 0,
|
||||||
missing: 0,
|
missing: 0,
|
||||||
total: 100,
|
total: 100,
|
||||||
},
|
},
|
||||||
@@ -158,17 +204,25 @@ export default function Page() {
|
|||||||
const onFinish = (solutions: UserSolution[]) => {
|
const onFinish = (solutions: UserSolution[]) => {
|
||||||
const solutionIds = solutions.map((x) => x.exercise);
|
const solutionIds = solutions.map((x) => x.exercise);
|
||||||
|
|
||||||
if (exam && exam.module === "writing" && solutions.length > 0 && !showSolutions) {
|
if (exam && (exam.module === "writing" || exam.module === "speaking") && solutions.length > 0 && !showSolutions) {
|
||||||
setHasBeenUploaded(true);
|
setHasBeenUploaded(true);
|
||||||
setIsEvaluationLoading(true);
|
setIsEvaluationLoading(true);
|
||||||
Promise.all(
|
Promise.all(
|
||||||
exam.exercises.map((exercise) => evaluateWritingAnswer(exam.id, exercise.id, solutions.find((x) => x.exercise === exercise.id)!)),
|
exam.exercises.map((exercise) =>
|
||||||
|
(exam.module === "writing" ? evaluateWritingAnswer : evaluateSpeakingAnswer)(
|
||||||
|
exam.id,
|
||||||
|
exercise.id,
|
||||||
|
solutions.find((x) => x.exercise === exercise.id)!,
|
||||||
|
),
|
||||||
|
),
|
||||||
).finally(() => {
|
).finally(() => {
|
||||||
setIsEvaluationLoading(false);
|
setIsEvaluationLoading(false);
|
||||||
setHasBeenUploaded(false);
|
setHasBeenUploaded(false);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
axios.get("/api/stats/update");
|
||||||
|
|
||||||
setUserSolutions([...userSolutions.filter((x) => !solutionIds.includes(x.exercise)), ...solutions]);
|
setUserSolutions([...userSolutions.filter((x) => !solutionIds.includes(x.exercise)), ...solutions]);
|
||||||
setModuleIndex((prev) => prev + 1);
|
setModuleIndex((prev) => prev + 1);
|
||||||
};
|
};
|
||||||
@@ -212,7 +266,7 @@ export default function Page() {
|
|||||||
|
|
||||||
const renderScreen = () => {
|
const renderScreen = () => {
|
||||||
if (selectedModules.length === 0) {
|
if (selectedModules.length === 0) {
|
||||||
return <Selection user={user!} onStart={setSelectedModules} />;
|
return <Selection user={user!} onStart={setSelectedModules} disableSelection />;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (moduleIndex >= selectedModules.length) {
|
if (moduleIndex >= selectedModules.length) {
|
||||||
@@ -243,11 +297,6 @@ export default function Page() {
|
|||||||
return <Writing exam={exam} onFinish={onFinish} showSolutions={showSolutions} />;
|
return <Writing exam={exam} onFinish={onFinish} showSolutions={showSolutions} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (exam && exam.module === "speaking" && showSolutions) {
|
|
||||||
setModuleIndex((prev) => prev + 1);
|
|
||||||
return <></>;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (exam && exam.module === "speaking") {
|
if (exam && exam.module === "speaking") {
|
||||||
return <Speaking exam={exam} onFinish={onFinish} showSolutions={showSolutions} />;
|
return <Speaking exam={exam} onFinish={onFinish} showSolutions={showSolutions} />;
|
||||||
}
|
}
|
||||||
@@ -268,8 +317,29 @@ export default function Page() {
|
|||||||
</Head>
|
</Head>
|
||||||
<ToastContainer />
|
<ToastContainer />
|
||||||
{user && (
|
{user && (
|
||||||
<Layout user={user} className="justify-between">
|
<Layout
|
||||||
{renderScreen()}
|
user={user}
|
||||||
|
className="justify-between"
|
||||||
|
focusMode={selectedModules.length !== 0}
|
||||||
|
onFocusLayerMouseEnter={() => setShowAbandonPopup(true)}
|
||||||
|
>
|
||||||
|
<>
|
||||||
|
{renderScreen()}
|
||||||
|
<AbandonPopup
|
||||||
|
isOpen={showAbandonPopup}
|
||||||
|
abandonPopupTitle="Leave Exam"
|
||||||
|
abandonPopupDescription="Are you sure you want to leave the exam? You will lose all your progress."
|
||||||
|
abandonConfirmButtonText="Confirm"
|
||||||
|
onAbandon={() => {
|
||||||
|
console.log('TODO: Handle Abandon');
|
||||||
|
setExam(undefined);
|
||||||
|
setSelectedModules([]);
|
||||||
|
setShowAbandonPopup(false)
|
||||||
|
return true;
|
||||||
|
}}
|
||||||
|
onCancel={() => setShowAbandonPopup(false)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
</Layout>
|
</Layout>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|||||||
349
src/pages/exercises.tsx
Normal file
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>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -16,6 +16,7 @@ import {Module} from "@/interfaces";
|
|||||||
import ProgressBar from "@/components/Low/ProgressBar";
|
import ProgressBar from "@/components/Low/ProgressBar";
|
||||||
import Layout from "@/components/High/Layout";
|
import Layout from "@/components/High/Layout";
|
||||||
import {calculateAverageLevel} from "@/utils/score";
|
import {calculateAverageLevel} from "@/utils/score";
|
||||||
|
import axios from "axios";
|
||||||
|
|
||||||
export const getServerSideProps = withIronSessionSsr(({req, res}) => {
|
export const getServerSideProps = withIronSessionSsr(({req, res}) => {
|
||||||
const user = req.session.user;
|
const user = req.session.user;
|
||||||
@@ -57,9 +58,9 @@ export default function Home() {
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<link rel="icon" href="/favicon.ico" />
|
<link rel="icon" href="/favicon.ico" />
|
||||||
</Head>
|
</Head>
|
||||||
<main className="w-full h-full min-h-[100vh] flex flex-col items-center justify-center bg-neutral-100">
|
<Layout user={user} navDisabled>
|
||||||
<Diagnostic user={user} onFinish={() => setShowDiagnostics(false)} />
|
<Diagnostic user={user} onFinish={() => setShowDiagnostics(false)} />
|
||||||
</main>
|
</Layout>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -79,7 +80,7 @@ export default function Home() {
|
|||||||
{user && (
|
{user && (
|
||||||
<Layout user={user}>
|
<Layout user={user}>
|
||||||
<section className="w-full flex gap-8">
|
<section className="w-full flex gap-8">
|
||||||
<img src={user.profilePicture} alt={user.name} className="aspect-square h-64 rounded-3xl drop-shadow-xl" />
|
<img src={user.profilePicture} alt={user.name} className="aspect-square h-64 rounded-3xl drop-shadow-xl object-cover" />
|
||||||
<div className="flex flex-col gap-4 py-4 w-full">
|
<div className="flex flex-col gap-4 py-4 w-full">
|
||||||
<div className="flex justify-between w-full gap-8">
|
<div className="flex justify-between w-full gap-8">
|
||||||
<div className="flex flex-col gap-2 py-2">
|
<div className="flex flex-col gap-2 py-2">
|
||||||
@@ -89,20 +90,20 @@ export default function Home() {
|
|||||||
<ProgressBar
|
<ProgressBar
|
||||||
label={`Level ${calculateAverageLevel(user.levels).toFixed(1)}`}
|
label={`Level ${calculateAverageLevel(user.levels).toFixed(1)}`}
|
||||||
percentage={100}
|
percentage={100}
|
||||||
color="blue"
|
color="purple"
|
||||||
className="max-w-xs w-32 self-end h-10"
|
className="max-w-xs w-32 self-end h-10"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<ProgressBar
|
<ProgressBar
|
||||||
label=""
|
label=""
|
||||||
percentage={Math.round((calculateAverageLevel(user.levels) * 100) / calculateAverageLevel(user.desiredLevels))}
|
percentage={Math.round((calculateAverageLevel(user.levels) * 100) / calculateAverageLevel(user.desiredLevels))}
|
||||||
color="blue"
|
color="red"
|
||||||
className="w-full h-3 drop-shadow-lg"
|
className="w-full h-3 drop-shadow-lg"
|
||||||
/>
|
/>
|
||||||
<div className="flex justify-between w-full mt-8">
|
<div className="flex justify-between w-full mt-8">
|
||||||
<div className="flex gap-4 items-center">
|
<div className="flex gap-4 items-center">
|
||||||
<div className="w-16 h-16 border border-mti-gray-platinum bg-mti-gray-smoke flex items-center justify-center rounded-xl">
|
<div className="w-16 h-16 border border-mti-gray-platinum bg-mti-gray-smoke flex items-center justify-center rounded-xl">
|
||||||
<BsFileEarmarkText className="w-8 h-8 text-mti-blue-light" />
|
<BsFileEarmarkText className="w-8 h-8 text-mti-red-light" />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
<span className="font-bold text-xl">{totalExams(stats)}</span>
|
<span className="font-bold text-xl">{totalExams(stats)}</span>
|
||||||
@@ -111,7 +112,7 @@ export default function Home() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex gap-4 items-center">
|
<div className="flex gap-4 items-center">
|
||||||
<div className="w-16 h-16 border border-mti-gray-platinum bg-mti-gray-smoke flex items-center justify-center rounded-xl">
|
<div className="w-16 h-16 border border-mti-gray-platinum bg-mti-gray-smoke flex items-center justify-center rounded-xl">
|
||||||
<BsPencil className="w-8 h-8 text-mti-blue-light" />
|
<BsPencil className="w-8 h-8 text-mti-red-light" />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
<span className="font-bold text-xl">{stats.length}</span>
|
<span className="font-bold text-xl">{stats.length}</span>
|
||||||
@@ -120,10 +121,10 @@ export default function Home() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex gap-4 items-center">
|
<div className="flex gap-4 items-center">
|
||||||
<div className="w-16 h-16 border border-mti-gray-platinum bg-mti-gray-smoke flex items-center justify-center rounded-xl">
|
<div className="w-16 h-16 border border-mti-gray-platinum bg-mti-gray-smoke flex items-center justify-center rounded-xl">
|
||||||
<BsStar className="w-8 h-8 text-mti-blue-light" />
|
<BsStar className="w-8 h-8 text-mti-red-light" />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
<span className="font-bold text-xl">{averageScore(stats)}%</span>
|
<span className="font-bold text-xl">{stats.length > 0 ? averageScore(stats) : 0}%</span>
|
||||||
<span className="font-normal text-base text-mti-gray-dim">Average Score</span>
|
<span className="font-normal text-base text-mti-gray-dim">Average Score</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -133,11 +134,7 @@ export default function Home() {
|
|||||||
<section className="flex flex-col gap-3">
|
<section className="flex flex-col gap-3">
|
||||||
<span className="font-bold text-lg">Bio</span>
|
<span className="font-bold text-lg">Bio</span>
|
||||||
<span className="text-mti-gray-taupe">
|
<span className="text-mti-gray-taupe">
|
||||||
Patricia Smith is a dedicated and enthusiastic student. Her passion for knowledge drives her to constantly seek new
|
{user.bio || "Your bio will appear here, you can change it by clicking on your name in the top right corner."}
|
||||||
academic challenges. She is recognized for her exemplary work ethic, active participation in the classroom, and commitment
|
|
||||||
to helping her peers. Her insatiable curiosity has led her to explore a wide range of areas of study, making her a
|
|
||||||
versatile and adaptable learner. Patricia is a true academic leader, inspiring other students to pursue their own
|
|
||||||
educational goals.
|
|
||||||
</span>
|
</span>
|
||||||
</section>
|
</section>
|
||||||
<section className="flex flex-col gap-3">
|
<section className="flex flex-col gap-3">
|
||||||
@@ -157,7 +154,7 @@ export default function Home() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="pl-14">
|
<div className="pl-14">
|
||||||
<ProgressBar
|
<ProgressBar
|
||||||
color="blue"
|
color="red"
|
||||||
label=""
|
label=""
|
||||||
percentage={Math.round((user.levels.reading * 100) / user.desiredLevels.reading)}
|
percentage={Math.round((user.levels.reading * 100) / user.desiredLevels.reading)}
|
||||||
className="w-full h-2"
|
className="w-full h-2"
|
||||||
@@ -178,7 +175,7 @@ export default function Home() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="pl-14">
|
<div className="pl-14">
|
||||||
<ProgressBar
|
<ProgressBar
|
||||||
color="blue"
|
color="red"
|
||||||
label=""
|
label=""
|
||||||
percentage={Math.round((user.levels.writing * 100) / user.desiredLevels.writing)}
|
percentage={Math.round((user.levels.writing * 100) / user.desiredLevels.writing)}
|
||||||
className="w-full h-2"
|
className="w-full h-2"
|
||||||
@@ -199,7 +196,7 @@ export default function Home() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="pl-14">
|
<div className="pl-14">
|
||||||
<ProgressBar
|
<ProgressBar
|
||||||
color="blue"
|
color="red"
|
||||||
label=""
|
label=""
|
||||||
percentage={Math.round((user.levels.listening * 100) / user.desiredLevels.listening)}
|
percentage={Math.round((user.levels.listening * 100) / user.desiredLevels.listening)}
|
||||||
className="w-full h-2"
|
className="w-full h-2"
|
||||||
@@ -220,7 +217,7 @@ export default function Home() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="pl-14">
|
<div className="pl-14">
|
||||||
<ProgressBar
|
<ProgressBar
|
||||||
color="blue"
|
color="red"
|
||||||
label=""
|
label=""
|
||||||
percentage={Math.round((user.levels.speaking * 100) / user.desiredLevels.speaking)}
|
percentage={Math.round((user.levels.speaking * 100) / user.desiredLevels.speaking)}
|
||||||
className="w-full h-2"
|
className="w-full h-2"
|
||||||
|
|||||||
@@ -7,14 +7,15 @@ import Head from "next/head";
|
|||||||
import useUser from "@/hooks/useUser";
|
import useUser from "@/hooks/useUser";
|
||||||
import {Divider} from "primereact/divider";
|
import {Divider} from "primereact/divider";
|
||||||
import Button from "@/components/Low/Button";
|
import Button from "@/components/Low/Button";
|
||||||
import {BsArrowRepeat} from "react-icons/bs";
|
import {BsArrowRepeat, BsCheck} from "react-icons/bs";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import Input from "@/components/Low/Input";
|
import Input from "@/components/Low/Input";
|
||||||
|
import clsx from "clsx";
|
||||||
|
|
||||||
export default function Login() {
|
export default function Login() {
|
||||||
const [email, setEmail] = useState("");
|
const [email, setEmail] = useState("");
|
||||||
const [password, setPassword] = useState("");
|
const [password, setPassword] = useState("");
|
||||||
const [showPassword, setShowPassword] = useState(false);
|
const [rememberPassword, setRememberPassword] = useState(false);
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
const {mutateUser} = useUser({
|
const {mutateUser} = useUser({
|
||||||
@@ -53,7 +54,7 @@ export default function Login() {
|
|||||||
<main className="w-full h-[100vh] flex bg-white text-black">
|
<main className="w-full h-[100vh] flex bg-white text-black">
|
||||||
<ToastContainer />
|
<ToastContainer />
|
||||||
<section className="h-full w-fit min-w-fit relative">
|
<section className="h-full w-fit min-w-fit relative">
|
||||||
<div className="absolute h-full w-full bg-mti-orange-light z-10 bg-opacity-50" />
|
<div className="absolute h-full w-full bg-mti-rose-light z-10 bg-opacity-50" />
|
||||||
<img src="/people-talking-tablet.png" alt="People smiling looking at a tablet" className="h-full aspect-auto" />
|
<img src="/people-talking-tablet.png" alt="People smiling looking at a tablet" className="h-full aspect-auto" />
|
||||||
</section>
|
</section>
|
||||||
<section className="h-full w-full flex flex-col items-center justify-center gap-2">
|
<section className="h-full w-full flex flex-col items-center justify-center gap-2">
|
||||||
@@ -65,7 +66,22 @@ export default function Login() {
|
|||||||
<form className="flex flex-col items-center gap-6 w-1/2" onSubmit={login}>
|
<form className="flex flex-col items-center gap-6 w-1/2" onSubmit={login}>
|
||||||
<Input type="email" name="email" onChange={(e) => setEmail(e)} placeholder="Enter email address" />
|
<Input type="email" name="email" onChange={(e) => setEmail(e)} placeholder="Enter email address" />
|
||||||
<Input type="password" name="password" onChange={(e) => setPassword(e)} placeholder="Password" />
|
<Input type="password" name="password" onChange={(e) => setPassword(e)} placeholder="Password" />
|
||||||
<Button className="mt-8 w-full" color="green" disabled={isLoading}>
|
<div className="flex justify-between w-full px-4">
|
||||||
|
<div className="flex gap-3 text-mti-gray-dim text-xs cursor-pointer" onClick={() => setRememberPassword((prev) => !prev)}>
|
||||||
|
<input type="checkbox" className="hidden" />
|
||||||
|
<div
|
||||||
|
className={clsx(
|
||||||
|
"w-4 h-4 rounded-sm flex items-center justify-center border border-mti-purple-light bg-white",
|
||||||
|
"transition duration-300 ease-in-out",
|
||||||
|
rememberPassword && "!bg-mti-purple-light ",
|
||||||
|
)}>
|
||||||
|
<BsCheck color="white" className="w-full h-full" />
|
||||||
|
</div>
|
||||||
|
<span>Remember my password</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-mti-purple-light text-xs">Forgot Password?</span>
|
||||||
|
</div>
|
||||||
|
<Button className="mt-8 w-full" color="purple" disabled={isLoading}>
|
||||||
{!isLoading && "Login"}
|
{!isLoading && "Login"}
|
||||||
{isLoading && (
|
{isLoading && (
|
||||||
<div className="flex items-center justify-center">
|
<div className="flex items-center justify-center">
|
||||||
@@ -76,7 +92,7 @@ export default function Login() {
|
|||||||
</form>
|
</form>
|
||||||
<span className="text-mti-gray-cool text-sm font-normal mt-8">
|
<span className="text-mti-gray-cool text-sm font-normal mt-8">
|
||||||
Don't have an account?{" "}
|
Don't have an account?{" "}
|
||||||
<Link className="text-mti-green-light" href="/register">
|
<Link className="text-mti-purple-light" href="/register">
|
||||||
Sign up
|
Sign up
|
||||||
</Link>
|
</Link>
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -1,9 +1,27 @@
|
|||||||
import {withIronSessionSsr} from "iron-session/next";
|
/* eslint-disable @next/next/no-img-element */
|
||||||
import {sessionOptions} from "@/lib/session";
|
|
||||||
import {User} from "@/interfaces/user";
|
|
||||||
import Head from "next/head";
|
import Head from "next/head";
|
||||||
import Navbar from "@/components/Navbar";
|
import Navbar from "@/components/Navbar";
|
||||||
import {Avatar} from "primereact/avatar";
|
import {BsFileEarmarkText, BsPencil, BsStar, BsBook, BsHeadphones, BsPen, BsMegaphone, BsArrowRepeat} from "react-icons/bs";
|
||||||
|
import {withIronSessionSsr} from "iron-session/next";
|
||||||
|
import {sessionOptions} from "@/lib/session";
|
||||||
|
import {ChangeEvent, useEffect, useRef, useState} from "react";
|
||||||
|
import useStats from "@/hooks/useStats";
|
||||||
|
import {averageScore, totalExams} from "@/utils/stats";
|
||||||
|
import useUser from "@/hooks/useUser";
|
||||||
|
import Sidebar from "@/components/Sidebar";
|
||||||
|
import Diagnostic from "@/components/Diagnostic";
|
||||||
|
import {toast, ToastContainer} from "react-toastify";
|
||||||
|
import {capitalize} from "lodash";
|
||||||
|
import {Module} from "@/interfaces";
|
||||||
|
import ProgressBar from "@/components/Low/ProgressBar";
|
||||||
|
import Layout from "@/components/High/Layout";
|
||||||
|
import {calculateAverageLevel} from "@/utils/score";
|
||||||
|
import Input from "@/components/Low/Input";
|
||||||
|
import Button from "@/components/Low/Button";
|
||||||
|
import {useRouter} from "next/router";
|
||||||
|
import Link from "next/link";
|
||||||
|
import axios from "axios";
|
||||||
|
import {ErrorMessage} from "@/constants/errors";
|
||||||
|
|
||||||
export const getServerSideProps = withIronSessionSsr(({req, res}) => {
|
export const getServerSideProps = withIronSessionSsr(({req, res}) => {
|
||||||
const user = req.session.user;
|
const user = req.session.user;
|
||||||
@@ -24,11 +42,79 @@ export const getServerSideProps = withIronSessionSsr(({req, res}) => {
|
|||||||
};
|
};
|
||||||
}, sessionOptions);
|
}, sessionOptions);
|
||||||
|
|
||||||
export default function Profile({user}: {user: User}) {
|
export default function Home() {
|
||||||
|
const [bio, setBio] = useState("");
|
||||||
|
const [name, setName] = useState("");
|
||||||
|
const [email, setEmail] = useState("");
|
||||||
|
const [password, setPassword] = useState("");
|
||||||
|
const [newPassword, setNewPassword] = useState("");
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [profilePicture, setProfilePicture] = useState("");
|
||||||
|
|
||||||
|
const profilePictureInput = useRef(null);
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const {user} = useUser({redirectTo: "/login"});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (user) {
|
||||||
|
setName(user.name);
|
||||||
|
setEmail(user.email);
|
||||||
|
setBio(user.bio);
|
||||||
|
setProfilePicture(user.profilePicture);
|
||||||
|
}
|
||||||
|
}, [user]);
|
||||||
|
|
||||||
|
const convertBase64 = (file: File) => {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const fileReader = new FileReader();
|
||||||
|
fileReader.readAsDataURL(file);
|
||||||
|
fileReader.onload = () => {
|
||||||
|
resolve(fileReader.result);
|
||||||
|
};
|
||||||
|
fileReader.onerror = (error) => {
|
||||||
|
reject(error);
|
||||||
|
};
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const uploadProfilePicture = async (event: ChangeEvent<HTMLInputElement>) => {
|
||||||
|
if (event.target.files && event.target.files[0]) {
|
||||||
|
const picture = event.target.files[0];
|
||||||
|
const base64 = await convertBase64(picture);
|
||||||
|
setProfilePicture(base64 as string);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateUser = async () => {
|
||||||
|
setIsLoading(true);
|
||||||
|
if (email !== user?.email && !password) {
|
||||||
|
toast.error("To update your e-mail you need to input your password!");
|
||||||
|
setIsLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newPassword && !password) {
|
||||||
|
toast.error("To update your password you need to input your current one!");
|
||||||
|
setIsLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const request = await axios.post("/api/users/update", {bio, name, email, password, newPassword, profilePicture});
|
||||||
|
if (request.status === 200) {
|
||||||
|
toast.success("Your profile has been updated!");
|
||||||
|
setTimeout(() => router.reload(), 800);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
toast.error((request.data as ErrorMessage).message);
|
||||||
|
setIsLoading(false);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Head>
|
<Head>
|
||||||
<title>IELTS GPT | Profile</title>
|
<title>IELTS GPT | Muscat Training Institute</title>
|
||||||
<meta
|
<meta
|
||||||
name="description"
|
name="description"
|
||||||
content="A training platform for the IELTS exam provided by the Muscat Training Institute and developed by eCrop."
|
content="A training platform for the IELTS exam provided by the Muscat Training Institute and developed by eCrop."
|
||||||
@@ -36,13 +122,86 @@ export default function Profile({user}: {user: User}) {
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<link rel="icon" href="/favicon.ico" />
|
<link rel="icon" href="/favicon.ico" />
|
||||||
</Head>
|
</Head>
|
||||||
<main className="w-full h-screen flex flex-col items-center bg-neutral-100">
|
<ToastContainer />
|
||||||
<div className="w-full h-full flex flex-col items-center justify-center p-4 relative">
|
{user && (
|
||||||
<section className="bg-white drop-shadow-xl p-4 rounded-xl w-96 flex flex-col items-center">
|
<Layout user={user}>
|
||||||
<Avatar image={user.profilePicture} size="xlarge" shape="circle" />
|
<section className="w-full flex flex-col gap-8 px-4 py-8">
|
||||||
|
<div className="flex w-full justify-between">
|
||||||
|
<div className="flex flex-col gap-8 w-2/3">
|
||||||
|
<h1 className="text-4xl font-bold mb-6">Edit Profile</h1>
|
||||||
|
<form className="flex flex-col items-center gap-6 w-full">
|
||||||
|
<Input
|
||||||
|
label="Name"
|
||||||
|
type="text"
|
||||||
|
name="name"
|
||||||
|
onChange={(e) => setName(e)}
|
||||||
|
placeholder="Enter your name"
|
||||||
|
defaultValue={name}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="E-mail Address"
|
||||||
|
type="email"
|
||||||
|
name="email"
|
||||||
|
onChange={(e) => setEmail(e)}
|
||||||
|
placeholder="Enter email address"
|
||||||
|
defaultValue={email}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="Old Password"
|
||||||
|
type="password"
|
||||||
|
name="password"
|
||||||
|
onChange={(e) => setPassword(e)}
|
||||||
|
placeholder="Enter your password"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="New Password"
|
||||||
|
type="password"
|
||||||
|
name="newPassword"
|
||||||
|
onChange={(e) => setNewPassword(e)}
|
||||||
|
placeholder="Enter your new password (optional)"
|
||||||
|
/>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-3 items-center w-48">
|
||||||
|
<img
|
||||||
|
src={profilePicture}
|
||||||
|
alt={user.name}
|
||||||
|
className="aspect-square h-48 w-48 rounded-full drop-shadow-xl self-end object-cover"
|
||||||
|
/>
|
||||||
|
<input type="file" className="hidden" onChange={uploadProfilePicture} accept="image/*" ref={profilePictureInput} />
|
||||||
|
<span
|
||||||
|
onClick={() => (profilePictureInput.current as any)?.click()}
|
||||||
|
className="cursor-pointer text-mti-purple-light text-sm">
|
||||||
|
Change picture
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-4 mt-8 mb-20">
|
||||||
|
<span className="text-lg font-bold">Bio</span>
|
||||||
|
<textarea
|
||||||
|
className="w-full h-full min-h-[148px] cursor-text px-7 py-8 input border-2 border-mti-gray-platinum bg-white rounded-3xl"
|
||||||
|
onChange={(e) => setBio(e.target.value)}
|
||||||
|
defaultValue={bio}
|
||||||
|
placeholder="Write your text here..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="self-end flex justify-between w-full gap-8 absolute bottom-8 left-0 px-8">
|
||||||
|
<Link href="/" className="max-w-[200px] self-end w-full">
|
||||||
|
<Button color="purple" variant="outline" className="max-w-[200px] self-end w-full">
|
||||||
|
Back
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
<Button color="purple" className="max-w-[200px] self-end w-full" onClick={updateUser} disabled={isLoading}>
|
||||||
|
Save Changes
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</Layout>
|
||||||
</main>
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,35 +1,23 @@
|
|||||||
/* eslint-disable @next/next/no-img-element */
|
/* eslint-disable @next/next/no-img-element */
|
||||||
import Head from "next/head";
|
import Head from "next/head";
|
||||||
import SingleDatasetChart from "@/components/UserResultChart";
|
|
||||||
import Navbar from "@/components/Navbar";
|
|
||||||
import ProfileCard from "@/components/ProfileCard";
|
|
||||||
import {withIronSessionSsr} from "iron-session/next";
|
import {withIronSessionSsr} from "iron-session/next";
|
||||||
import {sessionOptions} from "@/lib/session";
|
import {sessionOptions} from "@/lib/session";
|
||||||
import {Stat, User} from "@/interfaces/user";
|
import {Stat, User} from "@/interfaces/user";
|
||||||
import {useEffect, useState} from "react";
|
import {useEffect, useState} from "react";
|
||||||
import useStats from "@/hooks/useStats";
|
import useStats from "@/hooks/useStats";
|
||||||
import {averageScore, convertToUserSolutions, formatModuleTotalStats, groupByDate, groupBySession, totalExams} from "@/utils/stats";
|
import {convertToUserSolutions, groupByDate} from "@/utils/stats";
|
||||||
import {Divider} from "primereact/divider";
|
|
||||||
import useUser from "@/hooks/useUser";
|
|
||||||
import {Timeline} from "primereact/timeline";
|
|
||||||
import moment from "moment";
|
import moment from "moment";
|
||||||
import {AutoComplete} from "primereact/autocomplete";
|
|
||||||
import useUsers from "@/hooks/useUsers";
|
import useUsers from "@/hooks/useUsers";
|
||||||
import {Dropdown} from "primereact/dropdown";
|
|
||||||
import useExamStore from "@/stores/examStore";
|
import useExamStore from "@/stores/examStore";
|
||||||
import {Exam, ListeningExam, ReadingExam, SpeakingExam, WritingExam} from "@/interfaces/exam";
|
|
||||||
import {Module} from "@/interfaces";
|
import {Module} from "@/interfaces";
|
||||||
import axios from "axios";
|
import {ToastContainer} from "react-toastify";
|
||||||
import {toast, ToastContainer} from "react-toastify";
|
|
||||||
import {useRouter} from "next/router";
|
import {useRouter} from "next/router";
|
||||||
import Icon from "@mdi/react";
|
|
||||||
import {mdiArrowRight, mdiChevronRight} from "@mdi/js";
|
|
||||||
import {uniqBy} from "lodash";
|
import {uniqBy} from "lodash";
|
||||||
import {getExamById} from "@/utils/exams";
|
import {getExamById} from "@/utils/exams";
|
||||||
import {sortByModule} from "@/utils/moduleUtils";
|
import {sortByModule} from "@/utils/moduleUtils";
|
||||||
import Layout from "@/components/High/Layout";
|
import Layout from "@/components/High/Layout";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import {calculateAverageLevel, calculateBandScore} from "@/utils/score";
|
import {calculateBandScore} from "@/utils/score";
|
||||||
import {BsBook, BsHeadphones, BsMegaphone, BsPen} from "react-icons/bs";
|
import {BsBook, BsHeadphones, BsMegaphone, BsPen} from "react-icons/bs";
|
||||||
|
|
||||||
export const getServerSideProps = withIronSessionSsr(({req, res}) => {
|
export const getServerSideProps = withIronSessionSsr(({req, res}) => {
|
||||||
@@ -54,6 +42,7 @@ export const getServerSideProps = withIronSessionSsr(({req, res}) => {
|
|||||||
export default function History({user}: {user: User}) {
|
export default function History({user}: {user: User}) {
|
||||||
const [selectedUser, setSelectedUser] = useState<User>(user);
|
const [selectedUser, setSelectedUser] = useState<User>(user);
|
||||||
const [groupedStats, setGroupedStats] = useState<{[key: string]: Stat[]}>();
|
const [groupedStats, setGroupedStats] = useState<{[key: string]: Stat[]}>();
|
||||||
|
const [filter, setFilter] = useState<"months" | "weeks" | "days">();
|
||||||
|
|
||||||
const {users, isLoading: isUsersLoading} = useUsers();
|
const {users, isLoading: isUsersLoading} = useUsers();
|
||||||
const {stats, isLoading: isStatsLoading} = useStats(selectedUser?.id);
|
const {stats, isLoading: isStatsLoading} = useStats(selectedUser?.id);
|
||||||
@@ -71,6 +60,27 @@ export default function History({user}: {user: User}) {
|
|||||||
}
|
}
|
||||||
}, [stats, isStatsLoading]);
|
}, [stats, isStatsLoading]);
|
||||||
|
|
||||||
|
const toggleFilter = (value: "months" | "weeks" | "days") => {
|
||||||
|
setFilter((prev) => (prev === value ? undefined : value));
|
||||||
|
};
|
||||||
|
|
||||||
|
const filterStatsByDate = (stats: {[key: string]: Stat[]}) => {
|
||||||
|
if (filter) {
|
||||||
|
const filterDate = moment()
|
||||||
|
.subtract({[filter as string]: 1})
|
||||||
|
.format("x");
|
||||||
|
const filteredStats: {[key: string]: Stat[]} = {};
|
||||||
|
|
||||||
|
Object.keys(stats).forEach((timestamp) => {
|
||||||
|
if (timestamp >= filterDate) filteredStats[timestamp] = stats[timestamp];
|
||||||
|
});
|
||||||
|
|
||||||
|
return filteredStats;
|
||||||
|
}
|
||||||
|
|
||||||
|
return stats;
|
||||||
|
};
|
||||||
|
|
||||||
const formatTimestamp = (timestamp: string) => {
|
const formatTimestamp = (timestamp: string) => {
|
||||||
const date = moment(parseInt(timestamp));
|
const date = moment(parseInt(timestamp));
|
||||||
const formatter = "YYYY/MM/DD - HH:mm";
|
const formatter = "YYYY/MM/DD - HH:mm";
|
||||||
@@ -142,7 +152,7 @@ export default function History({user}: {user: User}) {
|
|||||||
.sort(sortByModule)
|
.sort(sortByModule)
|
||||||
.map((x) => x!.module),
|
.map((x) => x!.module),
|
||||||
);
|
);
|
||||||
router.push("/exam");
|
router.push("/exercises");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -152,9 +162,9 @@ export default function History({user}: {user: User}) {
|
|||||||
key={timestamp}
|
key={timestamp}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"flex flex-col gap-4 border border-mti-gray-platinum p-4 cursor-pointer rounded-xl transition ease-in-out duration-300",
|
"flex flex-col gap-4 border border-mti-gray-platinum p-4 cursor-pointer rounded-xl transition ease-in-out duration-300",
|
||||||
correct / total >= 0.7 && "hover:border-mti-green",
|
correct / total >= 0.7 && "hover:border-mti-purple",
|
||||||
correct / total >= 0.3 && correct / total < 0.7 && "hover:border-mti-blue",
|
correct / total >= 0.3 && correct / total < 0.7 && "hover:border-mti-red",
|
||||||
correct / total < 0.3 && "hover:border-mti-orange",
|
correct / total < 0.3 && "hover:border-mti-rose",
|
||||||
)}
|
)}
|
||||||
onClick={selectExam}
|
onClick={selectExam}
|
||||||
role="button">
|
role="button">
|
||||||
@@ -162,9 +172,9 @@ export default function History({user}: {user: User}) {
|
|||||||
<span className="font-medium">{formatTimestamp(timestamp)}</span>
|
<span className="font-medium">{formatTimestamp(timestamp)}</span>
|
||||||
<span
|
<span
|
||||||
className={clsx(
|
className={clsx(
|
||||||
correct / total >= 0.7 && "text-mti-green",
|
correct / total >= 0.7 && "text-mti-purple",
|
||||||
correct / total >= 0.3 && correct / total < 0.7 && "text-mti-blue",
|
correct / total >= 0.3 && correct / total < 0.7 && "text-mti-red",
|
||||||
correct / total < 0.3 && "text-mti-orange",
|
correct / total < 0.3 && "text-mti-rose",
|
||||||
)}>
|
)}>
|
||||||
Level{" "}
|
Level{" "}
|
||||||
{(aggregatedLevels.reduce((accumulator, current) => accumulator + current.level, 0) / aggregatedLevels.length).toFixed(1)}
|
{(aggregatedLevels.reduce((accumulator, current) => accumulator + current.level, 0) / aggregatedLevels.length).toFixed(1)}
|
||||||
@@ -207,24 +217,55 @@ export default function History({user}: {user: User}) {
|
|||||||
<ToastContainer />
|
<ToastContainer />
|
||||||
{user && (
|
{user && (
|
||||||
<Layout user={user}>
|
<Layout user={user}>
|
||||||
<div className="w-fit">
|
<div className="w-full flex justify-between items-center">
|
||||||
{!isUsersLoading && user.type !== "student" && (
|
<div className="w-fit">
|
||||||
<>
|
{!isUsersLoading && user.type !== "student" && (
|
||||||
<select
|
<>
|
||||||
className="select w-full max-w-xs bg-white border border-mti-gray-platinum outline-none font-normal text-base"
|
<select
|
||||||
onChange={(e) => setSelectedUser(users.find((x) => x.id === e.target.value)!)}>
|
className="select w-full max-w-xs bg-white border border-mti-gray-platinum outline-none font-normal text-base"
|
||||||
{users.map((x) => (
|
onChange={(e) => setSelectedUser(users.find((x) => x.id === e.target.value)!)}>
|
||||||
<option key={x.id} selected={selectedUser.id === x.id} value={x.id}>
|
{users.map((x) => (
|
||||||
{x.name}
|
<option key={x.id} selected={selectedUser.id === x.id} value={x.id}>
|
||||||
</option>
|
{x.name}
|
||||||
))}
|
</option>
|
||||||
</select>
|
))}
|
||||||
</>
|
</select>
|
||||||
)}
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-4">
|
||||||
|
<button
|
||||||
|
className={clsx(
|
||||||
|
"bg-mti-purple-ultralight text-mti-purple px-4 py-2 rounded-full hover:text-white hover:bg-mti-purple-light",
|
||||||
|
"transition duration-300 ease-in-out",
|
||||||
|
filter === "months" && "!bg-mti-purple-light !text-white",
|
||||||
|
)}
|
||||||
|
onClick={() => toggleFilter("months")}>
|
||||||
|
Last month
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={clsx(
|
||||||
|
"bg-mti-purple-ultralight text-mti-purple px-4 py-2 rounded-full hover:text-white hover:bg-mti-purple-light",
|
||||||
|
"transition duration-300 ease-in-out",
|
||||||
|
filter === "weeks" && "!bg-mti-purple-light !text-white",
|
||||||
|
)}
|
||||||
|
onClick={() => toggleFilter("weeks")}>
|
||||||
|
Last week
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={clsx(
|
||||||
|
"bg-mti-purple-ultralight text-mti-purple px-4 py-2 rounded-full hover:text-white hover:bg-mti-purple-light",
|
||||||
|
"transition duration-300 ease-in-out",
|
||||||
|
filter === "days" && "!bg-mti-purple-light !text-white",
|
||||||
|
)}
|
||||||
|
onClick={() => toggleFilter("days")}>
|
||||||
|
Last day
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{groupedStats && Object.keys(groupedStats).length > 0 && !isStatsLoading && (
|
{groupedStats && Object.keys(groupedStats).length > 0 && !isStatsLoading && (
|
||||||
<div className="grid grid-cols-3 w-full gap-6">
|
<div className="grid grid-cols-3 w-full gap-6">
|
||||||
{Object.keys(groupedStats)
|
{Object.keys(filterStatsByDate(groupedStats))
|
||||||
.sort((a, b) => parseInt(b) - parseInt(a))
|
.sort((a, b) => parseInt(b) - parseInt(a))
|
||||||
.map(customContent)}
|
.map(customContent)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
/* eslint-disable @next/next/no-img-element */
|
/* eslint-disable @next/next/no-img-element */
|
||||||
import {ToastContainer} from "react-toastify";
|
import {toast, ToastContainer} from "react-toastify";
|
||||||
import {useState} from "react";
|
import {useState} from "react";
|
||||||
import Head from "next/head";
|
import Head from "next/head";
|
||||||
import useUser from "@/hooks/useUser";
|
import useUser from "@/hooks/useUser";
|
||||||
@@ -7,14 +7,13 @@ import Button from "@/components/Low/Button";
|
|||||||
import {BsArrowRepeat} from "react-icons/bs";
|
import {BsArrowRepeat} from "react-icons/bs";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import Input from "@/components/Low/Input";
|
import Input from "@/components/Low/Input";
|
||||||
|
import axios from "axios";
|
||||||
|
|
||||||
export default function Register() {
|
export default function Register() {
|
||||||
const [name, setName] = useState("");
|
const [name, setName] = useState("");
|
||||||
const [email, setEmail] = useState("");
|
const [email, setEmail] = useState("");
|
||||||
const [password, setPassword] = useState("");
|
const [password, setPassword] = useState("");
|
||||||
const [showPassword, setShowPassword] = useState(false);
|
|
||||||
const [confirmPassword, setConfirmPassword] = useState("");
|
const [confirmPassword, setConfirmPassword] = useState("");
|
||||||
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
|
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
const {mutateUser} = useUser({
|
const {mutateUser} = useUser({
|
||||||
@@ -22,6 +21,30 @@ export default function Register() {
|
|||||||
redirectIfFound: true,
|
redirectIfFound: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const register = (e: any) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
if (confirmPassword !== password) {
|
||||||
|
toast.error("Your passwords do not match!", {toastId: "password-not-match"});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsLoading(true);
|
||||||
|
axios
|
||||||
|
.post("/api/register", {name, email, password, profilePicture: "/defaultAvatar.png"})
|
||||||
|
.then((response) => mutateUser(response.data.user))
|
||||||
|
.catch((error) => {
|
||||||
|
console.log(error.response.data);
|
||||||
|
|
||||||
|
if (error.response.status === 401) {
|
||||||
|
toast.error("There is already a user with that e-mail!");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
toast.error("There was something wrong, please try again!");
|
||||||
|
})
|
||||||
|
.finally(() => setIsLoading(false));
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Head>
|
<Head>
|
||||||
@@ -33,12 +56,12 @@ export default function Register() {
|
|||||||
<main className="w-full h-[100vh] flex bg-white text-black">
|
<main className="w-full h-[100vh] flex bg-white text-black">
|
||||||
<ToastContainer />
|
<ToastContainer />
|
||||||
<section className="h-full w-fit min-w-fit relative">
|
<section className="h-full w-fit min-w-fit relative">
|
||||||
<div className="absolute h-full w-full bg-mti-orange-light z-10 bg-opacity-50" />
|
<div className="absolute h-full w-full bg-mti-rose-light z-10 bg-opacity-50" />
|
||||||
<img src="/people-talking-tablet.png" alt="People smiling looking at a tablet" className="h-full aspect-auto" />
|
<img src="/people-talking-tablet.png" alt="People smiling looking at a tablet" className="h-full aspect-auto" />
|
||||||
</section>
|
</section>
|
||||||
<section className="h-full w-full flex flex-col items-center justify-center gap-12">
|
<section className="h-full w-full flex flex-col items-center justify-center gap-12">
|
||||||
<h1 className="font-bold text-4xl">Create new account</h1>
|
<h1 className="font-bold text-4xl">Create new account</h1>
|
||||||
<form className="flex flex-col items-center gap-6 w-1/2">
|
<form className="flex flex-col items-center gap-6 w-1/2" onSubmit={register}>
|
||||||
<Input type="text" name="name" onChange={(e) => setName(e)} placeholder="Enter your name" required />
|
<Input type="text" name="name" onChange={(e) => setName(e)} placeholder="Enter your name" required />
|
||||||
<Input type="email" name="email" onChange={(e) => setEmail(e)} placeholder="Enter email address" required />
|
<Input type="email" name="email" onChange={(e) => setEmail(e)} placeholder="Enter email address" required />
|
||||||
<Input type="password" name="password" onChange={(e) => setPassword(e)} placeholder="Enter your password" required />
|
<Input type="password" name="password" onChange={(e) => setPassword(e)} placeholder="Enter your password" required />
|
||||||
@@ -49,7 +72,7 @@ export default function Register() {
|
|||||||
placeholder="Confirm your password"
|
placeholder="Confirm your password"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
<Button className="mt-8 w-full" color="green" disabled={isLoading}>
|
<Button className="mt-8 w-full" color="purple" disabled={isLoading}>
|
||||||
{!isLoading && "Create account"}
|
{!isLoading && "Create account"}
|
||||||
{isLoading && (
|
{isLoading && (
|
||||||
<div className="flex items-center justify-center">
|
<div className="flex items-center justify-center">
|
||||||
@@ -58,7 +81,7 @@ export default function Register() {
|
|||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
<Link className="text-mti-green-light text-sm font-normal" href="/login">
|
<Link className="text-mti-purple-light text-sm font-normal" href="/login">
|
||||||
Sign in instead
|
Sign in instead
|
||||||
</Link>
|
</Link>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ export default function Stats() {
|
|||||||
{user && (
|
{user && (
|
||||||
<Layout user={user}>
|
<Layout user={user}>
|
||||||
<section className="w-full flex gap-8">
|
<section className="w-full flex gap-8">
|
||||||
<img src={user.profilePicture} alt={user.name} className="aspect-square h-64 rounded-3xl drop-shadow-xl" />
|
<img src={user.profilePicture} alt={user.name} className="aspect-square h-64 rounded-3xl drop-shadow-xl object-cover" />
|
||||||
<div className="flex flex-col gap-4 py-4 w-full">
|
<div className="flex flex-col gap-4 py-4 w-full">
|
||||||
<div className="flex justify-between w-full gap-8">
|
<div className="flex justify-between w-full gap-8">
|
||||||
<div className="flex flex-col gap-2 py-2">
|
<div className="flex flex-col gap-2 py-2">
|
||||||
@@ -79,20 +79,20 @@ export default function Stats() {
|
|||||||
<ProgressBar
|
<ProgressBar
|
||||||
label={`Level ${calculateAverageLevel(user.levels).toFixed(1)}`}
|
label={`Level ${calculateAverageLevel(user.levels).toFixed(1)}`}
|
||||||
percentage={100}
|
percentage={100}
|
||||||
color="blue"
|
color="purple"
|
||||||
className="max-w-xs w-32 self-end h-10"
|
className="max-w-xs w-32 self-end h-10"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<ProgressBar
|
<ProgressBar
|
||||||
label=""
|
label=""
|
||||||
percentage={Math.round((calculateAverageLevel(user.levels) * 100) / calculateAverageLevel(user.desiredLevels))}
|
percentage={Math.round((calculateAverageLevel(user.levels) * 100) / calculateAverageLevel(user.desiredLevels))}
|
||||||
color="blue"
|
color="red"
|
||||||
className="w-full h-3 drop-shadow-lg"
|
className="w-full h-3 drop-shadow-lg"
|
||||||
/>
|
/>
|
||||||
<div className="flex justify-between w-full mt-8">
|
<div className="flex justify-between w-full mt-8">
|
||||||
<div className="flex gap-4 items-center">
|
<div className="flex gap-4 items-center">
|
||||||
<div className="w-16 h-16 border border-mti-gray-platinum bg-mti-gray-smoke flex items-center justify-center rounded-xl">
|
<div className="w-16 h-16 border border-mti-gray-platinum bg-mti-gray-smoke flex items-center justify-center rounded-xl">
|
||||||
<BsFileEarmarkText className="w-8 h-8 text-mti-blue-light" />
|
<BsFileEarmarkText className="w-8 h-8 text-mti-red-light" />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
<span className="font-bold text-xl">{Object.keys(groupBySession(stats)).length}</span>
|
<span className="font-bold text-xl">{Object.keys(groupBySession(stats)).length}</span>
|
||||||
@@ -101,7 +101,7 @@ export default function Stats() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex gap-4 items-center">
|
<div className="flex gap-4 items-center">
|
||||||
<div className="w-16 h-16 border border-mti-gray-platinum bg-mti-gray-smoke flex items-center justify-center rounded-xl">
|
<div className="w-16 h-16 border border-mti-gray-platinum bg-mti-gray-smoke flex items-center justify-center rounded-xl">
|
||||||
<BsPencil className="w-8 h-8 text-mti-blue-light" />
|
<BsPencil className="w-8 h-8 text-mti-red-light" />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
<span className="font-bold text-xl">{stats.length}</span>
|
<span className="font-bold text-xl">{stats.length}</span>
|
||||||
@@ -110,7 +110,7 @@ export default function Stats() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex gap-4 items-center">
|
<div className="flex gap-4 items-center">
|
||||||
<div className="w-16 h-16 border border-mti-gray-platinum bg-mti-gray-smoke flex items-center justify-center rounded-xl">
|
<div className="w-16 h-16 border border-mti-gray-platinum bg-mti-gray-smoke flex items-center justify-center rounded-xl">
|
||||||
<BsStar className="w-8 h-8 text-mti-blue-light" />
|
<BsStar className="w-8 h-8 text-mti-red-light" />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
<span className="font-bold text-xl">{averageScore(stats)}%</span>
|
<span className="font-bold text-xl">{averageScore(stats)}%</span>
|
||||||
|
|||||||
@@ -118,7 +118,7 @@ export default function Page() {
|
|||||||
setIsRecording(false);
|
setIsRecording(false);
|
||||||
stopRecording();
|
stopRecording();
|
||||||
}}
|
}}
|
||||||
className="text-mti-green-light w-8 h-8 cursor-pointer"
|
className="text-mti-purple-light w-8 h-8 cursor-pointer"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
@@ -143,14 +143,14 @@ export default function Page() {
|
|||||||
setIsRecording(true);
|
setIsRecording(true);
|
||||||
resumeRecording();
|
resumeRecording();
|
||||||
}}
|
}}
|
||||||
className="text-mti-green-light w-8 h-8 cursor-pointer"
|
className="text-mti-purple-light w-8 h-8 cursor-pointer"
|
||||||
/>
|
/>
|
||||||
<BsCheckCircleFill
|
<BsCheckCircleFill
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setIsRecording(false);
|
setIsRecording(false);
|
||||||
stopRecording();
|
stopRecording();
|
||||||
}}
|
}}
|
||||||
className="text-mti-green-light w-8 h-8 cursor-pointer"
|
className="text-mti-purple-light w-8 h-8 cursor-pointer"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
import {Module} from "@/interfaces";
|
import {Module} from "@/interfaces";
|
||||||
import {Exam, UserSolution} from "@/interfaces/exam";
|
import {Exam, UserSolution} from "@/interfaces/exam";
|
||||||
import {Stat} from "@/interfaces/user";
|
|
||||||
import {create} from "zustand";
|
import {create} from "zustand";
|
||||||
|
|
||||||
export interface ExamState {
|
export interface ExamState {
|
||||||
exams: Exam[];
|
exams: Exam[];
|
||||||
userSolutions: UserSolution[];
|
userSolutions: UserSolution[];
|
||||||
showSolutions: boolean;
|
showSolutions: boolean;
|
||||||
|
hasExamEnded: boolean;
|
||||||
selectedModules: Module[];
|
selectedModules: Module[];
|
||||||
|
setHasExamEnded: (hasExamEnded: boolean) => void;
|
||||||
setUserSolutions: (userSolutions: UserSolution[]) => void;
|
setUserSolutions: (userSolutions: UserSolution[]) => void;
|
||||||
setExams: (exams: Exam[]) => void;
|
setExams: (exams: Exam[]) => void;
|
||||||
setShowSolutions: (showSolutions: boolean) => void;
|
setShowSolutions: (showSolutions: boolean) => void;
|
||||||
@@ -20,6 +21,7 @@ export const initialState = {
|
|||||||
userSolutions: [],
|
userSolutions: [],
|
||||||
showSolutions: false,
|
showSolutions: false,
|
||||||
selectedModules: [],
|
selectedModules: [],
|
||||||
|
hasExamEnded: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
const useExamStore = create<ExamState>((set) => ({
|
const useExamStore = create<ExamState>((set) => ({
|
||||||
@@ -28,6 +30,7 @@ const useExamStore = create<ExamState>((set) => ({
|
|||||||
setExams: (exams: Exam[]) => set(() => ({exams})),
|
setExams: (exams: Exam[]) => set(() => ({exams})),
|
||||||
setShowSolutions: (showSolutions: boolean) => set(() => ({showSolutions})),
|
setShowSolutions: (showSolutions: boolean) => set(() => ({showSolutions})),
|
||||||
setSelectedModules: (modules: Module[]) => set(() => ({selectedModules: modules})),
|
setSelectedModules: (modules: Module[]) => set(() => ({selectedModules: modules})),
|
||||||
|
setHasExamEnded: (hasExamEnded: boolean) => set(() => ({hasExamEnded})),
|
||||||
reset: () => set(() => initialState),
|
reset: () => set(() => initialState),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|||||||
@@ -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}};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|||||||
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;
|
||||||
|
}
|
||||||
@@ -4,18 +4,49 @@ type Type = "academic" | "general";
|
|||||||
|
|
||||||
export const writingReverseMarking: {[key: number]: number} = {
|
export const writingReverseMarking: {[key: number]: number} = {
|
||||||
9: 90,
|
9: 90,
|
||||||
|
8.5: 85,
|
||||||
8: 80,
|
8: 80,
|
||||||
|
7.5: 75,
|
||||||
7: 70,
|
7: 70,
|
||||||
|
6.5: 65,
|
||||||
6: 60,
|
6: 60,
|
||||||
|
5.5: 55,
|
||||||
5: 50,
|
5: 50,
|
||||||
|
4.5: 45,
|
||||||
4: 40,
|
4: 40,
|
||||||
|
3.5: 35,
|
||||||
3: 30,
|
3: 30,
|
||||||
|
2.5: 25,
|
||||||
2: 20,
|
2: 20,
|
||||||
|
1.5: 15,
|
||||||
1: 10,
|
1: 10,
|
||||||
|
0.5: 5,
|
||||||
0: 0,
|
0: 0,
|
||||||
};
|
};
|
||||||
|
|
||||||
const writingMarking: {[key: number]: number} = {
|
export const speakingReverseMarking: {[key: number]: number} = {
|
||||||
|
9: 90,
|
||||||
|
8.5: 85,
|
||||||
|
8: 80,
|
||||||
|
7.5: 75,
|
||||||
|
7: 70,
|
||||||
|
6.5: 65,
|
||||||
|
6: 60,
|
||||||
|
5.5: 55,
|
||||||
|
5: 50,
|
||||||
|
4.5: 45,
|
||||||
|
4: 40,
|
||||||
|
3.5: 35,
|
||||||
|
3: 30,
|
||||||
|
2.5: 25,
|
||||||
|
2: 20,
|
||||||
|
1.5: 15,
|
||||||
|
1: 10,
|
||||||
|
0.5: 5,
|
||||||
|
0: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const writingMarking: {[key: number]: number} = {
|
||||||
90: 9,
|
90: 9,
|
||||||
80: 8,
|
80: 8,
|
||||||
70: 7,
|
70: 7,
|
||||||
@@ -76,8 +107,8 @@ const moduleMarkings: {[key in Module]: {[key in Type]: {[key: number]: number}}
|
|||||||
general: writingMarking,
|
general: writingMarking,
|
||||||
},
|
},
|
||||||
speaking: {
|
speaking: {
|
||||||
academic: academicMarking,
|
academic: writingMarking,
|
||||||
general: academicMarking,
|
general: writingMarking,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -116,6 +116,7 @@ export const getExamsBySession = (stats: Stat[], session: string) => {
|
|||||||
|
|
||||||
export const groupBySession = (stats: Stat[]) => groupBy(stats, "session");
|
export const groupBySession = (stats: Stat[]) => groupBy(stats, "session");
|
||||||
export const groupByDate = (stats: Stat[]) => groupBy(stats, "date");
|
export const groupByDate = (stats: Stat[]) => groupBy(stats, "date");
|
||||||
|
export const groupByModule = (stats: Stat[]) => groupBy(stats, "module");
|
||||||
|
|
||||||
export const convertToUserSolutions = (stats: Stat[]): UserSolution[] => {
|
export const convertToUserSolutions = (stats: Stat[]): UserSolution[] => {
|
||||||
return stats.map((stat) => ({
|
return stats.map((stat) => ({
|
||||||
|
|||||||
@@ -7,6 +7,9 @@ module.exports = {
|
|||||||
mti: {
|
mti: {
|
||||||
orange: {DEFAULT: "#FF6000", dark: "#cc4402", light: "#ff790a", ultralight: "#ffdaa5"},
|
orange: {DEFAULT: "#FF6000", dark: "#cc4402", light: "#ff790a", ultralight: "#ffdaa5"},
|
||||||
green: {DEFAULT: "#307912", dark: "#2a6014", light: "#3d9f11", ultralight: "#c6edaf"},
|
green: {DEFAULT: "#307912", dark: "#2a6014", light: "#3d9f11", ultralight: "#c6edaf"},
|
||||||
|
purple: {DEFAULT: "#6A5FB1", dark: "#6354A1", light: "#7872BF", ultralight: "#D5D9F0"},
|
||||||
|
red: {DEFAULT: "#BB4747", dark: "#9D3838", light: "#CC5454", ultralight: "#F5D3D3"},
|
||||||
|
rose: {DEFAULT: "#D6352C", dark: "#B42921", light: "#EB5C54", ultralight: "#FCCFCC"},
|
||||||
blue: {DEFAULT: "#0696ff", dark: "#007ff8", light: "#1eb3ff", ultralight: "#b5edff"},
|
blue: {DEFAULT: "#0696ff", dark: "#007ff8", light: "#1eb3ff", ultralight: "#b5edff"},
|
||||||
white: {DEFAULT: "#ffffff", alt: "#FDFDFD"},
|
white: {DEFAULT: "#ffffff", alt: "#FDFDFD"},
|
||||||
gray: {
|
gray: {
|
||||||
|
|||||||
Reference in New Issue
Block a user