Compare commits
1 Commits
ENCOA-131_
...
feature-ge
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7962857a95 |
3
.gitignore
vendored
@@ -1,5 +1,3 @@
|
|||||||
src/constants/test_firebase.json
|
|
||||||
|
|
||||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||||
|
|
||||||
# dependencies
|
# dependencies
|
||||||
@@ -40,4 +38,3 @@ next-env.d.ts
|
|||||||
.env
|
.env
|
||||||
.yarn/*
|
.yarn/*
|
||||||
.history*
|
.history*
|
||||||
__ENV.js
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
#!/usr/bin/env sh
|
|
||||||
. "$(dirname -- "$0")/_/husky.sh"
|
|
||||||
|
|
||||||
yarn build
|
|
||||||
28
.vscode/launch.json
vendored
@@ -1,28 +0,0 @@
|
|||||||
{
|
|
||||||
"version": "0.2.0",
|
|
||||||
"configurations": [
|
|
||||||
{
|
|
||||||
"name": "Next.js: debug server-side",
|
|
||||||
"type": "node-terminal",
|
|
||||||
"request": "launch",
|
|
||||||
"command": "npm run dev"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Next.js: debug client-side",
|
|
||||||
"type": "chrome",
|
|
||||||
"request": "launch",
|
|
||||||
"url": "http://localhost:3000"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Next.js: debug full stack",
|
|
||||||
"type": "node-terminal",
|
|
||||||
"request": "launch",
|
|
||||||
"command": "npm run dev",
|
|
||||||
"serverReadyAction": {
|
|
||||||
"pattern": "- Local:.+(https?://.+)",
|
|
||||||
"uriFormat": "%s",
|
|
||||||
"action": "debugWithChrome"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -54,4 +54,4 @@ EXPOSE 3000
|
|||||||
ENV PORT 3000
|
ENV PORT 3000
|
||||||
ENV HOSTNAME localhost
|
ENV HOSTNAME localhost
|
||||||
|
|
||||||
CMD HOSTNAME="0.0.0.0" node server.js
|
CMD ["node", "server.js"]
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
{
|
|
||||||
"$schema": "https://ui.shadcn.com/schema.json",
|
|
||||||
"style": "new-york",
|
|
||||||
"rsc": false,
|
|
||||||
"tsx": true,
|
|
||||||
"tailwind": {
|
|
||||||
"config": "tailwind.config.ts",
|
|
||||||
"css": "src/styles/globals.css",
|
|
||||||
"baseColor": "neutral",
|
|
||||||
"cssVariables": false,
|
|
||||||
"prefix": ""
|
|
||||||
},
|
|
||||||
"aliases": {
|
|
||||||
"components": "@/components",
|
|
||||||
"utils": "@/lib/utils"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,57 +1,7 @@
|
|||||||
/** @type {import('next').NextConfig} */
|
/** @type {import('next').NextConfig} */
|
||||||
const websiteUrl = process.env.NODE_ENV === 'production' ? "https://encoach.com" : "http://localhost:3000";
|
|
||||||
const nextConfig = {
|
const nextConfig = {
|
||||||
reactStrictMode: false,
|
reactStrictMode: true,
|
||||||
output: "standalone",
|
output: "standalone",
|
||||||
async headers() {
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
source: "/api/packages",
|
|
||||||
headers: [
|
|
||||||
{key: "Access-Control-Allow-Credentials", value: "false"},
|
|
||||||
{key: "Access-Control-Allow-Origin", value: websiteUrl},
|
|
||||||
{
|
|
||||||
key: "Access-Control-Allow-Methods",
|
|
||||||
value: "GET",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "Access-Control-Allow-Headers",
|
|
||||||
value: "Accept, Accept-Version, Content-Length, Content-MD5, Content-Type, Date",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
source: "/api/tickets",
|
|
||||||
headers: [
|
|
||||||
{key: "Access-Control-Allow-Credentials", value: "false"},
|
|
||||||
{key: "Access-Control-Allow-Origin", value: websiteUrl},
|
|
||||||
{
|
|
||||||
key: "Access-Control-Allow-Methods",
|
|
||||||
value: "POST,OPTIONS",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "Access-Control-Allow-Headers",
|
|
||||||
value: "Accept, Accept-Version, Content-Length, Content-MD5, Content-Type, Date",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
source: "/api/users/agents",
|
|
||||||
headers: [
|
|
||||||
{key: "Access-Control-Allow-Credentials", value: "false"},
|
|
||||||
{key: "Access-Control-Allow-Origin", value: websiteUrl},
|
|
||||||
{
|
|
||||||
key: "Access-Control-Allow-Methods",
|
|
||||||
value: "POST,OPTIONS",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "Access-Control-Allow-Headers",
|
|
||||||
value: "Accept, Accept-Version, Content-Length, Content-MD5, Content-Type, Date",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
module.exports = nextConfig;
|
module.exports = nextConfig;
|
||||||
|
|||||||
11241
package-lock.json
generated
71
package.json
@@ -6,107 +6,56 @@
|
|||||||
"dev": "next dev",
|
"dev": "next dev",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "next lint",
|
"lint": "next lint"
|
||||||
"prepare": "husky install"
|
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@beam-australia/react-env": "^3.1.1",
|
"@headlessui/react": "^1.7.13",
|
||||||
"@dnd-kit/core": "^6.1.0",
|
|
||||||
"@dnd-kit/sortable": "^8.0.0",
|
|
||||||
"@firebase/util": "^1.9.7",
|
|
||||||
"@headlessui/react": "^2.1.2",
|
|
||||||
"@mdi/js": "^7.1.96",
|
"@mdi/js": "^7.1.96",
|
||||||
"@mdi/react": "^1.6.1",
|
"@mdi/react": "^1.6.1",
|
||||||
"@paypal/paypal-js": "^7.1.0",
|
"@next/font": "13.1.6",
|
||||||
"@paypal/react-paypal-js": "^8.1.3",
|
|
||||||
"@radix-ui/react-icons": "^1.3.0",
|
|
||||||
"@radix-ui/react-popover": "^1.1.1",
|
|
||||||
"@react-pdf/renderer": "^3.1.14",
|
|
||||||
"@react-spring/web": "^9.7.4",
|
|
||||||
"@tanstack/react-table": "^8.10.1",
|
|
||||||
"@types/node": "18.13.0",
|
"@types/node": "18.13.0",
|
||||||
"@types/react": "^18.3.3",
|
"@types/react": "18.0.27",
|
||||||
"@types/react-dom": "^18.3.0",
|
"@types/react-dom": "18.0.10",
|
||||||
"@use-gesture/react": "^10.3.1",
|
"axios": "^1.3.5",
|
||||||
"axios": "^1",
|
|
||||||
"axios-cache-interceptor": "^1",
|
|
||||||
"bcrypt": "^5.1.1",
|
|
||||||
"chart.js": "^4.2.1",
|
"chart.js": "^4.2.1",
|
||||||
"class-variance-authority": "^0.7.0",
|
"clsx": "^1.2.1",
|
||||||
"clsx": "^2.1.1",
|
|
||||||
"countries-list": "^3.0.1",
|
|
||||||
"country-codes-list": "^1.6.11",
|
|
||||||
"currency-symbol-map": "^5.1.0",
|
|
||||||
"daisyui": "^3.1.5",
|
"daisyui": "^3.1.5",
|
||||||
"eslint": "8.33.0",
|
"eslint": "8.33.0",
|
||||||
"eslint-config-next": "13.1.6",
|
"eslint-config-next": "13.1.6",
|
||||||
"exceljs": "^4.4.0",
|
|
||||||
"express-handlebars": "^7.1.2",
|
|
||||||
"firebase": "9.19.1",
|
"firebase": "9.19.1",
|
||||||
"firebase-admin": "^11.10.1",
|
|
||||||
"firebase-scrypt": "^2.2.0",
|
|
||||||
"formidable": "^3.5.0",
|
"formidable": "^3.5.0",
|
||||||
"formidable-serverless": "^1.1.1",
|
"formidable-serverless": "^1.1.1",
|
||||||
"framer-motion": "^9.0.2",
|
"framer-motion": "^9.0.2",
|
||||||
"howler": "^2.2.4",
|
|
||||||
"iron-session": "^6.3.1",
|
"iron-session": "^6.3.1",
|
||||||
"lodash": "^4.17.21",
|
"lodash": "^4.17.21",
|
||||||
"moment": "^2.29.4",
|
"moment": "^2.29.4",
|
||||||
"moment-timezone": "^0.5.44",
|
"next": "13.1.6",
|
||||||
"next": "^14.2.5",
|
|
||||||
"nodemailer": "^6.9.5",
|
|
||||||
"nodemailer-express-handlebars": "^6.1.0",
|
|
||||||
"primeicons": "^6.0.1",
|
"primeicons": "^6.0.1",
|
||||||
"primereact": "^9.2.3",
|
"primereact": "^9.2.3",
|
||||||
"qrcode": "^1.5.3",
|
|
||||||
"random-words": "^2.0.0",
|
|
||||||
"react": "18.2.0",
|
"react": "18.2.0",
|
||||||
"react-chartjs-2": "^5.2.0",
|
"react-chartjs-2": "^5.2.0",
|
||||||
"react-csv": "^2.2.2",
|
|
||||||
"react-currency-input-field": "^3.6.12",
|
|
||||||
"react-datepicker": "^4.18.0",
|
|
||||||
"react-diff-viewer": "^3.1.1",
|
|
||||||
"react-dom": "18.2.0",
|
"react-dom": "18.2.0",
|
||||||
"react-firebase-hooks": "^5.1.1",
|
"react-firebase-hooks": "^5.1.1",
|
||||||
"react-icons": "^5.3.0",
|
"react-icons": "^4.8.0",
|
||||||
"react-lineto": "^3.3.0",
|
"react-lineto": "^3.3.0",
|
||||||
"react-media-recorder": "1.6.5",
|
"react-media-recorder": "1.6.5",
|
||||||
"react-phone-number-input": "^3.3.6",
|
|
||||||
"react-player": "^2.12.0",
|
"react-player": "^2.12.0",
|
||||||
"react-select": "^5.7.5",
|
|
||||||
"react-string-replace": "^1.1.0",
|
"react-string-replace": "^1.1.0",
|
||||||
"react-toastify": "^9.1.2",
|
"react-toastify": "^9.1.2",
|
||||||
"react-tooltip": "^5.27.1",
|
|
||||||
"react-xarrows": "^2.0.2",
|
"react-xarrows": "^2.0.2",
|
||||||
"read-excel-file": "^5.7.1",
|
"swr": "^2.1.3",
|
||||||
"short-unique-id": "5.0.2",
|
|
||||||
"stripe": "^13.10.0",
|
|
||||||
"swr": "^2.2.5",
|
|
||||||
"tailwind-merge": "^2.5.2",
|
|
||||||
"tailwind-scrollbar-hide": "^1.1.7",
|
|
||||||
"tailwindcss-animate": "^1.0.7",
|
|
||||||
"typescript": "4.9.5",
|
"typescript": "4.9.5",
|
||||||
"use-file-picker": "^2.1.0",
|
|
||||||
"uuid": "^9.0.0",
|
"uuid": "^9.0.0",
|
||||||
"wavesurfer.js": "^6.6.4",
|
"wavesurfer.js": "^6.6.4",
|
||||||
"zustand": "^4.3.6"
|
"zustand": "^4.3.6"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@simbathesailor/use-what-changed": "^2.0.0",
|
|
||||||
"@types/blob-stream": "^0.1.33",
|
|
||||||
"@types/formidable": "^3.4.0",
|
"@types/formidable": "^3.4.0",
|
||||||
"@types/howler": "^2.2.11",
|
|
||||||
"@types/lodash": "^4.14.191",
|
"@types/lodash": "^4.14.191",
|
||||||
"@types/nodemailer": "^6.4.11",
|
|
||||||
"@types/nodemailer-express-handlebars": "^4.0.3",
|
|
||||||
"@types/qrcode": "^1.5.5",
|
|
||||||
"@types/react-csv": "^1.1.10",
|
|
||||||
"@types/react-datepicker": "^4.15.1",
|
|
||||||
"@types/uuid": "^9.0.1",
|
"@types/uuid": "^9.0.1",
|
||||||
"@types/wavesurfer.js": "^6.0.6",
|
"@types/wavesurfer.js": "^6.0.6",
|
||||||
"@wixc3/react-board": "^2.2.0",
|
"@wixc3/react-board": "^2.2.0",
|
||||||
"autoprefixer": "^10.4.13",
|
"autoprefixer": "^10.4.13",
|
||||||
"husky": "^8.0.3",
|
|
||||||
"postcss": "^8.4.21",
|
"postcss": "^8.4.21",
|
||||||
"tailwindcss": "^3.2.4"
|
"tailwindcss": "^3.2.4"
|
||||||
}
|
}
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 48 KiB After Width: | Height: | Size: 5.2 KiB |
|
Before Width: | Height: | Size: 5.5 KiB After Width: | Height: | Size: 13 KiB |
BIN
public/logo.png
|
Before Width: | Height: | Size: 6.4 KiB |
|
Before Width: | Height: | Size: 48 KiB |
@@ -1 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="#e8eaed"><path d="M440-280h80v-240h-80v240Zm40-320q17 0 28.5-11.5T520-640q0-17-11.5-28.5T480-680q-17 0-28.5 11.5T440-640q0 17 11.5 28.5T480-600Zm0 520q-83 0-156-31.5T197-197q-54-54-85.5-127T80-480q0-83 31.5-156T197-763q54-54 127-85.5T480-880q83 0 156 31.5T763-763q54 54 85.5 127T880-480q0 83-31.5 156T763-197q-54 54-127 85.5T480-80Zm0-80q134 0 227-93t93-227q0-134-93-227t-227-93q-134 0-227 93t-93 227q0 134 93 227t227 93Zm0-320Z"/></svg>
|
|
||||||
|
Before Width: | Height: | Size: 535 B |
|
Before Width: | Height: | Size: 3.0 MiB |
|
Before Width: | Height: | Size: 2.7 KiB |
|
Before Width: | Height: | Size: 3.1 KiB |
|
Before Width: | Height: | Size: 2.8 KiB |
|
Before Width: | Height: | Size: 3.2 KiB |
|
Before Width: | Height: | Size: 3.3 KiB |
|
Before Width: | Height: | Size: 3.5 KiB |
|
Before Width: | Height: | Size: 3.4 KiB |
|
Before Width: | Height: | Size: 3.5 KiB |
|
Before Width: | Height: | Size: 4.0 KiB |
|
Before Width: | Height: | Size: 3.9 KiB |
|
Before Width: | Height: | Size: 4.4 KiB |
|
Before Width: | Height: | Size: 2.9 KiB |
|
Before Width: | Height: | Size: 3.2 KiB |
|
Before Width: | Height: | Size: 2.9 KiB |
|
Before Width: | Height: | Size: 3.3 KiB |
|
Before Width: | Height: | Size: 3.4 KiB |
|
Before Width: | Height: | Size: 3.4 KiB |
|
Before Width: | Height: | Size: 3.4 KiB |
|
Before Width: | Height: | Size: 3.6 KiB |
|
Before Width: | Height: | Size: 4.0 KiB |
|
Before Width: | Height: | Size: 3.8 KiB |
|
Before Width: | Height: | Size: 4.2 KiB |
|
Before Width: | Height: | Size: 7.3 MiB |
@@ -1,193 +0,0 @@
|
|||||||
import Image from "next/image";
|
|
||||||
import clsx from "clsx";
|
|
||||||
import RadialProgressBar from "./RadialProgressBar";
|
|
||||||
import { AIDetectionAttributes } from "@/interfaces/exam";
|
|
||||||
import { Tooltip } from 'react-tooltip';
|
|
||||||
import SegmentedProgressBar from "./SegmentedProgressBar";
|
|
||||||
|
|
||||||
|
|
||||||
// Colors and texts scrapped from gpt's zero react bundle
|
|
||||||
const AIDetection: React.FC<AIDetectionAttributes> = ({ predicted_class, confidence_category, class_probabilities, sentences }) => {
|
|
||||||
const probabilityTooltipContent = `
|
|
||||||
Encoach's deep learning model predicts the <br/>
|
|
||||||
probability this text has been entirely <br/>
|
|
||||||
generated by AI. For instance, a 40% AI <br/>
|
|
||||||
probability does not indicate that the text<br/>
|
|
||||||
contains 40% AI-written content. Rather, it<br/>
|
|
||||||
indicates the text is more likely to be partially<br/>
|
|
||||||
human written than be entirely AI-written.
|
|
||||||
`;
|
|
||||||
const confidenceTooltipContent = `
|
|
||||||
Confidence scores are a safeguard to better<br/>
|
|
||||||
understand AI identification results. Encoach<br/>
|
|
||||||
trained it's deep learning model on a diverse<br/>
|
|
||||||
dataset of millions of human and AI-written<br/>
|
|
||||||
documents. Green scores indicate that you can scan<br/>
|
|
||||||
with confidence that the model has classified<br/>
|
|
||||||
many similar documents with high accuracy.<br/>
|
|
||||||
Red scores indicate that this text is dissimilar<br/>
|
|
||||||
to the ones in their training set, which can impact<br/>
|
|
||||||
the model's accuracy, and to proceed with caution.
|
|
||||||
`;
|
|
||||||
const confidenceKeywords = ["moderately", "highly", "confident", "uncertain"];
|
|
||||||
var confidence = {
|
|
||||||
low: {
|
|
||||||
ai: "Encoach is uncertain about this text. If Encoach had to classify it, it would be considered",
|
|
||||||
human: "Encoach is uncertain about this text. If Encoach had to classify it, it would likely be considered",
|
|
||||||
mixed: "Encoach is uncertain about this text. If Encoach had to classify it, it would likely be a"
|
|
||||||
},
|
|
||||||
medium: {
|
|
||||||
ai: "Encoach is moderately confident this text was",
|
|
||||||
human: "Encoach is moderately confident this text is entirely",
|
|
||||||
mixed: "Encoach is moderately confident this text is a"
|
|
||||||
},
|
|
||||||
high: {
|
|
||||||
ai: "Encoach is highly confident this text was",
|
|
||||||
human: "Encoach is highly confident this text is entirely",
|
|
||||||
mixed: "Encoach is highly confident this text is a"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
var classPrediction = {
|
|
||||||
ai: {
|
|
||||||
background: "bg-ai-detection-result-ai-bg",
|
|
||||||
color: "text-ai-detection-result-ai",
|
|
||||||
text: "ai generated"
|
|
||||||
},
|
|
||||||
mixed: {
|
|
||||||
background: "bg-ai-detection-result-mixed-bg",
|
|
||||||
color: "text-ai-detection-result-mixed",
|
|
||||||
text: "mix of ai and human"
|
|
||||||
},
|
|
||||||
human: {
|
|
||||||
background: "bg-ai-detection-result-human-bg",
|
|
||||||
color: "text-ai-detection-result-human",
|
|
||||||
text: "human"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const segments = [
|
|
||||||
{ percentage: Math.round(class_probabilities["human"] * 100), subtitle: 'human', color: "ai-detection-result-human" },
|
|
||||||
{ percentage: Math.round(class_probabilities["mixed"] * 100), subtitle: 'mixed', color: "ai-detection-result-mixed" },
|
|
||||||
{ percentage: Math.round(class_probabilities["ai"] * 100), subtitle: 'ai', color: "ai-detection-result-ai" }
|
|
||||||
];
|
|
||||||
const styleConfidenceText = (text: string): [string, string[]] => {
|
|
||||||
const keywords: string[] = [];
|
|
||||||
const styledText = text.split(" ").map(word => {
|
|
||||||
if (confidenceKeywords.includes(word)) {
|
|
||||||
keywords.push(word);
|
|
||||||
return `<span style="font-weight: 500; text-decoration: underline;">${word}</span>`;
|
|
||||||
}
|
|
||||||
return word
|
|
||||||
}).join(" ");
|
|
||||||
return [styledText, keywords];
|
|
||||||
};
|
|
||||||
const confidenceText = confidence[confidence_category][predicted_class];
|
|
||||||
const [styledText, keywords] = styleConfidenceText(confidenceText);
|
|
||||||
const tooltipStyle = {
|
|
||||||
"backgroundColor": "rgb(255, 255, 255)",
|
|
||||||
"color": "#8992B1",
|
|
||||||
boxShadow: '0 4px 6px rgba(0, 0, 0, 0.1)',
|
|
||||||
borderRadius: '0.125rem'
|
|
||||||
}
|
|
||||||
const highestProbability = Math.max(class_probabilities["ai"], class_probabilities["human"], class_probabilities["mixed"]);
|
|
||||||
const spanTextColor = highestProbability === class_probabilities["ai"]
|
|
||||||
? "#f4bf4f"
|
|
||||||
: highestProbability === class_probabilities["human"]
|
|
||||||
? "#50c08a"
|
|
||||||
: "#93aafb";
|
|
||||||
let spanClassName = highestProbability === class_probabilities["ai"]
|
|
||||||
? "text-ai-detection-result-ai"
|
|
||||||
: highestProbability === class_probabilities["human"]
|
|
||||||
? "text-ai-detection-result-human"
|
|
||||||
: "text-ai-detection-result-mixed";
|
|
||||||
spanClassName = `${spanClassName} font-bold text-lg`
|
|
||||||
const percentage = Math.round(highestProbability * 100)
|
|
||||||
const hasHighlightedForAI = sentences.some(item => item.highlight_sentence_for_ai);
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Tooltip id="probability-tooltip" className="z-50 bg-white shadow-md rounded-sm" style={tooltipStyle} />
|
|
||||||
<Tooltip id="confidence-tooltip" className="z-50 bg-white shadow-md rounded-sm" style={tooltipStyle} />
|
|
||||||
<div className="flex flex-col bg-white p-6 rounded-lg shadow-lg gap-16">
|
|
||||||
<h1 className="text-lg font-semibold">Encoach Detection Results</h1>
|
|
||||||
<div className="flex flex-row -md:flex-col -lg:gap-0 -xl:gap-10 gap-20 items-stretch -md:items-center">
|
|
||||||
<div className="flex -md:w-5/6 w-1/2 justify-center">
|
|
||||||
<div className="flex flex-col border rounded-xl">
|
|
||||||
<h1 className="border-b p-6 font-medium">Text Classification</h1>
|
|
||||||
<div className="flex flex-row gap-8 items-center p-6">
|
|
||||||
<RadialProgressBar
|
|
||||||
percentage={percentage}
|
|
||||||
text={predicted_class}
|
|
||||||
color={spanTextColor}
|
|
||||||
spanClassName={spanClassName}
|
|
||||||
/>
|
|
||||||
<div className="flex flex-col gap-1 text-sm">
|
|
||||||
<div className="flex flex-row items-center">
|
|
||||||
<span className="mr-2 text-ai-detection-result-ai-text font-semibold text-xl">
|
|
||||||
{`${Math.round(class_probabilities["ai"] * 100)}%`}
|
|
||||||
</span>
|
|
||||||
<span className="text-sm -md:text-xs text-ai-detection-text">Probability AI generated</span>
|
|
||||||
<a data-tooltip-id="probability-tooltip" data-tooltip-html={probabilityTooltipContent} className='ml-1 flex items-center justify-center'>
|
|
||||||
<Image src="/mat-icon-info.svg" width={24} height={24} alt="Probability Tooltip" />
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-row items-center gap-1">
|
|
||||||
<div className={clsx(
|
|
||||||
"rounded-full w-3 h-3",
|
|
||||||
confidence_category == 'low' ?
|
|
||||||
"bg-ai-detection-confidence-low border border-ai-detection-confidence-border" : "bg-ai-detection-confidence-low-transparent"
|
|
||||||
)}></div>
|
|
||||||
<div className={clsx(
|
|
||||||
"rounded-full w-3 h-3",
|
|
||||||
confidence_category == 'medium' ?
|
|
||||||
"bg-ai-detection-confidence-medium border border-ai-detection-confidence-border" : "bg-ai-detection-confidence-medium-transparent"
|
|
||||||
)}></div>
|
|
||||||
<div className={clsx(
|
|
||||||
"rounded-full w-3 h-3 mr-2",
|
|
||||||
confidence_category == 'high' ?
|
|
||||||
"bg-ai-detection-confidence-high border border-ai-detection-confidence-border" : "bg-ai-detection-confidence-high-transparent"
|
|
||||||
)}></div>
|
|
||||||
<span className="text-sm -md:text-xs text-ai-detection-text">{keywords.join(' ')}</span>
|
|
||||||
<a data-tooltip-id="confidence-tooltip" data-tooltip-html={confidenceTooltipContent} className='ml-1 flex items-center justify-center'>
|
|
||||||
<Image src="/mat-icon-info.svg" width={24} height={24} alt="Probability Tooltip" />
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col border rounded-xl -md:w-5/6 w-2/6">
|
|
||||||
<h1 className="border-b p-6 font-medium">Probability Breakdown</h1>
|
|
||||||
<div className="flex items-center w-full h-full">
|
|
||||||
<SegmentedProgressBar segments={segments} className="w-full px-8 -md:py-8 text-ai-detection-text" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
<div className="flex flex-row items-center">
|
|
||||||
<div dangerouslySetInnerHTML={{ __html: styledText }} className="mr-2"></div>
|
|
||||||
<div className={clsx(
|
|
||||||
"flex items-center justify-center p-2 rounded",
|
|
||||||
classPrediction[predicted_class]['color'],
|
|
||||||
classPrediction[predicted_class]['background']
|
|
||||||
)}>
|
|
||||||
<span className="text-sm">{classPrediction[predicted_class]['text']}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{(hasHighlightedForAI && <div>
|
|
||||||
Sentences that are likely written by AI are <span className="font-semibold bg-ai-detection-highlight">highlighted</span>.
|
|
||||||
</div>)}
|
|
||||||
</div>
|
|
||||||
</div >
|
|
||||||
<div>
|
|
||||||
{sentences.map((item, index) => (
|
|
||||||
<span
|
|
||||||
key={index}
|
|
||||||
className={item.highlight_sentence_for_ai ? 'bg-ai-detection-highlight' : ''}
|
|
||||||
>
|
|
||||||
{item.sentence}{' '}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
export default AIDetection;
|
|
||||||
@@ -7,11 +7,18 @@ interface Props {
|
|||||||
abandonPopupTitle: string;
|
abandonPopupTitle: string;
|
||||||
abandonPopupDescription: string;
|
abandonPopupDescription: string;
|
||||||
abandonConfirmButtonText: string;
|
abandonConfirmButtonText: string;
|
||||||
onAbandon: () => void;
|
onAbandon: Function;
|
||||||
onCancel: () => void;
|
onCancel: Function;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function AbandonPopup({isOpen, abandonPopupTitle, abandonPopupDescription, abandonConfirmButtonText, onAbandon, onCancel}: Props) {
|
export default function AbandonPopup({
|
||||||
|
isOpen,
|
||||||
|
abandonPopupTitle,
|
||||||
|
abandonPopupDescription,
|
||||||
|
abandonConfirmButtonText,
|
||||||
|
onAbandon,
|
||||||
|
onCancel,
|
||||||
|
}: Props) {
|
||||||
return (
|
return (
|
||||||
<Transition show={isOpen} as={Fragment}>
|
<Transition show={isOpen} as={Fragment}>
|
||||||
<Dialog onClose={onCancel} className="relative z-50">
|
<Dialog onClose={onCancel} className="relative z-50">
|
||||||
|
|||||||
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,63 +0,0 @@
|
|||||||
import clsx from "clsx";
|
|
||||||
import {IconType} from "react-icons";
|
|
||||||
import {MdSpaceDashboard} from "react-icons/md";
|
|
||||||
import {BsFileEarmarkText, BsClockHistory, BsPencil, BsGraphUp} from "react-icons/bs";
|
|
||||||
import {RiLogoutBoxFill} from "react-icons/ri";
|
|
||||||
import {SlPencil} from "react-icons/sl";
|
|
||||||
import {FaAward} from "react-icons/fa";
|
|
||||||
import Link from "next/link";
|
|
||||||
import {useRouter} from "next/router";
|
|
||||||
import axios from "axios";
|
|
||||||
import FocusLayer from "@/components/FocusLayer";
|
|
||||||
import {preventNavigation} from "@/utils/navigation.disabled";
|
|
||||||
interface Props {
|
|
||||||
path: string;
|
|
||||||
navDisabled?: boolean;
|
|
||||||
focusMode?: boolean;
|
|
||||||
onFocusLayerMouseEnter?: () => void;
|
|
||||||
className?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface NavProps {
|
|
||||||
Icon: IconType;
|
|
||||||
label: string;
|
|
||||||
path: string;
|
|
||||||
keyPath: string;
|
|
||||||
disabled?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
const Nav = ({Icon, label, path, keyPath, disabled = false}: NavProps) => (
|
|
||||||
<Link
|
|
||||||
href={!disabled ? keyPath : ""}
|
|
||||||
className={clsx(
|
|
||||||
"p-4 rounded-full flex gap-4 items-center cursor-pointer text-gray-500 hover:bg-mti-purple-light hover:text-white transition duration-300 ease-in-out",
|
|
||||||
path === keyPath && "bg-mti-purple-light text-white",
|
|
||||||
)}>
|
|
||||||
<Icon size={20} />
|
|
||||||
</Link>
|
|
||||||
);
|
|
||||||
|
|
||||||
export default function BottomBar({path, navDisabled = false, focusMode = false, onFocusLayerMouseEnter, className}: Props) {
|
|
||||||
const router = useRouter();
|
|
||||||
|
|
||||||
const logout = async () => {
|
|
||||||
axios.post("/api/logout").finally(() => {
|
|
||||||
setTimeout(() => router.reload(), 500);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const disableNavigation = preventNavigation(navDisabled, focusMode);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<section className={clsx("w-full bg-white py-2 drop-shadow-2xl shadow-2xl rounded-t-2xl", className)}>
|
|
||||||
<div className="flex justify-around gap-3">
|
|
||||||
<Nav disabled={disableNavigation} Icon={MdSpaceDashboard} label="Dashboard" path={path} keyPath="/" />
|
|
||||||
<Nav disabled={disableNavigation} Icon={BsFileEarmarkText} label="Exams" path={path} keyPath="/exam" />
|
|
||||||
<Nav disabled={disableNavigation} Icon={BsPencil} label="Exercises" path={path} keyPath="/exercises" />
|
|
||||||
<Nav disabled={disableNavigation} Icon={BsGraphUp} label="Stats" path={path} keyPath="/stats" />
|
|
||||||
<Nav disabled={disableNavigation} Icon={BsClockHistory} label="Record" path={path} keyPath="/record" />
|
|
||||||
</div>
|
|
||||||
{focusMode && <FocusLayer onFocusLayerMouseEnter={onFocusLayerMouseEnter} />}
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,141 +0,0 @@
|
|||||||
import {EmploymentStatus, EMPLOYMENT_STATUS, Gender, User} from "@/interfaces/user";
|
|
||||||
import {FormEvent, useEffect, useState} from "react";
|
|
||||||
import countryCodes from "country-codes-list";
|
|
||||||
import {RadioGroup} from "@headlessui/react";
|
|
||||||
import Input from "./Low/Input";
|
|
||||||
import clsx from "clsx";
|
|
||||||
import Button from "./Low/Button";
|
|
||||||
import {BsArrowRepeat} from "react-icons/bs";
|
|
||||||
import axios from "axios";
|
|
||||||
import {toast} from "react-toastify";
|
|
||||||
import {KeyedMutator} from "swr";
|
|
||||||
import CountrySelect from "./Low/CountrySelect";
|
|
||||||
import GenderInput from "@/components/High/GenderInput";
|
|
||||||
import EmploymentStatusInput from "@/components/High/EmploymentStatusInput";
|
|
||||||
import TimezoneSelect from "./Low/TImezoneSelect";
|
|
||||||
import moment from "moment";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
user: User;
|
|
||||||
mutateUser: KeyedMutator<User>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function DemographicInformationInput({user, mutateUser}: Props) {
|
|
||||||
const [country, setCountry] = useState(user.demographicInformation?.country);
|
|
||||||
const [phone, setPhone] = useState(user.demographicInformation?.phone);
|
|
||||||
const [passport_id, setPassportID] = useState<string | undefined>(user.type === "student" ? user.demographicInformation?.passport_id : undefined);
|
|
||||||
const [gender, setGender] = useState<Gender>();
|
|
||||||
const [employment, setEmployment] = useState<EmploymentStatus>();
|
|
||||||
const [timezone, setTimezone] = useState<string>(moment.tz.guess());
|
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
|
||||||
const [position, setPosition] = useState(
|
|
||||||
user.type === "corporate" || user.type === "mastercorporate"
|
|
||||||
? user.demographicInformation?.position
|
|
||||||
: user.demographicInformation?.employment,
|
|
||||||
);
|
|
||||||
|
|
||||||
const [companyName, setCompanyName] = useState<string>();
|
|
||||||
const [commercialRegistration, setCommercialRegistration] = useState<string>();
|
|
||||||
|
|
||||||
const save = (e?: FormEvent) => {
|
|
||||||
if (e) e.preventDefault();
|
|
||||||
setIsLoading(true);
|
|
||||||
|
|
||||||
axios
|
|
||||||
.patch("/api/users/update", {
|
|
||||||
demographicInformation: {
|
|
||||||
country,
|
|
||||||
phone: `+${countryCodes.findOne("countryCode" as any, country!).countryCallingCode}${phone}`,
|
|
||||||
gender,
|
|
||||||
employment: user.type === "corporate" ? undefined : employment,
|
|
||||||
position: user.type === "corporate" ? position : undefined,
|
|
||||||
passport_id,
|
|
||||||
timezone,
|
|
||||||
},
|
|
||||||
agentInformation: user.type === "agent" ? {companyName, commercialRegistration} : undefined,
|
|
||||||
})
|
|
||||||
.then((response) => mutateUser((response.data as {user: User}).user))
|
|
||||||
.catch(() => {
|
|
||||||
toast.error("Something went wrong, please try again later!", {toastId: "user-update-error"});
|
|
||||||
})
|
|
||||||
.finally(() => setIsLoading(false));
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col items-center justify-center gap-12 w-full">
|
|
||||||
<h2 className="font-semibold text-center text-xl max-w-[800px]">
|
|
||||||
Welcome to EnCoach, the ultimate platform dedicated to helping you master the IELTS ! We are thrilled that you have chosen us as your
|
|
||||||
learning companion on this journey towards achieving your desired IELTS score.
|
|
||||||
<br />
|
|
||||||
<br />
|
|
||||||
To make the most of your learning experience, we kindly request you to complete your profile. By providing some essential information
|
|
||||||
about yourself.
|
|
||||||
</h2>
|
|
||||||
<form className="flex flex-col items-center justify-items-center gap-6 w-full h-full -md:px-4 lg:w-1/2 mb-32" onSubmit={save}>
|
|
||||||
{user.type === "agent" && (
|
|
||||||
<div className="w-full flex gap-8">
|
|
||||||
<Input type="text" onChange={setCompanyName} name="companyName" label="Corporate Name" required />
|
|
||||||
<Input
|
|
||||||
type="text"
|
|
||||||
onChange={setCommercialRegistration}
|
|
||||||
name="commercialRegistration"
|
|
||||||
label="Commercial Registration"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="w-full grid grid-cols-2 gap-6">
|
|
||||||
<div className="relative flex flex-col gap-3 w-full">
|
|
||||||
<label className="font-normal text-base text-mti-gray-dim">Country *</label>
|
|
||||||
<CountrySelect value={country} onChange={setCountry} />
|
|
||||||
</div>
|
|
||||||
<Input type="tel" name="phone" label="Phone number" onChange={(e) => setPhone(e)} value={phone} placeholder="Enter phone number" required />
|
|
||||||
</div>
|
|
||||||
{user.type === "student" && (
|
|
||||||
<Input
|
|
||||||
type="text"
|
|
||||||
name="passport_id"
|
|
||||||
label="Passport/National ID"
|
|
||||||
onChange={(e) => setPassportID(e)}
|
|
||||||
value={passport_id}
|
|
||||||
placeholder="Enter National ID or Passport number"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="flex flex-col gap-3 w-full">
|
|
||||||
<label className="font-normal text-base text-mti-gray-dim">Timezone</label>
|
|
||||||
<TimezoneSelect value={timezone} onChange={setTimezone} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<GenderInput value={gender} onChange={setGender} />
|
|
||||||
{user.type === "corporate" && (
|
|
||||||
<Input name="position" onChange={setPosition} type="text" label="Department" placeholder="CEO, Head of Marketing..." required />
|
|
||||||
)}
|
|
||||||
{user.type !== "corporate" && <EmploymentStatusInput value={employment} onChange={setEmployment} />}
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<div className="self-end flex justify-end w-full gap-8 absolute bottom-8 left-0 px-8">
|
|
||||||
<Button
|
|
||||||
className="lg:mt-8 max-w-[400px] w-full self-end"
|
|
||||||
color="purple"
|
|
||||||
onClick={save}
|
|
||||||
disabled={
|
|
||||||
isLoading ||
|
|
||||||
!country ||
|
|
||||||
!phone ||
|
|
||||||
!gender ||
|
|
||||||
(user.type === "corporate" ? !position : !employment) ||
|
|
||||||
(user.type === "agent" ? !companyName || !commercialRegistration : false)
|
|
||||||
}>
|
|
||||||
{!isLoading && "Save information"}
|
|
||||||
{isLoading && (
|
|
||||||
<div className="flex items-center justify-center">
|
|
||||||
<BsArrowRepeat className="text-white animate-spin" size={25} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,30 +1,36 @@
|
|||||||
import {infoButtonStyle} from "@/constants/buttonStyles";
|
import {infoButtonStyle} from "@/constants/buttonStyles";
|
||||||
|
import {BAND_SCORES} from "@/constants/ielts";
|
||||||
import {Module} from "@/interfaces";
|
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 {getExam, getExamById} from "@/utils/exams";
|
import {getExamById} from "@/utils/exams";
|
||||||
import {MODULE_ARRAY} from "@/utils/moduleUtils";
|
|
||||||
import {writingMarking} from "@/utils/score";
|
import {writingMarking} from "@/utils/score";
|
||||||
import {Menu} from "@headlessui/react";
|
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 {useEffect, useState} from "react";
|
import {useState} from "react";
|
||||||
import {BsBook, BsChevronDown, BsHeadphones, BsMegaphone, BsPen, BsQuestionSquare} from "react-icons/bs";
|
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";
|
import Button from "./Low/Button";
|
||||||
import ModuleLevelSelector from "./Medium/ModuleLevelSelector";
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
user: User;
|
user: User;
|
||||||
onFinish: () => void;
|
onFinish: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const DIAGNOSTIC_EXAMS = [
|
||||||
|
["reading", "CurQtQoxWmHaJHeN0JW2"],
|
||||||
|
["listening", "Y6cMao8kUcVnPQOo6teV"],
|
||||||
|
["writing", "hbueuDaEZXV37EW7I12A"],
|
||||||
|
["speaking", "QVFm4pdcziJQZN2iUTDo"],
|
||||||
|
];
|
||||||
|
|
||||||
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 [levels, setLevels] = useState({reading: -1, listening: -1, writing: -1, speaking: -1, level: 0});
|
const [levels, setLevels] = useState({reading: -1, listening: -1, writing: -1, speaking: -1});
|
||||||
const [desiredLevels, setDesiredLevels] = useState({reading: 9, listening: 9, writing: 9, speaking: 9, level: 9});
|
const [desiredLevels, setDesiredLevels] = useState({reading: 9, listening: 9, writing: 9, speaking: 9});
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
@@ -37,13 +43,13 @@ export default function Diagnostic({onFinish}: Props) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const selectExam = () => {
|
const selectExam = () => {
|
||||||
const examPromises = MODULE_ARRAY.map((module) => getExam(module, true, "partial"));
|
const examPromises = DIAGNOSTIC_EXAMS.map((exam) => getExamById(exam[0] as Module, exam[1]));
|
||||||
|
|
||||||
Promise.all(examPromises).then((exams) => {
|
Promise.all(examPromises).then((exams) => {
|
||||||
if (exams.every((x) => !!x)) {
|
if (exams.every((x) => !!x)) {
|
||||||
setExams(exams.map((x) => x!));
|
setExams(exams.map((x) => x!));
|
||||||
setSelectedModules(exams.map((x) => x!.module));
|
setSelectedModules(exams.map((x) => x!.module));
|
||||||
router.push("/exercises");
|
router.push("/exam");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -52,7 +58,7 @@ export default function Diagnostic({onFinish}: Props) {
|
|||||||
axios
|
axios
|
||||||
.patch("/api/users/update", {
|
.patch("/api/users/update", {
|
||||||
focus,
|
focus,
|
||||||
levels: Object.values(levels).includes(-1) ? {reading: 0, listening: 0, writing: 0, speaking: 0, level: 0} : levels,
|
levels: Object.values(levels).includes(-1) ? {reading: -1, listening: -1, writing: -1, speaking: -1} : levels,
|
||||||
desiredLevels,
|
desiredLevels,
|
||||||
isFirstLogin: false,
|
isFirstLogin: false,
|
||||||
})
|
})
|
||||||
@@ -67,7 +73,7 @@ export default function Diagnostic({onFinish}: Props) {
|
|||||||
<div className="flex flex-col items-center justify-center gap-8 w-full">
|
<div className="flex flex-col items-center justify-center gap-8 w-full">
|
||||||
<h2 className="font-semibold text-xl">What is your current focus?</h2>
|
<h2 className="font-semibold text-xl">What is your current focus?</h2>
|
||||||
<div className="flex flex-col gap-16 w-full">
|
<div className="flex flex-col gap-16 w-full">
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-y-4 gap-x-16">
|
<div className="grid grid-cols-2 gap-y-4 gap-x-16">
|
||||||
<button
|
<button
|
||||||
onClick={() => setFocus("academic")}
|
onClick={() => setFocus("academic")}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
@@ -91,33 +97,118 @@ export default function Diagnostic({onFinish}: Props) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col items-center justify-center gap-8 w-full">
|
<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>
|
<h2 className="font-semibold text-xl">What is your current IELTS level?</h2>
|
||||||
<ModuleLevelSelector levels={levels} setLevels={setLevels} />
|
<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>
|
||||||
|
|
||||||
<div className="flex flex-col items-center justify-center gap-8 w-full mb-44">
|
<div className="self-end flex justify-between w-full gap-8 absolute bottom-8 left-0 px-8">
|
||||||
<h2 className="font-semibold text-xl">What is your desired IELTS level?</h2>
|
|
||||||
<ModuleLevelSelector levels={desiredLevels} setLevels={setDesiredLevels} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="md:self-end flex -md:flex-col justify-between w-full gap-8 absolute bottom-8 left-0 px-4 md:px-8">
|
|
||||||
<div className="w-full tooltip" data-tip="Your screen size is too small to perform a diagnostic test">
|
|
||||||
<Button
|
|
||||||
color="purple"
|
|
||||||
variant="outline"
|
|
||||||
className="group flex items-center justify-center gap-6 relative md:max-w-[400px] w-full md:hidden"
|
|
||||||
disabled>
|
|
||||||
<BsQuestionSquare className="text-mti-purple-light transition duration-300 ease-in-out" size={20} />
|
|
||||||
<span>Perform diagnostic test instead</span>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
<Button
|
<Button
|
||||||
onClick={() => updateUser(selectExam)}
|
onClick={() => updateUser(selectExam)}
|
||||||
color="purple"
|
color="purple"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="group flex items-center justify-center gap-6 relative md:max-w-[400px] w-full -md:hidden"
|
className="group flex items-center justify-center gap-6 relative max-w-[400px] w-full"
|
||||||
disabled={!focus}>
|
disabled={!focus}>
|
||||||
<BsQuestionSquare
|
<BsQuestionSquare
|
||||||
className="text-mti-purple-light group-hover:text-white transition duration-300 ease-in-out"
|
className="text-mti-purple-light group-hover:text-white transition duration-300 ease-in-out"
|
||||||
@@ -126,10 +217,12 @@ export default function Diagnostic({onFinish}: Props) {
|
|||||||
/>
|
/>
|
||||||
<span onClick={() => updateUser(selectExam)}>Perform diagnostic test instead</span>
|
<span onClick={() => updateUser(selectExam)}>Perform diagnostic test instead</span>
|
||||||
</Button>
|
</Button>
|
||||||
<Button color="purple" className="md:max-w-[400px] w-full" onClick={() => updateUser(onFinish)} disabled={isNextDisabled()}>
|
<Button color="purple" className="max-w-[400px] w-full" onClick={() => updateUser(onFinish)} disabled={isNextDisabled()}>
|
||||||
Next Step
|
Next Step
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,84 +0,0 @@
|
|||||||
import React, { useState, ReactNode, useRef, useEffect } from 'react';
|
|
||||||
import { animated, useSpring } from '@react-spring/web';
|
|
||||||
|
|
||||||
interface DropdownProps {
|
|
||||||
title: ReactNode;
|
|
||||||
open?: boolean;
|
|
||||||
className?: string;
|
|
||||||
contentWrapperClassName?: string;
|
|
||||||
bottomPadding?: number;
|
|
||||||
children: ReactNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
const Dropdown: React.FC<DropdownProps> = ({
|
|
||||||
title,
|
|
||||||
open = false,
|
|
||||||
className = "w-full text-left font-semibold flex justify-between items-center p-4",
|
|
||||||
contentWrapperClassName = "px-6",
|
|
||||||
bottomPadding = 12,
|
|
||||||
children
|
|
||||||
}) => {
|
|
||||||
const [isOpen, setIsOpen] = useState<boolean>(open);
|
|
||||||
const contentRef = useRef<HTMLDivElement>(null);
|
|
||||||
const [contentHeight, setContentHeight] = useState<number>(0);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
let resizeObserver: ResizeObserver | null = null;
|
|
||||||
|
|
||||||
if (contentRef.current) {
|
|
||||||
resizeObserver = new ResizeObserver(entries => {
|
|
||||||
for (let entry of entries) {
|
|
||||||
if (entry.borderBoxSize && entry.borderBoxSize.length > 0) {
|
|
||||||
const height = entry.borderBoxSize[0].blockSize;
|
|
||||||
setContentHeight(height + bottomPadding);
|
|
||||||
} else {
|
|
||||||
// Fallback for browsers that don't support borderBoxSize
|
|
||||||
const height = entry.contentRect.height;
|
|
||||||
setContentHeight(height + bottomPadding);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
resizeObserver.observe(contentRef.current);
|
|
||||||
}
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
if (resizeObserver) {
|
|
||||||
resizeObserver.disconnect();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}, [bottomPadding]);
|
|
||||||
|
|
||||||
const springProps = useSpring({
|
|
||||||
height: isOpen ? contentHeight : 0,
|
|
||||||
opacity: isOpen ? 1 : 0,
|
|
||||||
config: { tension: 300, friction: 30 }
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<button
|
|
||||||
onClick={() => setIsOpen(!isOpen)}
|
|
||||||
className={className}
|
|
||||||
>
|
|
||||||
{title}
|
|
||||||
<svg
|
|
||||||
className={`w-4 h-4 transform transition-transform ${isOpen ? 'rotate-180' : ''}`}
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
>
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
<animated.div style={springProps} className="overflow-hidden">
|
|
||||||
<div ref={contentRef} className={contentWrapperClassName} style={{paddingBottom: bottomPadding}}>
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
</animated.div>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default Dropdown;
|
|
||||||
178
src/components/Exercises/FillBlanks.tsx
Normal file
@@ -0,0 +1,178 @@
|
|||||||
|
import {FillBlanksExercise} from "@/interfaces/exam";
|
||||||
|
import useExamStore from "@/stores/examStore";
|
||||||
|
import clsx from "clsx";
|
||||||
|
import {Fragment, useEffect, useState} from "react";
|
||||||
|
import reactStringReplace from "react-string-replace";
|
||||||
|
import {CommonProps} from ".";
|
||||||
|
import Button from "../Low/Button";
|
||||||
|
|
||||||
|
interface WordsDrawerProps {
|
||||||
|
words: {word: string; isDisabled: boolean}[];
|
||||||
|
isOpen: boolean;
|
||||||
|
blankId?: string;
|
||||||
|
previouslySelectedWord?: string;
|
||||||
|
onCancel: () => void;
|
||||||
|
onAnswer: (answer: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function WordsDrawer({words, isOpen, blankId, previouslySelectedWord, onCancel, onAnswer}: WordsDrawerProps) {
|
||||||
|
const [selectedWord, setSelectedWord] = useState<string | undefined>(previouslySelectedWord);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
className={clsx(
|
||||||
|
"w-full h-full absolute top-0 left-0 bg-gradient-to-t from-mti-black to-transparent z-10",
|
||||||
|
isOpen ? "visible opacity-10" : "invisible opacity-0",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className={clsx(
|
||||||
|
"absolute w-full bg-white px-7 py-8 bottom-0 left-0 shadow-2xl rounded-2xl z-20 flex flex-col gap-8 transition-opacity duration-300 ease-in-out",
|
||||||
|
isOpen ? "visible opacity-100" : "invisible opacity-0",
|
||||||
|
)}>
|
||||||
|
<div className="w-full flex gap-2">
|
||||||
|
<div className="rounded-full w-6 h-6 flex items-center justify-center text-white bg-mti-purple-light">{blankId}</div>
|
||||||
|
<span> Choose the correct word:</span>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-6 gap-6">
|
||||||
|
{words.map(({word, isDisabled}) => (
|
||||||
|
<button
|
||||||
|
key={word}
|
||||||
|
onClick={() => setSelectedWord((prev) => (prev === word ? undefined : word))}
|
||||||
|
className={clsx(
|
||||||
|
"rounded-full py-3 text-center transition duration-300 ease-in-out",
|
||||||
|
selectedWord === word ? "text-white bg-mti-purple-light" : "bg-mti-purple-ultralight",
|
||||||
|
!isDisabled && "hover:text-white hover:bg-mti-purple",
|
||||||
|
"disabled:cursor-not-allowed disabled:text-mti-gray-dim",
|
||||||
|
)}
|
||||||
|
disabled={isDisabled}>
|
||||||
|
{word}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between w-full">
|
||||||
|
<Button color="purple" variant="outline" className="max-w-[200px] w-full" onClick={onCancel}>
|
||||||
|
Back
|
||||||
|
</Button>
|
||||||
|
<Button color="purple" className="max-w-[200px] w-full" onClick={() => onAnswer(selectedWord!)} disabled={!selectedWord}>
|
||||||
|
Confirm
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function FillBlanks({
|
||||||
|
id,
|
||||||
|
allowRepetition,
|
||||||
|
type,
|
||||||
|
prompt,
|
||||||
|
solutions,
|
||||||
|
text,
|
||||||
|
words,
|
||||||
|
userSolutions,
|
||||||
|
onNext,
|
||||||
|
onBack,
|
||||||
|
}: FillBlanksExercise & CommonProps) {
|
||||||
|
const [answers, setAnswers] = useState<{id: string; solution: string}[]>(userSolutions);
|
||||||
|
const [currentBlankId, setCurrentBlankId] = useState<string>();
|
||||||
|
const [isDrawerShowing, setIsDrawerShowing] = useState(false);
|
||||||
|
|
||||||
|
const hasExamEnded = useExamStore((state) => state.hasExamEnded);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setTimeout(() => setIsDrawerShowing(!!currentBlankId), 100);
|
||||||
|
}, [currentBlankId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (hasExamEnded) onNext({exercise: id, solutions: answers, score: calculateScore(), type});
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [hasExamEnded]);
|
||||||
|
|
||||||
|
const calculateScore = () => {
|
||||||
|
const 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 missing = total - answers.filter((x) => solutions.find((y) => x.id === y.id)).length;
|
||||||
|
|
||||||
|
return {total, correct, missing};
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderLines = (line: string) => {
|
||||||
|
return (
|
||||||
|
<span className="text-base leading-5">
|
||||||
|
{reactStringReplace(line, /({{\d+}})/g, (match) => {
|
||||||
|
const id = match.replaceAll(/[\{\}]/g, "");
|
||||||
|
const userSolution = answers.find((x) => x.id === id);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
className={clsx(
|
||||||
|
"rounded-full hover:text-white hover:bg-mti-purple transition duration-300 ease-in-out my-1",
|
||||||
|
!userSolution && "w-6 h-6 text-center text-mti-purple-light bg-mti-purple-ultralight",
|
||||||
|
currentBlankId === id && "text-white !bg-mti-purple-light ",
|
||||||
|
userSolution && "px-5 py-2 text-center text-white bg-mti-purple-light",
|
||||||
|
)}
|
||||||
|
onClick={() => setCurrentBlankId(id)}>
|
||||||
|
{userSolution ? userSolution.solution : id}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="flex flex-col gap-4 mt-4 h-full mb-20">
|
||||||
|
{(!!currentBlankId || isDrawerShowing) && (
|
||||||
|
<WordsDrawer
|
||||||
|
blankId={currentBlankId}
|
||||||
|
words={words.map((word) => ({word, isDisabled: allowRepetition ? false : answers.map((x) => x.solution).includes(word)}))}
|
||||||
|
previouslySelectedWord={currentBlankId ? answers.find((x) => x.id === currentBlankId)?.solution : undefined}
|
||||||
|
isOpen={isDrawerShowing}
|
||||||
|
onCancel={() => setCurrentBlankId(undefined)}
|
||||||
|
onAnswer={(solution: string) => {
|
||||||
|
setAnswers((prev) => [...prev.filter((x) => x.id !== currentBlankId), {id: currentBlankId!, solution}]);
|
||||||
|
setCurrentBlankId(undefined);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<span className="text-sm w-full leading-6">
|
||||||
|
{prompt.split("\\n").map((line, index) => (
|
||||||
|
<Fragment key={index}>
|
||||||
|
{line}
|
||||||
|
<br />
|
||||||
|
</Fragment>
|
||||||
|
))}
|
||||||
|
</span>
|
||||||
|
<span className="bg-mti-gray-smoke rounded-xl px-5 py-6">
|
||||||
|
{text.split("\\n").map((line, index) => (
|
||||||
|
<p key={index}>
|
||||||
|
{renderLines(line)}
|
||||||
|
<br />
|
||||||
|
</p>
|
||||||
|
))}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="self-end flex 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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,84 +0,0 @@
|
|||||||
import React, { useRef, useEffect, useState } from 'react';
|
|
||||||
import { animated, useSpring } from '@react-spring/web';
|
|
||||||
import clsx from 'clsx';
|
|
||||||
|
|
||||||
interface MCDropdownProps {
|
|
||||||
id: string;
|
|
||||||
options: { [key: string]: string };
|
|
||||||
onSelect: (value: string) => void;
|
|
||||||
selectedValue?: string;
|
|
||||||
className?: string;
|
|
||||||
width: number;
|
|
||||||
isOpen: boolean;
|
|
||||||
onToggle: (id: string) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const MCDropdown: React.FC<MCDropdownProps> = ({
|
|
||||||
id,
|
|
||||||
options,
|
|
||||||
onSelect,
|
|
||||||
selectedValue,
|
|
||||||
className = "relative",
|
|
||||||
width,
|
|
||||||
isOpen,
|
|
||||||
onToggle,
|
|
||||||
}) => {
|
|
||||||
const contentRef = useRef<HTMLDivElement>(null);
|
|
||||||
const [contentHeight, setContentHeight] = useState(0);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (contentRef.current) {
|
|
||||||
setContentHeight(contentRef.current.scrollHeight);
|
|
||||||
}
|
|
||||||
}, [options]);
|
|
||||||
|
|
||||||
const springProps = useSpring({
|
|
||||||
height: isOpen ? contentHeight : 0,
|
|
||||||
opacity: isOpen ? 1 : 0,
|
|
||||||
config: { tension: 300, friction: 30 }
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={`${className} inline-block`} style={{ width: `${width}px` }}>
|
|
||||||
<button
|
|
||||||
onClick={() => onToggle(id)}
|
|
||||||
className={
|
|
||||||
clsx("rounded-full hover:text-white transition duration-300 ease-in-out px-5 py-2 text-center w-full flex items-center justify-between",
|
|
||||||
selectedValue ? "bg-mti-purple text-white" : "bg-mti-purple-ultralight text-mti-purple-light"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<span className="truncate p-1">{selectedValue || 'Select an option'}</span>
|
|
||||||
<svg
|
|
||||||
className={`w-4 h-4 transform transition-transform ${isOpen ? 'rotate-180' : ''}`}
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
>
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
<animated.div
|
|
||||||
style={{ ...springProps, width: `${width}px` }}
|
|
||||||
className="absolute z-10 mt-1 overflow-hidden bg-white rounded-md shadow-lg"
|
|
||||||
>
|
|
||||||
<div ref={contentRef}>
|
|
||||||
{Object.entries(options).sort((a, b) => a[0].localeCompare(b[0])).map(([key, value]) => (
|
|
||||||
<div
|
|
||||||
key={key}
|
|
||||||
onClick={() => {
|
|
||||||
onSelect(value);
|
|
||||||
onToggle(id);
|
|
||||||
}}
|
|
||||||
className="p-4 hover:bg-mti-purple-ultralight cursor-pointer whitespace-nowrap"
|
|
||||||
>
|
|
||||||
<span>{value}</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</animated.div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default MCDropdown;
|
|
||||||
@@ -1,64 +0,0 @@
|
|||||||
import Button from "@/components/Low/Button";
|
|
||||||
import clsx from "clsx";
|
|
||||||
import { useState } from "react";
|
|
||||||
|
|
||||||
interface WordsDrawerProps {
|
|
||||||
words: {word: string; isDisabled: boolean}[];
|
|
||||||
isOpen: boolean;
|
|
||||||
blankId?: string;
|
|
||||||
previouslySelectedWord?: string;
|
|
||||||
onCancel: () => void;
|
|
||||||
onAnswer: (answer: string) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
const WordsDrawer: React.FC<WordsDrawerProps> = ({words, isOpen, blankId, previouslySelectedWord, onCancel, onAnswer}) => {
|
|
||||||
const [selectedWord, setSelectedWord] = useState<string | undefined>(previouslySelectedWord);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div
|
|
||||||
className={clsx(
|
|
||||||
"w-full h-full absolute top-0 left-0 bg-gradient-to-t from-mti-black to-transparent z-10",
|
|
||||||
isOpen ? "visible opacity-10" : "invisible opacity-0",
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<div
|
|
||||||
className={clsx(
|
|
||||||
"absolute w-full bg-white px-7 py-8 bottom-0 left-0 shadow-2xl rounded-2xl z-20 flex flex-col gap-8 transition-opacity duration-300 ease-in-out",
|
|
||||||
isOpen ? "visible opacity-100" : "invisible opacity-0",
|
|
||||||
)}>
|
|
||||||
<div className="w-full flex gap-2">
|
|
||||||
<div className="rounded-full w-6 h-6 flex items-center justify-center text-white bg-mti-purple-light">{blankId}</div>
|
|
||||||
<span> Choose the correct word:</span>
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-6 gap-6" key="word-array">
|
|
||||||
{words.map(({word, isDisabled}) => (
|
|
||||||
<button
|
|
||||||
key={`${word}_${blankId}`}
|
|
||||||
onClick={() => setSelectedWord((prev) => (prev === word ? undefined : word))}
|
|
||||||
className={clsx(
|
|
||||||
"rounded-full py-3 text-center transition duration-300 ease-in-out",
|
|
||||||
selectedWord === word ? "text-white bg-mti-purple-light" : "bg-mti-purple-ultralight",
|
|
||||||
!isDisabled && "hover:text-white hover:bg-mti-purple",
|
|
||||||
"disabled:cursor-not-allowed disabled:text-mti-gray-dim",
|
|
||||||
)}
|
|
||||||
disabled={isDisabled}>
|
|
||||||
{word}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-between w-full">
|
|
||||||
<Button color="purple" variant="outline" className="max-w-[200px] w-full" onClick={onCancel}>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button color="purple" className="max-w-[200px] w-full" onClick={() => onAnswer(selectedWord!)} disabled={!selectedWord}>
|
|
||||||
Confirm
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default WordsDrawer;
|
|
||||||
@@ -1,239 +0,0 @@
|
|||||||
import { FillBlanksExercise, FillBlanksMCOption } from "@/interfaces/exam";
|
|
||||||
import useExamStore from "@/stores/examStore";
|
|
||||||
import clsx from "clsx";
|
|
||||||
import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
||||||
import reactStringReplace from "react-string-replace";
|
|
||||||
import { CommonProps } from "..";
|
|
||||||
import Button from "../../Low/Button";
|
|
||||||
import { v4 } from "uuid";
|
|
||||||
import MCDropdown from "./MCDropdown";
|
|
||||||
|
|
||||||
const FillBlanks: React.FC<FillBlanksExercise & CommonProps> = ({
|
|
||||||
id,
|
|
||||||
type,
|
|
||||||
prompt,
|
|
||||||
solutions,
|
|
||||||
text,
|
|
||||||
words,
|
|
||||||
userSolutions,
|
|
||||||
variant,
|
|
||||||
onNext,
|
|
||||||
onBack,
|
|
||||||
}) => {
|
|
||||||
const { shuffles, exam, partIndex, questionIndex, exerciseIndex } = useExamStore((state) => state);
|
|
||||||
const [answers, setAnswers] = useState<{ id: string; solution: string }[]>(userSolutions);
|
|
||||||
const hasExamEnded = useExamStore((state) => state.hasExamEnded);
|
|
||||||
const setCurrentSolution = useExamStore((state) => state.setCurrentSolution);
|
|
||||||
const shuffleMaps = shuffles.find((x) => x.exerciseID == id)?.shuffles;
|
|
||||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
|
||||||
|
|
||||||
const excludeWordMCType = (x: any) => {
|
|
||||||
return typeof x === "string" ? x : (x as { letter: string; word: string });
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (hasExamEnded) onNext({ exercise: id, solutions: answers, score: calculateScore(), type });
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [hasExamEnded]);
|
|
||||||
|
|
||||||
let correctWords: any;
|
|
||||||
if (exam && (exam.module === "level" || exam.module === "reading") && exam.parts[partIndex].exercises[exerciseIndex].type === "fillBlanks") {
|
|
||||||
correctWords = (exam.parts[partIndex].exercises[exerciseIndex] as FillBlanksExercise).words;
|
|
||||||
}
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const handleClickOutside = (event: MouseEvent) => {
|
|
||||||
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
|
|
||||||
setOpenDropdownId(null);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
document.addEventListener('mousedown', handleClickOutside);
|
|
||||||
return () => {
|
|
||||||
document.removeEventListener('mousedown', handleClickOutside);
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const calculateScore = () => {
|
|
||||||
const total = text.match(/({{\d+}})/g)?.length || 0;
|
|
||||||
const correct = answers!.filter((x) => {
|
|
||||||
const solution = solutions.find((y) => x.id.toString() === y.id.toString())?.solution;
|
|
||||||
if (!solution) return false;
|
|
||||||
const option = correctWords!.find((w: any) => {
|
|
||||||
if (typeof w === "string") {
|
|
||||||
return w.toLowerCase() === x.solution.toLowerCase();
|
|
||||||
} else if ("letter" in w) {
|
|
||||||
return w.letter.toLowerCase() === x.solution.toLowerCase();
|
|
||||||
} else {
|
|
||||||
return w.id.toString() === x.id.toString();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
if (!option) return false;
|
|
||||||
|
|
||||||
if (typeof option === "string") {
|
|
||||||
return solution.toLowerCase() === option.toLowerCase();
|
|
||||||
} else if ("letter" in option) {
|
|
||||||
return solution.toLowerCase() === option.word.toLowerCase();
|
|
||||||
} else if ("options" in option) {
|
|
||||||
return option.options[solution as keyof typeof option.options] == x.solution;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}).length;
|
|
||||||
const missing = total - answers!.filter((x) => solutions.find((y) => x.id.toString() === y.id.toString())).length;
|
|
||||||
return { total, correct, missing };
|
|
||||||
};
|
|
||||||
|
|
||||||
const [openDropdownId, setOpenDropdownId] = useState<string | null>(null);
|
|
||||||
|
|
||||||
const renderLines = useCallback(
|
|
||||||
(line: string) => {
|
|
||||||
return (
|
|
||||||
<div className="text-xl leading-5" key={v4()} ref={dropdownRef}>
|
|
||||||
{reactStringReplace(line, /({{\d+}})/g, (match) => {
|
|
||||||
const id = match.replaceAll(/[\{\}]/g, "");
|
|
||||||
const userSolution = answers.find((x) => x.id === id);
|
|
||||||
const styles = clsx(
|
|
||||||
"rounded-full hover:text-white transition duration-300 ease-in-out my-1 px-5 py-2 text-center w-fit",
|
|
||||||
!userSolution && "text-center text-mti-purple-light bg-mti-purple-ultralight",
|
|
||||||
userSolution && "text-center text-mti-purple-dark bg-mti-purple-ultralight",
|
|
||||||
);
|
|
||||||
|
|
||||||
const currentSelection = words.find((x) => {
|
|
||||||
if (typeof x !== "string" && "id" in x) {
|
|
||||||
return (x as FillBlanksMCOption).id.toString() == id.toString();
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}) as FillBlanksMCOption;
|
|
||||||
|
|
||||||
return variant === "mc" ? (
|
|
||||||
<MCDropdown
|
|
||||||
id={id}
|
|
||||||
options={currentSelection.options}
|
|
||||||
onSelect={(value) => onSelection(id, value)}
|
|
||||||
selectedValue={userSolution?.solution}
|
|
||||||
className="inline-block py-2 px-1"
|
|
||||||
width={220}
|
|
||||||
isOpen={openDropdownId === id}
|
|
||||||
onToggle={()=> setOpenDropdownId(prevId => prevId === id ? null : id)}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<input
|
|
||||||
className={styles}
|
|
||||||
onChange={(e) => setAnswers((prev) => [...prev.filter((x) => x.id !== id), { id, solution: e.target.value }])}
|
|
||||||
value={userSolution?.solution}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
})
|
|
||||||
}
|
|
||||||
</div >
|
|
||||||
);
|
|
||||||
},
|
|
||||||
[variant, words, answers, openDropdownId],
|
|
||||||
);
|
|
||||||
|
|
||||||
const memoizedLines = useMemo(() => {
|
|
||||||
return text.split("\\n").map((line, index) => (
|
|
||||||
<p key={index} className={clsx(variant === "mc" && "whitespace-pre-wrap")}>
|
|
||||||
{renderLines(line)}
|
|
||||||
<br />
|
|
||||||
</p>
|
|
||||||
));
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [text, variant, renderLines]);
|
|
||||||
|
|
||||||
const onSelection = (questionID: string, value: string) => {
|
|
||||||
setAnswers((prev) => [...prev.filter((x) => x.id !== questionID), { id: questionID, solution: value }]);
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (variant === "mc") {
|
|
||||||
setCurrentSolution({ exercise: id, solutions: answers, score: calculateScore(), type, shuffleMaps: shuffleMaps });
|
|
||||||
}
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [answers]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col gap-4">
|
|
||||||
<div className="flex justify-between w-full gap-8">
|
|
||||||
<Button
|
|
||||||
color="purple"
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => onBack({ exercise: id, solutions: answers, score: calculateScore(), type, shuffleMaps: shuffleMaps })}
|
|
||||||
className="max-w-[200px] w-full"
|
|
||||||
disabled={exam && exam.module === "level" && partIndex === 0 && questionIndex === 0}>
|
|
||||||
Previous Page
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
color="purple"
|
|
||||||
onClick={() => {
|
|
||||||
onNext({ exercise: id, solutions: answers, score: calculateScore(), type, shuffleMaps: shuffleMaps });
|
|
||||||
}}
|
|
||||||
className="max-w-[200px] self-end w-full">
|
|
||||||
Next Page
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-col gap-4 mt-4 h-full w-full mb-20">
|
|
||||||
{variant !== "mc" && (
|
|
||||||
<span className="text-sm w-full leading-6">
|
|
||||||
{prompt.split("\\n").map((line, index) => (
|
|
||||||
<Fragment key={index}>
|
|
||||||
{line}
|
|
||||||
<br />
|
|
||||||
</Fragment>
|
|
||||||
))}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
<span className="bg-mti-gray-smoke rounded-xl px-5 py-6">{memoizedLines}</span>
|
|
||||||
{variant !== "mc" && (
|
|
||||||
<div className="bg-mti-gray-smoke rounded-xl px-5 py-6 flex flex-col gap-4">
|
|
||||||
<span className="font-medium text-mti-purple-dark">Options</span>
|
|
||||||
<div className="flex gap-4 flex-wrap">
|
|
||||||
{words.map((v) => {
|
|
||||||
v = excludeWordMCType(v);
|
|
||||||
const text = typeof v === "string" ? v : `${v.letter} - ${v.word}`;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<span
|
|
||||||
className={clsx(
|
|
||||||
"border border-mti-purple-light rounded-full px-3 py-0.5 transition ease-in-out duration-300",
|
|
||||||
!!answers.find(
|
|
||||||
(x) =>
|
|
||||||
x.solution.toLowerCase() ===
|
|
||||||
(typeof v === "string" ? v : "letter" in v ? v.letter : "").toLowerCase(),
|
|
||||||
) && "bg-mti-purple-dark text-white",
|
|
||||||
)}
|
|
||||||
key={v4()}>
|
|
||||||
{text}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</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, shuffleMaps: shuffleMaps })}
|
|
||||||
className="max-w-[200px] w-full"
|
|
||||||
disabled={exam && exam.module === "level" && partIndex === 0 && questionIndex === 0}>
|
|
||||||
Previous Page
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
color="purple"
|
|
||||||
onClick={() => {
|
|
||||||
onNext({ exercise: id, solutions: answers, score: calculateScore(), type, shuffleMaps: shuffleMaps });
|
|
||||||
}}
|
|
||||||
className="max-w-[200px] self-end w-full">
|
|
||||||
Next Page
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default FillBlanks;
|
|
||||||
@@ -1,308 +0,0 @@
|
|||||||
import {InteractiveSpeakingExercise} from "@/interfaces/exam";
|
|
||||||
import {CommonProps} from ".";
|
|
||||||
import {useEffect, useState} from "react";
|
|
||||||
import {BsCheckCircleFill, BsMicFill, BsPauseCircle, BsPlayCircle, BsTrashFill} from "react-icons/bs";
|
|
||||||
import dynamic from "next/dynamic";
|
|
||||||
import Button from "../Low/Button";
|
|
||||||
import useExamStore from "@/stores/examStore";
|
|
||||||
import {downloadBlob} from "@/utils/evaluation";
|
|
||||||
import axios from "axios";
|
|
||||||
|
|
||||||
const Waveform = dynamic(() => import("../Waveform"), {ssr: false});
|
|
||||||
const ReactMediaRecorder = dynamic(() => import("react-media-recorder").then((mod) => mod.ReactMediaRecorder), {
|
|
||||||
ssr: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
export default function InteractiveSpeaking({
|
|
||||||
id,
|
|
||||||
title,
|
|
||||||
first_title,
|
|
||||||
second_title,
|
|
||||||
examID,
|
|
||||||
type,
|
|
||||||
prompts,
|
|
||||||
userSolutions,
|
|
||||||
onNext,
|
|
||||||
onBack,
|
|
||||||
}: InteractiveSpeakingExercise & CommonProps) {
|
|
||||||
const [recordingDuration, setRecordingDuration] = useState(0);
|
|
||||||
const [isRecording, setIsRecording] = useState(false);
|
|
||||||
const [mediaBlob, setMediaBlob] = useState<string>();
|
|
||||||
const [answers, setAnswers] = useState<{prompt: string; blob: string; questionIndex: number}[]>([]);
|
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
|
||||||
|
|
||||||
const {questionIndex, setQuestionIndex} = useExamStore((state) => state);
|
|
||||||
const {userSolutions: storeUserSolutions, setUserSolutions} = useExamStore((state) => state);
|
|
||||||
|
|
||||||
const hasExamEnded = useExamStore((state) => state.hasExamEnded);
|
|
||||||
|
|
||||||
const back = async () => {
|
|
||||||
setIsLoading(true);
|
|
||||||
|
|
||||||
const answer = await saveAnswer(questionIndex);
|
|
||||||
if (questionIndex - 1 >= 0) {
|
|
||||||
setQuestionIndex(questionIndex - 1);
|
|
||||||
setIsLoading(false);
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setIsLoading(false);
|
|
||||||
|
|
||||||
onBack({
|
|
||||||
exercise: id,
|
|
||||||
solutions: [...answers.filter((x) => x.questionIndex !== questionIndex), answer],
|
|
||||||
score: {correct: 100, total: 100, missing: 0},
|
|
||||||
type,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const next = async () => {
|
|
||||||
setIsLoading(true);
|
|
||||||
|
|
||||||
const answer = await saveAnswer(questionIndex);
|
|
||||||
if (questionIndex + 1 < prompts.length) {
|
|
||||||
setQuestionIndex(questionIndex + 1);
|
|
||||||
setIsLoading(false);
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setIsLoading(false);
|
|
||||||
setQuestionIndex(0);
|
|
||||||
|
|
||||||
onNext({
|
|
||||||
exercise: id,
|
|
||||||
solutions: [...answers.filter((x) => x.questionIndex !== questionIndex), answer],
|
|
||||||
score: {correct: 100, total: 100, missing: 0},
|
|
||||||
type,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (userSolutions.length > 0 && answers.length === 0) {
|
|
||||||
const solutions = userSolutions as unknown as typeof answers;
|
|
||||||
setAnswers(solutions);
|
|
||||||
|
|
||||||
if (!mediaBlob) setMediaBlob(solutions.find((x) => x.questionIndex === questionIndex)?.blob);
|
|
||||||
}
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [userSolutions, mediaBlob, answers]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (hasExamEnded) {
|
|
||||||
const answer = {
|
|
||||||
questionIndex,
|
|
||||||
prompt: prompts[questionIndex].text,
|
|
||||||
blob: mediaBlob!,
|
|
||||||
};
|
|
||||||
|
|
||||||
onNext({
|
|
||||||
exercise: id,
|
|
||||||
solutions: [...answers.filter((x) => x.questionIndex !== questionIndex), answer],
|
|
||||||
score: {correct: 100, total: 100, missing: 0},
|
|
||||||
type,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [hasExamEnded]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
let recordingInterval: NodeJS.Timer | undefined = undefined;
|
|
||||||
if (isRecording) {
|
|
||||||
recordingInterval = setInterval(() => setRecordingDuration((prev) => prev + 1), 1000);
|
|
||||||
} else if (recordingInterval) {
|
|
||||||
clearInterval(recordingInterval);
|
|
||||||
}
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
if (recordingInterval) clearInterval(recordingInterval);
|
|
||||||
};
|
|
||||||
}, [isRecording]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (questionIndex <= answers.length - 1) {
|
|
||||||
const blob = answers.find((x) => x.questionIndex === questionIndex)?.blob;
|
|
||||||
setMediaBlob(blob);
|
|
||||||
}
|
|
||||||
}, [answers, questionIndex]);
|
|
||||||
|
|
||||||
const saveAnswer = async (index: number) => {
|
|
||||||
const answer = {
|
|
||||||
questionIndex,
|
|
||||||
prompt: prompts[questionIndex].text,
|
|
||||||
blob: mediaBlob!,
|
|
||||||
};
|
|
||||||
|
|
||||||
setAnswers((prev) => [...prev.filter((x) => x.questionIndex !== index), answer]);
|
|
||||||
setMediaBlob(undefined);
|
|
||||||
|
|
||||||
setUserSolutions([
|
|
||||||
...storeUserSolutions.filter((x) => x.exercise !== id),
|
|
||||||
{
|
|
||||||
exercise: id,
|
|
||||||
solutions: [...answers.filter((x) => x.questionIndex !== questionIndex), answer],
|
|
||||||
score: {correct: 100, total: 100, missing: 0},
|
|
||||||
module: "speaking",
|
|
||||||
exam: examID,
|
|
||||||
type,
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
|
|
||||||
return answer;
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col gap-4 mt-4 w-full">
|
|
||||||
<div className="flex justify-between w-full gap-8">
|
|
||||||
<Button color="purple" variant="outline" isLoading={isLoading} onClick={back} className="max-w-[200px] self-end w-full">
|
|
||||||
Back
|
|
||||||
</Button>
|
|
||||||
<Button color="purple" disabled={!mediaBlob} isLoading={isLoading} onClick={next} className="max-w-[200px] self-end w-full">
|
|
||||||
{questionIndex + 1 < prompts.length ? "Next Prompt" : "Submit"}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-col h-full w-full gap-9">
|
|
||||||
<div className="flex flex-col w-full gap-8 bg-mti-gray-smoke rounded-xl py-8 pb-12 px-16">
|
|
||||||
<div className="flex flex-col gap-3">
|
|
||||||
<span className="font-semibold">{!!first_title && !!second_title ? `${first_title} & ${second_title}` : title}</span>
|
|
||||||
</div>
|
|
||||||
{prompts && prompts.length > 0 && (
|
|
||||||
<div className="flex flex-col gap-4 w-full items-center">
|
|
||||||
<video key={questionIndex} autoPlay controls className="max-w-3xl rounded-xl">
|
|
||||||
<source src={prompts[questionIndex].video_url} />
|
|
||||||
</video>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<ReactMediaRecorder
|
|
||||||
audio
|
|
||||||
key={questionIndex}
|
|
||||||
onStop={(blob) => setMediaBlob(blob)}
|
|
||||||
render={({status, startRecording, stopRecording, pauseRecording, resumeRecording, clearBlobUrl, mediaBlobUrl}) => (
|
|
||||||
<div className="w-full p-4 px-8 bg-transparent border-2 border-mti-gray-platinum rounded-2xl flex-col gap-8 items-center">
|
|
||||||
<p className="text-base font-normal">Record your answer:</p>
|
|
||||||
<div className="flex gap-8 items-center justify-center py-8">
|
|
||||||
{status === "idle" && (
|
|
||||||
<>
|
|
||||||
<div className="w-full h-2 max-w-4xl bg-mti-gray-smoke rounded-full" />
|
|
||||||
{status === "idle" && (
|
|
||||||
<BsMicFill
|
|
||||||
onClick={() => {
|
|
||||||
setRecordingDuration(0);
|
|
||||||
startRecording();
|
|
||||||
setIsRecording(true);
|
|
||||||
}}
|
|
||||||
className="h-5 w-5 text-mti-gray-cool cursor-pointer"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{status === "recording" && (
|
|
||||||
<>
|
|
||||||
<div className="flex gap-4 items-center">
|
|
||||||
<span className="text-xs w-9">
|
|
||||||
{Math.floor(recordingDuration / 60)
|
|
||||||
.toString(10)
|
|
||||||
.padStart(2, "0")}
|
|
||||||
:
|
|
||||||
{Math.floor(recordingDuration % 60)
|
|
||||||
.toString(10)
|
|
||||||
.padStart(2, "0")}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="w-full h-2 max-w-4xl bg-mti-gray-smoke rounded-full" />
|
|
||||||
<div className="flex gap-4 items-center">
|
|
||||||
<BsPauseCircle
|
|
||||||
onClick={() => {
|
|
||||||
setIsRecording(false);
|
|
||||||
pauseRecording();
|
|
||||||
}}
|
|
||||||
className="text-red-500 w-8 h-8 cursor-pointer"
|
|
||||||
/>
|
|
||||||
<BsCheckCircleFill
|
|
||||||
onClick={() => {
|
|
||||||
setIsRecording(false);
|
|
||||||
stopRecording();
|
|
||||||
}}
|
|
||||||
className="text-mti-purple-light w-8 h-8 cursor-pointer"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{status === "paused" && (
|
|
||||||
<>
|
|
||||||
<div className="flex gap-4 items-center">
|
|
||||||
<span className="text-xs w-9">
|
|
||||||
{Math.floor(recordingDuration / 60)
|
|
||||||
.toString(10)
|
|
||||||
.padStart(2, "0")}
|
|
||||||
:
|
|
||||||
{Math.floor(recordingDuration % 60)
|
|
||||||
.toString(10)
|
|
||||||
.padStart(2, "0")}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="w-full h-2 max-w-4xl bg-mti-gray-smoke rounded-full" />
|
|
||||||
<div className="flex gap-4 items-center">
|
|
||||||
<BsPlayCircle
|
|
||||||
onClick={() => {
|
|
||||||
setIsRecording(true);
|
|
||||||
resumeRecording();
|
|
||||||
}}
|
|
||||||
className="text-mti-purple-light w-8 h-8 cursor-pointer"
|
|
||||||
/>
|
|
||||||
<BsCheckCircleFill
|
|
||||||
onClick={() => {
|
|
||||||
setIsRecording(false);
|
|
||||||
stopRecording();
|
|
||||||
}}
|
|
||||||
className="text-mti-purple-light w-8 h-8 cursor-pointer"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{status === "stopped" && mediaBlobUrl && (
|
|
||||||
<>
|
|
||||||
<Waveform audio={mediaBlobUrl} waveColor="#FCDDEC" progressColor="#EF5DA8" />
|
|
||||||
<div className="flex gap-4 items-center">
|
|
||||||
<BsTrashFill
|
|
||||||
className="text-mti-gray-cool cursor-pointer w-5 h-5"
|
|
||||||
onClick={() => {
|
|
||||||
setRecordingDuration(0);
|
|
||||||
clearBlobUrl();
|
|
||||||
setMediaBlob(undefined);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<BsMicFill
|
|
||||||
onClick={() => {
|
|
||||||
clearBlobUrl();
|
|
||||||
setRecordingDuration(0);
|
|
||||||
startRecording();
|
|
||||||
setIsRecording(true);
|
|
||||||
setMediaBlob(undefined);
|
|
||||||
}}
|
|
||||||
className="h-5 w-5 text-mti-gray-cool cursor-pointer"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="self-end flex justify-between w-full gap-8">
|
|
||||||
<Button color="purple" variant="outline" isLoading={isLoading} onClick={back} className="max-w-[200px] self-end w-full">
|
|
||||||
Back
|
|
||||||
</Button>
|
|
||||||
<Button color="purple" disabled={!mediaBlob} isLoading={isLoading} onClick={next} className="max-w-[200px] self-end w-full">
|
|
||||||
{questionIndex + 1 < prompts.length ? "Next Prompt" : "Submit"}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import {errorButtonStyle, infoButtonStyle} from "@/constants/buttonStyles";
|
import {errorButtonStyle, infoButtonStyle} from "@/constants/buttonStyles";
|
||||||
import {MatchSentenceExerciseOption, MatchSentenceExerciseSentence, MatchSentencesExercise} from "@/interfaces/exam";
|
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";
|
||||||
@@ -9,116 +9,35 @@ 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";
|
import useExamStore from "@/stores/examStore";
|
||||||
import {DndContext, DragEndEvent, useDraggable, useDroppable} from "@dnd-kit/core";
|
|
||||||
|
|
||||||
function DroppableQuestionArea({question, answer}: {question: MatchSentenceExerciseSentence; answer?: string}) {
|
|
||||||
const {isOver, setNodeRef} = useDroppable({id: `droppable_sentence_${question.id}`});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="grid grid-cols-3 gap-4" ref={setNodeRef}>
|
|
||||||
<div className="flex items-center gap-3 cursor-pointer col-span-2">
|
|
||||||
<button
|
|
||||||
className={clsx(
|
|
||||||
"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",
|
|
||||||
)}>
|
|
||||||
{question.id}
|
|
||||||
</button>
|
|
||||||
<span>{question.sentence}</span>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
key={`answer_${question.id}_${answer}`}
|
|
||||||
className={clsx("w-48 h-10 border rounded-xl flex items-center justify-center", isOver && "border-mti-purple-light")}>
|
|
||||||
{answer && `Paragraph ${answer}`}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function DraggableOptionArea({option}: {option: MatchSentenceExerciseOption}) {
|
|
||||||
const {attributes, listeners, setNodeRef, transform} = useDraggable({
|
|
||||||
id: `draggable_option_${option.id}`,
|
|
||||||
});
|
|
||||||
|
|
||||||
const style = transform
|
|
||||||
? {
|
|
||||||
transform: `translate3d(${transform.x}px, ${transform.y}px, 0)`,
|
|
||||||
zIndex: 99,
|
|
||||||
}
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={clsx("flex items-center justify-start gap-6 cursor-pointer")} ref={setNodeRef} style={style} {...listeners} {...attributes}>
|
|
||||||
<button
|
|
||||||
id={`option_${option.id}`}
|
|
||||||
// onClick={() => selectOption(id)}
|
|
||||||
className={clsx(
|
|
||||||
"bg-mti-purple-ultralight text-mti-purple hover:text-white hover:bg-mti-purple px-3 py-2 rounded-full z-10",
|
|
||||||
"transition duration-300 ease-in-out",
|
|
||||||
option.id,
|
|
||||||
)}>
|
|
||||||
Paragraph {option.id}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
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 [answers, setAnswers] = useState<{question: string; option: string}[]>(userSolutions);
|
const [answers, setAnswers] = useState<{question: string; option: string}[]>(userSolutions);
|
||||||
|
|
||||||
const hasExamEnded = useExamStore((state) => state.hasExamEnded);
|
const hasExamEnded = useExamStore((state) => state.hasExamEnded);
|
||||||
|
|
||||||
const setCurrentSolution = useExamStore((state) => state.setCurrentSolution);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setCurrentSolution({exercise: id, solutions: answers, score: calculateScore(), type});
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [answers, setAnswers]);
|
|
||||||
|
|
||||||
const handleDragEnd = (event: DragEndEvent) => {
|
|
||||||
if (event.over && event.over.id.toString().startsWith("droppable")) {
|
|
||||||
const optionID = event.active.id.toString().replace("draggable_option_", "");
|
|
||||||
const sentenceID = event.over.id.toString().replace("droppable_sentence_", "");
|
|
||||||
|
|
||||||
setAnswers((prev) => [...prev.filter((x) => x.question.toString() !== sentenceID), {question: sentenceID, option: optionID}]);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const calculateScore = () => {
|
const calculateScore = () => {
|
||||||
const total = sentences.length;
|
const total = sentences.length;
|
||||||
const correct = answers.filter(
|
const correct = answers.filter((x) => sentences.find((y) => y.id === x.question)?.solution === x.option || false).length;
|
||||||
(x) => sentences.find((y) => y.id.toString() === x.question.toString())?.solution === x.option || false,
|
const missing = total - answers.filter((x) => sentences.find((y) => y.id === x.question)).length;
|
||||||
).length;
|
|
||||||
const missing = total - answers.filter((x) => sentences.find((y) => y.id.toString() === x.question.toString())).length;
|
|
||||||
|
|
||||||
return {total, correct, missing};
|
return {total, correct, missing};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const selectOption = (option: string) => {
|
||||||
|
if (!selectedQuestion) return;
|
||||||
|
setAnswers((prev) => [...prev.filter((x) => x.question !== selectedQuestion), {question: selectedQuestion, option}]);
|
||||||
|
setSelectedQuestion(undefined);
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (hasExamEnded) onNext({exercise: id, solutions: answers, score: calculateScore(), type});
|
if (hasExamEnded) onNext({exercise: id, solutions: answers, score: calculateScore(), type});
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [hasExamEnded]);
|
}, [hasExamEnded]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-4 mt-4">
|
<>
|
||||||
<div className="flex justify-between w-full gap-8">
|
<div className="flex flex-col gap-4 mt-4 h-full mb-20">
|
||||||
<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>
|
|
||||||
|
|
||||||
<div className="flex flex-col gap-4 mt-4 h-full w-full mb-20">
|
|
||||||
<span className="text-sm w-full leading-6">
|
<span className="text-sm w-full leading-6">
|
||||||
{prompt.split("\\n").map((line, index) => (
|
{prompt.split("\\n").map((line, index) => (
|
||||||
<Fragment key={index}>
|
<Fragment key={index}>
|
||||||
@@ -127,29 +46,47 @@ export default function MatchSentences({id, options, type, prompt, sentences, us
|
|||||||
</Fragment>
|
</Fragment>
|
||||||
))}
|
))}
|
||||||
</span>
|
</span>
|
||||||
|
<div className="flex gap-8 w-full items-center justify-between bg-mti-gray-smoke rounded-xl px-24 py-6">
|
||||||
<DndContext onDragEnd={handleDragEnd}>
|
|
||||||
<div className="flex flex-col 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((question) => (
|
{sentences.map(({sentence, id}) => (
|
||||||
<DroppableQuestionArea
|
<div key={`question_${id}`} className="flex items-center justify-end gap-2 cursor-pointer">
|
||||||
key={`question_${question.id}`}
|
<span>{sentence} </span>
|
||||||
question={question}
|
<button
|
||||||
answer={answers.find((x) => x.question.toString() === question.id.toString())?.option}
|
id={id}
|
||||||
/>
|
onClick={() => setSelectedQuestion((prev) => (prev === id ? undefined : id))}
|
||||||
|
className={clsx(
|
||||||
|
"bg-mti-purple-ultralight text-mti-purple hover:text-white hover:bg-mti-purple w-8 h-8 rounded-full z-10",
|
||||||
|
"transition duration-300 ease-in-out",
|
||||||
|
selectedQuestion === id && "!text-white !bg-mti-purple",
|
||||||
|
id,
|
||||||
|
)}>
|
||||||
|
{id}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
<span>Drag one of these paragraphs into the slots above:</span>
|
{options.map(({sentence, id}) => (
|
||||||
<div className="flex gap-4 flex-wrap justify-center items-center max-w-lg">
|
<div key={`answer_${id}`} className={clsx("flex items-center justify-start gap-2 cursor-pointer")}>
|
||||||
{options.map((option) => (
|
<button
|
||||||
<DraggableOptionArea key={`answer_${option.id}`} option={option} />
|
id={id}
|
||||||
|
onClick={() => selectOption(id)}
|
||||||
|
className={clsx(
|
||||||
|
"bg-mti-purple-ultralight text-mti-purple hover:text-white hover:bg-mti-purple w-8 h-8 rounded-full z-10",
|
||||||
|
"transition duration-300 ease-in-out",
|
||||||
|
id,
|
||||||
|
)}>
|
||||||
|
{id}
|
||||||
|
</button>
|
||||||
|
<span>{sentence}</span>
|
||||||
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
{answers.map((solution, index) => (
|
||||||
|
<Xarrow key={index} start={solution.question} end={solution.option} lineColor="#7872BF" showHead={false} />
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</DndContext>
|
|
||||||
</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
|
||||||
@@ -167,6 +104,6 @@ export default function MatchSentences({id, options, type, prompt, sentences, us
|
|||||||
Next
|
Next
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,69 +1,45 @@
|
|||||||
/* eslint-disable @next/next/no-img-element */
|
/* eslint-disable @next/next/no-img-element */
|
||||||
import {MultipleChoiceExercise, MultipleChoiceQuestion, ShuffleMap} from "@/interfaces/exam";
|
import {MultipleChoiceExercise, MultipleChoiceQuestion} from "@/interfaces/exam";
|
||||||
import useExamStore from "@/stores/examStore";
|
import useExamStore from "@/stores/examStore";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import {useEffect, useState} from "react";
|
import {useEffect, useState} from "react";
|
||||||
import reactStringReplace from "react-string-replace";
|
|
||||||
import {CommonProps} from ".";
|
import {CommonProps} from ".";
|
||||||
import Button from "../Low/Button";
|
import Button from "../Low/Button";
|
||||||
import {v4} from "uuid";
|
|
||||||
|
|
||||||
function Question({
|
function Question({
|
||||||
id,
|
|
||||||
variant,
|
variant,
|
||||||
prompt,
|
prompt,
|
||||||
options,
|
options,
|
||||||
userSolution,
|
userSolution,
|
||||||
onSelectOption,
|
onSelectOption,
|
||||||
}: MultipleChoiceQuestion & {
|
}: MultipleChoiceQuestion & {userSolution: string | undefined; onSelectOption?: (option: string) => void; showSolution?: boolean}) {
|
||||||
userSolution: string | undefined;
|
|
||||||
onSelectOption?: (option: string) => void;
|
|
||||||
showSolution?: boolean;
|
|
||||||
}) {
|
|
||||||
const renderPrompt = (prompt: string) => {
|
|
||||||
return reactStringReplace(prompt, /(<u>.*?<\/u>)/g, (match) => {
|
|
||||||
const word = match.replaceAll("<u>", "").replaceAll("</u>", "");
|
|
||||||
return word.length > 0 ? <u key={v4()}>{word}</u> : null;
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-8">
|
<div className="flex flex-col gap-10">
|
||||||
{isNaN(Number(id)) ? (
|
<span className="">{prompt}</span>
|
||||||
<span className="text-lg">{renderPrompt(prompt).filter((x) => x?.toString() !== "<u>")}</span>
|
<div className="flex justify-between">
|
||||||
) : (
|
|
||||||
<span className="text-lg">
|
|
||||||
<>
|
|
||||||
{id} - <span>{renderPrompt(prompt).filter((x) => x?.toString() !== "<u>")}</span>
|
|
||||||
</>
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
<div className="flex flex-wrap gap-4 justify-between">
|
|
||||||
{variant === "image" &&
|
{variant === "image" &&
|
||||||
options.map((option) => (
|
options.map((option) => (
|
||||||
<div
|
<div
|
||||||
key={v4()}
|
key={option.id}
|
||||||
onClick={() => (onSelectOption ? onSelectOption(option.id.toString()) : 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 select-none",
|
"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.toString() && "border-mti-purple-light",
|
userSolution === option.id && "border-mti-purple-light",
|
||||||
)}>
|
)}>
|
||||||
<span key={v4()} className={clsx("text-sm", userSolution !== option.id.toString() && "opacity-50")}>
|
<span className={clsx("text-sm", userSolution !== option.id && "opacity-50")}>{option.id}</span>
|
||||||
{option.id.toString()}
|
<img src={option.src!} alt={`Option ${option.id}`} />
|
||||||
</span>
|
|
||||||
<img src={option.src!} alt={`Option ${option.id.toString()}`} />
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
{variant === "text" &&
|
{variant === "text" &&
|
||||||
options.map((option) => (
|
options.map((option) => (
|
||||||
<div
|
<div
|
||||||
key={v4()}
|
key={option.id}
|
||||||
onClick={() => (onSelectOption ? onSelectOption(option.id.toString()) : null)}
|
onClick={() => (onSelectOption ? onSelectOption(option.id) : null)}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"flex border p-4 rounded-xl gap-2 cursor-pointer bg-white text-base select-none",
|
"flex border p-4 rounded-xl gap-2 cursor-pointer bg-white text-sm",
|
||||||
userSolution === option.id.toString() && "!bg-mti-purple-light !text-white",
|
userSolution === option.id && "border-mti-purple-light",
|
||||||
)}>
|
)}>
|
||||||
<span className="font-semibold">{option.id.toString()}.</span>
|
<span className="font-semibold">{option.id}.</span>
|
||||||
<span>{option.text}</span>
|
<span>{option.text}</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -74,148 +50,66 @@ function Question({
|
|||||||
|
|
||||||
export default function MultipleChoice({id, prompt, type, questions, userSolutions, onNext, onBack}: MultipleChoiceExercise & CommonProps) {
|
export default function MultipleChoice({id, prompt, type, questions, userSolutions, onNext, onBack}: MultipleChoiceExercise & CommonProps) {
|
||||||
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, exerciseIndex, exam, shuffles, hasExamEnded, partIndex, setQuestionIndex, setCurrentSolution} = useExamStore(
|
const hasExamEnded = useExamStore((state) => state.hasExamEnded);
|
||||||
(state) => state,
|
|
||||||
);
|
|
||||||
|
|
||||||
const shuffleMaps = shuffles.find((x) => x.exerciseID == id)?.shuffles;
|
|
||||||
|
|
||||||
const scrollToTop = () => Array.from(document.getElementsByTagName("body")).forEach((body) => body.scrollTo(0, 0));
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (hasExamEnded) onNext({exercise: id, solutions: answers, score: calculateScore(), type});
|
if (hasExamEnded) onNext({exercise: id, solutions: answers, score: calculateScore(), type});
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [hasExamEnded]);
|
}, [hasExamEnded]);
|
||||||
|
|
||||||
const onSelectOption = (option: string, question: MultipleChoiceQuestion) => {
|
const onSelectOption = (option: string) => {
|
||||||
|
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}]);
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setCurrentSolution({exercise: id, solutions: answers, score: calculateScore(), type, shuffleMaps: shuffleMaps});
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [answers, setAnswers]);
|
|
||||||
|
|
||||||
const getShuffledSolution = (originalSolution: string, questionShuffleMap: ShuffleMap) => {
|
|
||||||
for (const [newPosition, originalPosition] of Object.entries(questionShuffleMap.map)) {
|
|
||||||
if (originalPosition === originalSolution) {
|
|
||||||
return newPosition;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return originalSolution;
|
|
||||||
};
|
|
||||||
|
|
||||||
const calculateScore = () => {
|
const calculateScore = () => {
|
||||||
const total = questions.length;
|
const total = questions.length;
|
||||||
const correct = answers.filter((x) => {
|
const correct = answers.filter((x) => questions.find((y) => y.id === x.question)?.solution === x.option || false).length;
|
||||||
const matchingQuestion = questions.find((y) => {
|
const missing = total - answers.filter((x) => questions.find((y) => y.id === x.question)).length;
|
||||||
return y.id.toString() === x.question.toString();
|
|
||||||
});
|
|
||||||
|
|
||||||
let isSolutionCorrect;
|
|
||||||
if (!shuffleMaps) {
|
|
||||||
isSolutionCorrect = matchingQuestion?.solution === x.option;
|
|
||||||
} else {
|
|
||||||
const shuffleMap = shuffleMaps.find((map) => map.questionID == x.question);
|
|
||||||
if (shuffleMap) {
|
|
||||||
isSolutionCorrect = getShuffledSolution(x.option, shuffleMap) == matchingQuestion?.solution;
|
|
||||||
} else {
|
|
||||||
isSolutionCorrect = matchingQuestion?.solution === x.option;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return isSolutionCorrect || false;
|
|
||||||
}).length;
|
|
||||||
const missing = total - answers!.filter((x) => questions.find((y) => x.question.toString() === y.id.toString())).length;
|
|
||||||
return {total, correct, missing};
|
return {total, correct, missing};
|
||||||
};
|
};
|
||||||
|
|
||||||
const next = () => {
|
const next = () => {
|
||||||
if (questionIndex + 1 >= questions.length - 1) {
|
if (questionIndex === questions.length - 1) {
|
||||||
onNext({exercise: id, solutions: answers, score: calculateScore(), type, shuffleMaps: shuffleMaps});
|
onNext({exercise: id, solutions: answers, score: calculateScore(), type});
|
||||||
} else {
|
} else {
|
||||||
setQuestionIndex(questionIndex + 2);
|
setQuestionIndex((prev) => prev + 1);
|
||||||
}
|
}
|
||||||
scrollToTop();
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const back = () => {
|
const back = () => {
|
||||||
if (questionIndex === 0) {
|
if (questionIndex === 0) {
|
||||||
onBack({exercise: id, solutions: answers, score: calculateScore(), type, shuffleMaps: shuffleMaps});
|
onBack({exercise: id, solutions: answers, score: calculateScore(), type});
|
||||||
} else {
|
} else {
|
||||||
if (exam?.module === "level" && typeof exam.parts[0].intro !== "undefined" && questionIndex === 0) return;
|
setQuestionIndex((prev) => prev - 1);
|
||||||
setQuestionIndex(questionIndex - 2);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
scrollToTop();
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-4">
|
<>
|
||||||
<div className="flex justify-between w-full gap-8">
|
<div className="flex flex-col gap-2 mt-4 h-full mb-20 bg-mti-gray-smoke rounded-xl px-16 py-8">
|
||||||
<Button
|
<span className="text-xl font-semibold">{prompt}</span>
|
||||||
color="purple"
|
|
||||||
variant="outline"
|
|
||||||
onClick={back}
|
|
||||||
className="max-w-[200px] w-full"
|
|
||||||
disabled={exam && exam.module === "level" && partIndex === 0 && questionIndex === 0}>
|
|
||||||
Back
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button color="purple" onClick={next} className="max-w-[200px] self-end w-full">
|
|
||||||
{exam &&
|
|
||||||
exam.module === "level" &&
|
|
||||||
partIndex === exam.parts.length - 1 &&
|
|
||||||
exerciseIndex === exam.parts[partIndex].exercises.length - 1 &&
|
|
||||||
questionIndex + 1 >= questions.length - 1
|
|
||||||
? "Submit"
|
|
||||||
: "Next"}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-col gap-4 mt-4 mb-20">
|
|
||||||
<div className="flex flex-col gap-8 h-fit w-full bg-mti-gray-smoke rounded-xl px-16 py-8">
|
|
||||||
{/*<span className="text-xl font-semibold mb-2">{"Select the appropriate option."}</span>*/}
|
|
||||||
{questionIndex < questions.length && (
|
{questionIndex < questions.length && (
|
||||||
<Question
|
<Question
|
||||||
{...questions[questionIndex]}
|
{...questions[questionIndex]}
|
||||||
userSolution={answers.find((x) => questions[questionIndex].id === x.question)?.option}
|
userSolution={answers.find((x) => questions[questionIndex].id === x.question)?.option}
|
||||||
onSelectOption={(option) => onSelectOption(option, questions[questionIndex])}
|
onSelectOption={onSelectOption}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{questionIndex + 1 < questions.length && (
|
|
||||||
<div className="flex flex-col gap-8 h-fit w-full bg-mti-gray-smoke rounded-xl px-16 py-8">
|
|
||||||
<Question
|
|
||||||
{...questions[questionIndex + 1]}
|
|
||||||
userSolution={answers.find((x) => questions[questionIndex + 1].id === x.question)?.option}
|
|
||||||
onSelectOption={(option) => onSelectOption(option, questions[questionIndex + 1])}
|
|
||||||
/>
|
|
||||||
</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="purple" variant="outline" onClick={back} className="max-w-[200px] w-full">
|
||||||
color="purple"
|
|
||||||
variant="outline"
|
|
||||||
onClick={back}
|
|
||||||
className="max-w-[200px] w-full"
|
|
||||||
disabled={exam && exam.module === "level" && partIndex === 0 && questionIndex === 0}>
|
|
||||||
Back
|
Back
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<Button color="purple" onClick={next} className="max-w-[200px] self-end w-full">
|
<Button color="purple" onClick={next} className="max-w-[200px] self-end w-full">
|
||||||
{exam &&
|
Next
|
||||||
exam.module === "level" &&
|
|
||||||
partIndex === exam.parts.length - 1 &&
|
|
||||||
exerciseIndex === exam.parts[partIndex].exercises.length - 1 &&
|
|
||||||
questionIndex + 1 >= questions.length - 1
|
|
||||||
? "Submit"
|
|
||||||
: "Next"}
|
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,61 +5,28 @@ import {BsCheckCircleFill, BsMicFill, BsPauseCircle, BsPlayCircle, BsTrashFill}
|
|||||||
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";
|
import useExamStore from "@/stores/examStore";
|
||||||
import {downloadBlob} from "@/utils/evaluation";
|
|
||||||
import axios from "axios";
|
|
||||||
import Modal from "../Modal";
|
|
||||||
|
|
||||||
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), {
|
||||||
ssr: false,
|
ssr: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
export default function Speaking({id, title, text, video_url, type, prompts, suffix, userSolutions, onNext, onBack}: SpeakingExercise & CommonProps) {
|
export default function Speaking({id, title, text, type, prompts, onNext, onBack}: SpeakingExercise & CommonProps) {
|
||||||
const [recordingDuration, setRecordingDuration] = useState(0);
|
const [recordingDuration, setRecordingDuration] = useState(0);
|
||||||
const [isRecording, setIsRecording] = useState(false);
|
const [isRecording, setIsRecording] = useState(false);
|
||||||
const [mediaBlob, setMediaBlob] = useState<string>();
|
const [mediaBlob, setMediaBlob] = useState<string>();
|
||||||
const [audioURL, setAudioURL] = useState<string>();
|
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
|
||||||
const [isPromptsModalOpen, setIsPromptsModalOpen] = useState(false);
|
|
||||||
const [inputText, setInputText] = useState("");
|
|
||||||
|
|
||||||
const hasExamEnded = useExamStore((state) => state.hasExamEnded);
|
const hasExamEnded = useExamStore((state) => state.hasExamEnded);
|
||||||
|
|
||||||
const saveToStorage = async () => {
|
|
||||||
if (mediaBlob && mediaBlob.startsWith("blob")) {
|
|
||||||
const blobBuffer = await downloadBlob(mediaBlob);
|
|
||||||
const audioFile = new File([blobBuffer], "audio.wav", {type: "audio/wav"});
|
|
||||||
|
|
||||||
const seed = Math.random().toString().replace("0.", "");
|
|
||||||
|
|
||||||
const formData = new FormData();
|
|
||||||
formData.append("audio", audioFile, `${seed}.wav`);
|
|
||||||
formData.append("root", "speaking_recordings");
|
|
||||||
|
|
||||||
const config = {
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "audio/wav",
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const response = await axios.post<{path: string}>("/api/storage/insert", formData, config);
|
|
||||||
if (audioURL) await axios.post("/api/storage/delete", {path: audioURL});
|
|
||||||
return response.data.path;
|
|
||||||
}
|
|
||||||
|
|
||||||
return undefined;
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (userSolutions.length > 0) {
|
if (hasExamEnded) {
|
||||||
const {solution} = userSolutions[0] as {solution?: string};
|
onNext({
|
||||||
if (solution && !mediaBlob) setMediaBlob(solution);
|
exercise: id,
|
||||||
if (solution && !solution.startsWith("blob")) setAudioURL(solution);
|
solutions: mediaBlob ? [{id, solution: mediaBlob}] : [],
|
||||||
|
score: {correct: 1, total: 1, missing: 0},
|
||||||
|
type,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}, [userSolutions, mediaBlob]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (hasExamEnded) next();
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [hasExamEnded]);
|
}, [hasExamEnded]);
|
||||||
|
|
||||||
@@ -76,80 +43,11 @@ export default function Speaking({id, title, text, video_url, type, prompts, suf
|
|||||||
};
|
};
|
||||||
}, [isRecording]);
|
}, [isRecording]);
|
||||||
|
|
||||||
const next = async () => {
|
|
||||||
onNext({
|
|
||||||
exercise: id,
|
|
||||||
solutions: mediaBlob ? [{id, solution: mediaBlob}] : [],
|
|
||||||
score: {correct: 0, total: 100, missing: 0},
|
|
||||||
type,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const back = async () => {
|
|
||||||
onBack({
|
|
||||||
exercise: id,
|
|
||||||
solutions: mediaBlob ? [{id, solution: mediaBlob}] : [],
|
|
||||||
score: {correct: 0, total: 100, missing: 0},
|
|
||||||
type,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleNoteWriting = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
|
||||||
const newText = e.target.value;
|
|
||||||
const words = newText.match(/\S+/g);
|
|
||||||
const wordCount = words ? words.length : 0;
|
|
||||||
|
|
||||||
if (wordCount <= 100) {
|
|
||||||
setInputText(newText);
|
|
||||||
} else {
|
|
||||||
let count = 0;
|
|
||||||
let lastIndex = 0;
|
|
||||||
const matches = newText.matchAll(/\S+/g);
|
|
||||||
for (const match of matches) {
|
|
||||||
count++;
|
|
||||||
if (count > 100) break;
|
|
||||||
lastIndex = match.index! + match[0].length;
|
|
||||||
}
|
|
||||||
|
|
||||||
setInputText(newText.slice(0, lastIndex));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-4 mt-4 w-full">
|
|
||||||
<div className="flex justify-between w-full gap-8">
|
|
||||||
<Button color="purple" variant="outline" isLoading={isLoading} onClick={back} className="max-w-[200px] self-end w-full">
|
|
||||||
Back
|
|
||||||
</Button>
|
|
||||||
<Button color="purple" isLoading={isLoading} disabled={!mediaBlob} onClick={next} className="max-w-[200px] self-end w-full">
|
|
||||||
Next
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-col h-full w-full gap-9">
|
<div className="flex flex-col h-full w-full gap-9">
|
||||||
<Modal title="Prompts" className="!w-96 aspect-square" isOpen={isPromptsModalOpen} onClose={() => setIsPromptsModalOpen(false)}>
|
<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 items-center justify-center gap-4 w-full h-full">
|
|
||||||
<div className="flex flex-col gap-1 ml-4">
|
|
||||||
{prompts.map((x, index) => (
|
|
||||||
<li className="italic" key={index}>
|
|
||||||
{x}
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
{!!suffix && <span className="font-bold">{suffix}</span>}
|
|
||||||
</div>
|
|
||||||
</Modal>
|
|
||||||
<div className="flex flex-col w-full gap-2 bg-mti-gray-smoke rounded-xl py-8 px-16">
|
|
||||||
<div className="flex flex-col gap-3">
|
<div className="flex flex-col gap-3">
|
||||||
<div className="flex flex-col gap-0">
|
|
||||||
<span className="font-semibold">{title}</span>
|
<span className="font-semibold">{title}</span>
|
||||||
{prompts.length > 0 && (
|
|
||||||
<span className="font-semibold">
|
|
||||||
You should talk for at least 1 minute and 30 seconds for your answer to be valid.
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{!video_url && (
|
|
||||||
<span className="font-regular">
|
<span className="font-regular">
|
||||||
{text.split("\\n").map((line, index) => (
|
{text.split("\\n").map((line, index) => (
|
||||||
<Fragment key={index}>
|
<Fragment key={index}>
|
||||||
@@ -158,33 +56,20 @@ export default function Speaking({id, title, text, video_url, type, prompts, suf
|
|||||||
</Fragment>
|
</Fragment>
|
||||||
))}
|
))}
|
||||||
</span>
|
</span>
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-6 items-center">
|
|
||||||
{video_url && (
|
|
||||||
<div className="flex flex-col gap-4 w-full items-center">
|
|
||||||
<video key={id} autoPlay controls className="max-w-3xl rounded-xl">
|
|
||||||
<source src={video_url} />
|
|
||||||
</video>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{prompts && prompts.length > 0 && <Button onClick={() => setIsPromptsModalOpen(true)}>View Prompts</Button>}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{prompts && prompts.length > 0 && (
|
{prompts && prompts.length > 0 && (
|
||||||
<div className="w-full h-full flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
<textarea
|
<span className="font-bold">You should talk about the following things:</span>
|
||||||
onContextMenu={(e) => e.preventDefault()}
|
<div className="flex flex-col gap-1 ml-4">
|
||||||
className="w-full h-full min-h-[200px] cursor-text px-7 py-8 input border-2 border-mti-gray-platinum bg-white rounded-3xl"
|
{prompts.map((x, index) => (
|
||||||
onChange={handleNoteWriting}
|
<li className="italic" key={index}>
|
||||||
value={inputText}
|
{x}
|
||||||
placeholder="Write your notes here..."
|
</li>
|
||||||
spellCheck={false}
|
))}
|
||||||
/>
|
</div>
|
||||||
<span className="text-base self-end text-mti-gray-cool">Word Count: {(inputText.match(/\S+/g) || []).length}/100</span>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
<ReactMediaRecorder
|
<ReactMediaRecorder
|
||||||
audio
|
audio
|
||||||
@@ -193,7 +78,7 @@ export default function Speaking({id, title, text, video_url, type, prompts, suf
|
|||||||
<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="w-full p-4 px-8 bg-transparent border-2 border-mti-gray-platinum rounded-2xl flex-col gap-8 items-center">
|
||||||
<p className="text-base font-normal">Record your answer:</p>
|
<p className="text-base font-normal">Record your answer:</p>
|
||||||
<div className="flex gap-8 items-center justify-center py-8">
|
<div className="flex gap-8 items-center justify-center py-8">
|
||||||
{status === "idle" && !mediaBlob && (
|
{status === "idle" && (
|
||||||
<>
|
<>
|
||||||
<div className="w-full h-2 max-w-4xl bg-mti-gray-smoke rounded-full" />
|
<div className="w-full h-2 max-w-4xl bg-mti-gray-smoke rounded-full" />
|
||||||
{status === "idle" && (
|
{status === "idle" && (
|
||||||
@@ -272,9 +157,9 @@ export default function Speaking({id, title, text, video_url, type, prompts, suf
|
|||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{((status === "stopped" && mediaBlobUrl) || (status === "idle" && mediaBlob)) && (
|
{status === "stopped" && mediaBlobUrl && (
|
||||||
<>
|
<>
|
||||||
<Waveform audio={mediaBlobUrl ? mediaBlobUrl : mediaBlob!} waveColor="#FCDDEC" progressColor="#EF5DA8" />
|
<Waveform audio={mediaBlobUrl} waveColor="#FCDDEC" progressColor="#EF5DA8" />
|
||||||
<div className="flex gap-4 items-center">
|
<div className="flex gap-4 items-center">
|
||||||
<BsTrashFill
|
<BsTrashFill
|
||||||
className="text-mti-gray-cool cursor-pointer w-5 h-5"
|
className="text-mti-gray-cool cursor-pointer w-5 h-5"
|
||||||
@@ -304,14 +189,35 @@ export default function Speaking({id, title, text, video_url, type, prompts, suf
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="self-end flex justify-between w-full gap-8">
|
<div className="self-end flex justify-between w-full gap-8">
|
||||||
<Button color="purple" variant="outline" isLoading={isLoading} onClick={back} className="max-w-[200px] self-end w-full">
|
<Button
|
||||||
|
color="purple"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() =>
|
||||||
|
onBack({
|
||||||
|
exercise: id,
|
||||||
|
solutions: mediaBlob ? [{id, solution: mediaBlob}] : [],
|
||||||
|
score: {correct: 1, total: 1, missing: 0},
|
||||||
|
type,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className="max-w-[200px] self-end w-full">
|
||||||
Back
|
Back
|
||||||
</Button>
|
</Button>
|
||||||
<Button color="purple" isLoading={isLoading} disabled={!mediaBlob} onClick={next} className="max-w-[200px] self-end w-full">
|
<Button
|
||||||
|
color="purple"
|
||||||
|
disabled={!mediaBlob}
|
||||||
|
onClick={() =>
|
||||||
|
onNext({
|
||||||
|
exercise: id,
|
||||||
|
solutions: mediaBlob ? [{id, solution: mediaBlob}] : [],
|
||||||
|
score: {correct: 1, total: 1, missing: 0},
|
||||||
|
type,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className="max-w-[200px] self-end w-full">
|
||||||
Next
|
Next
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ export default function TrueFalse({id, type, prompt, questions, userSolutions, o
|
|||||||
const [answers, setAnswers] = useState<{id: string; solution: "true" | "false" | "not_given"}[]>(userSolutions);
|
const [answers, setAnswers] = useState<{id: string; solution: "true" | "false" | "not_given"}[]>(userSolutions);
|
||||||
|
|
||||||
const hasExamEnded = useExamStore((state) => state.hasExamEnded);
|
const hasExamEnded = useExamStore((state) => state.hasExamEnded);
|
||||||
const setCurrentSolution = useExamStore((state) => state.setCurrentSolution);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (hasExamEnded) onNext({exercise: id, solutions: answers, score: calculateScore(), type});
|
if (hasExamEnded) onNext({exercise: id, solutions: answers, score: calculateScore(), type});
|
||||||
@@ -17,23 +16,12 @@ export default function TrueFalse({id, type, prompt, questions, userSolutions, o
|
|||||||
|
|
||||||
const calculateScore = () => {
|
const calculateScore = () => {
|
||||||
const total = questions.length || 0;
|
const total = questions.length || 0;
|
||||||
const correct = answers.filter(
|
const correct = answers.filter((x) => questions.find((y) => x.id === y.id)?.solution === x.solution.toLowerCase() || false).length;
|
||||||
(x) =>
|
const missing = total - answers.filter((x) => questions.find((y) => x.id === y.id)).length;
|
||||||
questions
|
|
||||||
.find((y) => x.id.toString() === y.id.toString())
|
|
||||||
?.solution?.toString()
|
|
||||||
.toLowerCase() === x.solution.toLowerCase() || false,
|
|
||||||
).length;
|
|
||||||
const missing = total - answers.filter((x) => questions.find((y) => x.id.toString() === y.id.toString())).length;
|
|
||||||
|
|
||||||
return {total, correct, missing};
|
return {total, correct, missing};
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setCurrentSolution({exercise: id, solutions: answers, score: calculateScore(), type});
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [answers, setAnswers]);
|
|
||||||
|
|
||||||
const toggleAnswer = (solution: "true" | "false" | "not_given", questionId: string) => {
|
const toggleAnswer = (solution: "true" | "false" | "not_given", questionId: string) => {
|
||||||
const answer = answers.find((x) => x.id === questionId);
|
const answer = answers.find((x) => x.id === questionId);
|
||||||
if (answer && answer.solution === solution) {
|
if (answer && answer.solution === solution) {
|
||||||
@@ -45,25 +33,8 @@ export default function TrueFalse({id, type, prompt, questions, userSolutions, o
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-4 mt-4">
|
<>
|
||||||
<div className="flex justify-between w-full gap-8">
|
<div className="flex flex-col gap-4 mt-4 h-full mb-20">
|
||||||
<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>
|
|
||||||
|
|
||||||
<div className="flex flex-col gap-4 mt-4 h-full w-full mb-20">
|
|
||||||
<span className="text-sm w-full leading-6">
|
<span className="text-sm w-full leading-6">
|
||||||
{prompt.split("\\n").map((line, index) => (
|
{prompt.split("\\n").map((line, index) => (
|
||||||
<Fragment key={index}>
|
<Fragment key={index}>
|
||||||
@@ -89,37 +60,33 @@ export default function TrueFalse({id, type, prompt, questions, userSolutions, o
|
|||||||
</div>
|
</div>
|
||||||
<span className="text-sm w-full leading-6">You can click a selected option again to deselect it.</span>
|
<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">
|
<div className="bg-mti-gray-smoke rounded-xl px-5 py-6 flex flex-col gap-8">
|
||||||
{questions.map((question, index) => {
|
{questions.map((question, index) => (
|
||||||
const id = question.id.toString();
|
<div key={question.id} className="flex flex-col gap-4">
|
||||||
|
|
||||||
return (
|
|
||||||
<div key={question.id.toString()} className="flex flex-col gap-4">
|
|
||||||
<span>
|
<span>
|
||||||
{index + 1}. {question.prompt}
|
{index + 1}. {question.prompt}
|
||||||
</span>
|
</span>
|
||||||
<div className="flex gap-4">
|
<div className="flex gap-4">
|
||||||
<Button
|
<Button
|
||||||
variant={answers.find((x) => x.id.toString() === id)?.solution === "true" ? "solid" : "outline"}
|
variant={answers.find((x) => x.id === question.id)?.solution === "true" ? "solid" : "outline"}
|
||||||
onClick={() => toggleAnswer("true", id)}
|
onClick={() => toggleAnswer("true", question.id)}
|
||||||
className="!py-2">
|
className="!py-2">
|
||||||
True
|
True
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant={answers.find((x) => x.id.toString() === id)?.solution === "false" ? "solid" : "outline"}
|
variant={answers.find((x) => x.id === question.id)?.solution === "false" ? "solid" : "outline"}
|
||||||
onClick={() => toggleAnswer("false", id)}
|
onClick={() => toggleAnswer("false", question.id)}
|
||||||
className="!py-2">
|
className="!py-2">
|
||||||
False
|
False
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant={answers.find((x) => x.id.toString() === id)?.solution === "not_given" ? "solid" : "outline"}
|
variant={answers.find((x) => x.id === question.id)?.solution === "not_given" ? "solid" : "outline"}
|
||||||
onClick={() => toggleAnswer("not_given", id)}
|
onClick={() => toggleAnswer("not_given", question.id)}
|
||||||
className="!py-2">
|
className="!py-2">
|
||||||
Not Given
|
Not Given
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
))}
|
||||||
})}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -139,6 +106,6 @@ export default function TrueFalse({id, type, prompt, questions, userSolutions, o
|
|||||||
Next
|
Next
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,8 +27,8 @@ function Blank({
|
|||||||
const [userInput, setUserInput] = useState(userSolution || "");
|
const [userInput, setUserInput] = useState(userSolution || "");
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const words = userInput.split(" ");
|
const words = userInput.split(" ").filter((x) => x !== "");
|
||||||
if (words.length > maxWords) {
|
if (words.length >= maxWords) {
|
||||||
toast.warning(`You have reached your word limit of ${maxWords} words!`, {toastId: "word-limit"});
|
toast.warning(`You have reached your word limit of ${maxWords} words!`, {toastId: "word-limit"});
|
||||||
setUserInput(words.join(" ").trim());
|
setUserInput(words.join(" ").trim());
|
||||||
}
|
}
|
||||||
@@ -36,7 +36,7 @@ function Blank({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<input
|
<input
|
||||||
className="py-2 px-3 mx-2 rounded-2xl w-48 bg-white focus:outline-none my-2"
|
className="py-2 px-3 rounded-2xl w-48 bg-white focus:outline-none my-2"
|
||||||
placeholder={id}
|
placeholder={id}
|
||||||
onChange={(e) => setUserInput(e.target.value)}
|
onChange={(e) => setUserInput(e.target.value)}
|
||||||
onBlur={() => setUserSolution(userInput)}
|
onBlur={() => setUserSolution(userInput)}
|
||||||
@@ -49,7 +49,7 @@ 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, setCurrentSolution} = useExamStore((state) => state);
|
const hasExamEnded = useExamStore((state) => state.hasExamEnded);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (hasExamEnded) onNext({exercise: id, solutions: answers, score: calculateScore(), type});
|
if (hasExamEnded) onNext({exercise: id, solutions: answers, score: calculateScore(), type});
|
||||||
@@ -61,20 +61,15 @@ export default function WriteBlanks({id, prompt, type, maxWords, solutions, user
|
|||||||
const correct = answers.filter(
|
const correct = answers.filter(
|
||||||
(x) =>
|
(x) =>
|
||||||
solutions
|
solutions
|
||||||
.find((y) => x.id.toString() === y.id.toString())
|
.find((y) => x.id === y.id)
|
||||||
?.solution.map((y) => y.toLowerCase().trim())
|
?.solution.map((y) => y.toLowerCase())
|
||||||
.includes(x.solution.toLowerCase().trim()) || false,
|
.includes(x.solution.toLowerCase()) || false,
|
||||||
).length;
|
).length;
|
||||||
const missing = total - answers.filter((x) => solutions.find((y) => x.id === y.id)).length;
|
const missing = total - answers.filter((x) => solutions.find((y) => x.id === y.id)).length;
|
||||||
|
|
||||||
return {total, correct, missing};
|
return {total, correct, missing};
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setCurrentSolution({exercise: id, solutions: answers, score: calculateScore(), type});
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [answers, setAnswers]);
|
|
||||||
|
|
||||||
const renderLines = (line: string) => {
|
const renderLines = (line: string) => {
|
||||||
return (
|
return (
|
||||||
<span className="text-base leading-5">
|
<span className="text-base leading-5">
|
||||||
@@ -92,31 +87,14 @@ export default function WriteBlanks({id, prompt, type, maxWords, solutions, user
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-4">
|
<>
|
||||||
<div className="flex justify-between w-full gap-8">
|
<div className="flex flex-col gap-4 mt-4 h-full mb-20">
|
||||||
<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>
|
|
||||||
|
|
||||||
<div className="flex flex-col gap-4 mt-4 h-full w-full mb-20">
|
|
||||||
<span className="text-sm w-full leading-6">
|
<span className="text-sm w-full leading-6">
|
||||||
{prompt.split("\\n").map((line, index) => (
|
{prompt.split("\\n").map((line, index) => (
|
||||||
<span key={index}>
|
<Fragment key={index}>
|
||||||
{line}
|
{line}
|
||||||
<br />
|
<br />
|
||||||
</span>
|
</Fragment>
|
||||||
))}
|
))}
|
||||||
</span>
|
</span>
|
||||||
<span className="bg-mti-gray-smoke rounded-xl px-5 py-6">
|
<span className="bg-mti-gray-smoke rounded-xl px-5 py-6">
|
||||||
@@ -145,6 +123,6 @@ export default function WriteBlanks({id, prompt, type, maxWords, solutions, user
|
|||||||
Next
|
Next
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
/* eslint-disable @next/next/no-img-element */
|
/* eslint-disable @next/next/no-img-element */
|
||||||
import {WritingExercise} from "@/interfaces/exam";
|
import {WritingExercise} from "@/interfaces/exam";
|
||||||
import {CommonProps} from ".";
|
import {CommonProps} from ".";
|
||||||
import React, {Fragment, useEffect, useRef, 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";
|
||||||
@@ -22,50 +22,11 @@ export default function Writing({
|
|||||||
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 [saveTimer, setSaveTimer] = useState(0);
|
|
||||||
|
|
||||||
const {userSolutions: storeUserSolutions, setUserSolutions} = useExamStore((state) => state);
|
|
||||||
const hasExamEnded = useExamStore((state) => state.hasExamEnded);
|
const hasExamEnded = useExamStore((state) => state.hasExamEnded);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const saveTimerInterval = setInterval(() => {
|
if (hasExamEnded) onNext({exercise: id, solutions: [{id, solution: inputText}], score: {correct: 1, total: 1, missing: 0}, type});
|
||||||
setSaveTimer((prev) => prev + 1);
|
|
||||||
}, 1000);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
clearInterval(saveTimerInterval);
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (inputText.length > 0 && saveTimer % 10 === 0) {
|
|
||||||
setUserSolutions([
|
|
||||||
...storeUserSolutions.filter((x) => x.exercise !== id),
|
|
||||||
{exercise: id, solutions: [{id, solution: inputText}], score: {correct: 100, total: 100, missing: 0}, type, module: "writing"},
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [saveTimer]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (localStorage.getItem("enable_paste")) return;
|
|
||||||
|
|
||||||
const listener = (e: KeyboardEvent) => {
|
|
||||||
if ((e.ctrlKey || e.metaKey) && e.key === "v") {
|
|
||||||
e.preventDefault();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
document.addEventListener("keydown", listener);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
document.removeEventListener("keydown", listener);
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (hasExamEnded)
|
|
||||||
onNext({exercise: id, solutions: [{id, solution: inputText}], score: {correct: 100, total: 100, missing: 0}, type, module: "writing"});
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [hasExamEnded]);
|
}, [hasExamEnded]);
|
||||||
|
|
||||||
@@ -84,34 +45,7 @@ export default function Writing({
|
|||||||
}, [inputText, wordCounter]);
|
}, [inputText, wordCounter]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-4 mt-4">
|
<>
|
||||||
<div className="flex justify-between w-full gap-8">
|
|
||||||
<Button
|
|
||||||
color="purple"
|
|
||||||
variant="outline"
|
|
||||||
onClick={() =>
|
|
||||||
onBack({exercise: id, solutions: [{id, solution: inputText}], score: {correct: 100, total: 100, missing: 0}, type})
|
|
||||||
}
|
|
||||||
className="max-w-[200px] self-end w-full">
|
|
||||||
Back
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
color="purple"
|
|
||||||
disabled={!isSubmitEnabled}
|
|
||||||
onClick={() =>
|
|
||||||
onNext({
|
|
||||||
exercise: id,
|
|
||||||
solutions: [{id, solution: inputText.replaceAll(/\s{2,}/g, " ")}],
|
|
||||||
score: {correct: 100, total: 100, missing: 0},
|
|
||||||
type,
|
|
||||||
module: "writing",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
className="max-w-[200px] self-end w-full">
|
|
||||||
Next
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{attachment && (
|
{attachment && (
|
||||||
<Transition show={isModalOpen} as={Fragment}>
|
<Transition show={isModalOpen} as={Fragment}>
|
||||||
<Dialog onClose={() => setIsModalOpen(false)} className="relative z-50">
|
<Dialog onClose={() => setIsModalOpen(false)} className="relative z-50">
|
||||||
@@ -145,8 +79,22 @@ export default function Writing({
|
|||||||
)}
|
)}
|
||||||
<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 className="whitespace-pre-wrap">{prefix.replaceAll("\\n", "\n")}</span>
|
<span>
|
||||||
<span className="font-semibold whitespace-pre-wrap">{prompt.replaceAll("\\n", "\n")}</span>
|
{prefix.split("\\n").map((line) => (
|
||||||
|
<>
|
||||||
|
{line}
|
||||||
|
<br />
|
||||||
|
</>
|
||||||
|
))}
|
||||||
|
</span>
|
||||||
|
<span className="font-semibold">
|
||||||
|
{prompt.split("\\n").map((line, index) => (
|
||||||
|
<Fragment key={index}>
|
||||||
|
<p>{line}</p>
|
||||||
|
<br />
|
||||||
|
</Fragment>
|
||||||
|
))}
|
||||||
|
</span>
|
||||||
{attachment && (
|
{attachment && (
|
||||||
<img
|
<img
|
||||||
onClick={() => setIsModalOpen(true)}
|
onClick={() => setIsModalOpen(true)}
|
||||||
@@ -158,16 +106,20 @@ export default function Writing({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="w-full h-full flex flex-col gap-4">
|
<div className="w-full h-full flex flex-col gap-4">
|
||||||
<span className="whitespace-pre-wrap">{suffix}</span>
|
<span>
|
||||||
|
{suffix.split("\\n").map((line) => (
|
||||||
|
<>
|
||||||
|
{line}
|
||||||
|
<br />
|
||||||
|
</>
|
||||||
|
))}
|
||||||
|
</span>
|
||||||
<textarea
|
<textarea
|
||||||
onContextMenu={(e) => e.preventDefault()}
|
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-[300px] cursor-text px-7 py-8 input border-2 border-mti-gray-platinum bg-white rounded-3xl"
|
|
||||||
onChange={(e) => setInputText(e.target.value)}
|
onChange={(e) => setInputText(e.target.value)}
|
||||||
value={inputText}
|
value={inputText}
|
||||||
placeholder="Write your text here..."
|
placeholder="Write your text here..."
|
||||||
spellCheck={false}
|
|
||||||
/>
|
/>
|
||||||
<span className="text-base self-end text-mti-gray-cool">Word Count: {inputText.split(" ").filter((x) => x !== "").length}</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -175,28 +127,18 @@ export default function Writing({
|
|||||||
<Button
|
<Button
|
||||||
color="purple"
|
color="purple"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() =>
|
onClick={() => onBack({exercise: id, solutions: [{id, solution: inputText}], score: {correct: 1, total: 1, missing: 0}, type})}
|
||||||
onBack({exercise: id, solutions: [{id, solution: inputText}], score: {correct: 100, total: 100, 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="purple"
|
color="purple"
|
||||||
disabled={!isSubmitEnabled}
|
disabled={!isSubmitEnabled}
|
||||||
onClick={() =>
|
onClick={() => onNext({exercise: id, solutions: [{id, solution: inputText}], score: {correct: 1, total: 1, missing: 0}, type})}
|
||||||
onNext({
|
|
||||||
exercise: id,
|
|
||||||
solutions: [{id, solution: inputText.replaceAll(/\s{2,}/g, " ")}],
|
|
||||||
score: {correct: 100, total: 100, missing: 0},
|
|
||||||
type,
|
|
||||||
module: "writing",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
className="max-w-[200px] self-end w-full">
|
className="max-w-[200px] self-end w-full">
|
||||||
Next
|
Next
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import {
|
import {
|
||||||
Exercise,
|
Exercise,
|
||||||
FillBlanksExercise,
|
FillBlanksExercise,
|
||||||
InteractiveSpeakingExercise,
|
|
||||||
MatchSentencesExercise,
|
MatchSentencesExercise,
|
||||||
MultipleChoiceExercise,
|
MultipleChoiceExercise,
|
||||||
SpeakingExercise,
|
SpeakingExercise,
|
||||||
@@ -17,46 +16,29 @@ import WriteBlanks from "./WriteBlanks";
|
|||||||
import Writing from "./Writing";
|
import Writing from "./Writing";
|
||||||
import Speaking from "./Speaking";
|
import Speaking from "./Speaking";
|
||||||
import TrueFalse from "./TrueFalse";
|
import TrueFalse from "./TrueFalse";
|
||||||
import InteractiveSpeaking from "./InteractiveSpeaking";
|
|
||||||
|
|
||||||
const MatchSentences = dynamic(() => import("@/components/Exercises/MatchSentences"), {ssr: false});
|
const MatchSentences = dynamic(() => import("@/components/Exercises/MatchSentences"), {ssr: false});
|
||||||
|
|
||||||
export interface CommonProps {
|
export interface CommonProps {
|
||||||
examID?: string;
|
|
||||||
onNext: (userSolutions: UserSolution) => void;
|
onNext: (userSolutions: UserSolution) => void;
|
||||||
onBack: (userSolutions: UserSolution) => void;
|
onBack: (userSolutions: UserSolution) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const renderExercise = (
|
export const renderExercise = (exercise: Exercise, onNext: (userSolutions: UserSolution) => void, onBack: (userSolutions: UserSolution) => void) => {
|
||||||
exercise: Exercise,
|
|
||||||
examID: string,
|
|
||||||
onNext: (userSolutions: UserSolution) => void,
|
|
||||||
onBack: (userSolutions: UserSolution) => void,
|
|
||||||
) => {
|
|
||||||
switch (exercise.type) {
|
switch (exercise.type) {
|
||||||
case "fillBlanks":
|
case "fillBlanks":
|
||||||
return <FillBlanks key={exercise.id} {...(exercise as FillBlanksExercise)} onNext={onNext} onBack={onBack} examID={examID} />;
|
return <FillBlanks {...(exercise as FillBlanksExercise)} onNext={onNext} onBack={onBack} />;
|
||||||
case "trueFalse":
|
case "trueFalse":
|
||||||
return <TrueFalse key={exercise.id} {...(exercise as TrueFalseExercise)} onNext={onNext} onBack={onBack} examID={examID} />;
|
return <TrueFalse {...(exercise as TrueFalseExercise)} onNext={onNext} onBack={onBack} />;
|
||||||
case "matchSentences":
|
case "matchSentences":
|
||||||
return <MatchSentences key={exercise.id} {...(exercise as MatchSentencesExercise)} onNext={onNext} onBack={onBack} examID={examID} />;
|
return <MatchSentences {...(exercise as MatchSentencesExercise)} onNext={onNext} onBack={onBack} />;
|
||||||
case "multipleChoice":
|
case "multipleChoice":
|
||||||
return <MultipleChoice key={exercise.id} {...(exercise as MultipleChoiceExercise)} onNext={onNext} onBack={onBack} examID={examID} />;
|
return <MultipleChoice {...(exercise as MultipleChoiceExercise)} onNext={onNext} onBack={onBack} />;
|
||||||
case "writeBlanks":
|
case "writeBlanks":
|
||||||
return <WriteBlanks key={exercise.id} {...(exercise as WriteBlanksExercise)} onNext={onNext} onBack={onBack} examID={examID} />;
|
return <WriteBlanks {...(exercise as WriteBlanksExercise)} onNext={onNext} onBack={onBack} />;
|
||||||
case "writing":
|
case "writing":
|
||||||
return <Writing key={exercise.id} {...(exercise as WritingExercise)} onNext={onNext} onBack={onBack} examID={examID} />;
|
return <Writing {...(exercise as WritingExercise)} onNext={onNext} onBack={onBack} />;
|
||||||
case "speaking":
|
case "speaking":
|
||||||
return <Speaking key={exercise.id} {...(exercise as SpeakingExercise)} onNext={onNext} onBack={onBack} examID={examID} />;
|
return <Speaking {...(exercise as SpeakingExercise)} onNext={onNext} onBack={onBack} />;
|
||||||
case "interactiveSpeaking":
|
|
||||||
return (
|
|
||||||
<InteractiveSpeaking
|
|
||||||
key={exercise.id}
|
|
||||||
{...(exercise as InteractiveSpeakingExercise)}
|
|
||||||
examID={examID}
|
|
||||||
onNext={onNext}
|
|
||||||
onBack={onBack}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,9 +1,13 @@
|
|||||||
import {useEffect, useState} from "react";
|
import {useEffect, useState} from "react";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
onFocusLayerMouseEnter?: () => void;
|
onFocusLayerMouseEnter: Function,
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function FocusLayer({onFocusLayerMouseEnter}: Props) {
|
export default function FocusLayer({
|
||||||
return <div className="absolute top-0 left-0 bottom-0 right-0" onMouseDown={onFocusLayerMouseEnter} />;
|
onFocusLayerMouseEnter,
|
||||||
|
}: Props) {
|
||||||
|
return (
|
||||||
|
<div className="bg-gray-700 bg-opacity-30 absolute top-0 left-0 bottom-0 right-0" onMouseEnter={onFocusLayerMouseEnter}/>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,130 +0,0 @@
|
|||||||
import { FillBlanksExercise, FillBlanksMCOption } from "@/interfaces/exam";
|
|
||||||
import React from "react";
|
|
||||||
import Input from "@/components/Low/Input";
|
|
||||||
import clsx from "clsx";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
exercise: FillBlanksExercise;
|
|
||||||
updateExercise: (data: any) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const FillBlanksEdit = (props: Props) => {
|
|
||||||
const { exercise, updateExercise } = props;
|
|
||||||
|
|
||||||
const typeCheckWordsMC = (words: any[]): words is FillBlanksMCOption[] => {
|
|
||||||
return Array.isArray(words) && words.every((word) => word && typeof word === "object" && "id" in word && "options" in word);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Input
|
|
||||||
type={exercise?.variant && exercise.variant === "mc" ? "textarea" : "text"}
|
|
||||||
label="Prompt"
|
|
||||||
name="prompt"
|
|
||||||
required
|
|
||||||
value={exercise.prompt}
|
|
||||||
onChange={(value) =>
|
|
||||||
updateExercise({
|
|
||||||
prompt: value,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Input
|
|
||||||
type={exercise?.variant && exercise.variant === "mc" ? "textarea" : "text"}
|
|
||||||
label="Text"
|
|
||||||
name="text"
|
|
||||||
required
|
|
||||||
value={exercise.text}
|
|
||||||
onChange={(value) =>
|
|
||||||
updateExercise({
|
|
||||||
text: exercise?.variant && exercise.variant === "mc" ? value : value,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<h1 className="mt-4">Solutions</h1>
|
|
||||||
<div className="w-full flex flex-wrap -mx-2">
|
|
||||||
{exercise.solutions.map((solution, index) => (
|
|
||||||
<div key={solution.id} className="flex sm:w-1/2 lg:w-1/4 px-2">
|
|
||||||
<Input
|
|
||||||
type="text"
|
|
||||||
label={`Solution ${index + 1}`}
|
|
||||||
name="solution"
|
|
||||||
required
|
|
||||||
value={solution.solution}
|
|
||||||
onChange={(value) =>
|
|
||||||
updateExercise({
|
|
||||||
solutions: exercise.solutions.map((sol) => (sol.id === solution.id ? { ...sol, solution: value } : sol)),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<h1 className="mt-4">Words</h1>
|
|
||||||
<div className={clsx(exercise?.variant && exercise.variant === "mc" ? "w-full flex flex-row" : "w-full flex flex-wrap -mx-2")}>
|
|
||||||
{exercise?.variant && exercise.variant === "mc" && typeCheckWordsMC(exercise.words) ?
|
|
||||||
(
|
|
||||||
<div className="flex flex-col w-full">
|
|
||||||
{exercise.words.flatMap((mcOptions, wordIndex) =>
|
|
||||||
<>
|
|
||||||
<label className="font-semibold">{`Word ${wordIndex + 1}`}</label>
|
|
||||||
<div className="flex flex-row">
|
|
||||||
{Object.entries(mcOptions.options).map(([key, value], optionIndex) => (
|
|
||||||
<div key={`${wordIndex}-${optionIndex}-${key}`} className="flex sm:w-1/2 lg:w-1/4 px-2 mb-4">
|
|
||||||
<Input
|
|
||||||
type="text"
|
|
||||||
label={`Option ${key}`}
|
|
||||||
name="word"
|
|
||||||
required
|
|
||||||
value={value}
|
|
||||||
onChange={(newValue) =>
|
|
||||||
updateExercise({
|
|
||||||
words: exercise.words.map((word, idx) =>
|
|
||||||
idx === wordIndex
|
|
||||||
? {
|
|
||||||
...(word as FillBlanksMCOption),
|
|
||||||
options: {
|
|
||||||
...(word as FillBlanksMCOption).options,
|
|
||||||
[key]: newValue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
: word
|
|
||||||
)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
:
|
|
||||||
(
|
|
||||||
exercise.words.map((word, index) => (
|
|
||||||
<div key={index} className="flex sm:w-1/2 lg:w-1/4 px-2">
|
|
||||||
<Input
|
|
||||||
type="text"
|
|
||||||
label={`Word ${index + 1}`}
|
|
||||||
name="word"
|
|
||||||
required
|
|
||||||
value={typeof word === "string" ? word : ("word" in word ? word.word : "")}
|
|
||||||
onChange={(value) =>
|
|
||||||
updateExercise({
|
|
||||||
words: exercise.words.map((sol, idx) =>
|
|
||||||
index === idx ? (typeof word === "string" ? value : { ...word, word: value }) : sol,
|
|
||||||
),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
))
|
|
||||||
)
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default FillBlanksEdit;
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
import React from "react";
|
|
||||||
|
|
||||||
const InteractiveSpeakingEdit = () => {
|
|
||||||
return null;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default InteractiveSpeakingEdit;
|
|
||||||
@@ -1,130 +0,0 @@
|
|||||||
import React from "react";
|
|
||||||
import { MatchSentencesExercise } from "@/interfaces/exam";
|
|
||||||
import Input from "@/components/Low/Input";
|
|
||||||
import Select from "@/components/Low/Select";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
exercise: MatchSentencesExercise;
|
|
||||||
updateExercise: (data: any) => void;
|
|
||||||
}
|
|
||||||
const MatchSentencesEdit = (props: Props) => {
|
|
||||||
const { exercise, updateExercise } = props;
|
|
||||||
|
|
||||||
const selectOptions = exercise.options.map((option) => ({
|
|
||||||
value: option.id,
|
|
||||||
label: option.id,
|
|
||||||
}));
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Input
|
|
||||||
type="text"
|
|
||||||
label="Prompt"
|
|
||||||
name="prompt"
|
|
||||||
required
|
|
||||||
value={exercise.prompt}
|
|
||||||
onChange={(value) =>
|
|
||||||
updateExercise({
|
|
||||||
prompt: value,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<h1>Solutions</h1>
|
|
||||||
<div className="w-full flex flex-wrap -mx-2">
|
|
||||||
{exercise.sentences.map((sentence, index) => (
|
|
||||||
<div key={sentence.id} className="flex flex-col w-full px-2">
|
|
||||||
<div className="flex w-full">
|
|
||||||
<Input
|
|
||||||
type="text"
|
|
||||||
label={`Sentence ${index + 1}`}
|
|
||||||
name="sentence"
|
|
||||||
required
|
|
||||||
value={sentence.sentence}
|
|
||||||
onChange={(value) =>
|
|
||||||
updateExercise({
|
|
||||||
sentences: exercise.sentences.map((iSol) =>
|
|
||||||
iSol.id === sentence.id
|
|
||||||
? {
|
|
||||||
...iSol,
|
|
||||||
sentence: value,
|
|
||||||
}
|
|
||||||
: iSol
|
|
||||||
),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
className="px-2"
|
|
||||||
/>
|
|
||||||
<div className="w-48 flex items-end px-2">
|
|
||||||
<Select
|
|
||||||
value={selectOptions.find(
|
|
||||||
(o) => o.value === sentence.solution
|
|
||||||
)}
|
|
||||||
options={selectOptions}
|
|
||||||
onChange={(value) => {
|
|
||||||
updateExercise({
|
|
||||||
sentences: exercise.sentences.map((iSol) =>
|
|
||||||
iSol.id === sentence.id
|
|
||||||
? {
|
|
||||||
...iSol,
|
|
||||||
solution: value?.value,
|
|
||||||
}
|
|
||||||
: iSol
|
|
||||||
),
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
<h1>Options</h1>
|
|
||||||
<div className="w-full flex flex-wrap -mx-2">
|
|
||||||
{exercise.options.map((option, index) => (
|
|
||||||
<div key={option.id} className="flex flex-col w-full px-2">
|
|
||||||
<div className="flex w-full">
|
|
||||||
<Input
|
|
||||||
type="text"
|
|
||||||
label={`Option ${index + 1}`}
|
|
||||||
name="option"
|
|
||||||
required
|
|
||||||
value={option.sentence}
|
|
||||||
onChange={(value) =>
|
|
||||||
updateExercise({
|
|
||||||
options: exercise.options.map((iSol) =>
|
|
||||||
iSol.id === option.id
|
|
||||||
? {
|
|
||||||
...iSol,
|
|
||||||
sentence: value,
|
|
||||||
}
|
|
||||||
: iSol
|
|
||||||
),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
className="px-2"
|
|
||||||
/>
|
|
||||||
<div className="w-48 flex items-end px-2">
|
|
||||||
<Select
|
|
||||||
value={{
|
|
||||||
value: option.id,
|
|
||||||
label: option.id,
|
|
||||||
}}
|
|
||||||
options={[
|
|
||||||
{
|
|
||||||
value: option.id,
|
|
||||||
label: option.id,
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
disabled
|
|
||||||
onChange={() => {}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default MatchSentencesEdit;
|
|
||||||
@@ -1,137 +0,0 @@
|
|||||||
import React from "react";
|
|
||||||
import Input from "@/components/Low/Input";
|
|
||||||
import {
|
|
||||||
MultipleChoiceExercise,
|
|
||||||
MultipleChoiceQuestion,
|
|
||||||
} from "@/interfaces/exam";
|
|
||||||
import Select from "@/components/Low/Select";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
exercise: MultipleChoiceExercise;
|
|
||||||
updateExercise: (data: any) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const variantOptions = [
|
|
||||||
{ value: "text", label: "Text", key: "text" },
|
|
||||||
{ value: "image", label: "Image", key: "src" },
|
|
||||||
];
|
|
||||||
|
|
||||||
const MultipleChoiceEdit = (props: Props) => {
|
|
||||||
const { exercise, updateExercise } = props;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<h1>Questions</h1>
|
|
||||||
<div className="w-full flex-no-wrap -mx-2">
|
|
||||||
{exercise.questions.map((question: MultipleChoiceQuestion, index) => {
|
|
||||||
const variantValue = variantOptions.find(
|
|
||||||
(o) => o.value === question.variant
|
|
||||||
);
|
|
||||||
|
|
||||||
const solutionsOptions = question.options.map((option) => ({
|
|
||||||
value: option.id,
|
|
||||||
label: option.id,
|
|
||||||
}));
|
|
||||||
|
|
||||||
const solutionValue = solutionsOptions.find(
|
|
||||||
(o) => o.value === question.solution
|
|
||||||
);
|
|
||||||
return (
|
|
||||||
<div key={question.id} className="flex w-full px-2 flex-col">
|
|
||||||
<span>Question ID: {question.id}</span>
|
|
||||||
|
|
||||||
<Input
|
|
||||||
type="text"
|
|
||||||
label="Prompt"
|
|
||||||
name="prompt"
|
|
||||||
required
|
|
||||||
value={question.prompt}
|
|
||||||
onChange={(value) =>
|
|
||||||
updateExercise({
|
|
||||||
questions: exercise.questions.map((sol) =>
|
|
||||||
sol.id === question.id ? { ...sol, prompt: value } : sol
|
|
||||||
),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<div className="flex w-full">
|
|
||||||
<div className="w-48 flex items-end px-2">
|
|
||||||
<Select
|
|
||||||
value={solutionValue}
|
|
||||||
options={solutionsOptions}
|
|
||||||
onChange={(value) => {
|
|
||||||
updateExercise({
|
|
||||||
questions: exercise.questions.map((sol) =>
|
|
||||||
sol.id === question.id
|
|
||||||
? { ...sol, solution: value?.value }
|
|
||||||
: sol
|
|
||||||
),
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="w-48 flex items-end px-2">
|
|
||||||
<Select
|
|
||||||
value={variantValue}
|
|
||||||
options={variantOptions}
|
|
||||||
onChange={(value) => {
|
|
||||||
updateExercise({
|
|
||||||
questions: exercise.questions.map((sol) =>
|
|
||||||
sol.id === question.id
|
|
||||||
? { ...sol, variant: value?.value }
|
|
||||||
: sol
|
|
||||||
),
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex w-full flex-wrap -mx-2">
|
|
||||||
{question.options.map((option) => (
|
|
||||||
<div
|
|
||||||
key={option.id}
|
|
||||||
className="flex sm:w-1/2 lg:w-1/4 px-2 px-2"
|
|
||||||
>
|
|
||||||
<Input
|
|
||||||
type="text"
|
|
||||||
label={`Option ${option.id}`}
|
|
||||||
name="option"
|
|
||||||
required
|
|
||||||
value={option.text}
|
|
||||||
onChange={(value) =>
|
|
||||||
updateExercise({
|
|
||||||
questions: exercise.questions.map((sol) =>
|
|
||||||
sol.id === question.id
|
|
||||||
? {
|
|
||||||
...sol,
|
|
||||||
options: sol.options.map((opt) => {
|
|
||||||
if (
|
|
||||||
opt.id === option.id &&
|
|
||||||
variantValue?.key
|
|
||||||
) {
|
|
||||||
return {
|
|
||||||
...opt,
|
|
||||||
[variantValue.key]: value,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return opt;
|
|
||||||
}),
|
|
||||||
}
|
|
||||||
: sol
|
|
||||||
),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default MultipleChoiceEdit;
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
|
|
||||||
const SpeakingEdit = () => {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default SpeakingEdit;
|
|
||||||
@@ -1,71 +0,0 @@
|
|||||||
import React from "react";
|
|
||||||
import { TrueFalseExercise } from "@/interfaces/exam";
|
|
||||||
import Input from "@/components/Low/Input";
|
|
||||||
import Select from "@/components/Low/Select";
|
|
||||||
interface Props {
|
|
||||||
exercise: TrueFalseExercise;
|
|
||||||
updateExercise: (data: any) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const options = [
|
|
||||||
{ value: "true", label: "True" },
|
|
||||||
{ value: "false", label: "False" },
|
|
||||||
{ value: "not_given", label: "Not Given" },
|
|
||||||
];
|
|
||||||
|
|
||||||
const TrueFalseEdit = (props: Props) => {
|
|
||||||
const { exercise, updateExercise } = props;
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Input
|
|
||||||
type="text"
|
|
||||||
label="Prompt"
|
|
||||||
name="prompt"
|
|
||||||
required
|
|
||||||
value={exercise.prompt}
|
|
||||||
onChange={(value) =>
|
|
||||||
updateExercise({
|
|
||||||
prompt: value,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<h1>Questions</h1>
|
|
||||||
<div className="w-full flex-no-wrap -mx-2">
|
|
||||||
{exercise.questions.map((question, index) => (
|
|
||||||
<div key={question.id} className="flex w-full px-2">
|
|
||||||
<Input
|
|
||||||
type="text"
|
|
||||||
label={`Question ${index + 1}`}
|
|
||||||
name="question"
|
|
||||||
required
|
|
||||||
value={question.prompt}
|
|
||||||
onChange={(value) =>
|
|
||||||
updateExercise({
|
|
||||||
questions: exercise.questions.map((sol) =>
|
|
||||||
sol.id === question.id ? { ...sol, prompt: value } : sol
|
|
||||||
),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<div className="w-48 flex items-end px-2">
|
|
||||||
<Select
|
|
||||||
value={options.find((o) => o.value === question.solution)}
|
|
||||||
options={options}
|
|
||||||
onChange={(value) => {
|
|
||||||
updateExercise({
|
|
||||||
questions: exercise.questions.map((sol) =>
|
|
||||||
sol.id === question.id ? { ...sol, solution: value?.value } : sol
|
|
||||||
),
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
className="h-18"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default TrueFalseEdit;
|
|
||||||
@@ -1,94 +0,0 @@
|
|||||||
import React from "react";
|
|
||||||
import Input from "@/components/Low/Input";
|
|
||||||
import { WriteBlanksExercise } from "@/interfaces/exam";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
exercise: WriteBlanksExercise;
|
|
||||||
updateExercise: (data: any) => void;
|
|
||||||
}
|
|
||||||
const WriteBlankEdits = (props: Props) => {
|
|
||||||
const { exercise, updateExercise } = props;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Input
|
|
||||||
type="text"
|
|
||||||
label="Prompt"
|
|
||||||
name="prompt"
|
|
||||||
required
|
|
||||||
value={exercise.prompt}
|
|
||||||
onChange={(value) =>
|
|
||||||
updateExercise({
|
|
||||||
prompt: value,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Input
|
|
||||||
type="text"
|
|
||||||
label="Text"
|
|
||||||
name="text"
|
|
||||||
required
|
|
||||||
value={exercise.text}
|
|
||||||
onChange={(value) =>
|
|
||||||
updateExercise({
|
|
||||||
text: value,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Input
|
|
||||||
type="text"
|
|
||||||
label="Max Words"
|
|
||||||
name="number"
|
|
||||||
required
|
|
||||||
value={exercise.maxWords}
|
|
||||||
onChange={(value) =>
|
|
||||||
updateExercise({
|
|
||||||
maxWords: Number(value),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<h1>Solutions</h1>
|
|
||||||
<div className="w-full flex flex-wrap -mx-2">
|
|
||||||
{exercise.solutions.map((solution) => (
|
|
||||||
<div key={solution.id} className="flex flex-col w-full px-2">
|
|
||||||
<span>Solution ID: {solution.id}</span>
|
|
||||||
{/* TODO: Consider adding an add and delete button */}
|
|
||||||
<div className="flex flex-wrap">
|
|
||||||
{solution.solution.map((sol, solIndex) => (
|
|
||||||
<Input
|
|
||||||
key={`${sol}-${solIndex}`}
|
|
||||||
type="text"
|
|
||||||
label={`Solution ${solIndex + 1}`}
|
|
||||||
name="solution"
|
|
||||||
required
|
|
||||||
value={sol}
|
|
||||||
onChange={(value) =>
|
|
||||||
updateExercise({
|
|
||||||
solutions: exercise.solutions.map((iSol) =>
|
|
||||||
iSol.id === solution.id
|
|
||||||
? {
|
|
||||||
...iSol,
|
|
||||||
solution: iSol.solution.map((iiSol, iiIndex) => {
|
|
||||||
if (iiIndex === solIndex) {
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
|
|
||||||
return iiSol;
|
|
||||||
}),
|
|
||||||
}
|
|
||||||
: iSol
|
|
||||||
),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
className="sm:w-1/2 lg:w-1/4 px-2"
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default WriteBlankEdits;
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
|
|
||||||
const WritingEdit = () => {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default WritingEdit;
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
import {EmploymentStatus, EMPLOYMENT_STATUS} from "@/interfaces/user";
|
|
||||||
import {RadioGroup} from "@headlessui/react";
|
|
||||||
import clsx from "clsx";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
value?: EmploymentStatus;
|
|
||||||
onChange: (value?: EmploymentStatus) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function EmploymentStatusInput({value, onChange}: Props) {
|
|
||||||
return (
|
|
||||||
<div className="relative flex flex-col gap-3 w-full">
|
|
||||||
<label className="font-normal text-base text-mti-gray-dim">Employment Status *</label>
|
|
||||||
<RadioGroup value={value} onChange={onChange} className="grid grid-cols-2 items-center gap-4 place-items-center">
|
|
||||||
{EMPLOYMENT_STATUS.map(({status, label}) => (
|
|
||||||
<RadioGroup.Option value={status} key={status}>
|
|
||||||
{({checked}) => (
|
|
||||||
<span
|
|
||||||
className={clsx(
|
|
||||||
"px-6 py-4 w-40 md:w-48 flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer",
|
|
||||||
"transition duration-300 ease-in-out",
|
|
||||||
!checked ? "bg-white border-mti-gray-platinum" : "bg-mti-purple-light border-mti-purple-dark text-white",
|
|
||||||
)}>
|
|
||||||
{label}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</RadioGroup.Option>
|
|
||||||
))}
|
|
||||||
</RadioGroup>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
import {Gender} from "@/interfaces/user";
|
|
||||||
import {RadioGroup} from "@headlessui/react";
|
|
||||||
import clsx from "clsx";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
value?: Gender;
|
|
||||||
onChange: (value?: Gender) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function GenderInput({value, onChange}: Props) {
|
|
||||||
return (
|
|
||||||
<div className="relative flex flex-col gap-3 w-full">
|
|
||||||
<label className="font-normal text-base text-mti-gray-dim">Gender *</label>
|
|
||||||
<RadioGroup value={value} onChange={onChange} className="flex flex-row gap-4 justify-between">
|
|
||||||
<RadioGroup.Option value="male">
|
|
||||||
{({checked}) => (
|
|
||||||
<span
|
|
||||||
className={clsx(
|
|
||||||
"px-6 py-4 w-28 flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer",
|
|
||||||
"transition duration-300 ease-in-out",
|
|
||||||
!checked ? "bg-white border-mti-gray-platinum" : "bg-mti-purple-light border-mti-purple-dark text-white",
|
|
||||||
)}>
|
|
||||||
Male
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</RadioGroup.Option>
|
|
||||||
<RadioGroup.Option value="female">
|
|
||||||
{({checked}) => (
|
|
||||||
<span
|
|
||||||
className={clsx(
|
|
||||||
"px-6 py-4 w-28 flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer",
|
|
||||||
"transition duration-300 ease-in-out",
|
|
||||||
!checked ? "bg-white border-mti-gray-platinum" : "bg-mti-purple-light border-mti-purple-dark text-white",
|
|
||||||
)}>
|
|
||||||
Female
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</RadioGroup.Option>
|
|
||||||
<RadioGroup.Option value="other">
|
|
||||||
{({checked}) => (
|
|
||||||
<span
|
|
||||||
className={clsx(
|
|
||||||
"px-6 py-4 w-28 flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer",
|
|
||||||
"transition duration-300 ease-in-out",
|
|
||||||
!checked ? "bg-white border-mti-gray-platinum" : "bg-mti-purple-light border-mti-purple-dark text-white",
|
|
||||||
)}>
|
|
||||||
Other
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</RadioGroup.Option>
|
|
||||||
</RadioGroup>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
import {User} from "@/interfaces/user";
|
import {User} from "@/interfaces/user";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import {useRouter} from "next/router";
|
import {useRouter} from "next/router";
|
||||||
import BottomBar from "../BottomBar";
|
|
||||||
import Navbar from "../Navbar";
|
import Navbar from "../Navbar";
|
||||||
import Sidebar from "../Sidebar";
|
import Sidebar from "../Sidebar";
|
||||||
|
|
||||||
@@ -10,36 +9,21 @@ interface Props {
|
|||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
className?: string;
|
className?: string;
|
||||||
navDisabled?: boolean;
|
navDisabled?: boolean;
|
||||||
focusMode?: boolean;
|
focusMode?: boolean
|
||||||
bgColor?: string;
|
onFocusLayerMouseEnter?: Function;
|
||||||
onFocusLayerMouseEnter?: () => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Layout({user, children, className, bgColor="bg-white", navDisabled = false, focusMode = false, onFocusLayerMouseEnter}: Props) {
|
export default function Layout({user, children, className, navDisabled = false, focusMode = false, onFocusLayerMouseEnter }: Props) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className={clsx("w-full min-h-full h-screen flex flex-col bg-mti-gray-smoke relative")}>
|
<main className="w-full min-h-full h-screen flex flex-col bg-mti-gray-smoke">
|
||||||
<Navbar
|
<Navbar user={user} navDisabled={navDisabled} focusMode={focusMode} onFocusLayerMouseEnter={onFocusLayerMouseEnter} />
|
||||||
path={router.pathname}
|
|
||||||
user={user}
|
|
||||||
navDisabled={navDisabled}
|
|
||||||
focusMode={focusMode}
|
|
||||||
onFocusLayerMouseEnter={onFocusLayerMouseEnter}
|
|
||||||
/>
|
|
||||||
<div className="h-full w-full flex gap-2">
|
<div className="h-full w-full flex gap-2">
|
||||||
<Sidebar
|
<Sidebar path={router.pathname} navDisabled={navDisabled} focusMode={focusMode} onFocusLayerMouseEnter={onFocusLayerMouseEnter}/>
|
||||||
path={router.pathname}
|
|
||||||
navDisabled={navDisabled}
|
|
||||||
focusMode={focusMode}
|
|
||||||
onFocusLayerMouseEnter={onFocusLayerMouseEnter}
|
|
||||||
className="-md:hidden"
|
|
||||||
user={user}
|
|
||||||
/>
|
|
||||||
<div
|
<div
|
||||||
className={clsx(
|
className={clsx(
|
||||||
`w-full min-h-full md:mr-8 ${bgColor} shadow-md rounded-2xl p-4 xl:p-10 pb-8 flex flex-col gap-8 relative overflow-hidden mt-2`,
|
"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",
|
||||||
bgColor !== "bg-white" ? "justify-center" : "h-fit",
|
|
||||||
className,
|
className,
|
||||||
)}>
|
)}>
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
@@ -1,179 +0,0 @@
|
|||||||
import useUsers from "@/hooks/useUsers";
|
|
||||||
import {Ticket, TicketStatus, TicketStatusLabel, TicketType, TicketTypeLabel} from "@/interfaces/ticket";
|
|
||||||
import {User} from "@/interfaces/user";
|
|
||||||
import {USER_TYPE_LABELS} from "@/resources/user";
|
|
||||||
import axios from "axios";
|
|
||||||
import moment from "moment";
|
|
||||||
import {useState} from "react";
|
|
||||||
import {toast} from "react-toastify";
|
|
||||||
import ShortUniqueId from "short-unique-id";
|
|
||||||
import Button from "../Low/Button";
|
|
||||||
import Input from "../Low/Input";
|
|
||||||
import Select from "../Low/Select";
|
|
||||||
import {checkAccess} from "@/utils/permissions";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
user: User;
|
|
||||||
ticket: Ticket;
|
|
||||||
onClose: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function TicketDisplay({user, ticket, onClose}: Props) {
|
|
||||||
const [subject] = useState(ticket.subject);
|
|
||||||
const [type, setType] = useState<TicketType>(ticket.type);
|
|
||||||
const [description] = useState(ticket.description);
|
|
||||||
const [reporter] = useState(ticket.reporter);
|
|
||||||
const [reportedFrom] = useState(ticket.reportedFrom);
|
|
||||||
const [status, setStatus] = useState(ticket.status);
|
|
||||||
const [assignedTo, setAssignedTo] = useState<string | null>(ticket.assignedTo || null);
|
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
|
||||||
|
|
||||||
const {users} = useUsers();
|
|
||||||
|
|
||||||
const submit = () => {
|
|
||||||
if (!type) return toast.error("Please choose a type!", {toastId: "missing-type"});
|
|
||||||
|
|
||||||
setIsLoading(true);
|
|
||||||
axios
|
|
||||||
.patch(`/api/tickets/${ticket.id}`, {
|
|
||||||
subject,
|
|
||||||
type,
|
|
||||||
description,
|
|
||||||
reporter,
|
|
||||||
reportedFrom,
|
|
||||||
status,
|
|
||||||
assignedTo,
|
|
||||||
})
|
|
||||||
.then(() => {
|
|
||||||
toast.success(`The ticket has been updated!`, {toastId: "submitted"});
|
|
||||||
onClose();
|
|
||||||
})
|
|
||||||
.catch((e) => {
|
|
||||||
console.error(e);
|
|
||||||
toast.error("Something went wrong, please try again later!", {
|
|
||||||
toastId: "error",
|
|
||||||
});
|
|
||||||
})
|
|
||||||
.finally(() => setIsLoading(false));
|
|
||||||
};
|
|
||||||
|
|
||||||
const del = () => {
|
|
||||||
if (!confirm("Are you sure you want to delete this ticket?")) return;
|
|
||||||
|
|
||||||
setIsLoading(true);
|
|
||||||
axios
|
|
||||||
.delete(`/api/tickets/${ticket.id}`)
|
|
||||||
.then(() => {
|
|
||||||
toast.success(`The ticket has been deleted!`, {toastId: "submitted"});
|
|
||||||
onClose();
|
|
||||||
})
|
|
||||||
.catch((e) => {
|
|
||||||
console.error(e);
|
|
||||||
toast.error("Something went wrong, please try again later!", {
|
|
||||||
toastId: "error",
|
|
||||||
});
|
|
||||||
})
|
|
||||||
.finally(() => setIsLoading(false));
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<form className="flex flex-col gap-4 pt-8">
|
|
||||||
<Input label="Subject" type="text" name="subject" placeholder="Subject..." value={subject} onChange={(e) => null} disabled />
|
|
||||||
|
|
||||||
<div className="-md:flex-col flex w-full items-center gap-4">
|
|
||||||
<div className="flex w-full flex-col gap-3">
|
|
||||||
<label className="text-mti-gray-dim text-base font-normal">Status</label>
|
|
||||||
<Select
|
|
||||||
options={Object.keys(TicketStatusLabel).map((x) => ({
|
|
||||||
value: x,
|
|
||||||
label: TicketStatusLabel[x as keyof typeof TicketStatusLabel],
|
|
||||||
}))}
|
|
||||||
value={{value: status, label: TicketStatusLabel[status]}}
|
|
||||||
onChange={(value) => setStatus((value?.value as TicketStatus) ?? undefined)}
|
|
||||||
placeholder="Status..."
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="flex w-full flex-col gap-3">
|
|
||||||
<label className="text-mti-gray-dim text-base font-normal">Type</label>
|
|
||||||
<Select
|
|
||||||
options={Object.keys(TicketTypeLabel).map((x) => ({
|
|
||||||
value: x,
|
|
||||||
label: TicketTypeLabel[x as keyof typeof TicketTypeLabel],
|
|
||||||
}))}
|
|
||||||
value={{value: type, label: TicketTypeLabel[type]}}
|
|
||||||
onChange={(value) => setType(value!.value as TicketType)}
|
|
||||||
placeholder="Type..."
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex w-full flex-col gap-3">
|
|
||||||
<label className="text-mti-gray-dim text-base font-normal">Assignee</label>
|
|
||||||
<Select
|
|
||||||
options={[
|
|
||||||
{value: "me", label: "Assign to me"},
|
|
||||||
...users
|
|
||||||
.filter((x) => checkAccess(x, ["admin", "developer", "agent"]))
|
|
||||||
.map((u) => ({
|
|
||||||
value: u.id,
|
|
||||||
label: `${u.name} - ${u.email}`,
|
|
||||||
})),
|
|
||||||
]}
|
|
||||||
disabled={checkAccess(user, ["agent"])}
|
|
||||||
value={
|
|
||||||
assignedTo
|
|
||||||
? {
|
|
||||||
value: assignedTo,
|
|
||||||
label: `${users.find((u) => u.id === assignedTo)?.name} - ${users.find((u) => u.id === assignedTo)?.email}`,
|
|
||||||
}
|
|
||||||
: null
|
|
||||||
}
|
|
||||||
onChange={(value) => (value ? setAssignedTo(value.value === "me" ? user.id : value.value) : setAssignedTo(null))}
|
|
||||||
placeholder="Assignee..."
|
|
||||||
isClearable
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="-md:flex-col flex w-full items-center gap-4">
|
|
||||||
<Input label="Reported From" type="text" name="reportedFrom" onChange={() => null} value={reportedFrom} disabled />
|
|
||||||
<Input label="Date" type="text" name="date" onChange={() => null} value={moment(ticket.date).format("DD/MM/YYYY - HH:mm")} disabled />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="-md:flex-col flex w-full items-center gap-4">
|
|
||||||
<Input label="Reporter's Name" type="text" name="reporter" onChange={() => null} value={reporter.name} disabled />
|
|
||||||
<Input label="Reporter's E-mail" type="text" name="reporter" onChange={() => null} value={reporter.email} disabled />
|
|
||||||
<Input
|
|
||||||
label="Reporter's Type"
|
|
||||||
type="text"
|
|
||||||
name="reporterType"
|
|
||||||
onChange={() => null}
|
|
||||||
value={USER_TYPE_LABELS[reporter.type]}
|
|
||||||
disabled
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<textarea
|
|
||||||
className="input border-mti-gray-platinum h-full min-h-[300px] w-full cursor-text rounded-3xl border bg-white px-7 py-8"
|
|
||||||
placeholder="Write your ticket's description here..."
|
|
||||||
contentEditable={false}
|
|
||||||
value={description}
|
|
||||||
spellCheck
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="-md:flex-col-reverse mt-2 flex w-full items-center justify-between gap-4">
|
|
||||||
<Button type="button" color="red" className="w-full md:max-w-[200px]" variant="outline" onClick={del} isLoading={isLoading}>
|
|
||||||
Delete
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<div className="-md:flex-col-reverse flex w-full items-center justify-end gap-4">
|
|
||||||
<Button type="button" color="red" className="w-full md:max-w-[200px]" variant="outline" onClick={onClose} isLoading={isLoading}>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button type="button" className="w-full md:max-w-[200px]" isLoading={isLoading} onClick={submit}>
|
|
||||||
Update
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,116 +0,0 @@
|
|||||||
import {Ticket, TicketType, TicketTypeLabel} from "@/interfaces/ticket";
|
|
||||||
import {User} from "@/interfaces/user";
|
|
||||||
import useExamStore from "@/stores/examStore";
|
|
||||||
import axios from "axios";
|
|
||||||
import {useState} from "react";
|
|
||||||
import {toast} from "react-toastify";
|
|
||||||
import ShortUniqueId from "short-unique-id";
|
|
||||||
import Button from "../Low/Button";
|
|
||||||
import Input from "../Low/Input";
|
|
||||||
import Select from "../Low/Select";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
user: User;
|
|
||||||
page: string;
|
|
||||||
onClose: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function TicketSubmission({user, page, onClose}: Props) {
|
|
||||||
const [subject, setSubject] = useState("");
|
|
||||||
const [type, setType] = useState<TicketType>();
|
|
||||||
const [description, setDescription] = useState("");
|
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
|
||||||
|
|
||||||
const examState = useExamStore((state) => state);
|
|
||||||
|
|
||||||
const submit = () => {
|
|
||||||
if (!type) return toast.error("Please choose a type!", {toastId: "missing-type"});
|
|
||||||
if (subject.trim() === "")
|
|
||||||
return toast.error("Please input a subject!", {
|
|
||||||
toastId: "missing-subject",
|
|
||||||
});
|
|
||||||
if (description.trim() === "")
|
|
||||||
return toast.error("Please describe your ticket!", {
|
|
||||||
toastId: "missing-desc",
|
|
||||||
});
|
|
||||||
|
|
||||||
setIsLoading(true);
|
|
||||||
|
|
||||||
const shortUID = new ShortUniqueId();
|
|
||||||
const ticket: Ticket = {
|
|
||||||
id: shortUID.randomUUID(8),
|
|
||||||
date: new Date().toISOString(),
|
|
||||||
reporter: {
|
|
||||||
id: user.id,
|
|
||||||
email: user.email,
|
|
||||||
name: user.name,
|
|
||||||
type: user.type,
|
|
||||||
},
|
|
||||||
status: "submitted",
|
|
||||||
subject,
|
|
||||||
type,
|
|
||||||
reportedFrom: page,
|
|
||||||
description,
|
|
||||||
examInformation:
|
|
||||||
page.includes("exam") || page.includes("exercises")
|
|
||||||
? {
|
|
||||||
exam: examState.exam?.id || "",
|
|
||||||
exams: examState.exams.map((x) => x.id),
|
|
||||||
exerciseIndex: examState.exerciseIndex,
|
|
||||||
moduleIndex: examState.moduleIndex,
|
|
||||||
partIndex: examState.partIndex,
|
|
||||||
questionIndex: examState.questionIndex,
|
|
||||||
selectedModules: examState.selectedModules,
|
|
||||||
}
|
|
||||||
: undefined,
|
|
||||||
};
|
|
||||||
|
|
||||||
axios
|
|
||||||
.post(`/api/tickets`, ticket)
|
|
||||||
.then(() => {
|
|
||||||
toast.success(`Your ticket has been submitted! You will be contacted by e-mail for further discussion.`, {toastId: "submitted"});
|
|
||||||
onClose();
|
|
||||||
})
|
|
||||||
.catch((e) => {
|
|
||||||
console.error(e);
|
|
||||||
toast.error("Something went wrong, please try again later!", {
|
|
||||||
toastId: "error",
|
|
||||||
});
|
|
||||||
})
|
|
||||||
.finally(() => setIsLoading(false));
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<form className="flex flex-col gap-4 pt-8">
|
|
||||||
<Input label="Subject" type="text" name="subject" placeholder="Subject..." onChange={(e) => setSubject(e)} />
|
|
||||||
<div className="-md:flex-col flex w-full items-center gap-4">
|
|
||||||
<div className="flex w-full flex-col gap-3">
|
|
||||||
<label className="text-mti-gray-dim text-base font-normal">Type</label>
|
|
||||||
<Select
|
|
||||||
options={Object.keys(TicketTypeLabel).map((x) => ({
|
|
||||||
value: x,
|
|
||||||
label: TicketTypeLabel[x as keyof typeof TicketTypeLabel],
|
|
||||||
}))}
|
|
||||||
onChange={(value) => setType((value?.value as TicketType) ?? undefined)}
|
|
||||||
placeholder="Type..."
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<Input label="Reporter" type="text" name="reporter" onChange={() => null} value={`${user.name} - ${user.email}`} disabled />
|
|
||||||
</div>
|
|
||||||
<textarea
|
|
||||||
className="input border-mti-gray-platinum h-full min-h-[300px] w-full cursor-text rounded-3xl border bg-white px-7 py-8"
|
|
||||||
onChange={(e) => setDescription(e.target.value)}
|
|
||||||
placeholder="Write your ticket's description here..."
|
|
||||||
spellCheck
|
|
||||||
/>
|
|
||||||
<div className="mt-2 flex w-full items-center justify-end gap-4">
|
|
||||||
<Button type="button" color="red" className="w-full max-w-[200px]" variant="outline" onClick={onClose} isLoading={isLoading}>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button type="button" className="w-full max-w-[200px]" isLoading={isLoading} onClick={submit}>
|
|
||||||
Submit
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
import { useCallback } from "react";
|
|
||||||
|
|
||||||
const HighlightContent: React.FC<{
|
|
||||||
html: string;
|
|
||||||
highlightPhrases: string[],
|
|
||||||
firstOccurence?: boolean
|
|
||||||
}> = ({
|
|
||||||
html,
|
|
||||||
highlightPhrases,
|
|
||||||
firstOccurence = false
|
|
||||||
}) => {
|
|
||||||
|
|
||||||
const createHighlightedContent = useCallback(() => {
|
|
||||||
if (highlightPhrases.length === 0) {
|
|
||||||
return { __html: html };
|
|
||||||
}
|
|
||||||
|
|
||||||
const escapeRegExp = (string: string) => {
|
|
||||||
return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
||||||
};
|
|
||||||
|
|
||||||
const regex = new RegExp(`(${highlightPhrases.map(escapeRegExp).join('|')})`, 'i');
|
|
||||||
const globalRegex = new RegExp(`(${highlightPhrases.map(escapeRegExp).join('|')})`, 'gi');
|
|
||||||
|
|
||||||
let highlightedHtml = html;
|
|
||||||
|
|
||||||
if (firstOccurence) {
|
|
||||||
highlightedHtml = html.replace(regex, (match) => `<span style="background-color: yellow;">${match}</span>`);
|
|
||||||
} else {
|
|
||||||
highlightedHtml = html.replace(globalRegex, (match) => `<span style="background-color: yellow;">${match}</span>`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return { __html: highlightedHtml };
|
|
||||||
}, [html, highlightPhrases, firstOccurence]);
|
|
||||||
|
|
||||||
return <div dangerouslySetInnerHTML={createHighlightedContent()} />;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default HighlightContent;
|
|
||||||
@@ -1,168 +0,0 @@
|
|||||||
import React, { useRef, useEffect, useState, useCallback, ReactNode } from 'react';
|
|
||||||
import { useSpring, animated } from '@react-spring/web';
|
|
||||||
import { useDrag } from '@use-gesture/react';
|
|
||||||
import clsx from 'clsx';
|
|
||||||
|
|
||||||
interface InfiniteCarouselProps {
|
|
||||||
children: React.ReactNode;
|
|
||||||
height: string;
|
|
||||||
speed?: number;
|
|
||||||
gap?: number;
|
|
||||||
overlay?: ReactNode;
|
|
||||||
overlayFunc?: (index: number) => void;
|
|
||||||
overlayClassName?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const InfiniteCarousel: React.FC<InfiniteCarouselProps> = ({
|
|
||||||
children,
|
|
||||||
height,
|
|
||||||
speed = 20000,
|
|
||||||
gap = 16,
|
|
||||||
overlay = undefined,
|
|
||||||
overlayFunc = undefined,
|
|
||||||
overlayClassName = ""
|
|
||||||
}) => {
|
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
|
||||||
const [containerWidth, setContainerWidth] = useState<number>(0);
|
|
||||||
const itemCount = React.Children.count(children);
|
|
||||||
const [isDragging, setIsDragging] = useState<boolean>(false);
|
|
||||||
const [itemWidth, setItemWidth] = useState<number>(0);
|
|
||||||
const [isInfinite, setIsInfinite] = useState<boolean>(true);
|
|
||||||
const dragStartX = useRef<number>(0);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const handleResize = () => {
|
|
||||||
if (containerRef.current) {
|
|
||||||
const containerWidth = containerRef.current.clientWidth;
|
|
||||||
setContainerWidth(containerWidth);
|
|
||||||
|
|
||||||
const firstChild = containerRef.current.firstElementChild?.firstElementChild as HTMLElement;
|
|
||||||
if (firstChild) {
|
|
||||||
const childWidth = firstChild.offsetWidth;
|
|
||||||
setItemWidth(childWidth);
|
|
||||||
|
|
||||||
const totalContentWidth = (childWidth + gap) * itemCount - gap;
|
|
||||||
setIsInfinite(totalContentWidth > containerWidth);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
handleResize();
|
|
||||||
window.addEventListener('resize', handleResize);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
window.removeEventListener('resize', handleResize);
|
|
||||||
};
|
|
||||||
}, [gap, itemCount]);
|
|
||||||
|
|
||||||
const totalWidth = (itemWidth + gap) * itemCount;
|
|
||||||
|
|
||||||
const [{ x }, api] = useSpring(() => ({
|
|
||||||
from: { x: 0 },
|
|
||||||
to: { x: -totalWidth },
|
|
||||||
config: { duration: speed },
|
|
||||||
loop: true,
|
|
||||||
}));
|
|
||||||
|
|
||||||
const startAnimation = useCallback(() => {
|
|
||||||
if (isInfinite) {
|
|
||||||
api.start({
|
|
||||||
from: { x: x.get() },
|
|
||||||
to: { x: x.get() - totalWidth },
|
|
||||||
config: { duration: speed },
|
|
||||||
loop: true,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
api.stop();
|
|
||||||
api.start({ x: 0, immediate: true });
|
|
||||||
}
|
|
||||||
}, [api, x, totalWidth, speed, isInfinite]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (containerWidth > 0 && !isDragging) {
|
|
||||||
startAnimation();
|
|
||||||
}
|
|
||||||
}, [containerWidth, isDragging, startAnimation]);
|
|
||||||
|
|
||||||
const bind = useDrag(({ down, movement: [mx], first }) => {
|
|
||||||
if (!isInfinite) return;
|
|
||||||
if (first) {
|
|
||||||
setIsDragging(true);
|
|
||||||
api.stop();
|
|
||||||
dragStartX.current = x.get();
|
|
||||||
}
|
|
||||||
if (down) {
|
|
||||||
let newX = dragStartX.current + mx;
|
|
||||||
newX = ((newX % totalWidth) + totalWidth) % totalWidth;
|
|
||||||
if (newX > 0) newX -= totalWidth;
|
|
||||||
api.start({ x: newX, immediate: true });
|
|
||||||
} else {
|
|
||||||
setIsDragging(false);
|
|
||||||
startAnimation();
|
|
||||||
}
|
|
||||||
}, {
|
|
||||||
filterTaps: true,
|
|
||||||
from: () => [x.get(), 0],
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className="overflow-hidden relative select-none"
|
|
||||||
style={{ height, touchAction: 'pan-y' }}
|
|
||||||
ref={containerRef}
|
|
||||||
{...(isInfinite ? bind() : {})}
|
|
||||||
>
|
|
||||||
<animated.div
|
|
||||||
className="flex"
|
|
||||||
style={{
|
|
||||||
display: 'flex',
|
|
||||||
willChange: 'transform',
|
|
||||||
transform: isInfinite
|
|
||||||
? x.to((x) => `translate3d(${x}px, 0, 0)`)
|
|
||||||
: 'none',
|
|
||||||
gap: `${gap}px`,
|
|
||||||
width: 'fit-content',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{React.Children.map(children, (child, i) => (
|
|
||||||
<div
|
|
||||||
key={i}
|
|
||||||
className="flex-shrink-0 relative"
|
|
||||||
>
|
|
||||||
{overlay !== undefined && overlayFunc !== undefined && (
|
|
||||||
<div className={clsx('absolute', overlayClassName)} onClick={() => overlayFunc(i)}>
|
|
||||||
{overlay}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div
|
|
||||||
className="select-none"
|
|
||||||
style={{ pointerEvents: 'none' }}
|
|
||||||
>
|
|
||||||
{child}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
{isInfinite && React.Children.map(children, (child, i) => (
|
|
||||||
<div
|
|
||||||
key={`clone-${i}`}
|
|
||||||
className="flex-shrink-0 relative"
|
|
||||||
>
|
|
||||||
{overlay !== undefined && overlayFunc !== undefined && (
|
|
||||||
<div className={clsx('absolute', overlayClassName)} onClick={() => overlayFunc(i)}>
|
|
||||||
{overlay}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div
|
|
||||||
className="select-none"
|
|
||||||
style={{ pointerEvents: 'none' }}
|
|
||||||
>
|
|
||||||
{child}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</animated.div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default InfiniteCarousel;
|
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
import {Column, flexRender, getCoreRowModel, getSortedRowModel, useReactTable} from "@tanstack/react-table";
|
|
||||||
|
|
||||||
export default function List<T>({data, columns}: {data: T[]; columns: any[]}) {
|
|
||||||
const table = useReactTable({
|
|
||||||
data,
|
|
||||||
columns: columns,
|
|
||||||
getCoreRowModel: getCoreRowModel(),
|
|
||||||
getSortedRowModel: getSortedRowModel(),
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<table className="rounded-xl bg-mti-purple-ultralight/40 w-full">
|
|
||||||
<thead>
|
|
||||||
{table.getHeaderGroups().map((headerGroup) => (
|
|
||||||
<tr key={headerGroup.id}>
|
|
||||||
{headerGroup.headers.map((header) => (
|
|
||||||
<th key={header.id} colSpan={header.colSpan}>
|
|
||||||
{header.isPlaceholder ? null : (
|
|
||||||
<>
|
|
||||||
<div
|
|
||||||
{...{
|
|
||||||
className: header.column.getCanSort() ? "cursor-pointer select-none py-4 text-left first:pl-4" : "",
|
|
||||||
onClick: header.column.getToggleSortingHandler(),
|
|
||||||
}}>
|
|
||||||
{flexRender(header.column.columnDef.header, header.getContext())}
|
|
||||||
{{
|
|
||||||
asc: " 🔼",
|
|
||||||
desc: " 🔽",
|
|
||||||
}[header.column.getIsSorted() as string] ?? null}
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</th>
|
|
||||||
))}
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</thead>
|
|
||||||
<tbody className="px-2">
|
|
||||||
{table.getRowModel().rows.map((row) => (
|
|
||||||
<tr className="odd:bg-white even:bg-mti-purple-ultralight/40 rounded-lg py-2" key={row.id}>
|
|
||||||
{row.getVisibleCells().map((cell) => (
|
|
||||||
<td className="px-4 py-2" key={cell.id}>
|
|
||||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
|
||||||
</td>
|
|
||||||
))}
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -11,10 +11,9 @@ interface Props {
|
|||||||
autoPlay?: boolean;
|
autoPlay?: boolean;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
onEnd?: () => void;
|
onEnd?: () => void;
|
||||||
disablePause?: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function AudioPlayer({src, color, autoPlay = false, disabled = false, onEnd, disablePause = false}: Props) {
|
export default function AudioPlayer({src, color, autoPlay = false, disabled = false, onEnd}: Props) {
|
||||||
const [isPlaying, setIsPlaying] = useState(false);
|
const [isPlaying, setIsPlaying] = useState(false);
|
||||||
const [duration, setDuration] = useState(0);
|
const [duration, setDuration] = useState(0);
|
||||||
const [currentTime, setCurrentTime] = useState(0);
|
const [currentTime, setCurrentTime] = useState(0);
|
||||||
@@ -22,19 +21,11 @@ export default function AudioPlayer({src, color, autoPlay = false, disabled = fa
|
|||||||
const audioPlayerRef = useRef<HTMLAudioElement | null>(null);
|
const audioPlayerRef = useRef<HTMLAudioElement | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const durationInterval = setInterval(() => {
|
if (audioPlayerRef && audioPlayerRef.current) {
|
||||||
if (duration > 0) clearInterval(durationInterval);
|
const seconds = Math.floor(audioPlayerRef.current.duration);
|
||||||
|
setDuration(seconds);
|
||||||
const seconds = Math.floor(audioPlayerRef?.current?.duration || 0);
|
}
|
||||||
if (seconds > 0) setDuration(seconds);
|
}, [audioPlayerRef?.current?.readyState]);
|
||||||
}, 300);
|
|
||||||
|
|
||||||
if (duration > 0) clearInterval(durationInterval);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
clearInterval(durationInterval);
|
|
||||||
};
|
|
||||||
}, [duration]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let playingInterval: NodeJS.Timer | undefined = undefined;
|
let playingInterval: NodeJS.Timer | undefined = undefined;
|
||||||
@@ -63,8 +54,8 @@ export default function AudioPlayer({src, color, autoPlay = false, disabled = fa
|
|||||||
<div className="w-full h-fit flex gap-4 items-center mt-2">
|
<div className="w-full h-fit flex gap-4 items-center mt-2">
|
||||||
{isPlaying && (
|
{isPlaying && (
|
||||||
<BsPauseFill
|
<BsPauseFill
|
||||||
className={clsx("text-mti-gray-cool cursor-pointer w-5 h-5", (disabled || disablePause) && "opacity-60 cursor-not-allowed")}
|
className={clsx("text-mti-gray-cool cursor-pointer w-5 h-5", disabled && "opacity-60 cursor-not-allowed")}
|
||||||
onClick={disabled || disablePause ? undefined : togglePlayPause}
|
onClick={disabled ? undefined : togglePlayPause}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{!isPlaying && (
|
{!isPlaying && (
|
||||||
|
|||||||
@@ -1,30 +0,0 @@
|
|||||||
import {Module} from "@/interfaces";
|
|
||||||
import clsx from "clsx";
|
|
||||||
import {BsBook, BsClipboard, BsHeadphones, BsMegaphone, BsPen} from "react-icons/bs";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
module: Module;
|
|
||||||
children: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function Badge({module, children}: Props) {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={module}
|
|
||||||
className={clsx(
|
|
||||||
"flex gap-2 items-center w-fit text-white -md:px-4 xl:px-4 md:px-2 py-2 rounded-xl",
|
|
||||||
module === "reading" && "bg-ielts-reading",
|
|
||||||
module === "listening" && "bg-ielts-listening",
|
|
||||||
module === "writing" && "bg-ielts-writing",
|
|
||||||
module === "speaking" && "bg-ielts-speaking",
|
|
||||||
module === "level" && "bg-ielts-level",
|
|
||||||
)}>
|
|
||||||
{module === "reading" && <BsBook className="w-4 h-4" />}
|
|
||||||
{module === "listening" && <BsHeadphones className="w-4 h-4" />}
|
|
||||||
{module === "writing" && <BsPen className="w-4 h-4" />}
|
|
||||||
{module === "speaking" && <BsMegaphone className="w-4 h-4" />}
|
|
||||||
{module === "level" && <BsClipboard className="w-4 h-4" />}
|
|
||||||
<span className="text-sm">{children}</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,36 +1,17 @@
|
|||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import {ReactNode} from "react";
|
import {ReactNode} from "react";
|
||||||
import {BsArrowRepeat} from "react-icons/bs";
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
color?: "rose" | "purple" | "red" | "green" | "gray" | "pink";
|
color?: "rose" | "purple" | "red";
|
||||||
variant?: "outline" | "solid";
|
variant?: "outline" | "solid";
|
||||||
className?: string;
|
className?: string;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
isLoading?: boolean;
|
|
||||||
padding?: string;
|
|
||||||
onClick?: () => void;
|
onClick?: () => void;
|
||||||
type?: "button" | "reset" | "submit";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Button({
|
export default function Button({color = "purple", variant = "solid", disabled = false, className, children, onClick}: Props) {
|
||||||
color = "purple",
|
|
||||||
variant = "solid",
|
|
||||||
disabled = false,
|
|
||||||
isLoading = false,
|
|
||||||
className,
|
|
||||||
children,
|
|
||||||
type,
|
|
||||||
padding = "py-4 px-6",
|
|
||||||
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: {
|
|
||||||
solid: "bg-mti-green-light text-white border border-mti-green-light hover:bg-mti-green disabled:text-mti-green disabled:bg-mti-green-ultralight selection:bg-mti-green-dark",
|
|
||||||
outline:
|
|
||||||
"bg-transparent text-mti-green-light border border-mti-green-light hover:bg-mti-green-light disabled:text-mti-green disabled:bg-mti-green-ultralight disabled:border-none selection:bg-mti-green-dark hover:text-white selection:text-white",
|
|
||||||
},
|
|
||||||
purple: {
|
purple: {
|
||||||
solid: "bg-mti-purple-light text-white border border-mti-purple-light hover:bg-mti-purple disabled:text-mti-purple disabled:bg-mti-purple-ultralight selection:bg-mti-purple-dark",
|
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:
|
||||||
@@ -41,40 +22,23 @@ export default function Button({
|
|||||||
outline:
|
outline:
|
||||||
"bg-transparent text-mti-red-light border border-mti-red-light hover:bg-mti-red-light disabled:text-mti-red disabled:bg-mti-red-ultralight disabled:border-none selection:bg-mti-red-dark hover:text-white selection:text-white",
|
"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",
|
||||||
},
|
},
|
||||||
gray: {
|
|
||||||
solid: "bg-mti-gray-davy text-white border border-mti-gray-davy hover:bg-mti-gray-davy disabled:text-mti-gray-davy disabled:bg-mti-gray-davy selection:bg-mti-gray-davy",
|
|
||||||
outline:
|
|
||||||
"bg-transparent text-mti-gray-davy border border-mti-gray-davy hover:bg-mti-gray-davy disabled:text-mti-gray-davy disabled:bg-mti-gray-davy disabled:border-none selection:bg-mti-gray-davy hover:text-white selection:text-white",
|
|
||||||
},
|
|
||||||
rose: {
|
rose: {
|
||||||
solid: "bg-mti-rose-light text-white border border-mti-rose-light hover:bg-mti-rose disabled:text-mti-rose disabled:bg-mti-rose-ultralight selection:bg-mti-rose-dark",
|
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-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",
|
"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",
|
||||||
},
|
},
|
||||||
pink: {
|
|
||||||
solid: "bg-ielts-speaking text-white border border-ielts-speaking hover:bg-ielts-speaking disabled:text-ielts-speaking disabled:bg-ielts-speaking-transparent selection:bg-ielts-speaking",
|
|
||||||
outline:
|
|
||||||
"bg-transparent text-ielts-speaking border border-ielts-speaking hover:bg-ielts-speaking disabled:text-ielts-speaking disabled:bg-ielts-speaking-transparent disabled:border-none selection:bg-ielts-speaking hover:text-white selection:text-white",
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
type={type}
|
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"rounded-full transition ease-in-out duration-300 disabled:cursor-not-allowed cursor-pointer select-none",
|
"py-4 px-6 rounded-full transition ease-in-out duration-300 disabled:cursor-not-allowed",
|
||||||
padding,
|
|
||||||
colorClassNames[color][variant],
|
|
||||||
className,
|
className,
|
||||||
|
colorClassNames[color][variant],
|
||||||
)}
|
)}
|
||||||
disabled={disabled || isLoading}>
|
disabled={disabled}>
|
||||||
{!isLoading && children}
|
{children}
|
||||||
{isLoading && (
|
|
||||||
<div className="flex items-center justify-center">
|
|
||||||
<BsArrowRepeat className="text-white animate-spin" size={25} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,32 +0,0 @@
|
|||||||
import clsx from "clsx";
|
|
||||||
import {ReactElement, ReactNode} from "react";
|
|
||||||
import {BsCheck} from "react-icons/bs";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
isChecked: boolean;
|
|
||||||
onChange: (isChecked: boolean) => void;
|
|
||||||
children: ReactNode;
|
|
||||||
disabled?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function Checkbox({isChecked, onChange, children, disabled}: Props) {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className="flex gap-3 items-center text-mti-gray-dim text-sm cursor-pointer"
|
|
||||||
onClick={() => {
|
|
||||||
if (disabled) return;
|
|
||||||
onChange(!isChecked);
|
|
||||||
}}>
|
|
||||||
<input type="checkbox" className="hidden" />
|
|
||||||
<div
|
|
||||||
className={clsx(
|
|
||||||
"w-6 h-6 min-w-6 min-h-6 rounded-md flex items-center justify-center border border-mti-purple-light bg-white",
|
|
||||||
"transition duration-300 ease-in-out",
|
|
||||||
isChecked && "!bg-mti-purple-light ",
|
|
||||||
)}>
|
|
||||||
<BsCheck color="white" className="w-full h-full" />
|
|
||||||
</div>
|
|
||||||
<span>{children}</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,83 +0,0 @@
|
|||||||
import {countries, TCountries} from "countries-list";
|
|
||||||
import {Fragment, useState} from "react";
|
|
||||||
import {Combobox, Transition} from "@headlessui/react";
|
|
||||||
import {BsChevronExpand} from "react-icons/bs";
|
|
||||||
import countryCodes from "country-codes-list";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
value?: string;
|
|
||||||
onChange?: (value: string) => void;
|
|
||||||
disabled?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
const mapCountries = (codes: string[]) => {
|
|
||||||
return codes.map((code) => ({
|
|
||||||
label: `${countryCodes.findOne("countryCode" as any, code).flag} ${countries[code as unknown as keyof TCountries].name} (+${
|
|
||||||
countries[code as unknown as keyof TCountries].phone
|
|
||||||
})`,
|
|
||||||
code,
|
|
||||||
}));
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function CountrySelect({value, disabled = false, onChange}: Props) {
|
|
||||||
const [query, setQuery] = useState("");
|
|
||||||
|
|
||||||
const filteredCountries =
|
|
||||||
query === ""
|
|
||||||
? mapCountries(Object.keys(countries))
|
|
||||||
: mapCountries(
|
|
||||||
Object.keys(countries).filter((x) =>
|
|
||||||
countries[x as unknown as keyof TCountries].name.toLowerCase().includes(query.toLowerCase()),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Combobox value={value} onChange={onChange} disabled={disabled}>
|
|
||||||
<div className="relative mt-1">
|
|
||||||
<div className="relative w-full cursor-default overflow-hidden ">
|
|
||||||
<Combobox.Input
|
|
||||||
className="py-6 w-full px-8 text-sm font-normal placeholder:text-mti-gray-cool bg-white disabled:bg-mti-gray-platinum/40 rounded-full border border-mti-gray-platinum focus:outline-none"
|
|
||||||
onChange={(e) => setQuery(e.target.value)}
|
|
||||||
displayValue={(code: string) => {
|
|
||||||
const country = countries[code as unknown as keyof TCountries];
|
|
||||||
|
|
||||||
return `${countryCodes.findOne("countryCode" as any, code)?.flag || ""} ${country?.name || "N/A"} (+${
|
|
||||||
country?.phone || "N/A"
|
|
||||||
})`;
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Combobox.Button className="absolute inset-y-0 right-0 flex items-center pr-8">
|
|
||||||
<BsChevronExpand />
|
|
||||||
</Combobox.Button>
|
|
||||||
</div>
|
|
||||||
<Transition
|
|
||||||
as={Fragment}
|
|
||||||
leave="transition ease-in duration-100"
|
|
||||||
leaveFrom="opacity-100"
|
|
||||||
leaveTo="opacity-0"
|
|
||||||
afterLeave={() => setQuery("")}>
|
|
||||||
<Combobox.Options className="absolute z-20 mt-1 max-h-60 w-full overflow-auto rounded-xl bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm">
|
|
||||||
{filteredCountries.length === 0 && query !== "" ? (
|
|
||||||
<div className="relative cursor-default select-none py-2 px-4 text-gray-700">Nothing found.</div>
|
|
||||||
) : (
|
|
||||||
filteredCountries.map((country) => (
|
|
||||||
<Combobox.Option
|
|
||||||
key={country.code}
|
|
||||||
value={country.code}
|
|
||||||
className={({active}) =>
|
|
||||||
`relative cursor-default select-none py-2 pl-10 pr-4 ${
|
|
||||||
active ? "bg-mti-purple-light text-white" : "text-gray-900"
|
|
||||||
}`
|
|
||||||
}>
|
|
||||||
{country.label}
|
|
||||||
</Combobox.Option>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</Combobox.Options>
|
|
||||||
</Transition>
|
|
||||||
</div>
|
|
||||||
</Combobox>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,51 +1,18 @@
|
|||||||
import clsx from "clsx";
|
import {useState} from "react";
|
||||||
import { useState } from "react";
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
type: "email" | "text" | "password" | "tel" | "number" | "textarea";
|
type: "email" | "text" | "password";
|
||||||
roundness?: "full" | "xl";
|
|
||||||
required?: boolean;
|
required?: boolean;
|
||||||
label?: string;
|
label?: string;
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
defaultValue?: string | number;
|
defaultValue?: string;
|
||||||
value?: string | number;
|
|
||||||
className?: string;
|
|
||||||
disabled?: boolean;
|
|
||||||
max?: number;
|
|
||||||
name: string;
|
name: string;
|
||||||
onChange: (value: string) => void;
|
onChange: (value: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Input({
|
export default function Input({type, label, placeholder, name, required = false, defaultValue, onChange}: Props) {
|
||||||
type,
|
|
||||||
label,
|
|
||||||
placeholder,
|
|
||||||
name,
|
|
||||||
required = false,
|
|
||||||
value,
|
|
||||||
defaultValue,
|
|
||||||
max,
|
|
||||||
className,
|
|
||||||
roundness = "full",
|
|
||||||
disabled = false,
|
|
||||||
onChange,
|
|
||||||
}: Props) {
|
|
||||||
const [showPassword, setShowPassword] = useState(false);
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
|
|
||||||
if (type === "textarea") {
|
|
||||||
return (
|
|
||||||
<textarea
|
|
||||||
onContextMenu={(e) => e.preventDefault()}
|
|
||||||
className="w-full h-full cursor-text px-7 py-8 input border-2 border-mti-gray-platinum bg-white rounded-3xl min-h-[200px]"
|
|
||||||
onChange={(e) => onChange(e.target.value)}
|
|
||||||
value={value}
|
|
||||||
placeholder={placeholder}
|
|
||||||
spellCheck={false}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
if (type === "password") {
|
if (type === "password") {
|
||||||
return (
|
return (
|
||||||
<div className="relative flex flex-col gap-3 w-full">
|
<div className="relative flex flex-col gap-3 w-full">
|
||||||
@@ -61,7 +28,6 @@ export default function Input({
|
|||||||
name={name}
|
name={name}
|
||||||
onChange={(e) => onChange(e.target.value)}
|
onChange={(e) => onChange(e.target.value)}
|
||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
defaultValue={defaultValue}
|
|
||||||
className="w-full px-8 py-6 text-sm font-normal placeholder:text-mti-gray-cool bg-white rounded-full border border-mti-gray-platinum focus:outline-none"
|
className="w-full px-8 py-6 text-sm font-normal placeholder:text-mti-gray-cool bg-white rounded-full border border-mti-gray-platinum focus:outline-none"
|
||||||
/>
|
/>
|
||||||
<p
|
<p
|
||||||
@@ -76,7 +42,7 @@ export default function Input({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={clsx("flex flex-col gap-3 w-full", className)}>
|
<div className="flex flex-col gap-3 w-full">
|
||||||
{label && (
|
{label && (
|
||||||
<label className="font-normal text-base text-mti-gray-dim">
|
<label className="font-normal text-base text-mti-gray-dim">
|
||||||
{label}
|
{label}
|
||||||
@@ -86,17 +52,9 @@ export default function Input({
|
|||||||
<input
|
<input
|
||||||
type={type}
|
type={type}
|
||||||
name={name}
|
name={name}
|
||||||
disabled={disabled}
|
|
||||||
value={value}
|
|
||||||
max={max}
|
|
||||||
onChange={(e) => onChange(e.target.value)}
|
onChange={(e) => onChange(e.target.value)}
|
||||||
min={type === "number" ? 0 : undefined}
|
|
||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
className={clsx(
|
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"
|
||||||
"px-8 py-6 text-sm font-normal bg-white border border-mti-gray-platinum focus:outline-none",
|
|
||||||
"placeholder:text-mti-gray-cool disabled:bg-mti-gray-platinum/40 disabled:text-mti-gray-dim disabled:cursor-not-allowed",
|
|
||||||
roundness === "full" ? "rounded-full" : "rounded-xl",
|
|
||||||
)}
|
|
||||||
required={required}
|
required={required}
|
||||||
defaultValue={defaultValue}
|
defaultValue={defaultValue}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -5,14 +5,11 @@ interface Props {
|
|||||||
label: string;
|
label: string;
|
||||||
percentage: number;
|
percentage: number;
|
||||||
color: "red" | "rose" | "purple" | Module;
|
color: "red" | "rose" | "purple" | Module;
|
||||||
mark?: number;
|
|
||||||
markLabel?: string;
|
|
||||||
useColor?: boolean;
|
useColor?: boolean;
|
||||||
className?: string;
|
className?: string;
|
||||||
textClassName?: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ProgressBar({label, percentage, color, mark, markLabel, useColor = false, className, textClassName}: 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} = {
|
||||||
red: "bg-mti-red-light",
|
red: "bg-mti-red-light",
|
||||||
rose: "bg-mti-rose-light",
|
rose: "bg-mti-rose-light",
|
||||||
@@ -21,7 +18,6 @@ export default function ProgressBar({label, percentage, color, mark, markLabel,
|
|||||||
listening: "bg-ielts-listening",
|
listening: "bg-ielts-listening",
|
||||||
writing: "bg-ielts-writing",
|
writing: "bg-ielts-writing",
|
||||||
speaking: "bg-ielts-speaking",
|
speaking: "bg-ielts-speaking",
|
||||||
level: "bg-ielts-level",
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -32,14 +28,11 @@ export default function ProgressBar({label, percentage, color, mark, markLabel,
|
|||||||
!useColor ? "bg-mti-gray-anti-flash" : progressColorClass[color],
|
!useColor ? "bg-mti-gray-anti-flash" : progressColorClass[color],
|
||||||
useColor && "bg-opacity-20",
|
useColor && "bg-opacity-20",
|
||||||
)}>
|
)}>
|
||||||
{mark && (
|
|
||||||
<div style={{left: `${mark}%`}} className={clsx("w-3 h-2 bg-mti-gray-davy/60 absolute -translate-x-1/2 top-0 z-20 cursor-pointer")} />
|
|
||||||
)}
|
|
||||||
<div
|
<div
|
||||||
style={{width: `${percentage}%`}}
|
style={{width: `${percentage}%`}}
|
||||||
className={clsx("absolute transition-all duration-300 ease-in-out top-0 left-0 h-full overflow-hidden", progressColorClass[color])}
|
className={clsx("absolute transition-all duration-300 ease-in-out top-0 left-0 h-full overflow-hidden", progressColorClass[color])}
|
||||||
/>
|
/>
|
||||||
<span className={clsx("z-[1] justify-self-center text-white text-sm font-bold", textClassName)}>{label}</span>
|
<span className="z-10 justify-self-center text-white text-sm font-bold">{label}</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,70 +0,0 @@
|
|||||||
import clsx from "clsx";
|
|
||||||
import {ComponentProps, useEffect, useState} from "react";
|
|
||||||
import ReactSelect, {GroupBase, StylesConfig} from "react-select";
|
|
||||||
|
|
||||||
interface Option {
|
|
||||||
[key: string]: any;
|
|
||||||
value: string | null;
|
|
||||||
label: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
defaultValue?: Option;
|
|
||||||
value?: Option | null;
|
|
||||||
options: Option[];
|
|
||||||
disabled?: boolean;
|
|
||||||
placeholder?: string;
|
|
||||||
onChange: (value: Option | null) => void;
|
|
||||||
isClearable?: boolean;
|
|
||||||
styles?: StylesConfig<Option, boolean, GroupBase<Option>>;
|
|
||||||
className?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function Select({value, defaultValue, options, placeholder, disabled, onChange, styles, isClearable, className}: Props) {
|
|
||||||
const [target, setTarget] = useState<HTMLElement>();
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (document) setTarget(document.body);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ReactSelect
|
|
||||||
className={
|
|
||||||
styles
|
|
||||||
? undefined
|
|
||||||
: clsx(
|
|
||||||
"placeholder:text-mti-gray-cool border-mti-gray-platinum w-full rounded-full border bg-white px-4 py-4 text-sm font-normal focus:outline-none",
|
|
||||||
disabled && "!bg-mti-gray-platinum/40 !text-mti-gray-dim cursor-not-allowed",
|
|
||||||
className,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
options={options}
|
|
||||||
value={value}
|
|
||||||
onChange={onChange as any}
|
|
||||||
placeholder={placeholder}
|
|
||||||
menuPortalTarget={target}
|
|
||||||
defaultValue={defaultValue}
|
|
||||||
styles={
|
|
||||||
styles || {
|
|
||||||
menuPortal: (base) => ({...base, zIndex: 9999}),
|
|
||||||
control: (styles) => ({
|
|
||||||
...styles,
|
|
||||||
paddingLeft: "4px",
|
|
||||||
border: "none",
|
|
||||||
outline: "none",
|
|
||||||
":focus": {
|
|
||||||
outline: "none",
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
option: (styles, state) => ({
|
|
||||||
...styles,
|
|
||||||
backgroundColor: state.isFocused ? "#D5D9F0" : state.isSelected ? "#7872BF" : "white",
|
|
||||||
color: state.isFocused ? "black" : styles.color,
|
|
||||||
}),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
isDisabled={disabled}
|
|
||||||
isClearable={isClearable}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,64 +0,0 @@
|
|||||||
import { Fragment, useState } from "react";
|
|
||||||
import { Combobox, Transition } from "@headlessui/react";
|
|
||||||
import { BsChevronExpand } from "react-icons/bs";
|
|
||||||
import moment from "moment-timezone";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
value?: string;
|
|
||||||
onChange?: (value: string) => void;
|
|
||||||
disabled?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function TimezoneSelect({
|
|
||||||
value,
|
|
||||||
disabled = false,
|
|
||||||
onChange,
|
|
||||||
}: Props) {
|
|
||||||
const [query, setQuery] = useState("");
|
|
||||||
|
|
||||||
const timezones = moment.tz.names();
|
|
||||||
|
|
||||||
const filteredTimezones = query === "" ? timezones : timezones.filter((x) => x.toLowerCase().includes(query.toLowerCase()));
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Combobox value={value} onChange={onChange} disabled={disabled}>
|
|
||||||
<div className="relative mt-1">
|
|
||||||
<div className="relative w-full cursor-default overflow-hidden ">
|
|
||||||
<Combobox.Input
|
|
||||||
className="py-6 w-full px-8 text-sm font-normal placeholder:text-mti-gray-cool bg-white disabled:bg-mti-gray-platinum/40 rounded-full border border-mti-gray-platinum focus:outline-none"
|
|
||||||
onChange={(e) => setQuery(e.target.value)}
|
|
||||||
/>
|
|
||||||
<Combobox.Button className="absolute inset-y-0 right-0 flex items-center pr-8">
|
|
||||||
<BsChevronExpand />
|
|
||||||
</Combobox.Button>
|
|
||||||
</div>
|
|
||||||
<Transition
|
|
||||||
as={Fragment}
|
|
||||||
leave="transition ease-in duration-100"
|
|
||||||
leaveFrom="opacity-100"
|
|
||||||
leaveTo="opacity-0"
|
|
||||||
afterLeave={() => setQuery("")}
|
|
||||||
>
|
|
||||||
<Combobox.Options className="absolute z-20 mt-1 max-h-60 w-full overflow-auto rounded-xl bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm">
|
|
||||||
{filteredTimezones.map((timezone: string) => (
|
|
||||||
<Combobox.Option
|
|
||||||
key={timezone}
|
|
||||||
value={timezone}
|
|
||||||
className={({ active }) =>
|
|
||||||
`relative cursor-default select-none py-2 pl-10 pr-4 ${
|
|
||||||
active
|
|
||||||
? "bg-mti-purple-light text-white"
|
|
||||||
: "text-gray-900"
|
|
||||||
}`
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{timezone}
|
|
||||||
</Combobox.Option>
|
|
||||||
))}
|
|
||||||
</Combobox.Options>
|
|
||||||
</Transition>
|
|
||||||
</div>
|
|
||||||
</Combobox>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,77 +0,0 @@
|
|||||||
import { Invite } from "@/interfaces/invite";
|
|
||||||
import { User } from "@/interfaces/user";
|
|
||||||
import axios from "axios";
|
|
||||||
import { useState } from "react";
|
|
||||||
import { BsArrowRepeat } from "react-icons/bs";
|
|
||||||
import { toast } from "react-toastify";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
invite: Invite;
|
|
||||||
users: User[];
|
|
||||||
reload: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function InviteCard({ invite, users, reload }: Props) {
|
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
|
||||||
|
|
||||||
const inviter = users.find((u) => u.id === invite.from);
|
|
||||||
const name = !inviter
|
|
||||||
? null
|
|
||||||
: inviter.type === "corporate"
|
|
||||||
? inviter.corporateInformation?.companyInformation?.name || inviter.name
|
|
||||||
: inviter.name;
|
|
||||||
|
|
||||||
const decide = (decision: "accept" | "decline") => {
|
|
||||||
if (!confirm(`Are you sure you want to ${decision} this invite?`)) return;
|
|
||||||
|
|
||||||
setIsLoading(true);
|
|
||||||
axios
|
|
||||||
.get(`/api/invites/${decision}/${invite.id}`)
|
|
||||||
.then(() => {
|
|
||||||
toast.success(
|
|
||||||
`Successfully ${decision === "accept" ? "accepted" : "declined"} the invite!`,
|
|
||||||
{ toastId: "success" },
|
|
||||||
);
|
|
||||||
reload();
|
|
||||||
})
|
|
||||||
.catch((e) => {
|
|
||||||
toast.success(`Something went wrong, please try again later!`, {
|
|
||||||
toastId: "error",
|
|
||||||
});
|
|
||||||
reload();
|
|
||||||
})
|
|
||||||
.finally(() => setIsLoading(false));
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="border-mti-gray-anti-flash flex min-w-[200px] flex-col gap-6 rounded-xl border p-4 text-black">
|
|
||||||
<span>Invited by {name}</span>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<button
|
|
||||||
onClick={() => decide("accept")}
|
|
||||||
disabled={isLoading}
|
|
||||||
className="bg-mti-green-ultralight hover:bg-mti-green-light w-24 rounded-lg p-2 px-4 transition duration-300 ease-in-out hover:text-white disabled:cursor-not-allowed"
|
|
||||||
>
|
|
||||||
{!isLoading && "Accept"}
|
|
||||||
{isLoading && (
|
|
||||||
<div className="flex items-center justify-center">
|
|
||||||
<BsArrowRepeat className="animate-spin text-white" size={25} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => decide("decline")}
|
|
||||||
disabled={isLoading}
|
|
||||||
className="bg-mti-red-ultralight hover:bg-mti-red-light w-24 rounded-lg p-2 px-4 transition duration-300 ease-in-out hover:text-white disabled:cursor-not-allowed"
|
|
||||||
>
|
|
||||||
{!isLoading && "Decline"}
|
|
||||||
{isLoading && (
|
|
||||||
<div className="flex items-center justify-center">
|
|
||||||
<BsArrowRepeat className="animate-spin text-white" size={25} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,121 +0,0 @@
|
|||||||
import {Module} from "@/interfaces";
|
|
||||||
import {writingMarking} from "@/utils/score";
|
|
||||||
import {Menu} from "@headlessui/react";
|
|
||||||
import {Dispatch, SetStateAction} from "react";
|
|
||||||
import {BsBook, BsChevronDown, BsHeadphones, BsMegaphone, BsPen} from "react-icons/bs";
|
|
||||||
|
|
||||||
type Levels = {[key in Module]: number};
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
levels: Levels;
|
|
||||||
setLevels: Dispatch<SetStateAction<Levels>>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function ModuleLevelSelector({levels, setLevels}: Props) {
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col gap-32 w-full">
|
|
||||||
<div className="grid grid-cols-1 md: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 overflow-y-scroll scrollbar-hide max-h-[230px] 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, 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 overflow-y-scroll scrollbar-hide max-h-[230px] origin-top top-full bg-white flex flex-col items-center w-full z-50 drop-shadow-lg rounded-2xl">
|
|
||||||
{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 overflow-y-scroll scrollbar-hide max-h-[230px] 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, 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 overflow-y-scroll scrollbar-hide max-h-[230px] 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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,190 +1,99 @@
|
|||||||
import { Module } from "@/interfaces";
|
import {Module} from "@/interfaces";
|
||||||
import { moduleLabels } from "@/utils/moduleUtils";
|
|
||||||
import clsx from "clsx";
|
|
||||||
import { ReactNode, useState } from "react";
|
|
||||||
import { BsBook, BsClipboard, BsHeadphones, BsMegaphone, BsPen, BsStopwatch } from "react-icons/bs";
|
|
||||||
import ProgressBar from "../Low/ProgressBar";
|
|
||||||
import Timer from "./Timer";
|
|
||||||
import { Exercise, LevelExam, MultipleChoiceExercise, ShuffleMap, UserSolution } from "@/interfaces/exam";
|
|
||||||
import { BsFillGrid3X3GapFill } from "react-icons/bs";
|
|
||||||
import Button from "../Low/Button";
|
|
||||||
import useExamStore from "@/stores/examStore";
|
import useExamStore from "@/stores/examStore";
|
||||||
import Modal from "../Modal";
|
import {moduleLabels} from "@/utils/moduleUtils";
|
||||||
import React from "react";
|
import clsx from "clsx";
|
||||||
|
import {motion} from "framer-motion";
|
||||||
|
import {ReactNode, useEffect, useState} from "react";
|
||||||
|
import {BsBook, BsHeadphones, BsMegaphone, BsPen, BsStopwatch} from "react-icons/bs";
|
||||||
|
import ProgressBar from "../Low/ProgressBar";
|
||||||
|
import TimerEndedModal from "../TimerEndedModal";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
minTimer: number;
|
minTimer: number;
|
||||||
module: Module;
|
module: Module;
|
||||||
examLabel?: string;
|
|
||||||
label?: string;
|
label?: string;
|
||||||
exerciseIndex: number;
|
exerciseIndex: number;
|
||||||
totalExercises: number;
|
totalExercises: number;
|
||||||
disableTimer?: boolean;
|
disableTimer?: boolean;
|
||||||
partLabel?: string;
|
|
||||||
showTimer?: boolean;
|
|
||||||
showSolutions?: boolean;
|
|
||||||
currentExercise?: Exercise;
|
|
||||||
runOnClick?: ((questionIndex: number) => void) | undefined;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ModuleTitle({
|
export default function ModuleTitle({minTimer, module, label, exerciseIndex, totalExercises, disableTimer = false}: Props) {
|
||||||
minTimer,
|
const [timer, setTimer] = useState(minTimer * 60);
|
||||||
module,
|
const [showModal, setShowModal] = useState(false);
|
||||||
label,
|
const [warningMode, setWarningMode] = useState(false);
|
||||||
examLabel,
|
const setHasExamEnded = useExamStore((state) => state.setHasExamEnded);
|
||||||
exerciseIndex,
|
|
||||||
totalExercises,
|
|
||||||
disableTimer = false,
|
|
||||||
partLabel,
|
|
||||||
showTimer = true,
|
|
||||||
showSolutions = false,
|
|
||||||
runOnClick = undefined
|
|
||||||
}: Props) {
|
|
||||||
const {
|
|
||||||
userSolutions,
|
|
||||||
partIndex,
|
|
||||||
exam
|
|
||||||
} = useExamStore((state) => state);
|
|
||||||
const examExerciseIndex = useExamStore((state) => state.exerciseIndex)
|
|
||||||
|
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
useEffect(() => {
|
||||||
|
if (!disableTimer) {
|
||||||
|
const timerInterval = setInterval(() => setTimer((prev) => prev - 1), 1000);
|
||||||
|
|
||||||
const moduleIcon: { [key in Module]: ReactNode } = {
|
return () => {
|
||||||
|
clearInterval(timerInterval);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}, [disableTimer, minTimer]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (timer <= 0) setShowModal(true);
|
||||||
|
}, [timer]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (timer < 300 && !warningMode) setWarningMode(true);
|
||||||
|
}, [timer, warningMode]);
|
||||||
|
|
||||||
|
const moduleIcon: {[key in Module]: ReactNode} = {
|
||||||
reading: <BsBook className="text-ielts-reading w-6 h-6" />,
|
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" />,
|
||||||
writing: <BsPen className="text-ielts-writing w-6 h-6" />,
|
writing: <BsPen className="text-ielts-writing w-6 h-6" />,
|
||||||
speaking: <BsMegaphone className="text-ielts-speaking w-6 h-6" />,
|
speaking: <BsMegaphone className="text-ielts-speaking w-6 h-6" />,
|
||||||
level: <BsClipboard className="text-ielts-level w-6 h-6" />,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const isMultipleChoiceLevelExercise = () => {
|
|
||||||
if (exam?.module === 'level' && typeof partIndex === "number" && partIndex > -1) {
|
|
||||||
const currentExercise = (exam as LevelExam).parts[partIndex].exercises[examExerciseIndex];
|
|
||||||
return currentExercise && currentExercise.type === 'multipleChoice';
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
};
|
|
||||||
|
|
||||||
const renderMCQuestionGrid = () => {
|
|
||||||
if (!isMultipleChoiceLevelExercise() && !userSolutions) return null;
|
|
||||||
|
|
||||||
const currentExercise = (exam as LevelExam).parts[partIndex!].exercises[examExerciseIndex] as MultipleChoiceExercise;
|
|
||||||
const userSolution = userSolutions!.find((x) => x.exercise.toString() == currentExercise.id.toString())!;
|
|
||||||
const answeredQuestions = new Set(userSolution.solutions.map(sol => sol.question.toString()));
|
|
||||||
const exerciseOffset = Number(currentExercise.questions[0].id);
|
|
||||||
const lastExercise = exerciseOffset + (currentExercise.questions.length - 1);
|
|
||||||
|
|
||||||
const getQuestionColor = (questionId: string, solution: string, userQuestionSolution: string | undefined) => {
|
|
||||||
const questionShuffleMap = userSolutions.reduce((foundMap, userSolution) => {
|
|
||||||
if (foundMap) return foundMap;
|
|
||||||
return userSolution.shuffleMaps?.find(map => map.questionID.toString() === questionId.toString()) || null;
|
|
||||||
}, null as ShuffleMap | null);
|
|
||||||
const newSolution = questionShuffleMap ? questionShuffleMap?.map[solution] : solution;
|
|
||||||
|
|
||||||
if (!userSolutions) return "";
|
|
||||||
|
|
||||||
if (!userQuestionSolution) {
|
|
||||||
return "!bg-mti-gray-davy !border--mti-gray-davy !text-mti-gray-davy !text-white hover:!bg-gray-700";
|
|
||||||
}
|
|
||||||
|
|
||||||
return userQuestionSolution === newSolution ?
|
|
||||||
"!bg-mti-purple-light !text-mti-purple-light !text-white hover:!bg-mti-purple-dark" :
|
|
||||||
"!bg-mti-rose-light !border-mti-rose-light !text-mti-rose-light !text-white hover:!bg-mti-rose-dark";
|
|
||||||
}
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<h3 className="text-xl font-semibold mb-4 text-center">{`Part ${partIndex + 1} (Questions ${exerciseOffset} - ${lastExercise})`}</h3>
|
<TimerEndedModal
|
||||||
<div className="grid grid-cols-5 gap-3 px-4 py-2">
|
isOpen={showModal}
|
||||||
{currentExercise.questions.map((_, index) => {
|
onClose={() => {
|
||||||
const questionNumber = exerciseOffset + index;
|
setHasExamEnded(true);
|
||||||
const isAnswered = answeredQuestions.has(questionNumber.toString());
|
setShowModal(false);
|
||||||
const solution = currentExercise.questions.find((x) => x.id.toString() == questionNumber.toString())!.solution;
|
}}
|
||||||
|
/>
|
||||||
const userQuestionSolution = currentExercise.userSolutions?.find((x) => x.question.toString() == questionNumber.toString())?.option;
|
<motion.div
|
||||||
return (
|
|
||||||
<Button
|
|
||||||
variant={showSolutions ? "solid" : (isAnswered ? "solid" : "outline")}
|
|
||||||
key={index}
|
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"w-12 h-12 flex items-center justify-center rounded-lg text-sm font-bold transition-all duration-200 ease-in-out",
|
"absolute top-4 right-6 bg-mti-gray-seasalt px-3 py-2 flex items-center gap-2 rounded-full text-mti-gray-davy",
|
||||||
(showSolutions ?
|
warningMode && !disableTimer && "bg-mti-red-light text-mti-gray-seasalt",
|
||||||
getQuestionColor(questionNumber.toString(), solution, userQuestionSolution) :
|
|
||||||
(isAnswered ?
|
|
||||||
"bg-mti-purple-light border-mti-purple-light text-white hover:bg-mti-purple-dark hover:border-mti-purple-dark" :
|
|
||||||
"bg-white border-gray-400 hover:bg-gray-100 hover:text-gray-700"
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)}
|
)}
|
||||||
onClick={() => { if (typeof runOnClick !== "undefined") { runOnClick(index); } setIsOpen(false); }}
|
initial={{scale: warningMode && !disableTimer ? 0.8 : 1}}
|
||||||
>
|
animate={{scale: warningMode && !disableTimer ? 1.1 : 1}}
|
||||||
{questionNumber}
|
transition={{repeat: Infinity, repeatType: "reverse", duration: 0.5, ease: "easeInOut"}}>
|
||||||
</Button>
|
<BsStopwatch className="w-4 h-4" />
|
||||||
);
|
<span className="text-sm font-semibold w-11">
|
||||||
})}
|
{timer > 0 && (
|
||||||
</div>
|
|
||||||
<p className="mt-4 text-sm text-gray-600 text-center">
|
|
||||||
Click a question number to jump to that question
|
|
||||||
</p>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
return (
|
|
||||||
<>
|
<>
|
||||||
{showTimer && <Timer minTimer={minTimer} disableTimer={disableTimer} />}
|
{Math.floor(timer / 60)
|
||||||
<div className="w-full">
|
.toString(10)
|
||||||
{partLabel && (
|
.padStart(2, "0")}
|
||||||
<div className="text-3xl space-y-4">
|
:
|
||||||
{partLabel.split("\n\n").map((partInstructions, index) => {
|
{Math.floor(timer % 60)
|
||||||
if (index === 0)
|
.toString(10)
|
||||||
return (
|
.padStart(2, "0")}
|
||||||
<p key={index} className="font-bold">
|
</>
|
||||||
{partInstructions}
|
|
||||||
</p>
|
|
||||||
);
|
|
||||||
else
|
|
||||||
return (
|
|
||||||
<div key={index} className="text-2xl font-semibold flex flex-col gap-2">
|
|
||||||
{partInstructions.split("\\n").map((line, lineIndex) => (
|
|
||||||
<span key={lineIndex} dangerouslySetInnerHTML={{__html: line.replace('that is not correct', 'that is <span class="font-bold"><u>not correct</u></span>')}}></span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
<div className={clsx("flex gap-6 w-full h-fit items-center", partLabel ? "mt-10" : "mt-5")}>
|
{timer <= 0 && <>00:00</>}
|
||||||
|
</span>
|
||||||
|
</motion.div>
|
||||||
|
<div className="flex gap-6 w-full h-fit items-center mt-5">
|
||||||
<div className="w-12 h-12 bg-mti-gray-smoke flex items-center justify-center rounded-lg">{moduleIcon[module]}</div>
|
<div className="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">
|
||||||
<div className="w-full flex justify-between">
|
<div className="w-full flex justify-between">
|
||||||
<span className="text-base font-semibold">
|
<span className="text-base font-semibold">
|
||||||
{module === "level"
|
{moduleLabels[module]} exam {label && `- ${label}`}
|
||||||
? (examLabel ? examLabel : "Placement Test")
|
|
||||||
: `${moduleLabels[module]} exam${label ? ` - ${label}` : ''}`
|
|
||||||
}
|
|
||||||
</span>
|
</span>
|
||||||
<span className="text-sm font-semibold self-end">
|
<span className="text-xs font-normal self-end text-mti-gray-davy">
|
||||||
Question {exerciseIndex}/{totalExercises}
|
Question {exerciseIndex}/{totalExercises}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<ProgressBar color={module} label="" percentage={(exerciseIndex * 100) / totalExercises} className="h-2 w-full" />
|
<ProgressBar color={module} label="" percentage={(exerciseIndex * 100) / totalExercises} className="h-2 w-full" />
|
||||||
</div>
|
</div>
|
||||||
{isMultipleChoiceLevelExercise() && (
|
|
||||||
<>
|
|
||||||
<Button variant="outline" onClick={() => setIsOpen(true)} padding="p-2" className="rounded-lg">
|
|
||||||
<BsFillGrid3X3GapFill size={24} />
|
|
||||||
</Button>
|
|
||||||
<Modal
|
|
||||||
isOpen={isOpen}
|
|
||||||
onClose={() => setIsOpen(false)}
|
|
||||||
className="w-full max-w-md transform overflow-hidden rounded-2xl bg-white shadow-xl transition-all"
|
|
||||||
>
|
|
||||||
<>
|
|
||||||
{renderMCQuestionGrid()}
|
|
||||||
</>
|
|
||||||
</Modal>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,206 +0,0 @@
|
|||||||
import { User } from "@/interfaces/user";
|
|
||||||
import { checkAccess } from "@/utils/permissions";
|
|
||||||
import Select from "../Low/Select";
|
|
||||||
import { ReactNode, useEffect, useState } from "react";
|
|
||||||
import clsx from "clsx";
|
|
||||||
import useUsers from "@/hooks/useUsers";
|
|
||||||
import useGroups from "@/hooks/useGroups";
|
|
||||||
import useRecordStore from "@/stores/recordStore";
|
|
||||||
|
|
||||||
|
|
||||||
type TimeFilter = "months" | "weeks" | "days";
|
|
||||||
type Filter = TimeFilter | "assignments" | undefined;
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
user: User;
|
|
||||||
filterState: {
|
|
||||||
filter: Filter,
|
|
||||||
setFilter: React.Dispatch<React.SetStateAction<Filter>>
|
|
||||||
},
|
|
||||||
assignments?: boolean;
|
|
||||||
children?: ReactNode
|
|
||||||
}
|
|
||||||
|
|
||||||
const defaultSelectableCorporate = {
|
|
||||||
value: "",
|
|
||||||
label: "All",
|
|
||||||
};
|
|
||||||
|
|
||||||
const RecordFilter: React.FC<Props> = ({
|
|
||||||
user,
|
|
||||||
filterState,
|
|
||||||
assignments = true,
|
|
||||||
children
|
|
||||||
}) => {
|
|
||||||
const { filter, setFilter } = filterState;
|
|
||||||
|
|
||||||
const [statsUserId, setStatsUserId] = useRecordStore((state) => [
|
|
||||||
state.selectedUser,
|
|
||||||
state.setSelectedUser
|
|
||||||
]);
|
|
||||||
|
|
||||||
const { users } = useUsers();
|
|
||||||
const { groups: allGroups } = useGroups({});
|
|
||||||
const { groups } = useGroups({ admin: user?.id, userType: user?.type });
|
|
||||||
|
|
||||||
const toggleFilter = (value: "months" | "weeks" | "days" | "assignments") => {
|
|
||||||
setFilter((prev) => (prev === value ? undefined : value));
|
|
||||||
};
|
|
||||||
|
|
||||||
const selectableCorporates = [
|
|
||||||
defaultSelectableCorporate,
|
|
||||||
...users
|
|
||||||
.filter((x) => groups.flatMap((g) => [g.admin, ...g.participants]).includes(x.id))
|
|
||||||
.filter((x) => x.type === "corporate")
|
|
||||||
.map((x) => ({
|
|
||||||
value: x.id,
|
|
||||||
label: `${x.name} - ${x.email}`,
|
|
||||||
})),
|
|
||||||
];
|
|
||||||
|
|
||||||
const [selectedCorporate, setSelectedCorporate] = useState<string>(defaultSelectableCorporate.value);
|
|
||||||
|
|
||||||
const getUsersList = (): User[] => {
|
|
||||||
if (selectedCorporate) {
|
|
||||||
const selectedCorporateGroups = allGroups.filter((x) => x.admin === selectedCorporate);
|
|
||||||
const selectedCorporateGroupsParticipants = selectedCorporateGroups.flatMap((x) => x.participants);
|
|
||||||
|
|
||||||
const userListWithUsers = selectedCorporateGroupsParticipants.map((x) => users.find((y) => y.id === x)) as User[];
|
|
||||||
return userListWithUsers.filter((x) => x);
|
|
||||||
}
|
|
||||||
|
|
||||||
return user.type !== "mastercorporate" ? users : users.filter((x) => groups.flatMap((g) => [g.admin, ...g.participants]).includes(x.id));
|
|
||||||
};
|
|
||||||
|
|
||||||
const corporateFilteredUserList = getUsersList();
|
|
||||||
|
|
||||||
const getSelectedUser = () => {
|
|
||||||
if (selectedCorporate) {
|
|
||||||
const userInCorporate = corporateFilteredUserList.find((x) => x.id === statsUserId);
|
|
||||||
return userInCorporate || corporateFilteredUserList[0];
|
|
||||||
}
|
|
||||||
|
|
||||||
return users.find((x) => x.id === statsUserId) || user;
|
|
||||||
};
|
|
||||||
|
|
||||||
const selectedUser = getSelectedUser();
|
|
||||||
const selectedUserSelectValue = selectedUser
|
|
||||||
? {
|
|
||||||
value: selectedUser.id,
|
|
||||||
label: `${selectedUser.name} - ${selectedUser.email}`,
|
|
||||||
}
|
|
||||||
: {
|
|
||||||
value: "",
|
|
||||||
label: "",
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="w-full flex -xl:flex-col -xl:gap-4 justify-between items-center">
|
|
||||||
<div className="xl:w-3/4">
|
|
||||||
{checkAccess(user, ["developer", "admin", "mastercorporate"]) && !children && (
|
|
||||||
<>
|
|
||||||
<label className="font-normal text-base text-mti-gray-dim">Corporate</label>
|
|
||||||
|
|
||||||
<Select
|
|
||||||
options={selectableCorporates}
|
|
||||||
value={selectableCorporates.find((x) => x.value === selectedCorporate)}
|
|
||||||
onChange={(value) => setSelectedCorporate(value?.value || "")}
|
|
||||||
styles={{
|
|
||||||
menuPortal: (base) => ({ ...base, zIndex: 9999 }),
|
|
||||||
option: (styles, state) => ({
|
|
||||||
...styles,
|
|
||||||
backgroundColor: state.isFocused ? "#D5D9F0" : state.isSelected ? "#7872BF" : "white",
|
|
||||||
color: state.isFocused ? "black" : styles.color,
|
|
||||||
}),
|
|
||||||
}}></Select>
|
|
||||||
<label className="font-normal text-base text-mti-gray-dim">User</label>
|
|
||||||
|
|
||||||
<Select
|
|
||||||
options={corporateFilteredUserList.map((x) => ({
|
|
||||||
value: x.id,
|
|
||||||
label: `${x.name} - ${x.email}`,
|
|
||||||
}))}
|
|
||||||
value={selectedUserSelectValue}
|
|
||||||
onChange={(value) => setStatsUserId(value?.value!)}
|
|
||||||
styles={{
|
|
||||||
menuPortal: (base) => ({ ...base, zIndex: 9999 }),
|
|
||||||
option: (styles, state) => ({
|
|
||||||
...styles,
|
|
||||||
backgroundColor: state.isFocused ? "#D5D9F0" : state.isSelected ? "#7872BF" : "white",
|
|
||||||
color: state.isFocused ? "black" : styles.color,
|
|
||||||
}),
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{(user.type === "corporate" || user.type === "teacher") && groups.length > 0 && !children && (
|
|
||||||
<>
|
|
||||||
<label className="font-normal text-base text-mti-gray-dim">User</label>
|
|
||||||
|
|
||||||
<Select
|
|
||||||
options={users
|
|
||||||
.filter((x) => groups.flatMap((y) => y.participants).includes(x.id))
|
|
||||||
.map((x) => ({
|
|
||||||
value: x.id,
|
|
||||||
label: `${x.name} - ${x.email}`,
|
|
||||||
}))}
|
|
||||||
value={selectedUserSelectValue}
|
|
||||||
onChange={(value) => setStatsUserId(value?.value!)}
|
|
||||||
styles={{
|
|
||||||
menuPortal: (base) => ({ ...base, zIndex: 9999 }),
|
|
||||||
option: (styles, state) => ({
|
|
||||||
...styles,
|
|
||||||
backgroundColor: state.isFocused ? "#D5D9F0" : state.isSelected ? "#7872BF" : "white",
|
|
||||||
color: state.isFocused ? "black" : styles.color,
|
|
||||||
}),
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
<div className="flex gap-4 w-full justify-center xl:justify-end">
|
|
||||||
{assignments && (
|
|
||||||
<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 === "assignments" && "!bg-mti-purple-light !text-white",
|
|
||||||
)}
|
|
||||||
onClick={() => toggleFilter("assignments")}>
|
|
||||||
Assignments
|
|
||||||
</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 === "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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default RecordFilter;
|
|
||||||
@@ -1,111 +0,0 @@
|
|||||||
import {Session} from "@/hooks/useSessions";
|
|
||||||
import useExamStore from "@/stores/examStore";
|
|
||||||
import {sortByModuleName} from "@/utils/moduleUtils";
|
|
||||||
import axios from "axios";
|
|
||||||
import clsx from "clsx";
|
|
||||||
import {capitalize} from "lodash";
|
|
||||||
import moment from "moment";
|
|
||||||
import {useState} from "react";
|
|
||||||
import {BsArrowRepeat, BsBook, BsClipboard, BsHeadphones, BsMegaphone, BsPen} from "react-icons/bs";
|
|
||||||
import {toast} from "react-toastify";
|
|
||||||
|
|
||||||
export default function SessionCard({
|
|
||||||
session,
|
|
||||||
reload,
|
|
||||||
loadSession,
|
|
||||||
}: {
|
|
||||||
session: Session;
|
|
||||||
reload: () => void;
|
|
||||||
loadSession: (session: Session) => Promise<void>;
|
|
||||||
}) {
|
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
|
||||||
|
|
||||||
const deleteSession = async () => {
|
|
||||||
if (!confirm("Are you sure you want to delete this session?")) return;
|
|
||||||
|
|
||||||
setIsLoading(true);
|
|
||||||
await axios
|
|
||||||
.delete(`/api/sessions/${session.sessionId}`)
|
|
||||||
.then(() => {
|
|
||||||
toast.success(`Successfully delete session "${session.sessionId}"`);
|
|
||||||
})
|
|
||||||
.catch((e) => {
|
|
||||||
console.log(e);
|
|
||||||
toast.error("Something went wrong, please try again later");
|
|
||||||
})
|
|
||||||
.finally(() => {
|
|
||||||
reload();
|
|
||||||
setIsLoading(false);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="border-mti-gray-anti-flash flex w-64 flex-col justify-between gap-3 rounded-xl border p-4 text-black">
|
|
||||||
<div className="flex flex-col gap-3">
|
|
||||||
<span className="flex gap-1">
|
|
||||||
<b>ID:</b>
|
|
||||||
{session.sessionId}
|
|
||||||
</span>
|
|
||||||
<span className="flex gap-1">
|
|
||||||
<b>Date:</b>
|
|
||||||
{moment(session.date).format("DD/MM/YYYY - HH:mm")}
|
|
||||||
</span>
|
|
||||||
{session.assignment && (
|
|
||||||
<span className="flex flex-col gap-0">
|
|
||||||
<b>Assignment:</b>
|
|
||||||
{session.assignment.name}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-3">
|
|
||||||
<div className="flex w-full items-center justify-between">
|
|
||||||
<div className="-md:mt-2 grid w-full grid-cols-4 place-items-center justify-center gap-2">
|
|
||||||
{session.selectedModules.sort(sortByModuleName).map((module) => (
|
|
||||||
<div
|
|
||||||
key={module}
|
|
||||||
data-tip={capitalize(module)}
|
|
||||||
className={clsx(
|
|
||||||
"-md:px-4 tooltip flex w-fit items-center gap-2 rounded-xl py-2 text-white md:px-2 xl:px-4",
|
|
||||||
module === "reading" && "bg-ielts-reading",
|
|
||||||
module === "listening" && "bg-ielts-listening",
|
|
||||||
module === "writing" && "bg-ielts-writing",
|
|
||||||
module === "speaking" && "bg-ielts-speaking",
|
|
||||||
module === "level" && "bg-ielts-level",
|
|
||||||
)}>
|
|
||||||
{module === "reading" && <BsBook className="h-4 w-4" />}
|
|
||||||
{module === "listening" && <BsHeadphones className="h-4 w-4" />}
|
|
||||||
{module === "writing" && <BsPen className="h-4 w-4" />}
|
|
||||||
{module === "speaking" && <BsMegaphone className="h-4 w-4" />}
|
|
||||||
{module === "level" && <BsClipboard className="h-4 w-4" />}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2 w-full">
|
|
||||||
<button
|
|
||||||
onClick={async () => await loadSession(session)}
|
|
||||||
disabled={isLoading}
|
|
||||||
className="bg-mti-green-ultralight w-full hover:bg-mti-green-light rounded-lg p-2 px-4 transition duration-300 ease-in-out hover:text-white disabled:cursor-not-allowed">
|
|
||||||
{!isLoading && "Resume"}
|
|
||||||
{isLoading && (
|
|
||||||
<div className="flex items-center justify-center">
|
|
||||||
<BsArrowRepeat className="animate-spin text-white" size={25} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={deleteSession}
|
|
||||||
disabled={isLoading}
|
|
||||||
className="bg-mti-red-ultralight w-full hover:bg-mti-red-light rounded-lg p-2 px-4 transition duration-300 ease-in-out hover:text-white disabled:cursor-not-allowed">
|
|
||||||
{!isLoading && "Delete"}
|
|
||||||
{isLoading && (
|
|
||||||
<div className="flex items-center justify-center">
|
|
||||||
<BsArrowRepeat className="animate-spin text-white" size={25} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,313 +0,0 @@
|
|||||||
import React from "react";
|
|
||||||
import {BsClock, BsXCircle} from "react-icons/bs";
|
|
||||||
import clsx from "clsx";
|
|
||||||
import {Stat, User} from "@/interfaces/user";
|
|
||||||
import {Module, Step} from "@/interfaces";
|
|
||||||
import ai_usage from "@/utils/ai.detection";
|
|
||||||
import {calculateBandScore} from "@/utils/score";
|
|
||||||
import moment from "moment";
|
|
||||||
import {Assignment} from "@/interfaces/results";
|
|
||||||
import {uuidv4} from "@firebase/util";
|
|
||||||
import {useRouter} from "next/router";
|
|
||||||
import {uniqBy} from "lodash";
|
|
||||||
import {sortByModule} from "@/utils/moduleUtils";
|
|
||||||
import {convertToUserSolutions} from "@/utils/stats";
|
|
||||||
import {getExamById} from "@/utils/exams";
|
|
||||||
import {Exam, UserSolution} from "@/interfaces/exam";
|
|
||||||
import ModuleBadge from "../ModuleBadge";
|
|
||||||
|
|
||||||
const formatTimestamp = (timestamp: string | number) => {
|
|
||||||
const time = typeof timestamp === "string" ? parseInt(timestamp) : timestamp;
|
|
||||||
const date = moment(time);
|
|
||||||
const formatter = "YYYY/MM/DD - HH:mm";
|
|
||||||
return date.format(formatter);
|
|
||||||
};
|
|
||||||
|
|
||||||
const aggregateScoresByModule = (stats: Stat[]): {module: Module; total: number; missing: number; correct: number}[] => {
|
|
||||||
const scores: {
|
|
||||||
[key in Module]: {total: number; missing: number; correct: number};
|
|
||||||
} = {
|
|
||||||
reading: {
|
|
||||||
total: 0,
|
|
||||||
correct: 0,
|
|
||||||
missing: 0,
|
|
||||||
},
|
|
||||||
listening: {
|
|
||||||
total: 0,
|
|
||||||
correct: 0,
|
|
||||||
missing: 0,
|
|
||||||
},
|
|
||||||
writing: {
|
|
||||||
total: 0,
|
|
||||||
correct: 0,
|
|
||||||
missing: 0,
|
|
||||||
},
|
|
||||||
speaking: {
|
|
||||||
total: 0,
|
|
||||||
correct: 0,
|
|
||||||
missing: 0,
|
|
||||||
},
|
|
||||||
level: {
|
|
||||||
total: 0,
|
|
||||||
correct: 0,
|
|
||||||
missing: 0,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
stats.forEach((x) => {
|
|
||||||
scores[x.module!] = {
|
|
||||||
total: scores[x.module!].total + x.score.total,
|
|
||||||
correct: scores[x.module!].correct + x.score.correct,
|
|
||||||
missing: scores[x.module!].missing + x.score.missing,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
return Object.keys(scores)
|
|
||||||
.filter((x) => scores[x as Module].total > 0)
|
|
||||||
.map((x) => ({module: x as Module, ...scores[x as Module]}));
|
|
||||||
};
|
|
||||||
|
|
||||||
interface StatsGridItemProps {
|
|
||||||
width?: string | undefined;
|
|
||||||
height?: string | undefined;
|
|
||||||
examNumber?: number | undefined;
|
|
||||||
stats: Stat[];
|
|
||||||
timestamp: string | number;
|
|
||||||
user: User;
|
|
||||||
assignments: Assignment[];
|
|
||||||
users: User[];
|
|
||||||
training?: boolean;
|
|
||||||
gradingSystem?: Step[];
|
|
||||||
selectedTrainingExams?: string[];
|
|
||||||
maxTrainingExams?: number;
|
|
||||||
setSelectedTrainingExams?: React.Dispatch<React.SetStateAction<string[]>>;
|
|
||||||
setExams: (exams: Exam[]) => void;
|
|
||||||
setShowSolutions: (show: boolean) => void;
|
|
||||||
setUserSolutions: (solutions: UserSolution[]) => void;
|
|
||||||
setSelectedModules: (modules: Module[]) => void;
|
|
||||||
setInactivity: (inactivity: number) => void;
|
|
||||||
setTimeSpent: (time: number) => void;
|
|
||||||
renderPdfIcon: (session: string, color: string, textColor: string) => React.ReactNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
const StatsGridItem: React.FC<StatsGridItemProps> = ({
|
|
||||||
stats,
|
|
||||||
timestamp,
|
|
||||||
user,
|
|
||||||
assignments,
|
|
||||||
users,
|
|
||||||
training,
|
|
||||||
selectedTrainingExams,
|
|
||||||
gradingSystem,
|
|
||||||
setSelectedTrainingExams,
|
|
||||||
setExams,
|
|
||||||
setShowSolutions,
|
|
||||||
setUserSolutions,
|
|
||||||
setSelectedModules,
|
|
||||||
setInactivity,
|
|
||||||
setTimeSpent,
|
|
||||||
renderPdfIcon,
|
|
||||||
width = undefined,
|
|
||||||
height = undefined,
|
|
||||||
examNumber = undefined,
|
|
||||||
maxTrainingExams = undefined,
|
|
||||||
}) => {
|
|
||||||
const router = useRouter();
|
|
||||||
const correct = stats.reduce((accumulator, current) => accumulator + current.score.correct, 0);
|
|
||||||
const total = stats.reduce((accumulator, current) => accumulator + current.score.total, 0);
|
|
||||||
const aggregatedScores = aggregateScoresByModule(stats).filter((x) => x.total > 0);
|
|
||||||
const assignmentID = stats.reduce((_, current) => current.assignment as any, "");
|
|
||||||
const assignment = assignments.find((a) => a.id === assignmentID);
|
|
||||||
const isDisabled = stats.some((x) => x.isDisabled);
|
|
||||||
|
|
||||||
const aiUsage = Math.round(ai_usage(stats) * 100);
|
|
||||||
|
|
||||||
const aggregatedLevels = aggregatedScores.map((x) => ({
|
|
||||||
module: x.module,
|
|
||||||
level: calculateBandScore(x.correct, x.total, x.module, user.focus),
|
|
||||||
}));
|
|
||||||
|
|
||||||
const textColor = clsx(
|
|
||||||
correct / total >= 0.7 && "text-mti-purple",
|
|
||||||
correct / total >= 0.3 && correct / total < 0.7 && "text-mti-red",
|
|
||||||
correct / total < 0.3 && "text-mti-rose",
|
|
||||||
);
|
|
||||||
|
|
||||||
const {timeSpent, inactivity, session} = stats[0];
|
|
||||||
|
|
||||||
const selectExam = () => {
|
|
||||||
if (
|
|
||||||
training &&
|
|
||||||
!isDisabled &&
|
|
||||||
typeof maxTrainingExams !== "undefined" &&
|
|
||||||
typeof setSelectedTrainingExams !== "undefined" &&
|
|
||||||
typeof timestamp == "string"
|
|
||||||
) {
|
|
||||||
setSelectedTrainingExams((prevExams) => {
|
|
||||||
const uniqueExams = [...new Set(stats.map((stat) => `${stat.module}-${stat.date}`))];
|
|
||||||
const indexes = uniqueExams.map((exam) => prevExams.indexOf(exam)).filter((index) => index !== -1);
|
|
||||||
if (indexes.length > 0) {
|
|
||||||
const newExams = [...prevExams];
|
|
||||||
indexes
|
|
||||||
.sort((a, b) => b - a)
|
|
||||||
.forEach((index) => {
|
|
||||||
newExams.splice(index, 1);
|
|
||||||
});
|
|
||||||
return newExams;
|
|
||||||
} else {
|
|
||||||
if (prevExams.length + uniqueExams.length <= maxTrainingExams) {
|
|
||||||
return [...prevExams, ...uniqueExams];
|
|
||||||
} else {
|
|
||||||
return prevExams;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
const examPromises = uniqBy(stats, "exam").map((stat) => {
|
|
||||||
return getExamById(stat.module, stat.exam);
|
|
||||||
});
|
|
||||||
|
|
||||||
if (isDisabled) return;
|
|
||||||
|
|
||||||
Promise.all(examPromises).then((exams) => {
|
|
||||||
if (exams.every((x) => !!x)) {
|
|
||||||
if (!!timeSpent) setTimeSpent(timeSpent);
|
|
||||||
if (!!inactivity) setInactivity(inactivity);
|
|
||||||
setUserSolutions(convertToUserSolutions(stats));
|
|
||||||
setShowSolutions(true);
|
|
||||||
setExams(exams.map((x) => x!).sort(sortByModule));
|
|
||||||
setSelectedModules(
|
|
||||||
exams
|
|
||||||
.map((x) => x!)
|
|
||||||
.sort(sortByModule)
|
|
||||||
.map((x) => x!.module),
|
|
||||||
);
|
|
||||||
router.push("/exercises");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const shouldRenderPDFIcon = () => {
|
|
||||||
if (assignment) {
|
|
||||||
return assignment.released;
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
};
|
|
||||||
|
|
||||||
const content = (
|
|
||||||
<>
|
|
||||||
<div className="w-full flex justify-between -md:items-center 2xl:items-center">
|
|
||||||
<div className="flex flex-col md:gap-1 -md:gap-2 2xl:gap-2">
|
|
||||||
<span className="font-medium">{formatTimestamp(timestamp)}</span>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
{!!timeSpent && (
|
|
||||||
<span className="text-sm flex gap-2 items-center tooltip" data-tip="Time Spent">
|
|
||||||
<BsClock /> {Math.floor(timeSpent / 60)} minutes
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{!!inactivity && (
|
|
||||||
<span className="text-sm flex gap-2 items-center tooltip" data-tip="Inactivity">
|
|
||||||
<BsXCircle /> {Math.floor(inactivity / 60)} minutes
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
<div className="flex flex-row gap-2">
|
|
||||||
{!!assignment && (assignment.released || assignment.released === undefined) && (
|
|
||||||
<span className={textColor}>
|
|
||||||
Level{" "}
|
|
||||||
{(
|
|
||||||
aggregatedLevels.reduce((accumulator, current) => accumulator + current.level, 0) / aggregatedLevels.length
|
|
||||||
).toFixed(1)}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{shouldRenderPDFIcon() && renderPdfIcon(session, textColor, textColor)}
|
|
||||||
</div>
|
|
||||||
{examNumber === undefined ? (
|
|
||||||
<>
|
|
||||||
{aiUsage >= 50 && user.type !== "student" && (
|
|
||||||
<div
|
|
||||||
className={clsx("ml-auto border px-1 rounded w-fit mr-1", {
|
|
||||||
"bg-orange-100 border-orange-400 text-orange-700": aiUsage < 80,
|
|
||||||
"bg-red-100 border-red-400 text-red-700": aiUsage >= 80,
|
|
||||||
})}>
|
|
||||||
<span className="text-xs">AI Usage</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<div className="flex justify-end">
|
|
||||||
<span className="font-semibold bg-gray-200 text-gray-800 px-2.5 py-0.5 rounded-full mt-0.5">{examNumber}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="w-full flex flex-col gap-1">
|
|
||||||
<div className={clsx("grid grid-cols-4 gap-2 place-items-start w-full -md:mt-2", examNumber !== undefined && "pr-10")}>
|
|
||||||
{!!assignment &&
|
|
||||||
(assignment.released || assignment.released === undefined) &&
|
|
||||||
aggregatedLevels.map(({module, level}) => <ModuleBadge key={module} module={module} level={level} />)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{assignment && (
|
|
||||||
<span className="font-light text-sm">
|
|
||||||
Assignment: {assignment.name}, Teacher: {users.find((u) => u.id === assignment.assigner)?.name}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div
|
|
||||||
key={uuidv4()}
|
|
||||||
className={clsx(
|
|
||||||
"flex flex-col justify-between gap-4 border border-mti-gray-platinum p-4 cursor-pointer rounded-xl transition ease-in-out duration-300 -md:hidden",
|
|
||||||
(isDisabled || (!!assignment && !assignment.released)) && "grayscale tooltip",
|
|
||||||
correct / total >= 0.7 && "hover:border-mti-purple",
|
|
||||||
correct / total >= 0.3 && correct / total < 0.7 && "hover:border-mti-red",
|
|
||||||
correct / total < 0.3 && "hover:border-mti-rose",
|
|
||||||
typeof selectedTrainingExams !== "undefined" &&
|
|
||||||
typeof timestamp === "string" &&
|
|
||||||
selectedTrainingExams.some((exam) => exam.includes(timestamp)) &&
|
|
||||||
"border-2 border-slate-600",
|
|
||||||
)}
|
|
||||||
onClick={() => {
|
|
||||||
if (!!assignment && !assignment.released) return;
|
|
||||||
if (examNumber === undefined) return selectExam();
|
|
||||||
return;
|
|
||||||
}}
|
|
||||||
style={{
|
|
||||||
...(width !== undefined && {width}),
|
|
||||||
...(height !== undefined && {height}),
|
|
||||||
}}
|
|
||||||
data-tip={isDisabled ? "This exam is still being evaluated..." : "This exam is still locked by its assigner..."}
|
|
||||||
role="button">
|
|
||||||
{content}
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
key={uuidv4()}
|
|
||||||
className={clsx(
|
|
||||||
"flex flex-col gap-4 border border-mti-gray-platinum p-4 cursor-pointer rounded-xl transition ease-in-out duration-300 -md:tooltip md:hidden",
|
|
||||||
correct / total >= 0.7 && "hover:border-mti-purple",
|
|
||||||
correct / total >= 0.3 && correct / total < 0.7 && "hover:border-mti-red",
|
|
||||||
correct / total < 0.3 && "hover:border-mti-rose",
|
|
||||||
)}
|
|
||||||
data-tip="Your screen size is too small to view previous exams."
|
|
||||||
style={{
|
|
||||||
...(width !== undefined && {width}),
|
|
||||||
...(height !== undefined && {height}),
|
|
||||||
}}
|
|
||||||
role="button">
|
|
||||||
{content}
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default StatsGridItem;
|
|
||||||
@@ -1,80 +0,0 @@
|
|||||||
import useExamStore from "@/stores/examStore";
|
|
||||||
import {useEffect, useState} from "react";
|
|
||||||
import {motion} from "framer-motion";
|
|
||||||
import TimerEndedModal from "../TimerEndedModal";
|
|
||||||
import clsx from "clsx";
|
|
||||||
import {BsStopwatch} from "react-icons/bs";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
minTimer: number;
|
|
||||||
disableTimer?: boolean;
|
|
||||||
standalone?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
const Timer: React.FC<Props> = ({minTimer, disableTimer, standalone = false}) => {
|
|
||||||
const [timer, setTimer] = useState(minTimer * 60);
|
|
||||||
const [showModal, setShowModal] = useState(false);
|
|
||||||
const [warningMode, setWarningMode] = useState(false);
|
|
||||||
|
|
||||||
const setHasExamEnded = useExamStore((state) => state.setHasExamEnded);
|
|
||||||
const {timeSpent} = useExamStore((state) => state);
|
|
||||||
|
|
||||||
useEffect(() => setTimer((prev) => prev - timeSpent), [timeSpent]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!disableTimer) {
|
|
||||||
const timerInterval = setInterval(() => setTimer((prev) => prev - 1), 1000);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
clearInterval(timerInterval);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}, [disableTimer, minTimer]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (timer <= 0) setShowModal(true);
|
|
||||||
}, [timer]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (timer < 300 && !warningMode) setWarningMode(true);
|
|
||||||
}, [timer, warningMode]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<TimerEndedModal
|
|
||||||
isOpen={showModal}
|
|
||||||
onClose={() => {
|
|
||||||
setHasExamEnded(true);
|
|
||||||
setShowModal(false);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<motion.div
|
|
||||||
className={clsx(
|
|
||||||
"absolute right-6 bg-mti-gray-seasalt px-4 py-3 flex items-center gap-2 rounded-full text-mti-gray-davy",
|
|
||||||
standalone ? "top-10" : "top-4",
|
|
||||||
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-6 h-6" />
|
|
||||||
<span className="text-lg font-bold w-12">
|
|
||||||
{timer > 0 && (
|
|
||||||
<>
|
|
||||||
{Math.floor(timer / 60)
|
|
||||||
.toString(10)
|
|
||||||
.padStart(2, "0")}
|
|
||||||
:
|
|
||||||
{Math.floor(timer % 60)
|
|
||||||
.toString(10)
|
|
||||||
.padStart(2, "0")}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{timer <= 0 && <>00:00</>}
|
|
||||||
</span>
|
|
||||||
</motion.div>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default Timer;
|
|
||||||
@@ -1,70 +0,0 @@
|
|||||||
import topics from "@/resources/topics";
|
|
||||||
import {useState} from "react";
|
|
||||||
import {BsArrowLeft, BsArrowRight} from "react-icons/bs";
|
|
||||||
import Button from "../Low/Button";
|
|
||||||
import Modal from "../Modal";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
isOpen: boolean;
|
|
||||||
initialTopics: string[];
|
|
||||||
onClose: VoidFunction;
|
|
||||||
selectTopics: (topics: string[]) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function TopicModal({isOpen, initialTopics, onClose, selectTopics}: Props) {
|
|
||||||
const [selectedTopics, setSelectedTopics] = useState([...initialTopics]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Modal isOpen={isOpen} onClose={onClose} title="Preferred Topics">
|
|
||||||
<div className="flex flex-col w-full h-full gap-4 mt-4">
|
|
||||||
<div className="w-full h-full grid grid-cols-2 -md:gap-1 gap-4">
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
<span className="border-b border-b-neutral-400/30">Available Topics</span>
|
|
||||||
<div className=" max-h-[500px] overflow-y-scroll scrollbar-hide">
|
|
||||||
{topics
|
|
||||||
.filter((x) => !selectedTopics.includes(x))
|
|
||||||
.map((x) => (
|
|
||||||
<div key={x} className="odd:bg-mti-purple-ultralight/40 p-2 flex justify-between items-center">
|
|
||||||
<span>{x}</span>
|
|
||||||
<button
|
|
||||||
onClick={() => setSelectedTopics((prev) => [...prev, x])}
|
|
||||||
className="border border-mti-purple-light cursor-pointer p-2 rounded-lg bg-white drop-shadow transition ease-in-out duration-300 hover:bg-mti-purple hover:text-white">
|
|
||||||
<BsArrowRight />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
<span className="border-b border-b-neutral-400/30">Preferred Topics ({selectedTopics.length || "All"})</span>
|
|
||||||
<div className=" max-h-[500px] overflow-y-scroll scrollbar-hide">
|
|
||||||
{selectedTopics.map((x) => (
|
|
||||||
<div key={x} className="odd:bg-mti-purple-ultralight/40 p-2 flex justify-between items-center text-right">
|
|
||||||
<button
|
|
||||||
onClick={() => setSelectedTopics((prev) => [...prev.filter((y) => y !== x)])}
|
|
||||||
className="border border-mti-purple-light cursor-pointer p-2 rounded-lg bg-white drop-shadow transition ease-in-out duration-300 hover:bg-mti-purple hover:text-white">
|
|
||||||
<BsArrowLeft />
|
|
||||||
</button>
|
|
||||||
<span>{x}</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="w-full flex gap-4 items-center justify-end">
|
|
||||||
<Button variant="outline" color="rose" className="w-full max-w-[200px]" onClick={onClose}>
|
|
||||||
Close
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
className="w-full max-w-[200px]"
|
|
||||||
onClick={() => {
|
|
||||||
selectTopics(selectedTopics);
|
|
||||||
onClose();
|
|
||||||
}}>
|
|
||||||
Select
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Modal>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,215 +0,0 @@
|
|||||||
import { User } from "@/interfaces/user";
|
|
||||||
import { Dialog, Transition } from "@headlessui/react";
|
|
||||||
import axios from "axios";
|
|
||||||
import clsx from "clsx";
|
|
||||||
import Image from "next/image";
|
|
||||||
import Link from "next/link";
|
|
||||||
import { useRouter } from "next/router";
|
|
||||||
import { Fragment } from "react";
|
|
||||||
import { BsXLg } from "react-icons/bs";
|
|
||||||
import { checkAccess, getTypesOfUser } from "@/utils/permissions";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
isOpen: boolean;
|
|
||||||
onClose: () => void;
|
|
||||||
path: string;
|
|
||||||
user: User;
|
|
||||||
disableNavigation?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function MobileMenu({
|
|
||||||
isOpen,
|
|
||||||
onClose,
|
|
||||||
path,
|
|
||||||
user,
|
|
||||||
disableNavigation,
|
|
||||||
}: Props) {
|
|
||||||
const router = useRouter();
|
|
||||||
|
|
||||||
const logout = async () => {
|
|
||||||
axios.post("/api/logout").finally(() => {
|
|
||||||
setTimeout(() => router.reload(), 500);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Transition appear show={isOpen} as={Fragment}>
|
|
||||||
<Dialog as="div" className="relative z-10" onClose={onClose}>
|
|
||||||
<Transition.Child
|
|
||||||
as={Fragment}
|
|
||||||
enter="ease-out duration-300"
|
|
||||||
enterFrom="opacity-0"
|
|
||||||
enterTo="opacity-100"
|
|
||||||
leave="ease-in duration-200"
|
|
||||||
leaveFrom="opacity-100"
|
|
||||||
leaveTo="opacity-0"
|
|
||||||
>
|
|
||||||
<div className="fixed inset-0 bg-black bg-opacity-25" />
|
|
||||||
</Transition.Child>
|
|
||||||
|
|
||||||
<div className="fixed inset-0 overflow-y-auto">
|
|
||||||
<div className="flex min-h-full items-center justify-center text-center">
|
|
||||||
<Transition.Child
|
|
||||||
as={Fragment}
|
|
||||||
enter="ease-out duration-300"
|
|
||||||
enterFrom="opacity-0 scale-95"
|
|
||||||
enterTo="opacity-100 scale-100"
|
|
||||||
leave="ease-in duration-200"
|
|
||||||
leaveFrom="opacity-100 scale-100"
|
|
||||||
leaveTo="opacity-0 scale-95"
|
|
||||||
>
|
|
||||||
<Dialog.Panel className="flex h-screen w-full transform flex-col gap-8 overflow-hidden bg-white text-left align-middle text-black shadow-xl transition-all">
|
|
||||||
<Dialog.Title
|
|
||||||
as="header"
|
|
||||||
className="-md:flex w-full items-center justify-between px-8 py-2 shadow-sm md:hidden"
|
|
||||||
>
|
|
||||||
<Link href={disableNavigation ? "" : "/"}>
|
|
||||||
<Image
|
|
||||||
src="/logo_title.png"
|
|
||||||
alt="EnCoach logo"
|
|
||||||
width={69}
|
|
||||||
height={69}
|
|
||||||
/>
|
|
||||||
</Link>
|
|
||||||
<div
|
|
||||||
className="cursor-pointer"
|
|
||||||
onClick={onClose}
|
|
||||||
tabIndex={0}
|
|
||||||
>
|
|
||||||
<BsXLg
|
|
||||||
className="text-mti-purple-light text-2xl"
|
|
||||||
onClick={onClose}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</Dialog.Title>
|
|
||||||
<div className="flex h-full flex-col gap-6 px-8 text-lg">
|
|
||||||
<Link
|
|
||||||
href={disableNavigation ? "" : "/"}
|
|
||||||
className={clsx(
|
|
||||||
"w-fit transition duration-300 ease-in-out",
|
|
||||||
path === "/" &&
|
|
||||||
"text-mti-purple-light border-b-mti-purple-light border-b-2 font-semibold "
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
Dashboard
|
|
||||||
</Link>
|
|
||||||
{checkAccess(user, ["student", "teacher", "developer"]) && (
|
|
||||||
<>
|
|
||||||
<Link
|
|
||||||
href={disableNavigation ? "" : "/exam"}
|
|
||||||
className={clsx(
|
|
||||||
"w-fit transition duration-300 ease-in-out",
|
|
||||||
path === "/exam" &&
|
|
||||||
"text-mti-purple-light border-b-mti-purple-light border-b-2 font-semibold "
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
Exams
|
|
||||||
</Link>
|
|
||||||
<Link
|
|
||||||
href={disableNavigation ? "" : "/exercises"}
|
|
||||||
className={clsx(
|
|
||||||
"w-fit transition duration-300 ease-in-out",
|
|
||||||
path === "/exercises" &&
|
|
||||||
"text-mti-purple-light border-b-mti-purple-light border-b-2 font-semibold "
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
Exercises
|
|
||||||
</Link>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
<Link
|
|
||||||
href={disableNavigation ? "" : "/stats"}
|
|
||||||
className={clsx(
|
|
||||||
"w-fit transition duration-300 ease-in-out",
|
|
||||||
path === "/stats" &&
|
|
||||||
"text-mti-purple-light border-b-mti-purple-light border-b-2 font-semibold "
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
Stats
|
|
||||||
</Link>
|
|
||||||
<Link
|
|
||||||
href={disableNavigation ? "" : "/record"}
|
|
||||||
className={clsx(
|
|
||||||
"w-fit transition duration-300 ease-in-out",
|
|
||||||
path === "/record" &&
|
|
||||||
"text-mti-purple-light border-b-mti-purple-light border-b-2 font-semibold "
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
Record
|
|
||||||
</Link>
|
|
||||||
{checkAccess(user, [
|
|
||||||
"admin",
|
|
||||||
"developer",
|
|
||||||
"agent",
|
|
||||||
"corporate",
|
|
||||||
"mastercorporate",
|
|
||||||
]) && (
|
|
||||||
<Link
|
|
||||||
href={disableNavigation ? "" : "/payment-record"}
|
|
||||||
className={clsx(
|
|
||||||
"w-fit transition duration-300 ease-in-out",
|
|
||||||
path === "/payment-record" &&
|
|
||||||
"text-mti-purple-light border-b-mti-purple-light border-b-2 font-semibold "
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
Payment Record
|
|
||||||
</Link>
|
|
||||||
)}
|
|
||||||
{checkAccess(user, [
|
|
||||||
"admin",
|
|
||||||
"developer",
|
|
||||||
"corporate",
|
|
||||||
"teacher",
|
|
||||||
"mastercorporate",
|
|
||||||
]) && (
|
|
||||||
<Link
|
|
||||||
href={disableNavigation ? "" : "/settings"}
|
|
||||||
className={clsx(
|
|
||||||
"w-fit transition duration-300 ease-in-out",
|
|
||||||
path === "/settings" &&
|
|
||||||
"text-mti-purple-light border-b-mti-purple-light border-b-2 font-semibold "
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
Settings
|
|
||||||
</Link>
|
|
||||||
)}
|
|
||||||
{checkAccess(user, ["admin", "developer", "agent"]) && (
|
|
||||||
<Link
|
|
||||||
href={disableNavigation ? "" : "/tickets"}
|
|
||||||
className={clsx(
|
|
||||||
"w-fit transition duration-300 ease-in-out",
|
|
||||||
path === "/tickets" &&
|
|
||||||
"text-mti-purple-light border-b-mti-purple-light border-b-2 font-semibold "
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
Tickets
|
|
||||||
</Link>
|
|
||||||
)}
|
|
||||||
<Link
|
|
||||||
href={disableNavigation ? "" : "/profile"}
|
|
||||||
className={clsx(
|
|
||||||
"w-fit transition duration-300 ease-in-out",
|
|
||||||
path === "/profile" &&
|
|
||||||
"text-mti-purple-light border-b-mti-purple-light border-b-2 font-semibold "
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
Profile
|
|
||||||
</Link>
|
|
||||||
|
|
||||||
<span
|
|
||||||
className={clsx(
|
|
||||||
"w-fit cursor-pointer justify-self-end transition duration-300 ease-in-out"
|
|
||||||
)}
|
|
||||||
onClick={logout}
|
|
||||||
>
|
|
||||||
Logout
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</Dialog.Panel>
|
|
||||||
</Transition.Child>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Dialog>
|
|
||||||
</Transition>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,57 +0,0 @@
|
|||||||
import {Dialog, Transition} from "@headlessui/react";
|
|
||||||
import clsx from "clsx";
|
|
||||||
import {Fragment, ReactElement} from "react";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
isOpen: boolean;
|
|
||||||
onClose: () => void;
|
|
||||||
title?: string;
|
|
||||||
className?: string;
|
|
||||||
titleClassName?: string;
|
|
||||||
children?: ReactElement;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function Modal({isOpen, title, className, titleClassName, onClose, children}: Props) {
|
|
||||||
return (
|
|
||||||
<Transition appear show={isOpen} as={Fragment}>
|
|
||||||
<Dialog as="div" className="relative z-[200]" onClose={onClose}>
|
|
||||||
<Transition.Child
|
|
||||||
as={Fragment}
|
|
||||||
enter="ease-out duration-300"
|
|
||||||
enterFrom="opacity-0"
|
|
||||||
enterTo="opacity-100"
|
|
||||||
leave="ease-in duration-200"
|
|
||||||
leaveFrom="opacity-100"
|
|
||||||
leaveTo="opacity-0">
|
|
||||||
<div className="fixed inset-0 bg-black bg-opacity-25" />
|
|
||||||
</Transition.Child>
|
|
||||||
|
|
||||||
<div className="fixed inset-0 overflow-y-auto">
|
|
||||||
<div className="flex min-h-full items-center justify-center p-4 text-center">
|
|
||||||
<Transition.Child
|
|
||||||
as={Fragment}
|
|
||||||
enter="ease-out duration-300"
|
|
||||||
enterFrom="opacity-0 scale-95"
|
|
||||||
enterTo="opacity-100 scale-100"
|
|
||||||
leave="ease-in duration-200"
|
|
||||||
leaveFrom="opacity-100 scale-100"
|
|
||||||
leaveTo="opacity-0 scale-95">
|
|
||||||
<Dialog.Panel
|
|
||||||
className={clsx(
|
|
||||||
"w-full max-w-6xl transform overflow-hidden rounded-2xl bg-white p-6 text-left align-middle shadow-xl transition-all",
|
|
||||||
className,
|
|
||||||
)}>
|
|
||||||
{title && (
|
|
||||||
<Dialog.Title as="h3" className={clsx(titleClassName ? titleClassName : "text-lg font-medium leading-6 text-gray-900")}>
|
|
||||||
{title}
|
|
||||||
</Dialog.Title>
|
|
||||||
)}
|
|
||||||
{children}
|
|
||||||
</Dialog.Panel>
|
|
||||||
</Transition.Child>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Dialog>
|
|
||||||
</Transition>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
import {Step} from "@/interfaces";
|
|
||||||
import {getGradingLabel, getLevelLabel} from "@/utils/score";
|
|
||||||
import clsx from "clsx";
|
|
||||||
import {BsBook, BsClipboard, BsHeadphones, BsMegaphone, BsPen} from "react-icons/bs";
|
|
||||||
|
|
||||||
const ModuleBadge: React.FC<{module: string; level?: number; gradingSystem?: Step[]; className?: string}> = ({
|
|
||||||
module,
|
|
||||||
level,
|
|
||||||
gradingSystem,
|
|
||||||
className,
|
|
||||||
}) => (
|
|
||||||
<div
|
|
||||||
className={clsx(
|
|
||||||
"flex gap-2 justify-center items-center w-fit text-white -md:px-4 xl:px-4 md:px-2 py-2 rounded-xl",
|
|
||||||
module === "reading" && "bg-ielts-reading",
|
|
||||||
module === "listening" && "bg-ielts-listening",
|
|
||||||
module === "writing" && "bg-ielts-writing",
|
|
||||||
module === "speaking" && "bg-ielts-speaking",
|
|
||||||
module === "level" && "bg-ielts-level",
|
|
||||||
className,
|
|
||||||
)}>
|
|
||||||
{module === "reading" && <BsBook className="w-4 h-4" />}
|
|
||||||
{module === "listening" && <BsHeadphones className="w-4 h-4" />}
|
|
||||||
{module === "writing" && <BsPen className="w-4 h-4" />}
|
|
||||||
{module === "speaking" && <BsMegaphone className="w-4 h-4" />}
|
|
||||||
{module === "level" && <BsClipboard className="w-4 h-4" />}
|
|
||||||
{/* do not switch to level && it will convert the 0.0 to 0*/}
|
|
||||||
{level !== undefined && (
|
|
||||||
<span className="text-sm">{module === "level" && gradingSystem ? getGradingLabel(level, gradingSystem) : level.toFixed(1)}</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
export default ModuleBadge;
|
|
||||||
@@ -1,165 +1,32 @@
|
|||||||
import {User} from "@/interfaces/user";
|
import {User} from "@/interfaces/user";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import FocusLayer from "@/components/FocusLayer";
|
import {Avatar} from "primereact/avatar";
|
||||||
import {preventNavigation} from "@/utils/navigation.disabled";
|
import FocusLayer from '@/components/FocusLayer';
|
||||||
import {useRouter} from "next/router";
|
import { preventNavigation } from "@/utils/navigation.disabled";
|
||||||
import {BsList, BsQuestionCircle, BsQuestionCircleFill} from "react-icons/bs";
|
|
||||||
import clsx from "clsx";
|
|
||||||
import moment from "moment";
|
|
||||||
import MobileMenu from "./MobileMenu";
|
|
||||||
import {useEffect, useState} from "react";
|
|
||||||
import {Type} from "@/interfaces/user";
|
|
||||||
import {USER_TYPE_LABELS} from "@/resources/user";
|
|
||||||
import useGroups from "@/hooks/useGroups";
|
|
||||||
import {isUserFromCorporate} from "@/utils/groups";
|
|
||||||
import Button from "./Low/Button";
|
|
||||||
import Modal from "./Modal";
|
|
||||||
import Input from "./Low/Input";
|
|
||||||
import TicketSubmission from "./High/TicketSubmission";
|
|
||||||
import {Module} from "@/interfaces";
|
|
||||||
import Badge from "./Low/Badge";
|
|
||||||
|
|
||||||
import {BsArrowRepeat, BsBook, BsCheck, BsCheckCircle, BsClipboard, BsHeadphones, BsMegaphone, BsPen, BsXCircle} from "react-icons/bs";
|
|
||||||
interface Props {
|
interface Props {
|
||||||
user: User;
|
user: User;
|
||||||
navDisabled?: boolean;
|
navDisabled?: boolean;
|
||||||
focusMode?: boolean;
|
focusMode?: boolean;
|
||||||
onFocusLayerMouseEnter?: () => void;
|
onFocusLayerMouseEnter?: Function;
|
||||||
path: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* eslint-disable @next/next/no-img-element */
|
/* eslint-disable @next/next/no-img-element */
|
||||||
export default function Navbar({user, path, navDisabled = false, focusMode = false, onFocusLayerMouseEnter}: Props) {
|
export default function Navbar({user, navDisabled = false, focusMode = false, onFocusLayerMouseEnter}: Props) {
|
||||||
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
|
||||||
const [disablePaymentPage, setDisablePaymentPage] = useState(true);
|
|
||||||
const [isTicketOpen, setIsTicketOpen] = useState(false);
|
|
||||||
|
|
||||||
const router = useRouter();
|
|
||||||
|
|
||||||
const disableNavigation = preventNavigation(navDisabled, focusMode);
|
const disableNavigation = preventNavigation(navDisabled, focusMode);
|
||||||
|
|
||||||
const expirationDateColor = (date: Date) => {
|
|
||||||
const momentDate = moment(date);
|
|
||||||
const today = moment(new Date());
|
|
||||||
|
|
||||||
if (today.add(1, "days").isAfter(momentDate)) return "!bg-mti-red-ultralight border-mti-red-light";
|
|
||||||
if (today.add(3, "days").isAfter(momentDate)) return "!bg-mti-rose-ultralight border-mti-rose-light";
|
|
||||||
if (today.add(7, "days").isAfter(momentDate)) return "!bg-mti-orange-ultralight border-mti-orange-light";
|
|
||||||
};
|
|
||||||
|
|
||||||
const showExpirationDate = () => {
|
|
||||||
if (!user.subscriptionExpirationDate) return false;
|
|
||||||
|
|
||||||
const momentDate = moment(user.subscriptionExpirationDate);
|
|
||||||
const today = moment(new Date());
|
|
||||||
|
|
||||||
return today.add(7, "days").isAfter(momentDate);
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (user.type !== "student" && user.type !== "teacher") return setDisablePaymentPage(false);
|
|
||||||
isUserFromCorporate(user.id).then((result) => setDisablePaymentPage(result));
|
|
||||||
}, [user]);
|
|
||||||
|
|
||||||
const badges = [
|
|
||||||
{
|
|
||||||
module: "reading",
|
|
||||||
icon: () => <BsBook className="h-4 w-4 text-white" />,
|
|
||||||
achieved: user.levels.reading >= user.desiredLevels.reading,
|
|
||||||
},
|
|
||||||
|
|
||||||
{
|
|
||||||
module: "listening",
|
|
||||||
icon: () => <BsHeadphones className="h-4 w-4 text-white" />,
|
|
||||||
achieved: user.levels.listening >= user.desiredLevels.listening,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
module: "writing",
|
|
||||||
icon: () => <BsPen className="h-4 w-4 text-white" />,
|
|
||||||
achieved: user.levels.writing >= user.desiredLevels.writing,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
module: "speaking",
|
|
||||||
icon: () => <BsMegaphone className="h-4 w-4 text-white" />,
|
|
||||||
achieved: user.levels.speaking >= user.desiredLevels.speaking,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
module: "level",
|
|
||||||
icon: () => <BsClipboard className="h-4 w-4 text-white" />,
|
|
||||||
achieved: user.levels.level >= user.desiredLevels.level,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<header className="w-full bg-transparent py-4 gap-2 flex items-center relative">
|
||||||
<Modal isOpen={isTicketOpen} onClose={() => setIsTicketOpen(false)} title="Submit a ticket">
|
<h1 className="font-bold text-2xl w-1/6 px-8">EnCoach</h1>
|
||||||
<TicketSubmission user={user} page={router.asPath} onClose={() => setIsTicketOpen(false)} />
|
<div className="flex justify-between w-5/6 mr-8">
|
||||||
</Modal>
|
<input type="text" placeholder="Search..." className="rounded-full py-4 px-6 border border-mti-gray-platinum outline-none" />
|
||||||
|
<Link href={disableNavigation ? "" : "/profile"} className="flex gap-3 items-center justify-end">
|
||||||
{user && (
|
<img src={user.profilePicture} alt={user.name} className="w-10 h-10 rounded-full object-cover" />
|
||||||
<MobileMenu disableNavigation={disableNavigation} path={path} isOpen={isMenuOpen} onClose={() => setIsMenuOpen(false)} user={user} />
|
<span className="text-right">{user.name}</span>
|
||||||
)}
|
|
||||||
<header className="-md:justify-between -md:px-4 relative flex w-full items-center bg-transparent py-2 md:gap-12 md:py-4">
|
|
||||||
<Link href={disableNavigation ? "" : "/"} className=" flex items-center gap-8 md:px-8">
|
|
||||||
<img src="/logo.png" alt="EnCoach's Logo" className="w-8 md:w-12" />
|
|
||||||
<h1 className="-md:hidden w-1/6 text-2xl font-bold">EnCoach</h1>
|
|
||||||
</Link>
|
</Link>
|
||||||
<div className="flex items-center justify-end gap-4 md:mr-8 md:w-5/6">
|
|
||||||
{user.type === "student" &&
|
|
||||||
badges.map((badge) => (
|
|
||||||
<div
|
|
||||||
key={badge.module}
|
|
||||||
className={`${
|
|
||||||
badge.achieved ? `bg-ielts-${badge.module}` : "bg-mti-gray-anti-flash"
|
|
||||||
} flex h-8 w-8 items-center justify-center rounded-full`}>
|
|
||||||
{badge.icon()}
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
{focusMode && <FocusLayer onFocusLayerMouseEnter={onFocusLayerMouseEnter}/>}
|
||||||
{/* OPEN TICKET SYSTEM */}
|
|
||||||
<button
|
|
||||||
className={clsx(
|
|
||||||
"border-mti-purple-light tooltip tooltip-bottom flex h-8 w-8 flex-col items-center justify-center rounded-full border p-1",
|
|
||||||
"hover:bg-mti-purple-light transition duration-300 ease-in-out hover:text-white z-20",
|
|
||||||
)}
|
|
||||||
data-tip="Submit a help/feedback ticket"
|
|
||||||
onClick={() => setIsTicketOpen(true)}>
|
|
||||||
<BsQuestionCircleFill />
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{showExpirationDate() && (
|
|
||||||
<Link
|
|
||||||
href={!!user.subscriptionExpirationDate && !disablePaymentPage ? "/payment" : ""}
|
|
||||||
data-tip="Expiry date"
|
|
||||||
className={clsx(
|
|
||||||
"flex w-fit cursor-pointer justify-center rounded-full border px-6 py-2 text-sm font-normal focus:outline-none",
|
|
||||||
"tooltip tooltip-bottom transition duration-300 ease-in-out",
|
|
||||||
!user.subscriptionExpirationDate
|
|
||||||
? "bg-mti-green-ultralight border-mti-green-light"
|
|
||||||
: expirationDateColor(user.subscriptionExpirationDate),
|
|
||||||
"border-mti-gray-platinum bg-white",
|
|
||||||
)}>
|
|
||||||
{!user.subscriptionExpirationDate && "Unlimited"}
|
|
||||||
{user.subscriptionExpirationDate && moment(user.subscriptionExpirationDate).format("DD/MM/YYYY")}
|
|
||||||
</Link>
|
|
||||||
)}
|
|
||||||
<Link href={disableNavigation ? "" : "/profile"} className="-md:hidden flex items-center justify-end gap-6">
|
|
||||||
<img src={user.profilePicture} alt={user.name} className="h-10 w-10 rounded-full object-cover" />
|
|
||||||
<span className="-md:hidden text-right">
|
|
||||||
{(user.type === "corporate" || user.type === "mastercorporate") && !!user.corporateInformation?.companyInformation?.name
|
|
||||||
? `${user.corporateInformation?.companyInformation.name} |`
|
|
||||||
: ""}{" "}
|
|
||||||
{user.name} | {USER_TYPE_LABELS[user.type]}
|
|
||||||
{user.type === "corporate" &&
|
|
||||||
!!user.demographicInformation?.position &&
|
|
||||||
` | ${user.demographicInformation?.position || "N/A"}`}
|
|
||||||
</span>
|
|
||||||
</Link>
|
|
||||||
<div className="cursor-pointer md:hidden" onClick={() => setIsMenuOpen(true)}>
|
|
||||||
<BsList className="text-mti-purple-light h-8 w-8" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{focusMode && <FocusLayer onFocusLayerMouseEnter={onFocusLayerMouseEnter} />}
|
|
||||||
</header>
|
</header>
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||