Compare commits

..

1 Commits

Author SHA1 Message Date
Cristiano Ferreira
7962857a95 Sidebar and button created. 2023-08-21 17:36:04 +01:00
628 changed files with 18088 additions and 75416 deletions

5
.gitignore vendored
View File

@@ -1,5 +1,3 @@
src/constants/test_firebase.json
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
@@ -40,6 +38,3 @@ next-env.d.ts
.env
.yarn/*
.history*
__ENV.js
settings.json

View File

@@ -1,4 +0,0 @@
#!/usr/bin/env sh
. "$(dirname -- "$0")/_/husky.sh"
yarn build

28
.vscode/launch.json vendored
View File

@@ -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"
}
}
]
}

View File

@@ -23,8 +23,6 @@ COPY . .
# Uncomment the following line in case you want to disable telemetry during the build.
# ENV NEXT_TELEMETRY_DISABLED 1
ENV MONGODB_URI "mongodb+srv://user:JKpFBymv0WLv3STj@encoach.lz18a.mongodb.net/?retryWrites=true&w=majority&appName=EnCoach"
RUN yarn build
# If using npm comment out above and use below instead
@@ -56,4 +54,4 @@ EXPOSE 3000
ENV PORT 3000
ENV HOSTNAME localhost
CMD HOSTNAME="0.0.0.0" node server.js
CMD ["node", "server.js"]

View File

@@ -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"
}
}

View File

@@ -1,57 +1,7 @@
/** @type {import('next').NextConfig} */
const websiteUrl = process.env.NODE_ENV === 'production' ? "https://encoach.com" : "http://localhost:3000";
const nextConfig = {
reactStrictMode: false,
reactStrictMode: true,
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;

9871
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -6,112 +6,56 @@
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint",
"prepare": "husky install"
"lint": "next lint"
},
"dependencies": {
"@beam-australia/react-env": "^3.1.1",
"@dnd-kit/core": "^6.1.0",
"@dnd-kit/modifiers": "^7.0.0",
"@dnd-kit/sortable": "^8.0.0",
"@firebase/util": "^1.9.7",
"@headlessui/react": "^2.1.2",
"@headlessui/react": "^1.7.13",
"@mdi/js": "^7.1.96",
"@mdi/react": "^1.6.1",
"@paypal/paypal-js": "^7.1.0",
"@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",
"@next/font": "13.1.6",
"@types/node": "18.13.0",
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
"@use-gesture/react": "^10.3.1",
"axios": "^1",
"axios-cache-interceptor": "^1",
"bcrypt": "^5.1.1",
"@types/react": "18.0.27",
"@types/react-dom": "18.0.10",
"axios": "^1.3.5",
"chart.js": "^4.2.1",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.1",
"countries-list": "^3.0.1",
"country-codes-list": "^1.6.11",
"currency-symbol-map": "^5.1.0",
"clsx": "^1.2.1",
"daisyui": "^3.1.5",
"deep-diff": "^1.0.2",
"eslint": "8.33.0",
"eslint-config-next": "13.1.6",
"exceljs": "^4.4.0",
"express-handlebars": "^7.1.2",
"firebase": "9.19.1",
"firebase-admin": "^11.10.1",
"firebase-scrypt": "^2.2.0",
"formidable": "^3.5.0",
"formidable-serverless": "^1.1.1",
"framer-motion": "^9.0.2",
"howler": "^2.2.4",
"immer": "^10.1.1",
"iron-session": "^6.3.1",
"lodash": "^4.17.21",
"moment": "^2.29.4",
"moment-timezone": "^0.5.44",
"mongodb": "^6.8.1",
"next": "^14.2.5",
"nodemailer": "^6.9.5",
"nodemailer-express-handlebars": "^6.1.0",
"next": "13.1.6",
"primeicons": "^6.0.1",
"primereact": "^9.2.3",
"qrcode": "^1.5.3",
"random-words": "^2.0.0",
"react": "18.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-firebase-hooks": "^5.1.1",
"react-icons": "^5.3.0",
"react-icons": "^4.8.0",
"react-lineto": "^3.3.0",
"react-media-recorder": "1.6.5",
"react-phone-number-input": "^3.3.6",
"react-player": "^2.12.0",
"react-select": "^5.7.5",
"react-string-replace": "^1.1.0",
"react-toastify": "^9.1.2",
"react-tooltip": "^5.27.1",
"react-xarrows": "^2.0.2",
"read-excel-file": "^5.7.1",
"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",
"swr": "^2.1.3",
"typescript": "4.9.5",
"use-file-picker": "^2.1.0",
"uuid": "^9.0.0",
"wavesurfer.js": "^6.6.4",
"zustand": "^4.3.6"
},
"devDependencies": {
"@simbathesailor/use-what-changed": "^2.0.0",
"@types/blob-stream": "^0.1.33",
"@types/deep-diff": "^1.0.5",
"@types/formidable": "^3.4.0",
"@types/howler": "^2.2.11",
"@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/wavesurfer.js": "^6.0.6",
"@wixc3/react-board": "^2.2.0",
"autoprefixer": "^10.4.13",
"husky": "^8.0.3",
"postcss": "^8.4.21",
"tailwindcss": "^3.2.4"
}

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 419 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 48 KiB

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.5 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.0 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 418 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.3 MiB

View File

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

View File

@@ -1,5 +1,5 @@
import {Dialog, Transition} from "@headlessui/react";
import { Fragment, useCallback, useEffect, useState } from "react";
import {Fragment} from "react";
import Button from "./Low/Button";
interface Props {
@@ -7,58 +7,21 @@ interface Props {
abandonPopupTitle: string;
abandonPopupDescription: string;
abandonConfirmButtonText: string;
onAbandon: () => void;
onCancel: () => void;
onAbandon: Function;
onCancel: Function;
}
export default function AbandonPopup({ isOpen, abandonPopupTitle, abandonPopupDescription, abandonConfirmButtonText, onAbandon, onCancel }: Props) {
const [isClosing, setIsClosing] = useState(false);
const [mounted, setMounted] = useState(false);
useEffect(() => {
if (isOpen) {
setMounted(true);
}
}, [isOpen]);
useEffect(() => {
if (!isOpen && mounted) {
const timer = setTimeout(() => {
setMounted(false);
setIsClosing(false);
}, 300);
return () => clearTimeout(timer);
}
}, [isOpen, mounted]);
const blockMultipleClicksClose = useCallback((cancel: boolean) => {
if (isClosing) return;
setIsClosing(true);
const func = cancel ? onCancel : onAbandon;
func();
const timer = setTimeout(() => {
setIsClosing(false);
}, 300);
return () => clearTimeout(timer);
}, [isClosing, onCancel, onAbandon]);
if (!mounted && !isOpen) return null;
export default function AbandonPopup({
isOpen,
abandonPopupTitle,
abandonPopupDescription,
abandonConfirmButtonText,
onAbandon,
onCancel,
}: Props) {
return (
<Transition
show={isOpen}
as={Fragment}
beforeEnter={() => setIsClosing(false)}
beforeLeave={() => setIsClosing(true)}
afterLeave={() => {
setIsClosing(false);
setMounted(false);
}}
>
<Dialog onClose={() => blockMultipleClicksClose(true)} className="relative z-50">
<Transition show={isOpen} as={Fragment}>
<Dialog onClose={onCancel} className="relative z-50">
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
@@ -83,10 +46,10 @@ export default function AbandonPopup({ isOpen, abandonPopupTitle, abandonPopupDe
<Dialog.Title className="font-bold text-xl">{abandonPopupTitle}</Dialog.Title>
<span>{abandonPopupDescription}</span>
<div className="w-full flex justify-between mt-8">
<Button color="purple" onClick={() => blockMultipleClicksClose(true)} variant="outline" className="max-w-[200px] self-end w-full">
<Button color="purple" onClick={onCancel} variant="outline" className="max-w-[200px] self-end w-full">
Cancel
</Button>
<Button color="purple" onClick={() => blockMultipleClicksClose(false)} className="max-w-[200px] self-end w-full">
<Button color="purple" onClick={onAbandon} className="max-w-[200px] self-end w-full">
{abandonConfirmButtonText}
</Button>
</div>

View File

@@ -1,32 +0,0 @@
import Image from "next/image";
import React from "react";
import { FaRegUser } from "react-icons/fa";
interface Props {
prefix: string;
name: string;
profileImage: string;
}
export default function RequestedBy({ prefix, name, profileImage }: Props) {
return (
<div className="flex items-center space-x-3">
<div className="flex items-center justify-center w-12 h-12 bg-gray-100 rounded-lg border border-gray-300">
<FaRegUser className="text-mti-purple-dark size-5"/>
</div>
<div>
<p className="text-sm font-medium text-gray-800">Requested by</p>
<div className="flex items-center space-x-2">
<p className="text-xs font-medium text-gray-800">{prefix} {name}</p>
<img
src={profileImage ? profileImage : "/defaultAvatar.png"}
alt={name}
width={24}
height={24}
className="w-6 h-6 rounded-full border-[1px] border-gray-400 border-opacity-50"
/>
</div>
</div>
</div>
);
};

View File

@@ -1,41 +0,0 @@
import React from "react";
import { PiCalendarDots } from "react-icons/pi";
interface Props {
date: number;
}
export default function StartedOn({ date }: Props) {
const formattedDate = new Date(date);
const yearMonthDay = formattedDate.toISOString().split("T")[0];
const fullDateTime = formattedDate.toLocaleString("en-US", {
year: "numeric",
month: "long",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
hour12: false,
});
return (
<div className="flex items-center space-x-3">
<div className="flex items-center justify-center w-12 h-12 bg-gray-100 rounded-lg border border-gray-300">
<PiCalendarDots className="text-mti-purple-dark size-7" />
</div>
<div>
<p className="pb-1 text-sm font-medium text-gray-800">Started on</p>
<div className="flex items-center">
<p
className="text-xs font-medium text-gray-800"
title={fullDateTime}
>
{yearMonthDay}
</p>
</div>
</div>
</div>
);
};

View File

@@ -1,23 +0,0 @@
import { ApprovalWorkflowStatus, ApprovalWorkflowStatusLabel } from "@/interfaces/approval.workflow";
import React from "react";
import { RiProgress5Line } from "react-icons/ri";
interface Props {
status: ApprovalWorkflowStatus;
}
export default function Status({ status }: Props) {
return (
<div className="flex items-center space-x-3">
<div className="flex items-center justify-center w-12 h-12 bg-gray-100 rounded-lg border border-gray-300">
<RiProgress5Line className="text-mti-purple-dark size-7"/>
</div>
<div>
<p className="pb-1 text-sm font-medium text-gray-800">Status</p>
<div className="flex items-center">
<p className="text-xs font-medium text-gray-800">{ApprovalWorkflowStatusLabel[status]}</p>
</div>
</div>
</div>
);
};

View File

@@ -1,14 +0,0 @@
import { MdTipsAndUpdates } from "react-icons/md";
interface Props {
text: string;
}
export default function Tip({ text }: Props) {
return (
<div className="flex flex-row gap-3 text-gray-500 font-medium">
<MdTipsAndUpdates size={25} />
<p>{text}</p>
</div>
);
};

View File

@@ -1,24 +0,0 @@
import Image from "next/image";
interface Props {
prefix: string;
name: string;
profileImage: string;
textSize?: string;
}
export default function UserWithProfilePic({ prefix, name, profileImage, textSize }: Props) {
const textClassName = `${textSize ? textSize : "text-xs"} font-medium`
return (
<div className="flex items-center space-x-2">
<p className={textClassName}>{prefix} {name}</p>
<img
src={profileImage ? profileImage : "/defaultAvatar.png"}
alt={name}
width={24}
height={24}
className="rounded-full h-auto border-[1px] border-gray-400 border-opacity-50"
/>
</div>
);
};

View File

@@ -1,136 +0,0 @@
import { EditableWorkflowStep } from "@/interfaces/approval.workflow";
import Option from "@/interfaces/option";
import { CorporateUser, DeveloperUser, MasterCorporateUser, TeacherUser } from "@/interfaces/user";
import Image from "next/image";
import { useEffect, useMemo, useState } from "react";
import { AiOutlineUserAdd } from "react-icons/ai";
import { BsTrash } from "react-icons/bs";
import { LuGripHorizontal } from "react-icons/lu";
import WorkflowStepNumber from "./WorkflowStepNumber";
import WorkflowStepSelects from "./WorkflowStepSelects";
interface Props extends Pick<EditableWorkflowStep, 'stepNumber' | 'assignees' | 'finalStep' | 'onDelete'> {
entityApprovers: (TeacherUser | CorporateUser | MasterCorporateUser | DeveloperUser)[];
onSelectChange: (numberOfSelects: number, index: number, value: Option | null) => void;
isCompleted: boolean,
}
export default function WorkflowEditableStepComponent({
stepNumber,
assignees = [null],
finalStep,
onDelete,
onSelectChange,
entityApprovers,
isCompleted,
}: Props) {
const [selects, setSelects] = useState<(Option | null | undefined)[]>([null]);
const [isAdding, setIsAdding] = useState(false);
const approverOptions: Option[] = useMemo(() =>
entityApprovers
.map((approver) => ({
value: approver.id,
label: approver.name,
icon: () => <img src={approver.profilePicture} alt={approver.name} />
}))
.sort((a, b) => a.label.localeCompare(b.label)),
[entityApprovers]
);
useEffect(() => {
if (assignees && assignees.length > 0) {
const initialSelects = assignees.map((assignee) =>
typeof assignee === 'string' ? approverOptions.find(option => option.value === assignee) || null : null
);
setSelects((prevSelects) => {
// This is needed to avoid unnecessary re-renders which can cause warning of a child component being re-rendered while parent is in the midle of also re-rendering.
const areEqual = initialSelects.length === prevSelects.length && initialSelects.every((option, idx) => option?.value === prevSelects[idx]?.value);
if (!areEqual) {
return initialSelects;
}
return prevSelects;
});
}
}, [assignees, approverOptions]);
const selectedValues = useMemo(() =>
selects.filter((opt): opt is Option => !!opt).map(opt => opt.value),
[selects]
);
const availableApproverOptions = useMemo(() =>
approverOptions.filter(opt => !selectedValues.includes(opt.value)),
[approverOptions, selectedValues]
);
const handleAddSelectComponent = () => {
setIsAdding(true); // I hate to use flags... but it was the only way i was able to prevent onSelectChange to cause parent component from re-rendering in the midle of EditableWorkflowStep rerender.
setSelects(prev => [...prev, null]);
};
useEffect(() => {
if (isAdding) {
onSelectChange(selects.length, selects.length - 1, null);
setIsAdding(false);
}
}, [selects.length, isAdding, onSelectChange]);
const handleSelectChangeAt = (numberOfSelects: number, index: number, option: Option | null) => {
const updated = [...selects];
updated[index] = option;
setSelects(updated);
onSelectChange(numberOfSelects, index, option);
};
return (
<div className="flex w-full">
<div className="flex flex-col items-center">
<WorkflowStepNumber stepNumber={stepNumber} completed={false} selected={false} />
{/* Vertical Bar connecting steps */}
{!finalStep && (
<div className="w-1 h-full min-h-10 bg-mti-purple-dark"></div>
)}
</div>
{stepNumber !== 1 && !finalStep && !isCompleted
? <LuGripHorizontal className="ml-3 mt-2 cursor-grab active:cursor-grabbing min-w-[25px] min-h-[25px]" />
: <div className="ml-3 mt-2" style={{ width: 25, height: 25 }}></div>
}
<div className="ml-10 mb-12">
<WorkflowStepSelects
approvers={availableApproverOptions}
selects={selects}
placeholder={stepNumber === 1 ? "Form Intake By:" : "Approval By:"}
onSelectChange={handleSelectChangeAt}
isCompleted={isCompleted}
/>
</div>
<div className="flex flex-row items-start mt-1.5 ml-3">
<button
type="button"
onClick={handleAddSelectComponent}
className="cursor-pointer"
>
<AiOutlineUserAdd className="size-7 hover:text-mti-purple-light transition ease-in-out duration-300" />
</button>
{stepNumber !== 1 && !finalStep && (
<button
className="cursor-pointer"
onClick={onDelete}
type="button"
>
<BsTrash className="size-6 mt-0.5 ml-3 hover:text-mti-purple-light transition ease-in-out duration-300" />
</button>
)}
</div>
</div>
);
};

View File

@@ -1,203 +0,0 @@
import { EditableApprovalWorkflow, EditableWorkflowStep } from "@/interfaces/approval.workflow";
import Option from "@/interfaces/option";
import { CorporateUser, DeveloperUser, MasterCorporateUser, TeacherUser } from "@/interfaces/user";
import { AnimatePresence, Reorder, motion } from "framer-motion";
import { FaRegCheckCircle, FaSpinner } from "react-icons/fa";
import { IoIosAddCircleOutline } from "react-icons/io";
import Button from "../Low/Button";
import Tip from "./Tip";
import WorkflowEditableStepComponent from "./WorkflowEditableStepComponent";
interface Props {
workflow: EditableApprovalWorkflow;
onWorkflowChange: (workflow: EditableApprovalWorkflow) => void;
entityApprovers: (TeacherUser | CorporateUser | MasterCorporateUser | DeveloperUser)[];
entityAvailableFormIntakers?: (TeacherUser | CorporateUser | MasterCorporateUser | DeveloperUser)[];
isLoading: boolean;
isRedirecting?: boolean;
}
export default function WorkflowForm({ workflow, onWorkflowChange, entityApprovers, entityAvailableFormIntakers, isLoading, isRedirecting }: Props) {
const lastStep = workflow.steps[workflow.steps.length - 1];
const renumberSteps = (steps: EditableWorkflowStep[]): EditableWorkflowStep[] => {
return steps.map((step, index) => ({
...step,
stepNumber: index + 1,
}));
};
const addStep = () => {
const newStep: EditableWorkflowStep = {
key: Date.now(),
stepType: "approval-by",
stepNumber: workflow.steps.length,
completed: false,
assignees: [null],
firstStep: false,
finalStep: false,
};
const updatedSteps = [
...workflow.steps.slice(0, -1),
newStep,
lastStep
];
onWorkflowChange({ ...workflow, steps: renumberSteps(updatedSteps) });
};
const handleDelete = (key: number | undefined) => {
if (!key) return;
const updatedSteps = workflow.steps.filter((step) => step.key !== key);
onWorkflowChange({ ...workflow, steps: renumberSteps(updatedSteps) });
};
const handleSelectChange = (key: number | undefined, numberOfSelects: number, index: number, selectedOption: Option | null) => {
if (!key) return;
const updatedSteps = workflow.steps.map((step) => {
if (step.key !== key) return step;
const assignees = step.assignees ?? [];
let newAssignees = [...assignees];
if (numberOfSelects === assignees.length) { // means no new select was added and instead one was changed
newAssignees[index] = selectedOption?.value;
} else if (numberOfSelects === assignees.length + 1) { // means a new select was added
newAssignees.push(selectedOption?.value || null);
}
return { ...step, assignees: newAssignees };
});
onWorkflowChange({ ...workflow, steps: updatedSteps });
};
const handleReorder = (newOrder: EditableWorkflowStep[]) => {
let draggableIndex = 0;
const updatedSteps = workflow.steps.map((step) => {
if (!step.firstStep && !step.finalStep && !step.completed) {
return newOrder[draggableIndex++];
}
// Keep static steps as-is
return step;
});
onWorkflowChange({ ...workflow, steps: renumberSteps(updatedSteps) });
};
return (
<>
{workflow.entityId && workflow.name &&
<div>
<div
className="flex flex-col gap-6"
>
<Tip text="Introduce here all the steps associated with this instance." />
<Button
color="purple"
variant="solid"
onClick={addStep}
type="button"
className="max-w-fit text-lg font-medium flex items-center gap-2 text-left mb-7"
>
<IoIosAddCircleOutline className="size-6" />
Add Step
</Button>
</div>
<Reorder.Group
axis="y"
values={workflow.steps}
onReorder={handleReorder}
className="flex flex-col gap-0"
>
<AnimatePresence>
{workflow.steps.map((step, index) =>
step.completed || step.firstStep || step.finalStep ? (
<motion.div
key={step.key}
layout
initial={{ opacity: 0, y: -30 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, x: 30 }}
transition={{ duration: 0.20 }}
>
<WorkflowEditableStepComponent
stepNumber={index + 1}
assignees={step.assignees}
finalStep={step.finalStep}
onDelete={() => handleDelete(step.key)}
onSelectChange={(numberOfSelects, idx, option) =>
handleSelectChange(step.key, numberOfSelects, idx, option)
}
entityApprovers={
step.stepNumber === 1 && entityAvailableFormIntakers
? entityAvailableFormIntakers
: entityApprovers
}
isCompleted={step.completed}
/>
</motion.div>
) : (
// Render non-completed steps as draggable items
<Reorder.Item
key={step.key}
value={step}
initial={{ opacity: 0, y: -30 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, x: 30 }}
transition={{ duration: 0.20 }}
layout
drag={!step.firstStep && !step.finalStep}
dragListener={!step.firstStep && !step.finalStep}
>
<WorkflowEditableStepComponent
stepNumber={index + 1}
assignees={step.assignees}
finalStep={step.finalStep}
onDelete={() => handleDelete(step.key)}
onSelectChange={(numberOfSelects, idx, option) =>
handleSelectChange(step.key, numberOfSelects, idx, option)
}
entityApprovers={
step.stepNumber === 1 && entityAvailableFormIntakers
? entityAvailableFormIntakers
: entityApprovers
}
isCompleted={step.completed}
/>
</Reorder.Item>
)
)}
<Button
type="submit"
color="purple"
variant="solid"
disabled={isLoading}
className="max-w-fit text-lg font-medium flex items-center gap-2 text-left -mt-4"
>
{isRedirecting ? (
<>
<FaSpinner className="animate-spin size-5" />
Redirecting...
</>
) : isLoading ? (
<>
<FaSpinner className="animate-spin size-5" />
Loading...
</>
) : (
<>
<FaRegCheckCircle className="size-5" />
Confirm Exam Workflow Pipeline
</>
)}
</Button>
</AnimatePresence>
</Reorder.Group>
</div>
}
</>
);
};

View File

@@ -1,101 +0,0 @@
import { getUserTypeLabel, getUserTypeLabelShort, WorkflowStep } from "@/interfaces/approval.workflow";
import WorkflowStepNumber from "./WorkflowStepNumber";
import clsx from "clsx";
import { RiThumbUpLine } from "react-icons/ri";
import { FaWpforms } from "react-icons/fa6";
import { User } from "@/interfaces/user";
import UserWithProfilePic from "./UserWithProfilePic";
interface Props extends WorkflowStep {
workflowAssignees: User[],
currentStep: boolean,
}
export default function WorkflowStepComponent({
workflowAssignees,
currentStep,
stepType,
stepNumber,
completed,
rejected = false,
completedBy,
assignees,
finalStep,
selected = false,
onClick,
}: Props) {
const completedByUser = workflowAssignees.find((assignee) => assignee.id === completedBy);
const assigneesUsers = workflowAssignees.filter(user => assignees.includes(user.id));
return (
<div
onClick={onClick}
className={clsx("flex flex-row gap-5 w-[600px] p-6 mb-5 rounded-2xl transition ease-in-out duration-300 cursor-pointer", {
"bg-mti-red-ultralight": rejected && selected,
"bg-mti-purple-ultralight": selected,
})}
>
<div className="relative flex flex-col items-center">
<WorkflowStepNumber stepNumber={stepNumber} selected={selected} completed={completed} finalStep={finalStep} rejected={rejected} />
{/* Vertical Bar connecting steps */}
{!finalStep && (
<div className="absolute w-1 bg-mti-purple-dark -bottom-20 top-11"></div>
)}
</div>
<div className="mt-1.5">
{stepType === "approval-by" ? (
<RiThumbUpLine size={25} />
) : (
<FaWpforms size={25} />
)
}
</div>
<div className="mt-1 flex flex-col gap-0">
{completed && completedBy && rejected ? (
<div className={clsx("text-xs font-medium", { "text-mti-purple-ultradark": selected, "text-gray-800": !selected })}>
<p className="text-sm font-medium text-gray-800">{stepType === "approval-by" ? `Approval: ${getUserTypeLabel(completedByUser!.type)} Approval` : `Form Intake: ${getUserTypeLabel(completedByUser!.type)} Intake`} </p>
<UserWithProfilePic
prefix={`Rejected by: ${getUserTypeLabelShort(completedByUser!.type)}`}
name={completedByUser!.name}
profileImage={completedByUser!.profilePicture}
/>
</div>
) : completed && completedBy && !rejected ? (
<div className={clsx("text-xs font-medium", { "text-mti-purple-ultradark": selected, "text-gray-800": !selected })}>
<p className="text-sm font-medium text-gray-800">{stepType === "approval-by" ? `Approval: ${getUserTypeLabel(completedByUser!.type)} Approval` : `Form Intake: ${getUserTypeLabel(completedByUser!.type)} Intake`} </p>
<UserWithProfilePic
prefix={`Completed by: ${getUserTypeLabelShort(completedByUser!.type)}`}
name={completedByUser!.name}
profileImage={completedByUser!.profilePicture}
/>
</div>
) : !completed && currentStep ? (
<div className={clsx("text-xs font-medium", { "text-mti-purple-ultradark": selected, "text-gray-800": !selected })}>
<p className="text-sm font-medium text-gray-800">{stepType === "approval-by" ? `Approval:` : `Form Intake:`} </p>
In Progress... Assignees:
<div className="flex flex-row flex-wrap gap-3 items-center">
{assigneesUsers.map(user => (
<span key={user.id}>
<UserWithProfilePic
prefix={getUserTypeLabelShort(user.type)}
name={user.name}
profileImage={user.profilePicture}
/>
</span>
))}
</div>
</div>
) : (
<div className={clsx("text-xs font-medium", { "text-mti-purple-ultradark": selected, "text-gray-800": !selected })}>
<p className="text-sm font-medium text-gray-800">{stepType === "approval-by" ? `Approval:` : `Form Intake:`} </p>
Waiting for previous steps...
</div>
)}
</div>
</div>
);
};

View File

@@ -1,31 +0,0 @@
import { WorkflowStep } from "@/interfaces/approval.workflow";
import clsx from "clsx";
import { IoCheckmarkDoneSharp, IoCheckmarkSharp } from "react-icons/io5";
import { RxCross2 } from "react-icons/rx";
type Props = Pick<WorkflowStep, 'stepNumber' | 'completed' | 'finalStep' | 'selected' | 'rejected'>
export default function WorkflowStepNumber({ stepNumber, selected = false, completed, rejected, finalStep }: Props) {
return (
<div
className={clsx(
'flex items-center justify-center min-w-11 min-h-11 rounded-full',
{
'bg-mti-red-dark text-mti-red-ultralight': rejected,
'bg-mti-purple-dark text-mti-purple-ultralight': selected,
'bg-mti-purple-ultralight text-gray-500': !selected,
}
)}
>
{rejected ? (
<RxCross2 className="text-xl font-bold" size={25}/>
) : completed && finalStep ? (
<IoCheckmarkDoneSharp className="text-xl font-bold" size={25} />
) : completed && !finalStep ? (
<IoCheckmarkSharp className="text-xl font-bold" size={25} />
) : (
<span className="text-lg font-semibold">{stepNumber}</span>
)}
</div>
);
};

View File

@@ -1,51 +0,0 @@
import Option from "@/interfaces/option";
import Select from "../Low/Select";
interface Props {
approvers: Option[];
selects: (Option | null | undefined)[];
placeholder: string;
onSelectChange: (numberOfSelects: number, index: number, value: Option | null) => void;
isCompleted: boolean;
}
export default function WorkflowStepSelects({
approvers,
selects,
placeholder,
onSelectChange,
isCompleted,
}: Props) {
return (
<div
className={"flex flex-wrap gap-0"}
>
{selects.map((option, index) => {
let classes = "px-2 rounded-none";
if (index === 0 && selects.length === 1) {
classes += " rounded-l-2xl rounded-r-2xl";
} else if (index === 0) {
classes += " rounded-l-2xl";
} else if (index === selects.length - 1) {
classes += " rounded-r-2xl";
}
return (
<div key={index} className="w-[275px]">
<Select
options={approvers}
value={option}
onChange={(option) => onSelectChange(selects.length, index, option)}
placeholder={placeholder}
flat
isClearable
className={classes}
disabled={isCompleted}
/>
</div>
);
})}
</div>
);
}

View File

@@ -1,148 +0,0 @@
import ProgressBar from "@/components/Low/ProgressBar";
import useUsers from "@/hooks/useUsers";
import { Module } from "@/interfaces";
import { Assignment } from "@/interfaces/results";
import { calculateBandScore } from "@/utils/score";
import clsx from "clsx";
import moment from "moment";
import { BsBook, BsClipboard, BsHeadphones, BsMegaphone, BsPen } from "react-icons/bs";
import { usePDFDownload } from "@/hooks/usePDFDownload";
import { useAssignmentArchive } from "@/hooks/useAssignmentArchive";
import { uniqBy } from "lodash";
import { useAssignmentUnarchive } from "@/hooks/useAssignmentUnarchive";
import { useAssignmentRelease } from "@/hooks/useAssignmentRelease";
import { getUserName } from "@/utils/users";
import { User } from "@/interfaces/user";
import { EntityWithRoles } from "@/interfaces/entity";
interface Props {
users: User[];
onClick?: () => void;
allowDownload?: boolean;
reload?: Function;
allowArchive?: boolean;
allowUnarchive?: boolean;
allowExcelDownload?: boolean;
entityObj?: EntityWithRoles
}
export default function AssignmentCard({
id,
name,
assigner,
startDate,
endDate,
entityObj,
assignees,
results,
exams,
archived,
onClick,
allowDownload,
reload,
allowArchive,
allowUnarchive,
allowExcelDownload,
users,
released,
}: Assignment & Props) {
const renderPdfIcon = usePDFDownload("assignments");
const renderExcelIcon = usePDFDownload("assignments", "excel");
const renderArchiveIcon = useAssignmentArchive(id, reload);
const renderUnarchiveIcon = useAssignmentUnarchive(id, reload);
const renderReleaseIcon = useAssignmentRelease(id, reload);
const calculateAverageModuleScore = (module: Module) => {
const resultModuleBandScores = results.map((r) => {
const moduleStats = r.stats.filter((s) => s.module === module);
const correct = moduleStats.reduce((acc, curr) => acc + curr.score.correct, 0);
const total = moduleStats.reduce((acc, curr) => acc + curr.score.total, 0);
return calculateBandScore(correct, total, module, r.type);
});
return resultModuleBandScores.length === 0 ? -1 : resultModuleBandScores.reduce((acc, curr) => acc + curr, 0) / results.length;
};
const uniqModules = uniqBy(exams, (x) => x.module);
const shouldRenderPDF = () => {
if (released && allowDownload) {
// in order to be downloadable, the assignment has to be released
// the component should have the allowDownload prop
// and the assignment should not have the level module
return uniqModules.every(({ module }) => module !== "level");
}
return false;
};
const shouldRenderExcel = () => {
if (released && allowExcelDownload) {
// in order to be downloadable, the assignment has to be released
// the component should have the allowExcelDownload prop
// and the assignment should have the level module
return uniqModules.some(({ module }) => module === "level");
}
return false;
};
return (
<div
onClick={onClick}
className="border-mti-gray-platinum flex h-fit w-[350px] cursor-pointer flex-col gap-6 rounded-xl border bg-white p-4 transition duration-300 ease-in-out hover:drop-shadow">
<div className="flex flex-col gap-3">
<div className="flex flex-row justify-between">
<h3 className="text-xl font-semibold">{name}</h3>
<div className="flex gap-2">
{shouldRenderPDF() && renderPdfIcon(id, "text-mti-gray-dim", "text-mti-gray-dim")}
{shouldRenderExcel() && renderExcelIcon(id, "text-mti-gray-dim", "text-mti-gray-dim")}
{allowArchive && !archived && renderArchiveIcon("text-mti-gray-dim", "text-mti-gray-dim")}
{allowUnarchive && archived && renderUnarchiveIcon("text-mti-gray-dim", "text-mti-gray-dim")}
{!released && renderReleaseIcon("text-mti-gray-dim", "text-mti-gray-dim")}
</div>
</div>
<ProgressBar
color={results.length / assignees.length < 0.5 ? "red" : "purple"}
percentage={(results.length / assignees.length) * 100}
label={`${results.length}/${assignees.length}`}
className="h-5"
textClassName={results.length / assignees.length < 0.5 ? "!text-mti-gray-dim font-light" : "text-white"}
/>
</div>
<div className="flex flex-col gap-1">
<span className="flex justify-between gap-1">
<span>{moment(startDate).format("DD/MM/YY, HH:mm")}</span>
<span>-</span>
<span>{moment(endDate).format("DD/MM/YY, HH:mm")}</span>
</span>
<span>Assigner: {getUserName(users.find((x) => x.id === assigner))}</span>
{entityObj && <span>Entity: {entityObj.label}</span>}
</div>
<div className="-md:mt-2 grid w-full grid-cols-4 place-items-start gap-2">
{uniqModules.map(({ module }) => (
<div
key={module}
className={clsx(
"-md:px-4 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" />}
{calculateAverageModuleScore(module) > -1 && (
<span className="text-sm">{calculateAverageModuleScore(module).toFixed(1)}</span>
)}
</div>
))}
</div>
</div>
);
}

View File

@@ -1,339 +0,0 @@
import Button from "@/components/Low/Button";
import ProgressBar from "@/components/Low/ProgressBar";
import Modal from "@/components/Modal";
import useUsers from "@/hooks/useUsers";
import { Module } from "@/interfaces";
import { Assignment } from "@/interfaces/results";
import { Stat, User } from "@/interfaces/user";
import useExamStore from "@/stores/exam";
import { getExamById } from "@/utils/exams";
import { sortByModule } from "@/utils/moduleUtils";
import { calculateBandScore } from "@/utils/score";
import { getUserName } from "@/utils/users";
import axios from "axios";
import clsx from "clsx";
import { capitalize, uniqBy } from "lodash";
import moment from "moment";
import { useRouter } from "next/router";
import { BsBook, BsClipboard, BsHeadphones, BsMegaphone, BsPen } from "react-icons/bs";
import { toast } from "react-toastify";
import { futureAssignmentFilter } from "@/utils/assignments";
interface Props {
isOpen: boolean;
users: User[];
assignment?: Assignment;
onClose: () => void;
}
export default function AssignmentView({ isOpen, users, assignment, onClose }: Props) {
const router = useRouter();
const dispatch = useExamStore((s) => s.dispatch);
const deleteAssignment = async () => {
if (!confirm("Are you sure you want to delete this assignment?")) return;
axios
.delete(`/api/assignments/${assignment?.id}`)
.then(() => toast.success(`Successfully deleted the assignment "${assignment?.name}".`))
.catch(() => toast.error("Something went wrong, please try again later."))
.finally(onClose);
};
const startAssignment = () => {
if (assignment) {
axios
.post(`/api/assignments/${assignment.id}/start`)
.then(() => {
toast.success(`The assignment "${assignment.name}" has been started successfully!`);
})
.catch((e) => {
console.log(e);
toast.error("Something went wrong, please try again later!");
});
}
};
const formatTimestamp = (timestamp: string) => {
const date = moment(parseInt(timestamp));
const formatter = "YYYY/MM/DD - HH:mm";
return date.format(formatter);
};
const calculateAverageModuleScore = (module: Module) => {
if (!assignment) return -1;
const resultModuleBandScores = assignment.results.map((r) => {
const moduleStats = r.stats.filter((s) => s.module === module);
const correct = moduleStats.reduce((acc, curr) => acc + curr.score.correct, 0);
const total = moduleStats.reduce((acc, curr) => acc + curr.score.total, 0);
return calculateBandScore(correct, total, module, r.type);
});
return resultModuleBandScores.length === 0 ? -1 : resultModuleBandScores.reduce((acc, curr) => acc + curr, 0) / assignment.results.length;
};
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.filter(x => !x.isPractice).forEach((x) => {
scores[x.module!] = {
total: scores[x.module!].total + x.score.total,
correct: scores[x.module!].correct + x.score.correct,
missing: scores[x.module!].missing + x.score.missing,
};
});
return Object.keys(scores)
.filter((x) => scores[x as Module].total > 0)
.map((x) => ({ module: x as Module, ...scores[x as Module] }));
};
const customContent = (stats: Stat[], user: string, focus: "academic" | "general") => {
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 aggregatedLevels = aggregatedScores.map((x) => ({
module: x.module,
level: calculateBandScore(x.correct, x.total, x.module, focus),
}));
const timeSpent = stats[0].timeSpent;
const selectExam = () => {
const examPromises = uniqBy(stats, "exam").map((stat) => getExamById(stat.module, stat.exam));
Promise.all(examPromises).then((exams) => {
if (exams.every((x) => !!x)) {
dispatch({
type: 'INIT_SOLUTIONS', payload: {
exams: exams.map((x) => x!).sort(sortByModule),
modules: exams
.map((x) => x!)
.sort(sortByModule)
.map((x) => x!.module),
stats
}
});
router.push("/exam");
}
});
};
const content = (
<>
<div className="-md:items-center flex w-full justify-between 2xl:items-center">
<div className="-md:gap-2 -md:items-center flex md:flex-col md:gap-1 2xl:flex-row 2xl:items-center 2xl:gap-2">
<span className="font-medium">{formatTimestamp(stats[0].date.toString())}</span>
{timeSpent && (
<>
<span className="md:hidden 2xl:flex">⢠</span>
<span className="text-sm">{Math.floor(timeSpent / 60)} minutes</span>
</>
)}
</div>
<span
className={clsx(
correct / total >= 0.7 && "text-mti-purple",
correct / total >= 0.3 && correct / total < 0.7 && "text-mti-red",
correct / total < 0.3 && "text-mti-rose",
)}>
Level{" "}
{(aggregatedLevels.reduce((accumulator, current) => accumulator + current.level, 0) / aggregatedLevels.length).toFixed(1)}
</span>
</div>
<div className="flex w-full flex-col gap-1">
<div className="-md:mt-2 grid w-full grid-cols-4 place-items-start gap-2">
{aggregatedLevels.map(({ module, level }) => (
<div
key={module}
className={clsx(
"-md:px-4 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" />}
<span className="text-sm">{level.toFixed(1)}</span>
</div>
))}
</div>
</div>
</>
);
return (
<div className="flex flex-col gap-2">
<span>
{(() => {
const student = users.find((u) => u.id === user);
return `${student?.name} (${student?.email})`;
})()}
</span>
<div
key={user}
className={clsx(
"border-mti-gray-platinum -md:hidden flex cursor-pointer flex-col gap-4 rounded-xl border p-4 transition duration-300 ease-in-out",
correct / total >= 0.7 && "hover:border-mti-purple",
correct / total >= 0.3 && correct / total < 0.7 && "hover:border-mti-red",
correct / total < 0.3 && "hover:border-mti-rose",
)}
onClick={selectExam}
role="button">
{content}
</div>
<div
key={user}
className={clsx(
"border-mti-gray-platinum -md:tooltip flex cursor-pointer flex-col gap-4 rounded-xl border p-4 transition duration-300 ease-in-out 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."
role="button">
{content}
</div>
</div>
);
};
const shouldRenderStart = () => {
if (assignment) {
if (futureAssignmentFilter(assignment)) {
return true;
}
}
return false;
};
return (
<Modal isOpen={isOpen} onClose={onClose} title={assignment?.name}>
<div className="mt-4 flex w-full flex-col gap-4">
<ProgressBar
color="purple"
label={`${assignment?.results.length}/${assignment?.assignees.length} assignees completed`}
className="h-6"
textClassName={
(assignment?.results.length || 0) / (assignment?.assignees.length || 1) < 0.5 ? "!text-mti-gray-dim font-light" : "text-white"
}
percentage={((assignment?.results.length || 0) / (assignment?.assignees.length || 1)) * 100}
/>
<div className="flex items-start gap-8">
<div className="flex flex-col gap-2">
<span>Start Date: {moment(assignment?.startDate).format("DD/MM/YY, HH:mm")}</span>
<span>End Date: {moment(assignment?.endDate).format("DD/MM/YY, HH:mm")}</span>
</div>
<div className="flex flex-col gap-2">
<span>
Assignees:{" "}
{users
.filter((u) => assignment?.assignees.includes(u.id))
.map((u) => `${u.name} (${u.email})`)
.join(", ")}
</span>
<span>Assigner: {getUserName(users.find((x) => x.id === assignment?.assigner))}</span>
</div>
</div>
<div className="flex flex-col gap-2">
<span className="text-xl font-bold">Average Scores</span>
<div className="-md:mt-2 flex w-full items-center gap-4">
{assignment &&
uniqBy(assignment.exams, (x) => x.module).map(({ module }) => (
<div
data-tip={capitalize(module)}
key={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" />}
{calculateAverageModuleScore(module) > -1 && (
<span className="text-sm">{calculateAverageModuleScore(module).toFixed(1)}</span>
)}
</div>
))}
</div>
</div>
<div className="flex flex-col gap-2">
<span className="text-xl font-bold">
Results ({assignment?.results.length}/{assignment?.assignees.length})
</span>
<div>
{assignment && assignment?.results.length > 0 && (
<div className="grid w-full grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3 xl:gap-6">
{assignment.results.map((r) => customContent(r.stats, r.user, r.type))}
</div>
)}
{assignment && assignment?.results.length === 0 && <span className="ml-1 font-semibold">No results yet...</span>}
</div>
</div>
<div className="flex gap-4 w-full items-center justify-end">
{assignment && (assignment.results.length === assignment.assignees.length || moment().isAfter(moment(assignment.endDate))) && (
<Button variant="outline" color="red" className="w-full max-w-[200px]" onClick={deleteAssignment}>
Delete
</Button>
)}
{/** if the assignment is not deemed as active yet, display start */}
{shouldRenderStart() && (
<Button variant="outline" color="green" className="w-full max-w-[200px]" onClick={startAssignment}>
Start
</Button>
)}
<Button onClick={onClose} className="w-full max-w-[200px]">
Close
</Button>
</div>
</div>
</Modal>
);
}

View File

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

View File

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

View File

@@ -1,149 +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: (user: User) => void;
}
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<{user: User}>("/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.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>
);
}

View File

@@ -1,34 +1,41 @@
import {infoButtonStyle} from "@/constants/buttonStyles";
import {BAND_SCORES} from "@/constants/ielts";
import {Module} from "@/interfaces";
import {User} from "@/interfaces/user";
import useExamStore from "@/stores/exam";
import {getExam, getExamById} from "@/utils/exams";
import {MODULE_ARRAY} from "@/utils/moduleUtils";
import useExamStore from "@/stores/examStore";
import {getExamById} from "@/utils/exams";
import {writingMarking} from "@/utils/score";
import {Menu} from "@headlessui/react";
import axios from "axios";
import clsx from "clsx";
import {capitalize} from "lodash";
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 {toast} from "react-toastify";
import Button from "./Low/Button";
import ModuleLevelSelector from "./Medium/ModuleLevelSelector";
interface Props {
user: User;
onFinish: () => void;
}
const DIAGNOSTIC_EXAMS = [
["reading", "CurQtQoxWmHaJHeN0JW2"],
["listening", "Y6cMao8kUcVnPQOo6teV"],
["writing", "hbueuDaEZXV37EW7I12A"],
["speaking", "QVFm4pdcziJQZN2iUTDo"],
];
export default function Diagnostic({onFinish}: Props) {
const [focus, setFocus] = useState<"academic" | "general">();
const [levels, setLevels] = useState({reading: -1, listening: -1, writing: -1, speaking: -1, level: 0});
const [desiredLevels, setDesiredLevels] = useState({reading: 9, listening: 9, writing: 9, speaking: 9, level: 9});
const [levels, setLevels] = useState({reading: -1, listening: -1, writing: -1, speaking: -1});
const [desiredLevels, setDesiredLevels] = useState({reading: 9, listening: 9, writing: 9, speaking: 9});
const router = useRouter();
const dispatch = useExamStore((state) => state.dispatch);
const setExams = useExamStore((state) => state.setExams);
const setSelectedModules = useExamStore((state) => state.setSelectedModules);
const isNextDisabled = () => {
if (!focus) return true;
@@ -36,11 +43,12 @@ export default function Diagnostic({onFinish}: Props) {
};
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) => {
if (exams.every((x) => !!x)) {
dispatch({type: 'INIT_EXAM', payload: {exams: exams.map((x) => x!), modules: exams.map((x) => x!.module)}})
setExams(exams.map((x) => x!));
setSelectedModules(exams.map((x) => x!.module));
router.push("/exam");
}
});
@@ -50,7 +58,7 @@ export default function Diagnostic({onFinish}: Props) {
axios
.patch("/api/users/update", {
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,
isFirstLogin: false,
})
@@ -65,7 +73,7 @@ export default function Diagnostic({onFinish}: Props) {
<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>
<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
onClick={() => setFocus("academic")}
className={clsx(
@@ -89,33 +97,118 @@ export default function Diagnostic({onFinish}: Props) {
</div>
</div>
</div>
<div className="flex flex-col items-center justify-center gap-8 w-full">
<h2 className="font-semibold text-xl">What is your current IELTS level?</h2>
<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 className="flex flex-col items-center justify-center gap-8 w-full mb-44">
<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>
<div className="self-end flex justify-between w-full gap-8 absolute bottom-8 left-0 px-8">
<Button
onClick={() => updateUser(selectExam)}
color="purple"
variant="outline"
className="group flex items-center justify-center gap-6 relative 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}>
<BsQuestionSquare
className="text-mti-purple-light group-hover:text-white transition duration-300 ease-in-out"
@@ -124,10 +217,12 @@ export default function Diagnostic({onFinish}: Props) {
/>
<span onClick={() => updateUser(selectExam)}>Perform diagnostic test instead</span>
</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
</Button>
</div>
</div>
</div>
</div>
);
}

View File

@@ -1,104 +0,0 @@
import React, { useState, ReactNode, useRef, useEffect } from 'react';
import { animated, useSpring } from '@react-spring/web';
interface DropdownProps {
title?: ReactNode;
open?: boolean;
setIsOpen?: React.Dispatch<React.SetStateAction<boolean>> | ((isOpen: boolean) => void);
className?: string;
contentWrapperClassName?: string;
titleClassName?: string;
bottomPadding?: number;
disabled?: boolean,
wrapperClassName?: string;
customTitle?: ReactNode;
children: ReactNode;
}
const Dropdown: React.FC<DropdownProps> = ({
title,
open = false,
titleClassName = "",
setIsOpen: externalSetIsOpen,
className = "w-full text-left font-semibold flex justify-between items-center p-4",
contentWrapperClassName = "px-6",
bottomPadding = 12,
disabled = false,
customTitle = undefined,
wrapperClassName,
children
}) => {
const [internalIsOpen, setInternalIsOpen] = useState<boolean>(open);
const isOpen = externalSetIsOpen !== undefined ? open : internalIsOpen;
const toggleOpen = externalSetIsOpen !== undefined ? externalSetIsOpen : setInternalIsOpen;
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 (
<div className={wrapperClassName}>
<button
onClick={() => toggleOpen(!isOpen)}
className={className}
disabled={disabled}
>
<div className='flex flex-row w-full justify-between items-center'>
{customTitle ? (
customTitle
) : (
<p className={titleClassName}>{title}</p>
)}
<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>
</div>
</button>
<animated.div style={springProps} className="overflow-hidden">
<div ref={contentRef} className={contentWrapperClassName} style={{ paddingBottom: bottomPadding }}>
{children}
</div>
</animated.div>
</div>
);
};
export default Dropdown;

View File

@@ -1,307 +0,0 @@
import React, { useEffect, useState } from 'react';
import { Tooltip } from 'react-tooltip';
import { ExerciseGen } from './generatedExercises';
import Image from 'next/image';
import clsx from 'clsx';
import { GiBrain } from 'react-icons/gi';
import { IoTextOutline } from 'react-icons/io5';
import { Switch } from '@headlessui/react';
import useExamEditorStore from '@/stores/examEditor';
import { Module } from '@/interfaces';
import { capitalize } from 'lodash';
import Select from '@/components/Low/Select';
import { Difficulty } from '@/interfaces/exam';
interface Props {
module: Module;
sectionId: number;
exercises: ExerciseGen[];
extraArgs?: Record<string, any>;
onSubmit: (configurations: ExerciseConfig[]) => void;
onDiscard: () => void;
selectedExercises: string[];
}
export interface ExerciseConfig {
type: string;
params: {
[key: string]: string | number | boolean;
};
}
const ExerciseWizard: React.FC<Props> = ({
module,
exercises,
extraArgs,
sectionId,
selectedExercises,
onSubmit,
onDiscard,
}) => {
const [configurations, setConfigurations] = useState<ExerciseConfig[]>([]);
const { currentModule } = useExamEditorStore();
const { difficulty } = useExamEditorStore(state => state.modules[currentModule]);
const randomDiff = difficulty.length === 1
? capitalize(difficulty[0])
: difficulty.length == 0 ?
"Random" :
`Selected (${difficulty.sort().map(dif => capitalize(dif)).join(", ")})` as Difficulty;
const DIFFICULTIES = difficulty.length === 1
? ["A1", "A2", "B1", "B2", "C1", "C2", "Random"]
: ["A1", "A2", "B1", "B2", "C1", "C2", randomDiff, "Random"];
useEffect(() => {
const initialConfigs = selectedExercises.map(exerciseType => {
const exercise = exercises.find(ex => {
const fullType = ex.extra?.find(e => e.param === 'name')?.value
? `${ex.type}/?name=${ex.extra.find(e => e.param === 'name')?.value}`
: ex.type;
return fullType === exerciseType;
});
const params: { [key: string]: string | number | boolean } = {};
exercise?.extra?.forEach(param => {
if (param.param !== 'name') {
if (exerciseType.includes('paragraphMatch') && param.param === 'quantity') {
params[param.param] = extraArgs?.text.split("\n\n").length || 1;
} else {
params[param.param || ''] = param.value ?? '';
}
}
});
return {
type: exerciseType,
params
};
});
setConfigurations(initialConfigs);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedExercises, exercises]);
const handleParameterChange = (
exerciseIndex: number,
paramName: string,
value: string | number | boolean
) => {
setConfigurations(prev => {
const newConfigs = [...prev];
newConfigs[exerciseIndex] = {
...newConfigs[exerciseIndex],
params: {
...newConfigs[exerciseIndex].params,
[paramName]: value
}
};
return newConfigs;
});
};
const renderParameterInput = (
param: NonNullable<ExerciseGen['extra']>[0],
exerciseIndex: number,
config: ExerciseConfig
) => {
if (typeof param.value === 'boolean') {
const currentValue = Boolean(config.params[param.param || '']);
return (
<div className="flex flex-row items-center ml-auto">
<GiBrain
className="mx-4"
size={28}
color={currentValue ? `#F3F4F6` : `#1F2937`}
/>
<Switch
checked={currentValue}
onChange={(value) => handleParameterChange(
exerciseIndex,
param.param || '',
value
)}
className={clsx(
"relative inline-flex h-[30px] w-[58px] shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus-visible:ring-2 focus-visible:ring-white/75",
currentValue ? `bg-[#F3F4F6]` : `bg-[#1F2937]`
)}
>
<span
aria-hidden="true"
className={clsx(
"pointer-events-none inline-block h-[26px] w-[26px] transform rounded-full bg-white shadow-lg ring-0 transition duration-200 ease-in-out",
currentValue ? 'translate-x-7' : 'translate-x-0'
)}
/>
</Switch>
<IoTextOutline
className="mx-4"
size={28}
color={!currentValue ? `#F3F4F6` : `#1F2937`}
/>
<Tooltip id={`${exerciseIndex}`} className="z-50 bg-white shadow-md rounded-sm" />
<a data-tooltip-id={`${exerciseIndex}`} data-tooltip-html="Generate or use placeholder?" className='ml-1 flex items-center justify-center'>
<Image src="/mat-icon-info.svg" width={24} height={24} alt={"AI Generated?"} />
</a>
</div>
);
}
if ('type' in param && param.type === 'text') {
return (
<div className="space-y-2">
<div className="flex items-center gap-2">
<label className="text-sm font-medium text-white">
{param.label}
</label>
{param.tooltip && (
<>
<Tooltip id={config.type} className="z-50 bg-white shadow-md rounded-sm" />
<a data-tooltip-id={config.type} data-tooltip-html={param.tooltip} className='ml-1 flex items-center justify-center'>
<Image src="/mat-icon-info.svg" width={24} height={24} alt={param.tooltip} />
</a>
</>
)}
</div>
<input
type="text"
value={config.params[param.param || ''] as string}
onChange={(e) => handleParameterChange(
exerciseIndex,
param.param || '',
e.target.value
)}
className="px-3 py-2 shadow-lg rounded-md text-mti-gray-dim w-full"
placeholder="Enter here..."
/>
</div>
);
}
const inputValue = Number(config.params[param.param || '1'].toString()) || config.params[param.param!];
const isParagraphMatch = config.type.split("?name=")[1] === "paragraphMatch";
const maxParagraphs = isParagraphMatch ? extraArgs!.text.split("\n\n").length : 50;
return (
<div className="space-y-2">
<div className="flex items-center gap-2">
<label className="text-sm font-medium text-white">
{`${param.label}${isParagraphMatch ? ` (out of ${extraArgs!.text.split("\n\n").length} paragraphs)` : ""}`}
</label>
{param.tooltip && (
<>
<Tooltip id={config.type} className="z-50 bg-white shadow-md rounded-sm" />
<a data-tooltip-id={config.type} data-tooltip-html={param.tooltip} className='ml-1 flex items-center justify-center'>
<Image src="/mat-icon-info.svg" width={24} height={24} alt={param.tooltip} />
</a>
</>
)}
</div>
{param.param === "difficulty" ?
<Select
options={DIFFICULTIES.map((x) => ({ value: x, label: x }))}
onChange={(value) => {
handleParameterChange(
exerciseIndex,
param.param || '',
value?.value || ''
);
}}
value={{ value: config.params[param.param] !== "" ? config.params[param.param] as string : randomDiff , label: config.params[param.param] !== "" ? config.params[param.param] as string : randomDiff }}
flat
/>
:
<input
type="number"
value={inputValue as number}
onChange={(e) => handleParameterChange(
exerciseIndex,
param.param || '',
e.target.value ? Number(e.target.value) : ''
)}
className="px-3 py-2 shadow-lg rounded-md text-mti-gray-dim w-full"
min={1}
max={maxParagraphs}
/>
}
</div>
);
};
const renderExerciseHeader = (
exercise: ExerciseGen,
exerciseIndex: number,
config: ExerciseConfig,
extraParams: boolean,
) => {
const generateParam = exercise.extra?.find(param => param.param === 'generate');
return (
<div className={clsx("flex items-center w-full", extraParams ? "mb-4" : "py-4")}>
<div className="flex items-center gap-2">
<exercise.icon className="h-5 w-5" />
<h3 className="font-medium text-lg">{exercise.label}</h3>
</div>
{/* when placeholders are done uncomment this*/}
{/*generateParam && renderParameterInput(generateParam, exerciseIndex, config)*/}
</div>
);
};
return (
<div className="space-y-6 px-4 py-6">
{configurations.map((config, exerciseIndex) => {
const exercise = exercises.find(ex => {
const fullType = ex.extra?.find(e => e.param === 'name')?.value
? `${ex.type}/?name=${ex.extra.find(e => e.param === 'name')?.value}`
: ex.type;
return fullType === config.type;
});
if (!exercise) return null;
const nonGenerateParams = exercise.extra?.filter(
param => param.param !== 'name' && param.param !== 'generate'
);
return (
<div
key={config.type}
className={`bg-ielts-${module}/70 text-white rounded-lg p-4 shadow-xl`}
>
{renderExerciseHeader(exercise, exerciseIndex, config, (exercise.extra || []).length > 2)}
{nonGenerateParams && nonGenerateParams.length > 0 && (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{nonGenerateParams.map(param => (
<div key={param.param}>
{renderParameterInput(param, exerciseIndex, config)}
</div>
))}
</div>
)}
</div>
);
})}
<div className="flex justify-between">
<button
onClick={onDiscard}
className={`px-4 py-2 bg-red-500 text-white rounded-md hover:bg-red-400 transition-colors`}
>
Cancel
</button>
<button
onClick={() => onSubmit(configurations)}
className={`px-4 py-2 bg-ielts-${module} text-white rounded-md hover:bg-ielts-${module}/80 transition-colors`}
>
Add Exercises
</button>
</div>
</div>
);
};
export default ExerciseWizard;

View File

@@ -1,471 +0,0 @@
import {
FaListUl,
FaUnderline,
FaPen,
FaBookOpen,
FaEnvelope,
FaComments,
FaHandshake,
FaParagraph,
FaLightbulb,
FaHeadphones,
FaWpforms,
} from 'react-icons/fa6';
import {
FaEdit,
FaFileAlt,
FaUserFriends,
FaCheckSquare,
FaQuestionCircle,
} from 'react-icons/fa';
import { ExerciseGen } from './generatedExercises';
import { BsListCheck } from 'react-icons/bs';
const quantity = (quantity: number, tooltip?: string) => {
return {
param: "quantity",
label: "Quantity",
tooltip: tooltip ? tooltip : "Exercise Quantity",
value: quantity
}
}
const difficulty = () => {
return {
param: "difficulty",
label: "Difficulty",
tooltip: "Exercise difficulty",
}
}
const generate = () => {
return {
param: "generate",
value: true
}
}
const reading = (passage: number) => {
const readingExercises = [
{
label: `Passage ${passage} - Multiple Choice`,
type: `reading_${passage}`,
icon: BsListCheck,
sectionId: passage,
extra: [
{
param: "name",
value: "multipleChoice"
},
quantity(5, "Quantity of Multiple Choice Questions"),
difficulty(),
generate()
],
module: "reading"
},
{
label: `Passage ${passage} - Fill Blanks`,
type: `reading_${passage}`,
icon: FaEdit,
sectionId: passage,
extra: [
{
param: "name",
value: "fillBlanks"
},
{
param: "num_random_words",
label: "Random Words",
tooltip: "Words that are not the solution",
value: 1
},
quantity(4, "Quantity of Blanks"),
difficulty(),
generate()
],
module: "reading"
},
{
label: `Passage ${passage} - Write Blanks`,
type: `reading_${passage}`,
icon: FaPen,
sectionId: passage,
extra: [
{
param: "name",
value: "writeBlanks"
},
{
param: "max_words",
label: "Word Limit",
tooltip: "How many words a solution can have",
value: 3
},
quantity(4, "Quantity of Blanks"),
difficulty(),
generate()
],
module: "reading"
},
{
label: `Passage ${passage} - True False`,
type: `reading_${passage}`,
icon: FaCheckSquare,
sectionId: passage,
extra: [
{
param: "name",
value: "trueFalse"
},
quantity(4, "Quantity of Statements"),
difficulty(),
generate()
],
module: "reading"
},
{
label: `Passage ${passage} - Paragraph Match`,
type: `reading_${passage}`,
icon: FaParagraph,
sectionId: passage,
extra: [
{
param: "name",
value: "paragraphMatch"
},
quantity(5, "Quantity of Matches"),
difficulty(),
generate()
],
module: "reading"
},
];
if (passage === 3) {
readingExercises.push(
{
label: `Passage 3 - Idea Match`,
type: `reading_3`,
icon: FaLightbulb,
sectionId: passage,
extra: [
{
param: "name",
value: "ideaMatch"
},
quantity(5, "Quantity of Ideas"),
difficulty(),
generate()
],
module: "reading"
},
);
}
return readingExercises;
}
const listening = (section: number) => {
const listeningExercises = [
{
label: `Section ${section} - Multiple Choice`,
type: `listening_${section}`,
icon: FaHeadphones,
sectionId: section,
extra: [
{
param: "name",
value: section == 3 ? "multipleChoice3Options" : "multipleChoice"
},
quantity(5, "Quantity of Multiple Choice Questions"),
difficulty(),
generate()
],
module: "listening"
},
{
label: `Section ${section} - Write Blanks: Questions`,
type: `listening_${section}`,
icon: FaQuestionCircle,
sectionId: section,
extra: [
{
param: "name",
value: "writeBlanksQuestions"
},
quantity(5, "Quantity of Blanks"),
difficulty(),
generate()
],
module: "listening"
},
{
label: `Section ${section} - True False`,
type: `listening_${section}`,
icon: FaCheckSquare,
sectionId: section,
extra: [
{
param: "name",
value: "trueFalse"
},
quantity(4, "Quantity of Statements"),
difficulty(),
generate()
],
module: "listening"
},
];
if (section === 1 || section === 4) {
listeningExercises.push(
{
label: `Section ${section} - Write Blanks: Fill`,
type: `listening_${section}`,
icon: FaEdit,
sectionId: section,
extra: [
{
param: "name",
value: "writeBlanksFill"
},
quantity(5, "Quantity of Blanks"),
difficulty(),
generate()
],
module: "listening"
}
);
listeningExercises.push(
{
label: `Section ${section} - Write Blanks: Form`,
type: `listening_${section}`,
sectionId: section,
icon: FaWpforms,
extra: [
{
param: "name",
value: "writeBlanksForm"
},
quantity(5, "Quantity of Blanks"),
difficulty(),
generate()
],
module: "listening"
}
);
}
return listeningExercises;
}
const EXERCISES: ExerciseGen[] = [
/*{
label: "Multiple Choice",
type: "multipleChoice",
icon: FaListUl,
extra: [
{
param: "name",
value: "multipleChoice"
},
quantity(10, "Amount"),
difficulty(),
generate()
],
module: "level"
},*/
{
label: "Multiple Choice: Blank Space",
type: "mcBlank",
icon: FaEdit,
extra: [
{
param: "name",
value: "mcBlank"
},
quantity(10, "Amount"),
difficulty(),
generate()
],
module: "level"
},
{
label: "Multiple Choice: Underlined",
type: "mcUnderline",
icon: FaUnderline,
extra: [
{
param: "name",
value: "mcUnderline"
},
quantity(10, "Amount"),
difficulty(),
generate()
],
module: "level"
},
/*{
label: "Blank Space", <- Assuming this is FillBlanks aswell
type: "blankSpaceText",
icon: FaPen,
extra: [
quantity(10, "Nº of Blanks"),
{
label: "Passage Word Size",
param: "text_size",
value: "250"
},
difficulty(),
generate()
],
module: "level"
},*/
{
label: "Fill Blanks: Multiple Choice",
type: "fillBlanksMC",
icon: FaPen,
extra: [
{
param: "name",
value: "fillBlanksMC"
},
quantity(10, "Nº of Blanks"),
{
label: "Passage Word Size",
param: "text_size",
value: "250"
},
difficulty(),
generate()
],
module: "level"
},
// Removing this since level supports reading aswell
/*{
label: "Reading Passage: Multiple Choice",
type: "passageUtas",
icon: FaBookOpen,
extra: [
{
param: "name",
value: "passageUtas"
},
// in the utas exam there was only mc so I'm assuming short answers are deprecated
//{
// label: "Short Answers",
// param: "sa_qty",
// value: "10"
//},
quantity(10, "Multiple Choice Quantity"),
{
label: "Reading Passage Topic",
param: "topic",
value: "",
type: "text"
},
{
label: "Passage Word Size",
param: "text_size",
value: "700"
},
difficulty(),
generate()
],
module: "level"
},*/
{
label: "Task 1 - Letter",
type: "writing_letter",
icon: FaEnvelope,
extra: [
{
label: "Letter Topic",
param: "topic",
value: "",
type: "text"
},
difficulty(),
generate()
],
module: "writing"
},
{
label: "Task 2 - Essay",
type: "writing_2",
icon: FaFileAlt,
extra: [
{
label: "Essay Topic",
param: "topic",
value: "",
type: "text"
},
difficulty(),
generate()
],
module: "writing"
},
{
label: "Exercise 1",
type: "speaking_1",
icon: FaComments,
extra: [
difficulty(),
generate(),
{
label: "First Topic",
param: "first_topic",
value: "",
type: "text"
},
{
label: "Second Topic",
param: "second_topic",
value: "",
type: "text"
},
],
module: "speaking"
},
{
label: "Exercise 2",
type: "speaking_2",
icon: FaUserFriends,
extra: [
difficulty(),
generate(),
{
label: "Topic",
param: "topic",
value: "",
type: "text"
},
],
module: "speaking"
},
{
label: "Interactive",
type: "speaking_3",
icon: FaHandshake,
extra: [
difficulty(),
generate(),
{
label: "Topic",
param: "topic",
value: "",
type: "text"
},
],
module: "speaking"
},
...reading(1),
...reading(2),
...reading(3),
...listening(1),
...listening(2),
...listening(3),
...listening(4),
]
export default EXERCISES;

View File

@@ -1,22 +0,0 @@
import { IconType } from "react-icons";
export interface GeneratedExercises {
exercises: Record<string, string>[];
sectionId: number;
module: string;
}
export interface GeneratorState {
loading: boolean;
sectionId: number;
}
export interface ExerciseGen {
label: string;
type: string;
icon: IconType;
sectionId?: number;
extra?: { param: string; value?: string | number | boolean; label?: string; tooltip?: string, type?: string}[];
module: string
}

View File

@@ -1,275 +0,0 @@
import EXERCISES from "./exercises";
import clsx from "clsx";
import { ExerciseGen, GeneratedExercises, GeneratorState } from "./generatedExercises";
import Modal from "@/components/Modal";
import { useCallback, useState } from "react";
import ExerciseWizard, { ExerciseConfig } from "./ExerciseWizard";
import { generate } from "../SettingsEditor/Shared/Generate";
import { Module } from "@/interfaces";
import useExamEditorStore from "@/stores/examEditor";
import { LevelPart, ListeningPart, Message, ReadingPart } from "@/interfaces/exam";
import { BsArrowRepeat } from "react-icons/bs";
interface ExercisePickerProps {
module: string;
sectionId: number;
extraArgs?: Record<string, any>;
levelSectionId?: number;
level?: boolean;
}
const DIFFICULTIES: string[] = ["A1", "A2", "B1", "B2", "C1", "C2"];
const ExercisePicker: React.FC<ExercisePickerProps> = ({
module,
sectionId,
extraArgs = undefined,
levelSectionId,
level = false
}) => {
const { currentModule } = useExamEditorStore();
const { difficulty, sections } = useExamEditorStore((store) => store.modules[level ? "level" : currentModule]);
const section = sections.find((s) => s.sectionId === (level ? levelSectionId : sectionId));
const [pickerOpen, setPickerOpen] = useState(false);
const [localSelectedExercises, setLocalSelectedExercises] = useState<string[]>([]);
const state = section?.state;
const getFullExerciseType = (exercise: ExerciseGen): string => {
if (exercise.extra && exercise.extra.length > 0) {
const extraValue = exercise.extra.find(e => e.param === 'name')?.value;
return extraValue ? `${exercise.type}/?name=${extraValue}` : exercise.type;
}
return exercise.type;
};
const handleChange = (exercise: ExerciseGen) => {
const fullType = getFullExerciseType(exercise);
setLocalSelectedExercises(prev => {
const newSelected = prev.includes(fullType)
? prev.filter(type => type !== fullType)
: [...prev, fullType];
return newSelected;
});
};
const moduleExercises = (sectionId && !["level", "writing", "speaking"].includes(module) ? EXERCISES.filter((ex) => ex.module === module && ex.sectionId == sectionId) : EXERCISES.filter((ex) => ex.module === module));
const onModuleSpecific = useCallback((configurations: ExerciseConfig[]) => {
const exercises = configurations.map(config => {
const exerciseType = config.type.split('name=')[1];
return {
type: exerciseType,
quantity: Number(config.params.quantity || 1),
...(config.params.num_random_words !== undefined && {
num_random_words: Number(config.params.num_random_words)
}),
...(config.params.max_words !== undefined && {
max_words: Number(config.params.max_words)
}),
...((DIFFICULTIES.includes(config.params.difficulty as string) || config.params.difficulty === "Random") && {
difficulty: config.params.difficulty
})
};
});
let context = {};
if (module === 'reading') {
const readingState = state as ReadingPart | LevelPart;
context = {
text: readingState.text!.content
};
} else if (module === 'listening') {
const listeningState = state as ListeningPart | LevelPart;
const script = listeningState.script;
if (sectionId === 1 || sectionId === 3) {
const dialog = script as Message[];
context = {
text: dialog.map((d) => `${d.name}: ${d.text}`).join("\n")
};
} else if (sectionId === 2 || sectionId === 4) {
context = {
text: script as string
};
}
}
if (!["speaking", "writing"].includes(module)) {
generate(
sectionId,
module as Module,
level ? `exercises-${module}` : "exercises",
{
method: 'POST',
body: {
...context,
exercises,
difficulty
}
},
(data: any) => [{
exercises: data.exercises
}],
levelSectionId,
level
);
} else if (module === "writing") {
configurations.forEach((config) => {
let queryParams = {
difficulty: config.params.difficulty ? config.params.difficulty as string: difficulty,
...(config.params.topic !== '' && { topic: config.params.topic as string })
};
generate(
config.type === 'writing_letter' ? 1 : 2,
"writing",
config.type,
{
method: 'GET',
queryParams
},
(data: any) => [{
prompt: data.question,
difficulty: data.difficulty
}],
levelSectionId,
level
);
});
} else {
configurations.forEach((config) => {
let queryParams = Object.fromEntries(
Object.entries({
topic: config.params.topic as string,
first_topic: config.params.first_topic as string,
second_topic: config.params.second_topic as string,
difficulty: config.params.difficulty ? config.params.difficulty as string: difficulty,
}).filter(([_, value]) => value && value !== '')
);
let query = Object.keys(queryParams).length === 0 ? undefined : queryParams;
generate(
Number(config.type.split('_')[1]),
"speaking",
config.type,
{
method: 'GET',
queryParams: query
},
(data: any) => {
switch (Number(config.type.split('_')[1])) {
case 1:
return [{
prompts: data.questions,
first_topic: data.first_topic,
second_topic: data.second_topic,
difficulty: data.difficulty
}];
case 2:
return [{
topic: data.topic,
question: data.question,
prompts: data.prompts,
suffix: data.suffix,
difficulty: data.difficulty
}];
case 3:
return [{
topic: data.topic,
questions: data.questions,
difficulty: data.difficulty
}];
default:
return [data];
}
},
levelSectionId,
level
);
});
}
setLocalSelectedExercises([]);
setPickerOpen(false);
}, [
sectionId,
levelSectionId,
level,
module,
state,
difficulty,
setPickerOpen
]);
if (section === undefined) return <></>;
return (
<>
<Modal isOpen={pickerOpen} onClose={() => setPickerOpen(false)} title="Exercise Wizard"
titleClassName={clsx(
"text-2xl font-semibold text-center py-4",
`bg-ielts-${module} text-white`,
"shadow-sm",
"-mx-6 -mt-6",
"mb-6"
)}
>
<ExerciseWizard
module={module as Module}
selectedExercises={localSelectedExercises}
sectionId={sectionId}
exercises={moduleExercises}
onSubmit={onModuleSpecific}
onDiscard={() => setPickerOpen(false)}
extraArgs={extraArgs}
/>
</Modal>
<div className="flex flex-col gap-4 px-4" key={sectionId}>
<div className="space-y-2">
{moduleExercises.map((exercise) => {
const fullType = getFullExerciseType(exercise);
return (
<label
key={fullType}
className={`flex items-center space-x-3 text-white font-semibold cursor-pointer p-2 hover:bg-ielts-${exercise.module}/70 rounded bg-ielts-${exercise.module}/90`}
>
<input
type="checkbox"
name="exercise"
value={fullType}
checked={localSelectedExercises.includes(fullType)}
onChange={() => handleChange(exercise)}
className="h-5 w-5"
/>
<div className="flex items-center space-x-2">
<exercise.icon className="h-5 w-5 text-white" />
<span>{exercise.label}</span>
</div>
</label>
);
})}
</div>
<div className="flex flex-row justify-center">
<button
className={
clsx("flex items-center justify-center px-4 py-2 text-white rounded-xl transition-colors duration-300 disabled:cursor-not-allowed",
`bg-ielts-${module}/70 border border-ielts-${module} hover:bg-ielts-${module} disabled:bg-ielts-${module}/40 `,
)
}
onClick={() => setPickerOpen(true)}
disabled={localSelectedExercises.length === 0}
>
{section.generating === "exercises" ? (
<div key={`section-${sectionId}`} className="flex items-center justify-center">
<BsArrowRepeat className="text-white animate-spin" size={25} />
</div>
) : (
<>{["speaking", "writing"].includes(module) ? "Add Exercises" : "Set Up Exercises"} ({localSelectedExercises.length}) </>
)}
</button>
</div>
</div>
</>
);
};
export default ExercisePicker;

View File

@@ -1,247 +0,0 @@
import { toast } from "react-toastify";
export type TextToken = {
type: 'text';
content: string;
isWhitespace: boolean;
isLineBreak?: boolean;
};
export type BlankToken = {
type: 'blank';
id: number;
};
type Token = TextToken | BlankToken;
export type BlankState = {
id: number;
position: number;
};
export const getTextSegments = (text: string): Token[] => {
const tokens: Token[] = [];
let lastIndex = 0;
const regex = /{{(\d+)}}/g;
let match;
const addTextTokens = (text: string) => {
// Split by newlines first
const lines = text.replaceAll("\\n",'\n').split(/(\n)/);
lines.forEach((line, i) => {
if (line === '\n') {
tokens.push({
type: 'text',
content: '<br>',
isWhitespace: false,
isLineBreak: true
});
return;
}
const normalizedText = line.replace(/\s+/g, ' ');
if (normalizedText) {
const parts = normalizedText.split(/(\s)/);
parts.forEach(part => {
if (part) {
tokens.push({
type: 'text',
content: part,
isWhitespace: /^\s+$/.test(part)
});
}
});
}
});
};
while ((match = regex.exec(text)) !== null) {
if (match.index > lastIndex) {
addTextTokens(text.slice(lastIndex, match.index));
}
tokens.push({
type: 'blank',
id: parseInt(match[1])
});
lastIndex = regex.lastIndex;
}
if (lastIndex < text.length) {
addTextTokens(text.slice(lastIndex));
}
return tokens;
}
export const reconstructTextFromTokens = (tokens: Token[]): string => {
return tokens.map(token => {
if (token.type === 'blank') {
return `{{${token.id}}}`;
}
if (token.type === 'text' && token.isLineBreak) {
return '\n';
}
return token.content;
}).join('');
}
export type BlanksState = {
text: string;
blanks: BlankState[];
selectedBlankId: number | null;
draggedItemId: string | null;
textMode: boolean;
setEditing: React.Dispatch<React.SetStateAction<boolean>>;
};
export type BlanksAction =
| { type: "SET_TEXT"; payload: string }
| { type: "SET_BLANKS"; payload: BlankState[] }
| { type: "ADD_BLANK" }
| { type: "REMOVE_BLANK"; payload: number }
| { type: "SELECT_BLANK"; payload: number | null }
| { type: "SET_DRAGGED_ITEM"; payload: string | null }
| { type: "MOVE_BLANK"; payload: { blankId: number; newPosition: number } }
| { type: "TOGGLE_EDIT_MODE" }
| { type: "RESET", payload: { text: string } };
export const blanksReducer = (state: BlanksState, action: BlanksAction): BlanksState => {
switch (action.type) {
case "SET_TEXT": {
return {
...state,
text: action.payload,
};
}
case "SET_BLANKS": {
return {
...state,
blanks: action.payload,
};
}
case "ADD_BLANK":
state.setEditing(true);
const newBlankId = Math.max(...state.blanks.map(b => b.id), 0) + 1;
const newBlanks = [
...state.blanks,
{ id: newBlankId, position: state.blanks.length }
];
const newText = state.text + ` {{${newBlankId}}}`;
return {
...state,
blanks: newBlanks,
text: newText
};
case "REMOVE_BLANK": {
if (state.blanks.length === 1) {
toast.error("There needs to be at least 1 blank!");
break;
}
state.setEditing(true);
const blanksToKeep = state.blanks.filter(b => b.id !== action.payload);
const updatedBlanks = blanksToKeep.map((blank, index) => ({
...blank,
position: index
}));
const tokens = getTextSegments(state.text).filter(
token => !(token.type === 'blank' && token.id === action.payload)
);
const newText = reconstructTextFromTokens(tokens);
return {
...state,
blanks: updatedBlanks,
text: newText,
selectedBlankId: state.selectedBlankId === action.payload ? null : state.selectedBlankId
};
}
case "MOVE_BLANK": {
state.setEditing(true);
const { blankId, newPosition } = action.payload;
const tokens = getTextSegments(state.text);
// Find the current position of the blank
const currentPosition = tokens.findIndex(
token => token.type === 'blank' && token.id === blankId
);
if (currentPosition === -1) return state;
// Remove the blank and its surrounding whitespace
const blankToken = tokens[currentPosition];
tokens.splice(currentPosition, 1);
// When inserting at new position, ensure there's whitespace around the blank
let insertPosition = newPosition;
const prevToken = tokens[insertPosition - 1];
const nextToken = tokens[insertPosition];
// Insert space before if needed
if (!prevToken || (prevToken.type === 'text' && !prevToken.isWhitespace)) {
tokens.splice(insertPosition, 0, {
type: 'text',
content: ' ',
isWhitespace: true
});
insertPosition++;
}
// Insert the blank
tokens.splice(insertPosition, 0, blankToken);
insertPosition++;
// Insert space after if needed
if (!nextToken || (nextToken.type === 'text' && !nextToken.isWhitespace)) {
tokens.splice(insertPosition, 0, {
type: 'text',
content: ' ',
isWhitespace: true
});
}
// Reconstruct the text
const newText = reconstructTextFromTokens(tokens);
// Update blank positions
const updatedBlanks = tokens.reduce((acc, token, idx) => {
if (token.type === 'blank') {
acc.push({ id: token.id, position: idx });
}
return acc;
}, [] as BlankState[]);
return {
...state,
text: newText,
blanks: updatedBlanks
};
}
case "SELECT_BLANK":
return { ...state, selectedBlankId: action.payload };
case "SET_DRAGGED_ITEM":
state.setEditing(true);
return { ...state, draggedItemId: action.payload };
case "TOGGLE_EDIT_MODE":
return { ...state, textMode: !state.textMode };
case "RESET":
return {
text: action.payload.text || "",
blanks: [],
selectedBlankId: null,
draggedItemId: null,
textMode: false,
setEditing: state.setEditing
};
}
return state;
};

View File

@@ -1,129 +0,0 @@
import { useDraggable, useDroppable } from "@dnd-kit/core";
import clsx from "clsx";
import { MdClose, MdDelete, MdDragIndicator } from "react-icons/md";
import { CSS } from "@dnd-kit/utilities";
import { useEffect, useState } from "react";
import ConfirmDeleteBtn from "../../Shared/ConfirmDeleteBtn";
interface BlankProps {
id: number;
module: string;
variant: "text" | "bank";
isSelected?: boolean;
isDragging?: boolean;
onSelect?: (id: number) => void;
onRemove?: (id: number) => void;
disabled?: boolean;
}
export const Blank: React.FC<BlankProps> = ({
id,
module,
variant,
isSelected,
isDragging,
onSelect,
onRemove,
disabled,
}) => {
const { attributes, listeners, setNodeRef, transform } = useDraggable({
id: `${variant}-blank-${id}`,
disabled: disabled || variant !== "text",
});
const style = transform ? {
transform: CSS.Translate.toString(transform),
transition: 'none',
zIndex: 999,
position: 'relative' as const,
touchAction: 'none',
} : {
transition: 'transform 0.2s cubic-bezier(0.25, 1, 0.5, 1)',
touchAction: 'none',
position: 'relative' as const,
};
const handleClick = (e: React.MouseEvent) => {
if (variant === "bank" && !disabled && onSelect) {
onSelect(id);
}
};
const dragProps = variant === "text" ? {
...attributes,
...listeners,
} : {};
return (
<div
ref={setNodeRef}
style={style}
className={clsx(
"group relative inline-flex items-center gap-2 px-2 py-1.5 rounded-lg select-none",
"transform-gpu transition-colors duration-150",
"hover:ring-2 hover:ring-offset-1 shadow-sm",
(
isSelected ? (
isDragging ?
`bg-ielts-${module}/20 bg-ielts-${module} hover:ring-ielts-${module}/50` :
`bg-ielts-${module}/20 bg-ielts-${module}/80 hover:ring-ielts-${module}/40`
)
: `bg-ielts-${module}/20 bg-ielts-${module} hover:ring-ielts-${module}/50`
),
!disabled && (variant === "text" ? "cursor-grab active:cursor-grabbing" : "cursor-pointer"),
disabled && "cursor-default",
variant === "bank" && "w-12"
)}
onClick={variant === "bank" ? handleClick : undefined}
{...dragProps}
role="button"
>
{variant === "text" && (
<span
className={clsx(
"text-xl p-1.5 -ml-1 rounded-md",
"transition-colors duration-150"
)}
title="Drag to reorder"
>
{isSelected ?
<MdDragIndicator className="transform scale-125" color="white" /> :
<MdDragIndicator className="transform scale-125" color="#898492" />
}
</span>
)}
<span className={clsx(
"font-semibold px-1 text-mti-gray-taupe",
isSelected && !isDragging && "text-white"
)}>
{id}
</span>
{onRemove && !isDragging && (
<ConfirmDeleteBtn
onDelete={() => onRemove(id)}
size="md"
position="top-right"
className="-translate-y-2 translate-x-1.5"
/>
)}
</div>
);
};
export const DropZone: React.FC<{ index: number, module: string; }> = ({ index, module }) => {
const { setNodeRef, isOver } = useDroppable({
id: `drop-${index}`,
});
return (
<span
ref={setNodeRef}
className={clsx(
"inline-block h-6 w-4 mx-px transition-all duration-200 select-none",
isOver ? `bg-ielts-${module}/20 w-4.5` : `bg-transparent hover:bg-ielts-${module}/20`
)}
role="presentation"
/>
);
};

View File

@@ -1,58 +0,0 @@
import { MdDelete } from "react-icons/md";
interface Props {
letter: string;
word: string;
isSelected: boolean;
onClick: () => void;
onRemove?: () => void;
onEdit?: (newWord: string) => void;
isEditMode?: boolean;
}
const FillBlanksWord: React.FC<Props> = ({
letter,
word,
isSelected,
onClick,
onRemove,
onEdit,
isEditMode
}) => {
return (
<div className="w-full flex items-center gap-2">
{isEditMode ? (
<div className="min-w-0 flex-1 flex items-center gap-2 p-2 rounded-md border border-gray-200">
<span className="font-medium min-w-[24px] text-center shrink-0">{letter}</span>
<input
type="text"
value={word}
onChange={(e) => onEdit?.(e.target.value)}
className="w-full min-w-0 focus:outline-none"
/>
</div>
) : (
<button
onClick={onClick}
className={`
min-w-0 flex-1 flex items-center gap-2 p-2 rounded-md border text-left transition-colors
${isSelected ? 'border-blue-500 bg-blue-100' : 'border-gray-200'}
`}
>
<span className="font-medium min-w-[24px] text-center shrink-0">{letter}</span>
<span className="truncate">{word}</span>
</button>
)}
{isEditMode && onRemove && (
<button
onClick={onRemove}
className="p-1 rounded text-red-500 hover:bg-gray-100 shrink-0"
aria-label="Remove word"
>
<MdDelete className="h-4 w-4" />
</button>
)}
</div>
);
};
export default FillBlanksWord;

View File

@@ -1,347 +0,0 @@
import { Difficulty, FillBlanksExercise, ReadingPart } from "@/interfaces/exam";
import { useCallback, useEffect, useReducer, useState } from "react";
import BlanksEditor from "..";
import { Card, CardContent } from "@/components/ui/card";
import { MdEdit, MdEditOff } from "react-icons/md";
import FillBlanksWord from "./FillBlanksWord";
import { FaPlus } from "react-icons/fa";
import useExamEditorStore from "@/stores/examEditor";
import { blanksReducer, BlankState, getTextSegments } from "../BlanksReducer";
import useSectionEdit from "@/components/ExamEditor/Hooks/useSectionEdit";
import { AlertItem } from "../../Shared/Alert";
import validateBlanks from "../validateBlanks";
import { toast } from "react-toastify";
import setEditingAlert from "../../Shared/setEditingAlert";
import PromptEdit from "../../Shared/PromptEdit";
interface Word {
letter: string;
word: string;
}
const FillBlanksLetters: React.FC<{ exercise: FillBlanksExercise; sectionId: number }> = ({ exercise, sectionId }) => {
const { currentModule, dispatch } = useExamEditorStore();
const difficulty = useExamEditorStore((state) => state.modules[currentModule].difficulty);
const { state } = useExamEditorStore(
(state) => state.modules[currentModule].sections.find((section) => section.sectionId === sectionId)!
);
const section = state as ReadingPart;
const [alerts, setAlerts] = useState<AlertItem[]>([]);
const [local, setLocal] = useState(exercise);
const [selectedBlankId, setSelectedBlankId] = useState<string | null>(null);
const [answers, setAnswers] = useState<Map<string, string>>(
new Map(exercise.solutions.map(({ id, solution }) => [id, solution]))
);
const [isEditMode, setIsEditMode] = useState(false);
const [newWord, setNewWord] = useState('');
const [editing, setEditing] = useState(false);
const updateLocal = (exercise: FillBlanksExercise) => {
setLocal(exercise);
setEditingAlert(true, setAlerts);
setEditing(true);
};
const [blanksState, blanksDispatcher] = useReducer(blanksReducer, {
text: exercise.text || "",
blanks: [],
selectedBlankId: null,
draggedItemId: null,
textMode: false,
setEditing,
});
const { handleSave, handleDiscard, handleDelete, handlePractice } = useSectionEdit({
sectionId,
editing,
setEditing,
onSave: () => {
if (!validateBlanks(blanksState.blanks, answers, alerts, setAlerts)) {
toast.error("Please fix the errors before saving!");
return;
}
setEditing(false);
setAlerts([]);
const updatedExercise = {
...local,
text: blanksState.text,
solutions: Array.from(answers.entries()).map(([id, solution]) => ({
id,
solution
}))
};
const newState = { ...section };
newState.exercises = newState.exercises.map((ex) =>
ex.id === exercise.id ? updatedExercise : ex
);
dispatch({ type: 'UPDATE_SECTION_STATE', payload: { sectionId, update: newState, module: currentModule } });
},
onDiscard: () => {
setSelectedBlankId(null);
setAnswers(new Map(exercise.solutions.map(({ id, solution }) => [id, solution])));
setIsEditMode(false);
setNewWord('');
setLocal(exercise);
blanksDispatcher({ type: "RESET", payload: { text: exercise.text } });
blanksDispatcher({ type: "SET_TEXT", payload: exercise.text || "" });
const tokens = getTextSegments(exercise.text || "");
const initialBlanks = tokens.reduce((acc, token, idx) => {
if (token.type === 'blank') {
acc.push({ id: token.id, position: idx });
}
return acc;
}, [] as BlankState[]);
blanksDispatcher({ type: "SET_BLANKS", payload: initialBlanks });
},
onDelete: () => {
const newSection = {
...section,
exercises: section.exercises.filter((ex) => ex.id !== local.id)
};
dispatch({ type: "UPDATE_SECTION_STATE", payload: { sectionId, update: newSection, module: currentModule } });
},
onPractice: () => {
const updatedExercise = {
...local,
isPractice: !local.isPractice,
};
const newState = { ...section };
newState.exercises = newState.exercises.map((ex) =>
ex.id === exercise.id ? updatedExercise : ex
);
setLocal((prev) => ({...prev, isPractice: !local.isPractice}))
dispatch({ type: 'UPDATE_SECTION_STATE', payload: { sectionId, update: newState, module: currentModule } });
}
});
useEffect(() => {
if (!editing) {
setLocal(exercise);
setAnswers(new Map(exercise.solutions.map(({ id, solution }) => [id, solution])));
}
}, [exercise, editing]);
const handleWordSelect = (word: string) => {
if (!selectedBlankId) return;
if (!editing) setEditing(true);
const newAnswers = new Map(answers);
newAnswers.set(selectedBlankId, word);
setAnswers(newAnswers);
setLocal(prev => ({
...prev,
solutions: Array.from(newAnswers.entries()).map(([id, solution]) => ({
id,
solution
}))
}));
};
const handleAddWord = () => {
const word = newWord.trim();
if (!word) return;
setLocal(prev => {
const nextLetter = String.fromCharCode(65 + prev.words.length);
return {
...prev,
words: [...prev.words, { letter: nextLetter, word }]
};
});
setNewWord('');
};
const handleRemoveWord = (index: number) => {
if (!editing) setEditing(true);
if (answers.size === 1) {
toast.error("There needs to be at least 1 word!");
return;
}
setLocal(prev => {
const newWords = prev.words.filter((_, i) => i !== index) as Word[];
const removedWord = prev.words[index] as Word;
const newAnswers = new Map(answers);
for (const [blankId, answer] of newAnswers.entries()) {
if (answer === removedWord.word) {
newAnswers.delete(blankId);
}
}
setAnswers(newAnswers);
return {
...prev,
words: newWords,
solutions: Array.from(newAnswers.entries()).map(([id, solution]) => ({
id,
solution
}))
};
});
};
const handleEditWord = (index: number, newWord: string) => {
if (!editing) setEditing(true);
setLocal(prev => {
const newWords = [...prev.words] as Word[];
const oldWord = newWords[index].word;
newWords[index] = { ...newWords[index], word: newWord };
const newAnswers = new Map(answers);
for (const [blankId, answer] of newAnswers.entries()) {
if (answer === oldWord) {
newAnswers.set(blankId, newWord);
}
}
setAnswers(newAnswers);
return {
...prev,
words: newWords,
solutions: Array.from(newAnswers.entries()).map(([id, solution]) => ({
id,
solution
}))
};
});
};
const handleBlankRemove = (blankId: number) => {
if (!editing) setEditing(true);
const newAnswers = new Map(answers);
newAnswers.delete(blankId.toString());
setAnswers(newAnswers);
setLocal(prev => ({
...prev,
solutions: Array.from(newAnswers.entries()).map(([id, solution]) => ({
id,
solution
}))
}));
blanksDispatcher({ type: "REMOVE_BLANK", payload: blankId });
};
useEffect(() => {
validateBlanks(blanksState.blanks, answers, alerts, setAlerts);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [answers, blanksState.blanks, blanksState.textMode])
useEffect(()=> {
setEditingAlert(editing, setAlerts);
}, [editing])
const saveDifficulty = useCallback((diff: Difficulty) => {
if (!difficulty.includes(diff)) {
dispatch({ type: 'UPDATE_MODULE', payload: { updates: { difficulty: [...difficulty, diff]} } });
}
const updatedExercise = { ...exercise, difficulty: diff };
const newState = { ...section };
newState.exercises = newState.exercises.map((ex) => ex.id === exercise.id ? updatedExercise : ex );
dispatch({ type: 'UPDATE_SECTION_STATE', payload: { sectionId, update: newState, module: currentModule } });
}, [currentModule, difficulty, dispatch, exercise, section, sectionId]);
return (
<div className="space-y-4">
<BlanksEditor
alerts={alerts}
editing={editing}
state={blanksState}
difficulty={exercise.difficulty}
saveDifficulty={saveDifficulty}
blanksDispatcher={blanksDispatcher}
description="Place blanks and assign words from the word bank"
initialText={local.text}
module={currentModule}
showBlankBank={true}
onBlankSelect={(blankId) => setSelectedBlankId(blankId?.toString() || null)}
onBlankRemove={handleBlankRemove}
onSave={handleSave}
onDiscard={handleDiscard}
onDelete={handleDelete}
setEditing={setEditing}
onPractice={handlePractice}
isEvaluationEnabled={!local.isPractice}
prompt={local.prompt}
updatePrompt={(prompt: string) => updateLocal({...local, prompt})}
>
<>
{!blanksState.textMode && <Card className="p-4">
<CardContent>
<div className="flex justify-between items-center mb-4">
<div className="text-lg font-semibold">Word Bank</div>
<button
onClick={() => setIsEditMode(!isEditMode)}
className="p-2 hover:bg-gray-100 rounded-lg transition-colors"
>
{isEditMode ?
<MdEditOff size={20} className="text-gray-500" /> :
<MdEdit size={20} className="text-gray-500" />
}
</button>
</div>
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-2">
{(local.words as Word[]).map((wordItem, index) => (
<FillBlanksWord
key={wordItem.letter}
letter={wordItem.letter}
word={wordItem.word}
isSelected={answers.get(selectedBlankId || '') === wordItem.word}
onClick={() => handleWordSelect(wordItem.word)}
onRemove={isEditMode ? () => handleRemoveWord(index) : undefined}
onEdit={isEditMode ? (newWord) => handleEditWord(index, newWord) : undefined}
isEditMode={isEditMode}
/>
))}
</div>
{isEditMode && (
<div className="flex flex-row mt-8">
<input
type="text"
value={newWord}
onChange={(e) => setNewWord(e.target.value)}
placeholder="Enter new word"
className="flex-1 px-3 py-2 border border-r-0 rounded-l-md focus:outline-none"
name=""
/>
<button
onClick={handleAddWord}
disabled={!isEditMode || newWord === ""}
className="px-4 bg-blue-500 text-white rounded-r-md border border-blue-500 hover:bg-blue-600 disabled:opacity-50 disabled:cursor-not-allowed"
>
<FaPlus className="h-4 w-4" />
</button>
</div>
)}
</CardContent>
</Card>
}
</>
</BlanksEditor>
</div>
);
};
export default FillBlanksLetters;

View File

@@ -1,67 +0,0 @@
import clsx from "clsx";
interface MCOptionProps {
id: string;
options: {
A: string;
B: string;
C: string;
D: string;
};
selectedOption?: string;
onSelect: (option: string) => void;
isEditMode?: boolean;
onEdit?: (key: 'A' | 'B' | 'C' | 'D', value: string) => void;
onRemove?: () => void;
}
const MCOption: React.FC<MCOptionProps> = ({
id,
options,
selectedOption,
onSelect,
isEditMode,
onEdit,
}) => {
const optionKeys = ['A', 'B', 'C', 'D'] as const;
return (
<div className="w-full">
<div className="flex items-center justify-between mb-2">
<span className="font-medium">Question {id}</span>
</div>
<div className="grid grid-cols-2 gap-2">
{optionKeys.map((key) => (
<div key={key} className="flex gap-2">
{isEditMode ? (
<div className="flex-1 flex items-center gap-2 p-2 rounded-md border border-gray-200">
<span className="font-medium min-w-[24px] text-center">{key}</span>
<input
type="text"
value={options[key]}
onChange={(e) => onEdit?.(key, e.target.value)}
className="w-full focus:outline-none"
/>
</div>
) : (
<button
onClick={() => onSelect(key)}
className={clsx(
"flex-1 flex items-center gap-2 p-2 rounded-md border transition-colors text-left",
selectedOption === key
? "border-blue-500 bg-blue-100"
: "border-gray-200 hover:bg-blue-50"
)}
>
<span className="font-medium min-w-[24px] text-center">{key}</span>
<span>{options[key]}</span>
</button>
)}
</div>
))}
</div>
</div>
);
};
export default MCOption;

View File

@@ -1,338 +0,0 @@
import { Difficulty, FillBlanksExercise, FillBlanksMCOption, ReadingPart } from "@/interfaces/exam";
import { useCallback, useEffect, useReducer, useState } from "react";
import BlanksEditor from "..";
import { Card, CardContent } from "@/components/ui/card";
import useExamEditorStore from "@/stores/examEditor";
import { blanksReducer, BlankState, getTextSegments } from "../BlanksReducer";
import useSectionEdit from "@/components/ExamEditor/Hooks/useSectionEdit";
import { AlertItem } from "../../Shared/Alert";
import validateBlanks from "../validateBlanks";
import { toast } from "react-toastify";
import setEditingAlert from "../../Shared/setEditingAlert";
import { MdEdit, MdEditOff } from "react-icons/md";
import MCOption from "./MCOption";
const FillBlanksMC: React.FC<{ exercise: FillBlanksExercise; sectionId: number }> = ({ exercise, sectionId }) => {
const { currentModule, dispatch } = useExamEditorStore();
const difficulty = useExamEditorStore((state) => state.modules[currentModule].difficulty);
const { state } = useExamEditorStore(
(state) => state.modules[currentModule].sections.find((section) => section.sectionId === sectionId)!
);
const section = state as ReadingPart;
const [alerts, setAlerts] = useState<AlertItem[]>([]);
const [local, setLocal] = useState(exercise);
const [selectedBlankId, setSelectedBlankId] = useState<string | null>(null);
const [answers, setAnswers] = useState<Map<string, string>>(() => {
return new Map(
exercise.solutions.map(({ id, solution }) => [
id.toString(),
solution
])
);
});
const [isEditMode, setIsEditMode] = useState(false);
const [editing, setEditing] = useState(false);
const updateLocal = (exercise: FillBlanksExercise) => {
setLocal(exercise);
setEditingAlert(true, setAlerts);
setEditing(true);
};
const [blanksState, blanksDispatcher] = useReducer(blanksReducer, {
text: exercise.text || "",
blanks: [],
selectedBlankId: null,
draggedItemId: null,
textMode: false,
setEditing,
});
const { handleSave, handleDiscard, handleDelete, handlePractice } = useSectionEdit({
sectionId,
editing,
setEditing,
onSave: () => {
if (!validateBlanks(blanksState.blanks, answers, alerts, setAlerts)) {
toast.error("Please fix the errors before saving!");
return;
}
setEditing(false);
setAlerts([]);
const updatedExercise = {
...local,
text: blanksState.text,
solutions: Array.from(answers.entries()).map(([id, solution]) => ({
id,
solution
}))
};
const newState = { ...section };
newState.exercises = newState.exercises.map((ex) =>
ex.id === exercise.id ? updatedExercise : ex
);
dispatch({ type: 'UPDATE_SECTION_STATE', payload: { sectionId, update: newState, module: currentModule } });
},
onDiscard: () => {
setSelectedBlankId(null);
setAnswers(new Map(exercise.solutions.map(({ id, solution }) => [id, solution])));
setIsEditMode(false);
setLocal(exercise);
blanksDispatcher({ type: "RESET", payload: { text: exercise.text } });
blanksDispatcher({ type: "SET_TEXT", payload: exercise.text || "" });
const tokens = getTextSegments(exercise.text || "");
const initialBlanks = tokens.reduce((acc, token, idx) => {
if (token.type === 'blank') {
acc.push({ id: token.id, position: idx });
}
return acc;
}, [] as BlankState[]);
blanksDispatcher({ type: "SET_BLANKS", payload: initialBlanks });
},
onDelete: () => {
const newSection = {
...section,
exercises: section.exercises.filter((ex) => ex.id !== local.id)
};
dispatch({ type: "UPDATE_SECTION_STATE", payload: { sectionId, update: newSection, module: currentModule } });
},
onPractice: () => {
const updatedExercise = {
...local,
isPractice: !local.isPractice
};
const newState = { ...section };
newState.exercises = newState.exercises.map((ex) =>
ex.id === exercise.id ? updatedExercise : ex
);
setLocal((prev) => ({ ...prev, isPractice: !local.isPractice }))
dispatch({ type: 'UPDATE_SECTION_STATE', payload: { sectionId, update: newState, module: currentModule } });
}
});
useEffect(() => {
if (!editing) {
setLocal(exercise);
setAnswers(new Map(exercise.solutions.map(({ id, solution }) => [id, solution])));
}
}, [exercise, editing]);
const handleOptionSelect = (option: string) => {
if (!selectedBlankId) return;
if (!editing) setEditing(true);
const newAnswers = new Map(answers);
newAnswers.set(selectedBlankId, option);
setAnswers(newAnswers);
setLocal(prev => ({
...prev,
solutions: Array.from(newAnswers.entries()).map(([id, solution]) => ({
id,
solution
}))
}));
};
const handleEditOption = (mcOptionIndex: number, key: keyof FillBlanksMCOption['options'], value: string) => {
if (!editing) setEditing(true);
setLocal(prev => {
const newWords = [...prev.words] as FillBlanksMCOption[];
const mcOption = newWords[mcOptionIndex] as FillBlanksMCOption;
const newOptions = { ...mcOption.options, [key]: value };
newWords[mcOptionIndex] = { ...mcOption, options: newOptions };
const oldValue = (mcOption.options as any)[key];
const newAnswers = new Map(answers);
for (const [blankId, answer] of newAnswers.entries()) {
if (answer === oldValue) {
newAnswers.set(blankId, value);
}
}
setAnswers(newAnswers);
return {
...prev,
words: newWords,
solutions: Array.from(newAnswers.entries()).map(([id, solution]) => ({
id,
solution
}))
};
});
};
useEffect(() => {
validateBlanks(blanksState.blanks, answers, alerts, setAlerts);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [answers, blanksState.blanks, blanksState.textMode]);
useEffect(() => {
setEditingAlert(editing, setAlerts);
}, [editing]);
useEffect(() => {
if (!editing) {
setLocal(exercise);
setAnswers(new Map(
exercise.solutions.map(({ id, solution }) => [
id.toString(),
solution
])
));
}
}, [exercise, editing]);
useEffect(() => {
setAnswers(new Map(
exercise.solutions.map(({ id, solution }) => [
id.toString(),
solution
])
));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const handleBlankRemove = (blankId: number) => {
if (!editing) setEditing(true);
const newAnswers = new Map(answers);
newAnswers.delete(blankId.toString());
setAnswers(newAnswers);
setLocal(prev => ({
...prev,
words: (prev.words as FillBlanksMCOption[]).filter(w => w.id !== blankId.toString()),
solutions: Array.from(newAnswers.entries()).map(([id, solution]) => ({
id,
solution
}))
}));
blanksDispatcher({ type: "REMOVE_BLANK", payload: blankId });
};
useEffect(() => {
const existingWordIds = new Set((local.words as FillBlanksMCOption[]).map(word => word.id));
const blanksMissingWords = blanksState.blanks.filter(blank => !existingWordIds.has(blank.id.toString()));
if (blanksMissingWords.length > 0) {
setLocal(prev => {
const newWords = [...prev.words] as FillBlanksMCOption[];
blanksMissingWords.forEach(blank => {
const newMCOption: FillBlanksMCOption = {
id: blank.id.toString(),
options: {
A: 'Option A',
B: 'Option B',
C: 'Option C',
D: 'Option D'
}
};
newWords.push(newMCOption);
});
return {
...prev,
words: newWords,
solutions: Array.from(answers.entries()).map(([id, solution]) => ({
id,
solution
}))
};
});
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [blanksState.blanks]);
const saveDifficulty = useCallback((diff: Difficulty) => {
if (!difficulty.includes(diff)) {
dispatch({ type: 'UPDATE_MODULE', payload: { updates: { difficulty: [...difficulty, diff]} } });
}
const updatedExercise = { ...exercise, difficulty: diff };
const newState = { ...section };
newState.exercises = newState.exercises.map((ex) => ex.id === exercise.id ? updatedExercise : ex );
dispatch({ type: 'UPDATE_SECTION_STATE', payload: { sectionId, update: newState, module: currentModule } });
}, [currentModule, difficulty, dispatch, exercise, section, sectionId]);
return (
<div className="space-y-4">
<BlanksEditor
alerts={alerts}
editing={editing}
state={blanksState}
difficulty={exercise.difficulty}
saveDifficulty={saveDifficulty}
blanksDispatcher={blanksDispatcher}
description="Place blanks and select the correct answer from multiple choice options"
initialText={local.text}
module={currentModule}
showBlankBank={true}
onBlankSelect={(blankId) => setSelectedBlankId(blankId?.toString() || null)}
onSave={handleSave}
onDiscard={handleDiscard}
onDelete={handleDelete}
onPractice={handlePractice}
setEditing={setEditing}
onBlankRemove={handleBlankRemove}
isEvaluationEnabled={!local.isPractice}
prompt={local.prompt}
updatePrompt={(prompt: string) => updateLocal({ ...local, prompt })}
>
{!blanksState.textMode && selectedBlankId && (
<Card className="p-4">
<CardContent>
<div className="flex justify-between items-center mb-4">
<div className="text-lg font-semibold">Multiple Choice Options</div>
<button
onClick={() => setIsEditMode(!isEditMode)}
className="p-2 hover:bg-gray-100 rounded-lg transition-colors"
>
{isEditMode ?
<MdEditOff size={20} className="text-gray-500" /> :
<MdEdit size={20} className="text-gray-500" />
}
</button>
</div>
{(local.words as FillBlanksMCOption[]).map((mcOption) => {
if (mcOption.id.toString() !== selectedBlankId) return null;
return (
<MCOption
key={mcOption.id}
id={mcOption.id}
options={mcOption.options}
selectedOption={answers.get(selectedBlankId)}
onSelect={(option) => handleOptionSelect(option)}
isEditMode={isEditMode}
onEdit={(key, value) => handleEditOption(
(local.words as FillBlanksMCOption[]).findIndex(w => w.id === mcOption.id),
key as "A" | "B" | "C" | "D",
value
)}
/>
);
})}
</CardContent>
</Card>
)}
</BlanksEditor>
</div>
);
};
export default FillBlanksMC;

View File

@@ -1,47 +0,0 @@
import { MdDelete, MdAdd } from "react-icons/md";
interface AlternativeSolutionProps {
solutions: string[];
onAdd: () => void;
onRemove: (index: number) => void;
onEdit: (index: number, value: string) => void;
}
const AlternativeSolutions: React.FC<AlternativeSolutionProps> = ({
solutions,
onAdd,
onRemove,
onEdit,
}) => {
return (
<div className="space-y-2 mt-4">
{solutions.map((solution, index) => (
<div key={index} className="flex items-center gap-2">
<input
type="text"
value={solution}
onChange={(e) => onEdit(index, e.target.value)}
className="flex-1 p-2 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:outline-none"
placeholder={`Solution ${index + 1}`}
/>
<button
onClick={() => onRemove(index)}
className="p-2 text-gray-500 hover:text-red-500 hover:bg-red-50 rounded-lg transition-colors"
title="Delete solution"
>
<MdDelete size={20} />
</button>
</div>
))}
<button
onClick={onAdd}
className="w-full mt-2 p-2 border-2 border-dashed border-gray-300 rounded-lg hover:border-blue-500 hover:bg-blue-50 transition-colors flex items-center justify-center gap-2 text-gray-600 hover:text-blue-600"
>
<MdAdd size={18} />
Add Alternative Solution
</button>
</div>
);
};
export default AlternativeSolutions;

View File

@@ -1,234 +0,0 @@
import useSectionEdit from "@/components/ExamEditor/Hooks/useSectionEdit";
import { Card, CardContent } from "@/components/ui/card";
import { WriteBlanksExercise, ReadingPart, Difficulty } from "@/interfaces/exam";
import useExamEditorStore from "@/stores/examEditor";
import { useState, useReducer, useEffect, useCallback } from "react";
import { toast } from "react-toastify";
import BlanksEditor from "..";
import { AlertItem } from "../../Shared/Alert";
import setEditingAlert from "../../Shared/setEditingAlert";
import { blanksReducer } from "../BlanksReducer";
import { validateWriteBlanks } from "./validation";
import AlternativeSolutions from "./AlternativeSolutions";
const WriteBlanksFill: React.FC<{ exercise: WriteBlanksExercise; sectionId: number }> = ({ exercise, sectionId }) => {
const { currentModule, dispatch } = useExamEditorStore();
const difficulty = useExamEditorStore((state) => state.modules[currentModule].difficulty);
const { state } = useExamEditorStore(
(state) => state.modules[currentModule].sections.find((section) => section.sectionId === sectionId)!
);
const section = state as ReadingPart;
const [alerts, setAlerts] = useState<AlertItem[]>([]);
const [local, setLocal] = useState(exercise);
const [selectedBlankId, setSelectedBlankId] = useState<string | null>(null);
const [editing, setEditing] = useState(false);
const updateLocal = (exercise: WriteBlanksExercise) => {
setLocal(exercise);
setEditingAlert(true, setAlerts);
setEditing(true);
};
const [blanksState, blanksDispatcher] = useReducer(blanksReducer, {
text: exercise.text || "",
blanks: [],
selectedBlankId: null,
draggedItemId: null,
textMode: false,
setEditing,
});
const { handleSave, handleDiscard, handleDelete, handlePractice } = useSectionEdit({
sectionId,
editing,
setEditing,
onSave: () => {
if (!validateWriteBlanks(local.solutions, local.maxWords, setAlerts)) {
toast.error("Please fix the errors before saving!");
return;
}
setEditing(false);
setAlerts([]);
const updatedExercise = {
...local,
text: blanksState.text,
};
const newState = { ...section };
newState.exercises = newState.exercises.map((ex) =>
ex.id === exercise.id ? updatedExercise : ex
);
dispatch({ type: 'UPDATE_SECTION_STATE', payload: { sectionId, update: newState, module: currentModule } });
},
onDiscard: () => {
setSelectedBlankId(null);
setLocal(exercise);
blanksDispatcher({ type: "RESET", payload: { text: exercise.text } });
},
onDelete: () => {
const newSection = {
...section,
exercises: section.exercises.filter((ex) => ex.id !== local.id)
};
dispatch({ type: "UPDATE_SECTION_STATE", payload: { sectionId, update: newSection, module: currentModule } });
},
onPractice: () => {
const updatedExercise = {
...local,
isPractice: !local.isPractice
};
const newState = { ...section };
newState.exercises = newState.exercises.map((ex) =>
ex.id === exercise.id ? updatedExercise : ex
);
setLocal((prev) => ({ ...prev, isPractice: !local.isPractice }))
dispatch({ type: 'UPDATE_SECTION_STATE', payload: { sectionId, update: newState, module: currentModule } });
}
});
useEffect(() => {
if (!editing) {
setLocal(exercise);
}
}, [exercise, editing]);
const handleAddSolution = (blankId: string) => {
if (!editing) setEditing(true);
setLocal(prev => ({
...prev,
solutions: prev.solutions.map(s =>
s.id === blankId
? { ...s, solution: [...s.solution, ""] }
: s
)
}));
};
const handleRemoveSolution = (blankId: string, index: number) => {
if (!editing) setEditing(true);
const solutions = local.solutions.find(s => s.id === blankId);
if (solutions && solutions.solution.length <= 1) {
toast.error("Each blank must have at least one solution!");
return;
}
setLocal(prev => ({
...prev,
solutions: prev.solutions.map(s =>
s.id === blankId
? { ...s, solution: s.solution.filter((_, i) => i !== index) }
: s
)
}));
};
const handleEditSolution = (blankId: string, index: number, value: string) => {
if (!editing) setEditing(true);
setLocal(prev => ({
...prev,
solutions: prev.solutions.map(s =>
s.id === blankId
? {
...s,
solution: s.solution.map((sol, i) => i === index ? value : sol)
}
: s
)
}));
};
const handleBlankRemove = (blankId: number) => {
if (!editing) setEditing(true);
setLocal(prev => ({
...prev,
solutions: prev.solutions.filter(s => s.id !== blankId.toString())
}));
blanksDispatcher({ type: "REMOVE_BLANK", payload: blankId });
};
useEffect(() => {
validateWriteBlanks(local.solutions, local.maxWords, setAlerts);
}, [local.solutions, local.maxWords]);
useEffect(() => {
setEditingAlert(editing, setAlerts);
}, [editing]);
const saveDifficulty = useCallback((diff: Difficulty) => {
if (!difficulty.includes(diff)) {
dispatch({ type: 'UPDATE_MODULE', payload: { updates: { difficulty: [...difficulty, diff]} } });
}
const updatedExercise = { ...exercise, difficulty: diff };
const newState = { ...section };
newState.exercises = newState.exercises.map((ex) => ex.id === exercise.id ? updatedExercise : ex );
dispatch({ type: 'UPDATE_SECTION_STATE', payload: { sectionId, update: newState, module: currentModule } });
}, [currentModule, difficulty, dispatch, exercise, section, sectionId]);
return (
<div className="space-y-4">
<BlanksEditor
title="Write Blanks: Fill"
alerts={alerts}
editing={editing}
state={blanksState}
blanksDispatcher={blanksDispatcher}
description={local.prompt}
initialText={local.text}
difficulty={exercise.difficulty}
saveDifficulty={saveDifficulty}
module={currentModule}
showBlankBank={true}
onBlankSelect={(blankId) => setSelectedBlankId(blankId?.toString() || null)}
onBlankRemove={handleBlankRemove}
onSave={handleSave}
onDiscard={handleDiscard}
onDelete={handleDelete}
onPractice={handlePractice}
setEditing={setEditing}
isEvaluationEnabled={!local.isPractice}
prompt={local.prompt}
updatePrompt={(prompt: string) => updateLocal({ ...local, prompt })}
>
{!blanksState.textMode && (
<Card>
<CardContent className="p-4">
<div className="flex items-center justify-between mb-4">
<span className="text-lg font-semibold">
{selectedBlankId
? `Solutions for Blank ${selectedBlankId}`
: "Click a blank to edit its solutions"}
</span>
{selectedBlankId && (
<span className="text-sm text-gray-500">
Max words per solution: {local.maxWords}
</span>
)}
</div>
<div className="grid grid-cols-1 gap-4">
{selectedBlankId && (
<AlternativeSolutions
solutions={local.solutions.find(s => s.id === selectedBlankId)?.solution || []}
onAdd={() => handleAddSolution(selectedBlankId)}
onRemove={(index: number) => handleRemoveSolution(selectedBlankId, index)}
onEdit={(index: number, value: string) => handleEditSolution(selectedBlankId, index, value)}
/>
)}
</div>
</CardContent>
</Card>
)}
</BlanksEditor>
</div>
);
};
export default WriteBlanksFill;

View File

@@ -1,58 +0,0 @@
import { AlertItem } from "../../Shared/Alert";
export const validateWriteBlanks = (
solutions: { id: string; solution: string[] }[],
maxWords: number,
setAlerts: React.Dispatch<React.SetStateAction<AlertItem[]>>
): boolean => {
let isValid = true;
const emptySolutions = solutions.flatMap(s =>
s.solution.map((sol, index) => ({
blankId: s.id,
solutionIndex: index,
isEmpty: !sol.trim()
}))
).filter(({ isEmpty }) => isEmpty);
if (emptySolutions.length > 0) {
isValid = false;
setAlerts(prev => {
const filtered = prev.filter(a => !a.tag?.startsWith('empty-solution'));
return [...filtered, ...emptySolutions.map(({ blankId, solutionIndex }) => ({
variant: "error" as const,
tag: `empty-solution-${blankId}-${solutionIndex}`,
description: `Solution ${solutionIndex + 1} for blank ${blankId} cannot be empty`
}))];
});
} else {
setAlerts(prev => prev.filter(a => !a.tag?.startsWith('empty-solution')));
}
if (maxWords > 0) {
const invalidWordCount = solutions.flatMap(s =>
s.solution.map((sol, index) => ({
blankId: s.id,
solutionIndex: index,
wordCount: sol.trim().split(/\s+/).length
}))
).filter(({ wordCount }) => wordCount > maxWords);
if (invalidWordCount.length > 0) {
isValid = false;
setAlerts(prev => {
const filtered = prev.filter(a => !a.tag?.startsWith('word-count'));
return [...filtered, ...invalidWordCount.map(({ blankId, solutionIndex, wordCount }) => ({
variant: "error" as const,
tag: `word-count-${blankId}-${solutionIndex}`,
description: `Solution ${solutionIndex + 1} for blank ${blankId} exceeds maximum of ${maxWords} words (current: ${wordCount} words)`
}))];
});
} else {
setAlerts(prev => prev.filter(a => !a.tag?.startsWith('word-count')));
}
}
return isValid;
};

View File

@@ -1,283 +0,0 @@
import React, { useCallback, useMemo, useReducer, useEffect, ReactNode } from "react";
import {
DndContext,
DragEndEvent,
DragStartEvent,
MeasuringStrategy,
PointerSensor,
useSensor,
useSensors,
} from "@dnd-kit/core";
import {
restrictToWindowEdges,
snapCenterToCursor,
} from "@dnd-kit/modifiers";
import AutoExpandingTextArea from "@/components/Low/AutoExpandingTextarea";
import Header from "../../Shared/Header";
import Alert, { AlertItem } from "../Shared/Alert";
import clsx from "clsx";
import { Card, CardContent } from "@/components/ui/card";
import { Blank, DropZone } from "./DragNDrop";
import { getTextSegments, BlankState, BlanksState, BlanksAction, BlankToken } from "./BlanksReducer";
import PromptEdit from "../Shared/PromptEdit";
import { Difficulty } from "@/interfaces/exam";
interface Props {
title?: string;
initialText: string;
description: string;
difficulty?: Difficulty;
saveDifficulty: (difficulty: Difficulty) => void;
state: BlanksState;
module: string;
editing: boolean;
showBlankBank: boolean;
alerts: AlertItem[];
prompt: string;
updatePrompt: (prompt: string) => void;
setEditing: React.Dispatch<React.SetStateAction<boolean>>;
blanksDispatcher: React.Dispatch<BlanksAction>
onBlankSelect?: (blankId: number | null) => void;
onBlankRemove: (blankId: number) => void;
onSave: () => void;
onDiscard: () => void;
onDelete: () => void;
onPractice: () => void;
isEvaluationEnabled?: boolean;
children: ReactNode;
}
const BlanksEditor: React.FC<Props> = ({
title = "Fill Blanks",
initialText,
description,
difficulty,
saveDifficulty,
state,
editing,
module,
children,
showBlankBank = true,
alerts,
blanksDispatcher,
onBlankSelect,
onBlankRemove,
onSave,
onDiscard,
onDelete,
onPractice,
isEvaluationEnabled,
setEditing,
prompt,
updatePrompt
}) => {
useEffect(() => {
const tokens = getTextSegments(initialText);
const initialBlanks = tokens.reduce((acc, token, idx) => {
if (token.type === 'blank') {
acc.push({ id: token.id, position: idx });
}
return acc;
}, [] as BlankState[]);
blanksDispatcher({ type: "SET_TEXT", payload: initialText });
blanksDispatcher({ type: "SET_BLANKS", payload: initialBlanks });
}, [initialText, blanksDispatcher]);
const tokens = useMemo(() => {
return getTextSegments(state.text || "");
}, [state.text]);
const handleDragStart = useCallback((event: DragStartEvent) => {
blanksDispatcher({ type: "SET_DRAGGED_ITEM", payload: event.active.id.toString() });
}, [blanksDispatcher]);
const handleDragEnd = useCallback(
(event: DragEndEvent) => {
const { active, over } = event;
if (!over) return;
const blankId = parseInt(active.id.toString().split("-").pop() || "");
const dropIndex = parseInt(over.id.toString().split("-")[1]);
blanksDispatcher({
type: "MOVE_BLANK",
payload: { blankId, newPosition: dropIndex },
});
blanksDispatcher({ type: "SET_DRAGGED_ITEM", payload: null });
},
[blanksDispatcher]
);
const handleTextChange = useCallback(
(newText: string) => {
const processedText = newText.replace(/\[(\d+)\]/g, "{{$1}}");
const existingBlankIds = getTextSegments(state.text)
.filter(token => token.type === 'blank')
.map(token => (token as BlankToken).id);
const newBlankIds = getTextSegments(processedText)
.filter(token => token.type === 'blank')
.map(token => (token as BlankToken).id);
const removedBlankIds = existingBlankIds.filter(id => !newBlankIds.includes(id));
removedBlankIds.forEach(id => {
onBlankRemove(id);
});
blanksDispatcher({ type: "SET_TEXT", payload: processedText });
},
[blanksDispatcher, state.text, onBlankRemove]
);
useEffect(() => {
if (onBlankSelect !== undefined) onBlankSelect(state.selectedBlankId);
}, [state.selectedBlankId, onBlankSelect]);
const handleBlankSelect = (blankId: number) => {
blanksDispatcher({
type: "SELECT_BLANK",
payload: blankId === state.selectedBlankId ? null : blankId,
});
};
const handleBlankRemove = useCallback((blankId: number) => {
onBlankRemove(blankId);
blanksDispatcher({ type: "REMOVE_BLANK", payload: blankId });
}, [blanksDispatcher, onBlankRemove]);
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: {
distance: 4,
tolerance: 5,
},
})
);
const modifiers = [snapCenterToCursor, restrictToWindowEdges];
const measuring = {
droppable: {
strategy: MeasuringStrategy.Always,
},
};
return (
<div className="space-y-4 p-4">
<Header
title={title}
description={description}
editing={editing}
difficulty={difficulty}
saveDifficulty={saveDifficulty}
handleSave={onSave}
handleDelete={onDelete}
handleDiscard={onDiscard}
handlePractice={onPractice}
isEvaluationEnabled={isEvaluationEnabled}
/>
{alerts.length > 0 && <Alert alerts={alerts} />}
<PromptEdit value={prompt} onChange={(text: string) => updatePrompt(text)} />
<Card>
<CardContent className="p-4 text-white font-semibold flex gap-2">
<button
onClick={() => blanksDispatcher({ type: "ADD_BLANK" })}
className={`px-3 py-1.5 bg-ielts-${module} rounded-md hover:bg-ielts-${module}/50 transition-colors`}
>
Add Blank
</button>
<button
onClick={() => blanksDispatcher({ type: "TOGGLE_EDIT_MODE" })}
className={clsx(
"px-3 py-1.5 rounded-md transition-colors",
`bg-ielts-${module} text-white hover:bg-ielts-${module}/50`
)}
>
{state.textMode ? "Drag Mode" : "Text Mode"}
</button>
</CardContent>
</Card>
<DndContext
sensors={sensors}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
modifiers={modifiers}
measuring={measuring}
>
<Card>
<CardContent className="p-4">
{state.textMode ? (
<AutoExpandingTextArea
value={state.text.replace(/{{(\d+)}}/g, "[$1]")}
onChange={(text) => { handleTextChange(text); if (!editing) setEditing(true) }}
className="w-full h-full min-h-[200px] p-2 bg-white border rounded-md"
placeholder="Enter text here. Use [1], [2], etc. for blanks..."
/>
) : (
<div className="leading-relaxed p-4">
{tokens.map((token, index) => {
const isWordToken = token.type === 'text' && !token.isWhitespace;
const showDropZone = isWordToken || token.type === 'blank';
return (
<React.Fragment key={index}>
{showDropZone && <DropZone index={index} module={module} />}
{token.type === 'blank' ? (
<Blank
id={token.id}
module={module}
variant="text"
isSelected={token.id === state.selectedBlankId}
isDragging={state.draggedItemId === `text-blank-${token.id}`}
/>
) : token.isLineBreak ? (
<br />
) : (
<span className="select-none">{token.content}</span>
)}
</React.Fragment>
);
})}
{tokens.length > 0 &&
tokens[tokens.length - 1].type === 'text' && (
<DropZone index={tokens.length} module={module} />
)}
</div>
)}
</CardContent>
</Card>
{(!state.textMode && showBlankBank) && (
<Card>
<CardContent className="flex flex-wrap gap-2 p-4">
{state.blanks.map(blank => (
<Blank
key={blank.id}
id={blank.id}
module={module}
variant="bank"
isSelected={blank.id === state.selectedBlankId}
isDragging={state.draggedItemId === `bank-blank-${blank.id}`}
onSelect={handleBlankSelect}
onRemove={handleBlankRemove}
disabled={state.textMode}
/>
))}
</CardContent>
</Card>
)}
{children}
</DndContext>
</div>
);
}
export default BlanksEditor;

View File

@@ -1,38 +0,0 @@
import { AlertItem } from "../Shared/Alert";
import { BlankState } from "./BlanksReducer";
const validateBlanks = (
blanks: BlankState[],
answers: Map<string, string>,
alerts: AlertItem[],
setAlerts: React.Dispatch<React.SetStateAction<AlertItem[]>>,
save: boolean = false,
): boolean => {
const unfilledBlanks = blanks.filter(blank => !answers.has(blank.id.toString()));
const filteredAlerts = alerts.filter(alert => alert.tag !== "unfilled-blanks");
if (unfilledBlanks.length > 0) {
if (!save && !filteredAlerts.some(alert => alert.tag === "editing")) {
filteredAlerts.push({
variant: "info",
description: "You have unsaved changes. Don't forget to save your work!",
tag: "editing"
});
}
setAlerts([
...filteredAlerts,
{
variant: "error",
tag: "unfilled-blanks",
description: `${unfilledBlanks.length} blank${unfilledBlanks.length > 1 ? 's' : ''} ${unfilledBlanks.length > 1 ? 'are' : 'is'} missing a word (blanks: ${unfilledBlanks.map(blank => blank.id).join(", ")})`
}
]);
return false;
} else if (filteredAlerts.length !== alerts.length) {
setAlerts(filteredAlerts);
}
return true;
};
export default validateBlanks;

View File

@@ -1,45 +0,0 @@
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { MatchSentenceExerciseOption } from "@/interfaces/exam";
import { MdVisibilityOff } from "react-icons/md";
interface Props {
showReference: boolean;
selectedReference: string | null;
options: MatchSentenceExerciseOption[];
headings: boolean;
setShowReference: React.Dispatch<React.SetStateAction<boolean>>;
}
const ReferenceViewer: React.FC<Props> = ({ showReference, selectedReference, options, setShowReference, headings = true}) => (
<div
className={`fixed inset-y-0 right-0 w-96 bg-white shadow-lg transform transition-transform duration-300 z-50 ease-in-out ${showReference ? 'translate-x-0' : 'translate-x-full'}`}
>
<div className="h-full flex flex-col">
<div className="p-4 border-b bg-gray-50 flex justify-between items-center">
<h3 className="font-semibold text-gray-800">{headings ? "Reference Paragraphs" : "Authors"}</h3>
<button
onClick={() => setShowReference(false)}
className="p-2 hover:bg-gray-200 rounded-full"
>
<MdVisibilityOff size={20} />
</button>
</div>
<div className="flex-1 overflow-y-auto p-4">
<div className="space-y-4">
{options.map((option) => (
<Card key={option.id} className={`bg-gray-50 transition-all duration-200 ${selectedReference === option.id ? 'ring-2 ring-blue-500' : ''}`}>
<CardHeader className="pb-2">
<CardTitle className="text-md text-black">{headings ? "Paragraph" : "Author" } {option.id}</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm text-gray-600">{option.sentence}</p>
</CardContent>
</Card>
))}
</div>
</div>
</div>
</div>
);
export default ReferenceViewer;

View File

@@ -1,261 +0,0 @@
import React, { useState, useMemo, useEffect, useCallback } from 'react';
import {
MdAdd,
MdVisibility,
MdVisibilityOff
} from 'react-icons/md';
import { Difficulty, MatchSentencesExercise, ReadingPart } from '@/interfaces/exam';
import Alert, { AlertItem } from '../Shared/Alert';
import ReferenceViewer from './ParagraphViewer';
import Header from '../../Shared/Header';
import SortableQuestion from '../Shared/SortableQuestion';
import QuestionsList from '../Shared/QuestionsList';
import useExamEditorStore from '@/stores/examEditor';
import useSectionEdit from '../../Hooks/useSectionEdit';
import validateMatchSentences from './validation';
import setEditingAlert from '../Shared/setEditingAlert';
import { toast } from 'react-toastify';
import { DragEndEvent } from '@dnd-kit/core';
import { handleMatchSentencesReorder } from '@/stores/examEditor/reorder/local';
import PromptEdit from '../Shared/PromptEdit';
const MatchSentences: React.FC<{ exercise: MatchSentencesExercise, sectionId: number }> = ({ exercise, sectionId }) => {
const { currentModule, dispatch } = useExamEditorStore();
const difficulty = useExamEditorStore((state) => state.modules[currentModule].difficulty);
const { state } = useExamEditorStore(
(state) => state.modules[currentModule].sections.find((section) => section.sectionId === sectionId)!
);
const section = state as ReadingPart;
const [local, setLocal] = useState(exercise);
const [selectedParagraph, setSelectedParagraph] = useState<string | null>(null);
const [showReference, setShowReference] = useState(false);
const [alerts, setAlerts] = useState<AlertItem[]>([]);
const updateLocal = (exercise: MatchSentencesExercise) => {
setLocal(exercise);
setEditing(true);
};
const { editing, setEditing, handleSave, handleDiscard, handleDelete, handlePractice } = useSectionEdit({
sectionId,
onSave: () => {
const isValid = validateMatchSentences(local.sentences, setAlerts);
if (!isValid) {
toast.error("Please fix the errors before saving!");
return;
}
setEditing(false);
setAlerts([]);
const newState = { ...section };
newState.exercises = newState.exercises.map((ex) => ex.id === exercise.id ? local : ex);
dispatch({ type: 'UPDATE_SECTION_STATE', payload: { sectionId, update: newState, module: currentModule } });
},
onDiscard: () => {
setLocal(exercise);
setSelectedParagraph(null);
setShowReference(false);
},
onDelete: () => {
const newSection = {
...section,
exercises: section.exercises.filter((ex) => ex.id !== local.id)
};
dispatch({ type: "UPDATE_SECTION_STATE", payload: { sectionId, update: newSection, module: currentModule } });
},
onPractice: () => {
const updatedExercise = {
...local,
isPractice: !local.isPractice
};
const newState = { ...section };
newState.exercises = newState.exercises.map((ex) =>
ex.id === exercise.id ? updatedExercise : ex
);
setLocal((prev) => ({...prev, isPractice: !local.isPractice}))
dispatch({ type: 'UPDATE_SECTION_STATE', payload: { sectionId, update: newState, module: currentModule } });
}
});
const usedOptions = useMemo(() => {
return local.sentences.reduce((acc, sentence) => {
if (sentence.solution) {
acc.add(sentence.solution);
}
return acc;
}, new Set<string>());
}, [local.sentences]);
const addHeading = () => {
const newId = (parseInt(local.sentences[local.sentences.length - 1].id) + 1).toString();
updateLocal({
...local,
sentences: [
...local.sentences,
{
id: newId,
sentence: "",
solution: ""
}
]
});
};
const updateHeading = (index: number, field: string, value: string) => {
const newSentences = [...local.sentences];
if (field === 'solution') {
const oldSolution = newSentences[index].solution;
if (oldSolution) {
usedOptions.delete(oldSolution);
}
}
newSentences[index] = { ...newSentences[index], [field]: value };
updateLocal({ ...local, sentences: newSentences });
};
const deleteHeading = (index: number) => {
if (local.sentences.length <= 1) {
toast.error(`There needs to be at least one ${exercise.variant && exercise.variant == "ideaMatch" ? "idea/opinion" : "heading"}!`);
return;
}
const deletedSolution = local.sentences[index].solution;
if (deletedSolution) {
usedOptions.delete(deletedSolution);
}
const newSentences = local.sentences.filter((_, i) => i !== index);
updateLocal({ ...local, sentences: newSentences });
};
useEffect(() => {
validateMatchSentences(local.sentences, setAlerts);
}, [local.sentences]);
useEffect(() => {
setEditingAlert(editing, setAlerts);
}, [editing]);
const handleDragEnd = (event: DragEndEvent) => {
updateLocal(handleMatchSentencesReorder(event, local));
}
const saveDifficulty = useCallback((diff: Difficulty) => {
if (!difficulty.includes(diff)) {
dispatch({ type: 'UPDATE_MODULE', payload: { updates: { difficulty: [...difficulty, diff]} } });
}
const updatedExercise = { ...exercise, difficulty: diff };
const newState = { ...section };
newState.exercises = newState.exercises.map((ex) => ex.id === exercise.id ? updatedExercise : ex );
dispatch({ type: 'UPDATE_SECTION_STATE', payload: { sectionId, update: newState, module: currentModule } });
}, [currentModule, difficulty, dispatch, exercise, section, sectionId]);
return (
<div className="flex flex-col mx-auto p-2">
<Header
title={exercise.variant && exercise.variant == "ideaMatch" ? "Idea Match" : "Paragraph Match"}
description={`Edit ${exercise.variant && exercise.variant == "ideaMatch" ? "ideas/opinions" : "headings"} and their matches`}
editing={editing}
difficulty={exercise.difficulty}
saveDifficulty={saveDifficulty}
handleSave={handleSave}
handleDelete={handleDelete}
handleDiscard={handleDiscard}
handlePractice={handlePractice}
isEvaluationEnabled={!local.isPractice}
>
<button
onClick={() => setShowReference(!showReference)}
className="px-4 py-2 bg-gray-100 hover:bg-gray-200 text-gray-700 rounded-lg transition-colors flex items-center gap-2"
>
{showReference ? <MdVisibilityOff size={18} /> : <MdVisibility size={18} />}
{showReference ? 'Hide Reference' : 'Show Reference'}
</button>
</Header>
<div className="space-y-4">
{alerts.length > 0 && <Alert alerts={alerts} />}
<PromptEdit
value={local.prompt}
onChange={(text) => updateLocal({ ...local, prompt: text })}
/>
<QuestionsList
ids={local.sentences.map(s => s.id)}
handleDragEnd={handleDragEnd}
>
{local.sentences.map((sentence, index) => (
<SortableQuestion
key={sentence.id}
id={sentence.id}
index={index}
deleteQuestion={() => deleteHeading(index)}
onFocus={() => setSelectedParagraph(sentence.solution)}
>
<>
<input
type="text"
value={sentence.sentence}
onChange={(e) => updateHeading(index, 'sentence', e.target.value)}
className="w-full p-3 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:outline-none text-mti-gray-dim"
placeholder={`Enter ${exercise.variant && exercise.variant == "ideaMatch" ? "idea/opinion" : "heading"} ...`}
/>
<div className="flex items-center gap-3">
<select
value={sentence.solution}
onChange={(e) => {
updateHeading(index, 'solution', e.target.value);
setSelectedParagraph(e.target.value);
}}
className="w-full p-3 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:outline-none bg-white text-mti-gray-dim"
>
<option value="">Select matching {exercise.variant == "ideaMatch" ? "author" : "paragraph"}...</option>
{local.options.map((option) => {
const isUsed = usedOptions.has(option.id);
const isCurrentSelection = sentence.solution === option.id;
return (
<option
key={option.id}
value={option.id}
disabled={isUsed && !isCurrentSelection}
>
{exercise.variant == "ideaMatch" ? "Author" : "Paragraph"} {option.id}
</option>
);
})}
</select>
</div>
</>
</SortableQuestion>
))}
</QuestionsList>
{(section.text !== undefined && section.text.content.split("\n\n").length - 1) === local.sentences.length && (
<button
onClick={addHeading}
className="w-full p-4 border-2 border-dashed border-gray-300 rounded-lg hover:border-blue-500 hover:bg-blue-50 transition-colors flex items-center justify-center gap-2 text-gray-600 hover:text-blue-600"
>
<MdAdd size={18} />
Add New Match
</button>
)}
</div>
<ReferenceViewer
headings={exercise.variant !== "ideaMatch"}
showReference={showReference}
selectedReference={selectedParagraph}
options={local.options}
setShowReference={setShowReference}
/>
</div>
);
};
export default MatchSentences;

View File

@@ -1,42 +0,0 @@
import { AlertItem } from "../Shared/Alert";
const validateMatchSentences = (
sentences: {id: string; sentence: string; solution: string;}[],
setAlerts: React.Dispatch<React.SetStateAction<AlertItem[]>>
): boolean => {
let hasErrors = false;
const emptySentences = sentences.filter(s => !s.sentence.trim());
if (emptySentences.length > 0) {
hasErrors = true;
setAlerts(prev => {
const filteredAlerts = prev.filter(alert => !alert.tag?.startsWith('empty-sentence'));
return [...filteredAlerts, ...emptySentences.map(s => ({
variant: "error" as const,
tag: `empty-sentence-${s.id}`,
description: `Heading ${s.id} is empty`
}))];
});
} else {
setAlerts(prev => prev.filter(alert => !alert.tag?.startsWith('empty-sentence')));
}
const unmatchedSentences = sentences.filter(s => !s.solution);
if (unmatchedSentences.length > 0) {
hasErrors = true;
setAlerts(prev => {
const filteredAlerts = prev.filter(alert => !alert.tag?.startsWith('unmatched-sentence'));
return [...filteredAlerts, ...unmatchedSentences.map(s => ({
variant: "error" as const,
tag: `unmatched-sentence-${s.id}`,
description: `Heading ${s.id} has no paragraph selected`
}))];
});
} else {
setAlerts(prev => prev.filter(alert => !alert.tag?.startsWith('unmatched-sentence')));
}
return !hasErrors;
};
export default validateMatchSentences;

View File

@@ -1,191 +0,0 @@
import { MultipleChoiceQuestion } from "@/interfaces/exam";
import clsx from "clsx";
import { useEffect, useState } from "react";
import { MdEdit, MdEditOff } from "react-icons/md";
interface UnderlineQuestionProps {
question: MultipleChoiceQuestion;
onQuestionChange: (updatedQuestion: MultipleChoiceQuestion) => void;
onValidationChange?: (isValid: boolean) => void;
}
interface Option {
id: string;
text?: string;
src?: string;
}
export const UnderlineQuestion: React.FC<UnderlineQuestionProps> = ({
question,
onQuestionChange,
onValidationChange,
}) => {
const [isEditing, setIsEditing] = useState(false);
const [validationErrors, setValidationErrors] = useState<string[]>([]);
const stripUnderlineTags = (text: string = '') => text.replace(/<\/?u>/g, '');
const addUnderlineTags = (text: string, options: Option[]) => {
let result = text;
// Sort options by length (longest first) to handle overlapping matches
const sortedOptions = [...options]
.filter(opt => opt.text?.trim() && opt.text.trim().length > 1)
.sort((a, b) => ((b.text?.length || 0) - (a.text?.length || 0)));
for (const option of sortedOptions) {
if (!option.text?.trim()) continue;
const optionText = stripUnderlineTags(option.text).trim();
const textLower = result.toLowerCase();
const optionLower = optionText.toLowerCase();
let startIndex = textLower.indexOf(optionLower);
while (startIndex !== -1) {
// Check if this portion is already underlined
const beforeTag = result.slice(Math.max(0, startIndex - 3), startIndex);
const afterTag = result.slice(startIndex + optionText.length, startIndex + optionText.length + 4);
if (!beforeTag.includes('<u>') && !afterTag.includes('</u>')) {
const before = result.substring(0, startIndex);
const match = result.substring(startIndex, startIndex + optionText.length);
const after = result.substring(startIndex + optionText.length);
result = `${before}<u>${match}</u>${after}`;
}
// Find next occurrence
startIndex = textLower.indexOf(optionLower, startIndex + 1);
}
}
return result;
};
const validateQuestion = (q: MultipleChoiceQuestion) => {
const errors: string[] = [];
const rawPrompt = stripUnderlineTags(q.prompt).toLowerCase();
q.options.forEach((option) => {
if (option.text?.trim() && !rawPrompt.includes(stripUnderlineTags(option.text).trim().toLowerCase())) {
errors.push(`Option ${option.id} text not found in prompt`);
}
});
setValidationErrors(errors);
onValidationChange?.(errors.length === 0);
return errors.length === 0;
};
useEffect(() => {
validateQuestion(question);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [question]);
const handlePromptChange = (value: string) => {
const newPrompt = addUnderlineTags(value, question.options);
onQuestionChange({
...question,
prompt: newPrompt
});
};
const handleOptionChange = (optionIndex: number, value: string) => {
const updatedOptions = question.options.map((opt, idx) =>
idx === optionIndex ? { ...opt, text: value } : opt
);
const strippedPrompt = stripUnderlineTags(question.prompt);
const newPrompt = addUnderlineTags(strippedPrompt, updatedOptions);
onQuestionChange({
...question,
prompt: newPrompt,
options: updatedOptions
});
};
return (
<div className="space-y-4">
<div className="flex gap-2 items-center">
{isEditing ? (
<input
value={stripUnderlineTags(question.prompt)}
onChange={(e) => handlePromptChange(e.target.value)}
className="flex-1 p-3 border rounded-lg focus:outline-none"
placeholder="Enter text for underlining..."
/>
) : (
<div
className="flex-1 p-3 border rounded-lg min-h-[50px]"
dangerouslySetInnerHTML={{ __html: question.prompt || '' }}
/>
)}
<button
onClick={() => setIsEditing(!isEditing)}
className="p-2 hover:bg-gray-100 rounded-lg transition-colors"
>
{isEditing ?
<MdEditOff size={24} className="text-gray-500" /> :
<MdEdit size={24} className="text-gray-500" />
}
</button>
</div>
{validationErrors.length > 0 && (
<div className="text-red-500 text-sm">
{validationErrors.map((error, index) => (
<div key={index}>{error}</div>
))}
</div>
)}
<div className="space-y-2">
{question.options.map((option, optionIndex) => {
const isInvalidOption = option.text?.trim() &&
!stripUnderlineTags(question.prompt || '').toLowerCase()
.includes(stripUnderlineTags(option.text).trim().toLowerCase());
return (
<div key={option.id} className="flex gap-2">
<label
className={clsx(
"flex-none w-12 p-3 text-center rounded-lg border-2 transition-all cursor-pointer",
question.solution === option.id
? 'border-blue-500 bg-blue-50 text-blue-700'
: 'border-gray-200 hover:border-gray-300'
)}
>
<input
type="radio"
name={`solution-${question.id}`}
value={option.id}
checked={question.solution === option.id}
onChange={(e) => onQuestionChange({
...question,
solution: e.target.value
})}
className="sr-only"
/>
{option.id}
</label>
<input
type="text"
value={stripUnderlineTags(option.text || '')}
onChange={(e) => handleOptionChange(optionIndex, e.target.value)}
className={clsx(
"flex-1 p-3 border rounded-lg focus:ring-2 focus:outline-none",
isInvalidOption
? "border-red-500 focus:ring-red-500 bg-red-50"
: "focus:ring-blue-500"
)}
placeholder={`Option ${option.id}...`}
/>
</div>
);
})}
</div>
</div>
);
};
export default UnderlineQuestion;

View File

@@ -1,177 +0,0 @@
import Header from "@/components/ExamEditor/Shared/Header";
import QuestionsList from "../../Shared/QuestionsList";
import SortableQuestion from "../../Shared/SortableQuestion";
import UnderlineQuestion from "./UnderlineQuestion";
import useSectionEdit from "@/components/ExamEditor/Hooks/useSectionEdit";
import { toast } from "react-toastify";
import setEditingAlert from "../../Shared/setEditingAlert";
import { Difficulty, LevelPart, ListeningPart, MultipleChoiceExercise, MultipleChoiceQuestion, ReadingPart } from "@/interfaces/exam";
import useExamEditorStore from "@/stores/examEditor";
import { useCallback, useEffect, useState } from "react";
import { MdAdd } from "react-icons/md";
import Alert, { AlertItem } from "../../Shared/Alert";
import PromptEdit from "../../Shared/PromptEdit";
const UnderlineMultipleChoice: React.FC<{exercise: MultipleChoiceExercise, sectionId: number}> = ({
exercise,
sectionId,
}) => {
const { currentModule, dispatch } = useExamEditorStore();
const difficulty = useExamEditorStore((state) => state.modules[currentModule].difficulty);
const { state } = useExamEditorStore(
(state) => state.modules[currentModule].sections.find((section) => section.sectionId === sectionId)!
);
const section = state as ReadingPart | ListeningPart | LevelPart;
const [local, setLocal] = useState(exercise);
const [alerts, setAlerts] = useState<AlertItem[]>([]);
useEffect(() => {
setLocal(exercise);
}, [exercise]);
const updateLocal = (exercise: MultipleChoiceExercise) => {
setLocal(exercise);
setEditingAlert(true, setAlerts);
setEditing(true);
};
const handleQuestionChange = (questionIndex: number, updatedQuestion: MultipleChoiceQuestion) => {
const newQuestions = [...local.questions];
newQuestions[questionIndex] = updatedQuestion;
updateLocal({ ...local, questions: newQuestions });
};
const addQuestion = () => {
const newId = (parseInt(local.questions[local.questions.length - 1].id) + 1).toString();
const options = Array.from({ length: 4 }, (_, i) => ({
id: String.fromCharCode(65 + i),
text: ''
}));
updateLocal({
...local,
questions: [
...local.questions,
{
prompt: "",
solution: "",
id: newId,
options,
variant: "text"
},
]
});
};
const deleteQuestion = (index: number) => {
if (local.questions.length === 1) {
toast.error("There needs to be at least one question!");
return;
}
const newQuestions = local.questions.filter((_, i) => i !== index);
updateLocal({ ...local, questions: newQuestions });
};
const { editing, handleSave, handleDiscard, handleDelete, handlePractice, setEditing } = useSectionEdit({
sectionId,
onSave: () => {
setEditing(false);
setAlerts([]);
const newSection = {
...section,
exercises: section.exercises.map((ex) =>
ex.id === local.id ? local : ex
)
};
dispatch({ type: "UPDATE_SECTION_STATE", payload: { sectionId, update: newSection, module: currentModule } });
},
onDiscard: () => {
setAlerts([]);
setLocal(exercise);
setEditing(false);
},
onDelete: () => {
const newSection = {
...section,
exercises: section.exercises.filter((ex) => ex.id !== local.id)
};
dispatch({ type: "UPDATE_SECTION_STATE", payload: { sectionId, update: newSection, module: currentModule } });
},
onPractice: () => {
const updatedExercise = {
...local,
isPractice: !local.isPractice
};
const newState = { ...section };
newState.exercises = newState.exercises.map((ex) =>
ex.id === exercise.id ? updatedExercise : ex
);
setLocal((prev) => ({...prev, isPractice: !local.isPractice}))
dispatch({ type: 'UPDATE_SECTION_STATE', payload: { sectionId, update: newState, module: currentModule } });
}
});
const saveDifficulty = useCallback((diff: Difficulty) => {
if (!difficulty.includes(diff)) {
dispatch({ type: 'UPDATE_MODULE', payload: { updates: { difficulty: [...difficulty, diff]} } });
}
const updatedExercise = { ...exercise, difficulty: diff };
const newState = { ...section };
newState.exercises = newState.exercises.map((ex) => ex.id === exercise.id ? updatedExercise : ex );
dispatch({ type: 'UPDATE_SECTION_STATE', payload: { sectionId, update: newState, module: currentModule } });
}, [currentModule, difficulty, dispatch, exercise, section, sectionId]);
return (
<div className="p-4">
<Header
title='Underline Multiple Choice Exercise'
description="Edit questions with 4 underline options each"
editing={editing}
difficulty={exercise.difficulty}
saveDifficulty={saveDifficulty}
handleSave={handleSave}
handleDelete={handleDelete}
handlePractice={handlePractice}
handleDiscard={handleDiscard}
isEvaluationEnabled={!local.isPractice}
/>
{alerts.length > 0 && <Alert className="mb-6" alerts={alerts} />}
<PromptEdit value={local.prompt} onChange={(prompt: string) => updateLocal({...local, prompt})} />
<div className="space-y-4">
<QuestionsList
ids={local.questions.map(q => q.id)}
handleDragEnd={()=> {}}
>
{local.questions.map((question, questionIndex) => (
<SortableQuestion
key={question.id}
id={question.id}
index={questionIndex}
deleteQuestion={() => deleteQuestion(questionIndex)}
>
<UnderlineQuestion
question={question}
onQuestionChange={(updatedQuestion) =>
handleQuestionChange(questionIndex, updatedQuestion)
}
/>
</SortableQuestion>
))}
</QuestionsList>
<button
onClick={addQuestion}
className="w-full p-4 border-2 border-dashed border-gray-300 rounded-lg hover:border-blue-500 hover:bg-blue-50 transition-colors flex items-center justify-center gap-2 text-gray-600 hover:text-blue-600"
>
<MdAdd size={18} />
Add New Question
</button>
</div>
</div>
);
};
export default UnderlineMultipleChoice;

View File

@@ -1,300 +0,0 @@
import React, { useCallback, useEffect, useState } from 'react';
import { Card, CardContent } from '@/components/ui/card';
import {
MdAdd,
MdEdit,
MdEditOff,
} from 'react-icons/md';
import { ReadingPart, MultipleChoiceExercise, MultipleChoiceQuestion, LevelPart, ListeningPart, Difficulty } from '@/interfaces/exam';
import clsx from 'clsx';
import useExamEditorStore from '@/stores/examEditor';
import { toast } from 'react-toastify';
import { DragEndEvent } from '@dnd-kit/core';
import useSectionEdit from '@/components/ExamEditor/Hooks/useSectionEdit';
import Header from '@/components/ExamEditor/Shared/Header';
import Alert, { AlertItem } from '../../Shared/Alert';
import QuestionsList from '../../Shared/QuestionsList';
import SortableQuestion from '../../Shared/SortableQuestion';
import setEditingAlert from '../../Shared/setEditingAlert';
import { handleMultipleChoiceReorder } from '@/stores/examEditor/reorder/local';
import PromptEdit from '../../Shared/PromptEdit';
interface MultipleChoiceProps {
exercise: MultipleChoiceExercise;
sectionId: number;
optionsQuantity: number;
}
const validateMultipleChoiceQuestions = (
questions: MultipleChoiceQuestion[],
optionsQuantity: number,
setAlerts: React.Dispatch<React.SetStateAction<AlertItem[]>>
) => {
const validationAlerts: AlertItem[] = [];
questions.forEach((question, index) => {
if (!question.prompt.trim()) {
validationAlerts.push({
variant: 'error',
tag: `missing-prompt-${index}`,
description: `Question ${index + 1} is missing a prompt`
});
}
if (!question.solution) {
validationAlerts.push({
variant: 'error',
tag: `missing-solution-${index}`,
description: `Question ${index + 1} is missing a solution`
});
}
if (question.options.length !== optionsQuantity) {
validationAlerts.push({
variant: 'error',
tag: `invalid-options-${index}`,
description: `Question ${index + 1} must have exactly ${optionsQuantity} options`
});
}
question.options.forEach((option, optionIndex) => {
if (option.text && option.text.trim() === "") {
validationAlerts.push({
variant: 'error',
tag: `empty-option-${index}-${optionIndex}`,
description: `Question ${index + 1} has an empty option`
});
}
});
});
setAlerts(prev => {
const editingAlert = prev.find(alert => alert.tag === 'editing');
return [...validationAlerts, ...(editingAlert ? [editingAlert] : [])];
});
return validationAlerts.length === 0;
};
const MultipleChoice: React.FC<MultipleChoiceProps> = ({ exercise, sectionId, optionsQuantity }) => {
const { currentModule, dispatch } = useExamEditorStore();
const difficulty = useExamEditorStore((state) => state.modules[currentModule].difficulty);
const { state } = useExamEditorStore(
(state) => state.modules[currentModule].sections.find((section) => section.sectionId === sectionId)!
);
const section = state as ReadingPart | ListeningPart| LevelPart;
const [local, setLocal] = useState(exercise);
const [editingPrompt, setEditingPrompt] = useState(false);
const [alerts, setAlerts] = useState<AlertItem[]>([]);
const updateLocal = (exercise: MultipleChoiceExercise) => {
setLocal(exercise);
setEditingAlert(true, setAlerts);
setEditing(true);
};
const updateQuestion = (index: number, field: string, value: string) => {
const newQuestions = [...local.questions];
newQuestions[index] = { ...newQuestions[index], [field]: value };
updateLocal({ ...local, questions: newQuestions });
};
const updateOption = (questionIndex: number, optionIndex: number, value: string) => {
const newQuestions = [...local.questions];
const newOptions = [...newQuestions[questionIndex].options];
newOptions[optionIndex] = { ...newOptions[optionIndex], text: value };
newQuestions[questionIndex] = { ...newQuestions[questionIndex], options: newOptions };
updateLocal({ ...local, questions: newQuestions });
};
const addQuestion = () => {
const newId = (parseInt(local.questions[local.questions.length - 1].id) + 1).toString();
const options = Array.from({ length: optionsQuantity }, (_, i) => ({
id: String.fromCharCode(65 + i),
text: ''
}));
updateLocal({
...local,
questions: [
...local.questions,
{
prompt: "",
solution: "",
id: newId,
options,
variant: "text"
},
]
});
};
const deleteQuestion = (index: number) => {
if (local.questions.length === 1) {
toast.error("There needs to be at least one question!");
return;
}
const newQuestions = local.questions.filter((_, i) => i !== index);
const minId = Math.min(...newQuestions.map(q => parseInt(q.id)));
const updatedQuestions = newQuestions.map((question, i) => ({
...question,
id: String(minId + i)
}));
updateLocal({ ...local, questions: updatedQuestions });
};
const { editing, handleSave, handleDiscard, handleDelete, handlePractice, setEditing } = useSectionEdit({
sectionId,
onSave: () => {
const isValid = validateMultipleChoiceQuestions(
local.questions,
optionsQuantity,
setAlerts
);
if (!isValid) {
toast.error("Please fix the errors before saving!");
return;
}
setEditing(false);
setAlerts([]);
const newSection = {
...section,
exercises: section.exercises.map((ex) => ex.id === local.id ? local : ex)
};
dispatch({ type: "UPDATE_SECTION_STATE", payload: { sectionId, update: newSection, module: currentModule } });
},
onDiscard: () => {
setEditing(false);
setAlerts([]);
setLocal(exercise);
},
onDelete: () => {
const newSection = {
...section,
exercises: section.exercises.filter((ex) => ex.id !== local.id)
};
dispatch({ type: "UPDATE_SECTION_STATE", payload: { sectionId, update: newSection, module: currentModule } });
},
onPractice: () => {
const updatedExercise = {
...local,
isPractice: !local.isPractice
};
const newState = { ...section };
newState.exercises = newState.exercises.map((ex) =>
ex.id === exercise.id ? updatedExercise : ex
);
setLocal((prev) => ({...prev, isPractice: !local.isPractice}))
dispatch({ type: 'UPDATE_SECTION_STATE', payload: { sectionId, update: newState, module: currentModule } });
}
});
useEffect(() => {
validateMultipleChoiceQuestions(local.questions, optionsQuantity, setAlerts);
}, [local.questions, optionsQuantity]);
const handleDragEnd = (event: DragEndEvent) => {
setEditingAlert(true, setAlerts);
setEditing(true);
setLocal(handleMultipleChoiceReorder(event, local));
};
const saveDifficulty = useCallback((diff: Difficulty) => {
if (!difficulty.includes(diff)) {
dispatch({ type: 'UPDATE_MODULE', payload: { updates: { difficulty: [...difficulty, diff]} } });
}
const updatedExercise = { ...exercise, difficulty: diff };
const newState = { ...section };
newState.exercises = newState.exercises.map((ex) => ex.id === exercise.id ? updatedExercise : ex );
dispatch({ type: 'UPDATE_SECTION_STATE', payload: { sectionId, update: newState, module: currentModule } });
}, [currentModule, difficulty, dispatch, exercise, section, sectionId]);
return (
<div className="p-4">
<Header
title='Multiple Choice Exercise'
description={`Edit questions with ${optionsQuantity} options each`}
editing={editing}
difficulty={exercise.difficulty}
saveDifficulty={saveDifficulty}
handleSave={handleSave}
handleDelete={handleDelete}
handleDiscard={handleDiscard}
handlePractice={handlePractice}
isEvaluationEnabled={!local.isPractice}
/>
{alerts.length > 0 && <Alert className="mb-6" alerts={alerts} />}
<PromptEdit value={local.prompt} onChange={(prompt: string) => updateLocal({...local, prompt})} />
<div className="space-y-4">
<QuestionsList
ids={local.questions.map(q => q.id)}
handleDragEnd={handleDragEnd}
>
{local.questions.map((question, questionIndex) => (
<SortableQuestion
key={question.id}
id={question.id}
index={questionIndex}
deleteQuestion={deleteQuestion}
>
<div className="space-y-4">
<input
type="text"
value={question.prompt}
onChange={(e) => updateQuestion(questionIndex, 'prompt', e.target.value)}
className="w-full p-3 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:outline-none"
placeholder="Enter question..."
/>
<div className="space-y-2">
{question.options.map((option, optionIndex) => (
<div key={option.id} className="flex gap-2">
<label
className={clsx(
"flex-none w-12 p-3 text-center rounded-lg border-2 transition-all cursor-pointer",
question.solution === option.id
? 'border-blue-500 bg-blue-50 text-blue-700'
: 'border-gray-200 hover:border-gray-300'
)}
>
<input
type="radio"
name={`solution-${question.id}`}
value={option.id}
checked={question.solution === option.id}
onChange={(e) => updateQuestion(questionIndex, 'solution', e.target.value)}
className="sr-only"
/>
{option.id}
</label>
<input
type="text"
value={option.text}
onChange={(e) => updateOption(questionIndex, optionIndex, e.target.value)}
className="flex-1 p-3 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:outline-none"
placeholder={`Option ${option.id}...`}
/>
</div>
))}
</div>
</div>
</SortableQuestion>
))}
</QuestionsList>
<button
onClick={addQuestion}
className="w-full p-4 border-2 border-dashed border-gray-300 rounded-lg hover:border-blue-500 hover:bg-blue-50 transition-colors flex items-center justify-center gap-2 text-gray-600 hover:text-blue-600"
>
<MdAdd size={18} />
Add New Question
</button>
</div>
</div>
);
};
export default MultipleChoice;

View File

@@ -1,17 +0,0 @@
import { MultipleChoiceExercise } from "@/interfaces/exam";
import Vanilla from "./Vanilla";
import MultipleChoiceUnderline from "./Underline";
const MultipleChoice: React.FC<{sectionId: number; exercise: MultipleChoiceExercise}> = (props) => {
const {exercise} = props;
const length = exercise.questions[0].options.length;
if (exercise.questions[0].prompt.includes('<u>')) {
return <MultipleChoiceUnderline {...props} />
}
return (<Vanilla {...props} optionsQuantity={length}/>);
}
export default MultipleChoice;

View File

@@ -1,86 +0,0 @@
import AutoExpandingTextArea from "@/components/Low/AutoExpandingTextarea";
import { useState } from "react";
import { FaEdit, FaFemale, FaMale } from "react-icons/fa";
import { FaTrash } from "react-icons/fa6";
import { ScriptLine } from ".";
interface MessageProps {
message: ScriptLine & { position: 'left' | 'right' };
color: string;
editing: boolean;
onEdit?: (text: string) => void;
onDelete?: () => void;
}
const Message: React.FC<MessageProps> = ({ message, color, editing, onEdit, onDelete }) => {
const [isEditing, setIsEditing] = useState(false);
const [editText, setEditText] = useState(message.text);
return (
<div className={`flex items-start gap-2 ${message.position === 'left' ? 'justify-start' : 'justify-end'}`}>
<div className="flex flex-col w-[50%]">
<div className={`flex items-center gap-2 ${message.position === 'right' && 'self-end'}`}>
{message.gender === 'male' ? (
<FaMale className="w-5 h-5 text-blue-500" />
) : (
<FaFemale className="w-5 h-5 text-pink-500" />
)}
<span className="text-sm font-medium">{message.name}</span>
</div>
<div className={`rounded-lg p-3 bg-${color}-100 relative group mt-1`}>
{isEditing ? (
<div className="flex flex-col gap-2">
<AutoExpandingTextArea
value={editText}
onChange={setEditText}
placeholder="Edit message..."
className="w-full min-h-[96px] px-4 py-2 border border-gray-200 rounded-lg focus:ring-1 focus:ring-blue-500 focus:border-blue-500 text-base resize-none"
/>
<div className="flex justify-between">
<button
className="px-3 py-1 bg-blue-500 text-white rounded-md hover:bg-blue-600 transition-colors text-sm font-medium"
onClick={() => {
onEdit?.(editText);
setIsEditing(false);
}}
>
Save
</button>
<button
className="px-3 py-1 bg-red-500 rounded-md hover:bg-gray-100 transition-colors text-sm font-medium text-white"
onClick={() => setIsEditing(false)}
>
Cancel
</button>
</div>
</div>
) : (
<div className="flex items-start justify-between gap-2">
<p className="text-gray-700 whitespace-pre-wrap flex-grow">{message.text}</p>
{editing && (
<div className="flex-shrink-0 opacity-0 group-hover:opacity-100 transition-opacity">
<div className="flex gap-1">
<button
onClick={() => setIsEditing(true)}
className="p-1 rounded hover:bg-gray-200 transition-colors"
>
<FaEdit className="w-3.5 h-3.5 text-gray-600" />
</button>
<button
onClick={onDelete}
className="p-1 rounded hover:bg-gray-200 transition-colors"
>
<FaTrash className="w-3.5 h-3.5 text-red-500" />
</button>
</div>
</div>
)}
</div>
)}
</div>
</div>
</div>
);
};
export default Message;

View File

@@ -1,360 +0,0 @@
import React, { useState, useMemo } from 'react';
import { Script } from "@/interfaces/exam";
import Message from './Message';
import AutoExpandingTextArea from '@/components/Low/AutoExpandingTextarea';
import { Card, CardContent } from '@/components/ui/card';
import Input from '@/components/Low/Input';
import { FaFemale, FaMale, FaPlus } from 'react-icons/fa';
import clsx from 'clsx';
import { toast } from 'react-toastify';
export interface Speaker {
id: number;
name: string;
gender: 'male' | 'female';
color: string;
position: 'left' | 'right';
}
type Gender = 'male' | 'female';
export interface ScriptLine {
name: string;
gender: Gender;
text: string;
voice?: string;
}
interface MessageWithPosition extends ScriptLine {
position: 'left' | 'right';
}
interface Props {
section: number;
editing?: boolean;
local?: Script;
setLocal: (script: Script) => void;
}
const colorOptions = [
'red', 'blue', 'green', 'purple', 'pink', 'indigo', 'teal', 'orange',
'cyan', 'emerald', 'sky', 'violet', 'fuchsia', 'rose', 'lime', 'slate'
];
const ScriptEditor: React.FC<Props> = ({ section, editing = false, local, setLocal }) => {
const isConversation = [1, 3].includes(section);
const speakerCount = section === 1 ? 2 : 4;
if (local === undefined) {
if (isConversation) {
setLocal([]);
} else {
setLocal('');
}
}
const [selectedSpeaker, setSelectedSpeaker] = useState<string>('');
const [newMessage, setNewMessage] = useState('');
const [speakers, setSpeakers] = useState<Speaker[]>(() => {
if (local === undefined) {
return Array.from({ length: speakerCount }, (_, index) => ({
id: index,
name: '',
gender: 'male',
color: colorOptions[index],
position: index % 2 === 0 ? 'left' : 'right'
}));
}
const existingScript = local as ScriptLine[];
const existingSpeakers = new Set<string>();
const speakerGenders = new Map<string, 'male' | 'female'>();
if (Array.isArray(existingScript)) {
existingScript.forEach(line => {
existingSpeakers.add(line.name);
speakerGenders.set(line.name, line.gender.toLowerCase() === 'female' ? 'female' : 'male' as 'male' | 'female');
});
}
const speakerArray = Array.from(existingSpeakers);
const totalNeeded = Math.max(speakerCount, speakerArray.length);
return Array.from({ length: totalNeeded }, (_, index) => {
if (index < speakerArray.length) {
return {
id: index,
name: speakerArray[index],
gender: speakerGenders.get(speakerArray[index]) || 'male',
color: colorOptions[index],
position: index % 2 === 0 ? 'left' : 'right'
};
}
return {
id: index,
name: '',
gender: 'male',
color: colorOptions[index],
position: index % 2 === 0 ? 'left' : 'right'
};
});
});
const speakerProperties = useMemo(() => {
return speakers.reduce((acc, speaker) => {
if (speaker.name) {
acc[speaker.name] = {
color: speaker.color,
gender: speaker.gender
};
}
return acc;
}, {} as Record<string, { color: string; gender: 'male' | 'female' }>);
}, [speakers]);
const allSpeakersConfigured = useMemo(() => {
return speakers.every(speaker => speaker.name.trim() !== '');
}, [speakers]);
const updateSpeaker = (index: number, updates: Partial<Speaker>) => {
const updatedSpeakers = speakers.map((speaker, i) => {
if (i === index) {
return { ...speaker, ...updates };
}
return speaker;
});
setSpeakers(updatedSpeakers);
if (Array.isArray(local)) {
if ('name' in updates && speakers[index].name) {
const oldName = speakers[index].name;
const newName = updates.name || '';
const updatedScript = local.map(line => {
if (line.name === oldName) {
return { ...line, name: newName };
}
return line;
});
setLocal(updatedScript);
}
if ('gender' in updates && speakers[index].name && updates.gender) {
const name = speakers[index].name;
const newGender = updates.gender;
const updatedScript = local.map(line => {
if (line.name === name) {
return { ...line, gender: newGender };
}
return line;
});
setLocal(updatedScript);
}
}
if ('name' in updates && speakers[index].name === selectedSpeaker) {
setSelectedSpeaker(updates.name || '');
}
};
const addMessage = () => {
if (!isConversation || !selectedSpeaker || !newMessage.trim()) return;
if (!Array.isArray(local)) return;
const speaker = speakers.find(s => s.name === selectedSpeaker);
if (!speaker) return;
const newLine: ScriptLine = {
name: selectedSpeaker,
gender: speaker.gender,
text: newMessage.trim()
};
const updatedScript = [...local, newLine];
setLocal(updatedScript);
setNewMessage('');
};
const updateMessage = (index: number, newText: string) => {
if (!Array.isArray(local)) return;
const updatedScript = [...local];
updatedScript[index] = { ...updatedScript[index], text: newText };
setLocal(updatedScript);
};
const deleteMessage = (index: number) => {
if (!Array.isArray(local)) return;
const updatedScript = local.filter((_, i) => i !== index);
setLocal(updatedScript);
};
const updateMonologue = (text: string) => {
setLocal(text);
};
const messages = useMemo(() => {
if (typeof local === 'string' || !Array.isArray(local)) return [];
return local.reduce<MessageWithPosition[]>((acc, line, index) => {
const normalizedLine = {
...line,
gender: line.gender.toLowerCase() === 'female' ? 'female' : 'male'
} as ScriptLine;
if (index === 0) {
acc.push({ ...normalizedLine, position: 'left' });
} else {
const prevMsg = acc[index - 1];
const position = line.name === prevMsg.name
? prevMsg.position
: (prevMsg.position === 'left' ? 'right' : 'left');
acc.push({ ...normalizedLine, position });
}
return acc;
}, []);
}, [local]);
if (!isConversation) {
if (typeof local !== 'string') {
toast.error(`Section ${section} is monologue based, but the import contained a conversation!`);
setLocal('');
return null;
}
return (
<Card>
<CardContent className="py-10">
<div className="w-full">
{editing ? (
<AutoExpandingTextArea
value={local as string}
onChange={updateMonologue}
placeholder='Write the monologue here...'
/>
) : (
<div className="p-8 shadow-inner border border-gray-200 bg-gray-50 rounded-xl">
<span>{(local as string) || "Edit, generate or import your own audio."}</span>
</div>
)}
</div>
</CardContent>
</Card>
);
}
if (typeof local === 'string') {
toast.error(`Section ${section} is conversation based, but the import contained a monologue!`);
setLocal([]);
return null;
}
return (
<Card>
<CardContent className="py-10">
<div className="space-y-6">
{editing && (
<div className="bg-white rounded-2xl p-6 shadow-inner border mb-8">
<h3 className="text-lg font-medium text-gray-700 mb-6">Edit Conversation</h3>
<div className="space-y-4 mb-6">
{speakers.map((speaker, index) => (
<div key={index} className="flex items-center gap-4">
<div className="flex-1">
<Input
type="text"
name=""
value={speaker.name}
onChange={(text) => updateSpeaker(index, { name: text })}
placeholder="Speaker name"
/>
</div>
<div className="w-[140px] relative">
<select
value={speaker.gender}
onChange={(e) => updateSpeaker(index, { gender: e.target.value as 'male' | 'female' })}
className="w-full appearance-none px-4 py-2 border border-gray-200 rounded-full text-base bg-white focus:ring-1 focus:ring-blue-500 focus:outline-none"
>
<option value="female">Female</option>
<option value="male">Male</option>
</select>
<div className="absolute right-3 top-2.5 pointer-events-none">
{speaker.gender === 'male' ? (
<FaMale className="w-5 h-5 text-blue-500" />
) : (
<FaFemale className="w-5 h-5 text-pink-500" />
)}
</div>
</div>
</div>
))}
</div>
<div className="flex gap-4">
<div className="w-[240px] flex flex-col gap-2">
<select
value={selectedSpeaker}
onChange={(e) => setSelectedSpeaker(e.target.value)}
disabled={!allSpeakersConfigured}
className="w-full h-[42px] px-4 appearance-none border border-gray-200 rounded-full focus:ring-1 focus:ring-blue-500 focus:outline-none bg-white text-gray-700 text-base disabled:bg-gray-100"
>
<option value="">Select Speaker ...</option>
{speakers.filter(s => s.name).map((speaker) => (
<option key={speaker.id} value={speaker.name}>
{speaker.name}
</option>
))}
</select>
<button
onClick={addMessage}
disabled={!selectedSpeaker || !newMessage.trim() || !allSpeakersConfigured}
className={clsx(
"w-full h-[42px] rounded-lg flex items-center justify-center gap-2 transition-colors font-medium",
!selectedSpeaker || !newMessage.trim() || !allSpeakersConfigured
? 'bg-gray-100 text-gray-500 cursor-not-allowed'
: 'bg-blue-500 text-white hover:bg-blue-600'
)}
>
<FaPlus className="w-4 h-4" />
Add
</button>
</div>
<div className="flex-1">
<AutoExpandingTextArea
value={newMessage}
onChange={setNewMessage}
placeholder={allSpeakersConfigured ? "Type your message..." : "Configure all speakers first"}
disabled={!allSpeakersConfigured}
className="w-full min-h-[96px] px-4 py-2 border border-gray-200 rounded-lg focus:ring-1 focus:ring-blue-500 focus:border-blue-500 text-base resize-none disabled:bg-gray-100"
/>
</div>
</div>
</div>
)}
<div className="space-y-4">
{messages.map((message, index) => {
const properties = speakerProperties[message.name];
if (!properties) return null;
return (
<Message
key={index}
message={message}
color={properties.color}
editing={editing}
onEdit={(text: string) => updateMessage(index, text)}
onDelete={() => deleteMessage(index)}
/>
);
})}
</div>
</div>
</CardContent>
</Card>
);
};
export default ScriptEditor;

View File

@@ -1,61 +0,0 @@
import clsx from "clsx";
import { BiErrorCircle } from "react-icons/bi";
import { IoInformationCircle } from "react-icons/io5";
export interface AlertItem {
variant: "info" | "error";
description: string;
tag?: string;
}
interface Props {
alerts: AlertItem[];
className?: string;
}
const Alert: React.FC<Props> = ({ alerts, className }) => {
const hasError = alerts.some(alert => alert.variant === "error");
const alertsToShow = hasError ? alerts.filter(alert => alert.variant === "error") : alerts;
if (alertsToShow.length === 0) return null;
return (
<div className={clsx("space-y-2", className)}>
{alertsToShow.map((alert, index) => (
<div
key={index}
className={clsx(
"border rounded-xl flex items-center gap-2 py-2 px-4",
{
'bg-amber-50': alert.variant === 'info',
'bg-red-50': alert.variant === 'error'
}
)}
>
{alert.variant === 'info' ? (
<IoInformationCircle
className="h-5 w-5 text-amber-700"
/>
) : (
<BiErrorCircle
className="h-5 w-5 text-red-700"
/>
)}
<p
className={clsx(
"font-medium py-0.5",
{
'text-amber-700': alert.variant === 'info',
'text-red-700': alert.variant === 'error'
}
)}
>
{alert.description}
</p>
</div>
))}
</div>
);
};
export default Alert;

View File

@@ -1,14 +0,0 @@
import clsx from "clsx";
const GenLoader: React.FC<{module: string, custom?: string, className?: string}> = ({module, custom, className}) => {
return (
<div className={clsx("w-full cursor-text px-7 py-8 border-2 border-mti-gray-platinum rounded-3xl", className)}>
<div className="flex flex-col items-center justify-center animate-pulse">
<span className={`loading loading-infinity w-32 bg-ielts-${module}`} />
<span className={`font-bold text-2xl text-ielts-${module}`}>{`${custom ? custom : "Generating..."}`}</span>
</div>
</div>
);
}
export default GenLoader;

View File

@@ -1,70 +0,0 @@
import AutoExpandingTextArea from "@/components/Low/AutoExpandingTextarea";
import { Card, CardContent } from "@/components/ui/card";
import { useState } from "react";
import { MdEdit, MdEditOff } from "react-icons/md";
interface Props {
value: string;
onChange: (text: string) => void;
wrapperCard?: boolean;
}
const PromptEdit: React.FC<Props> = ({ value, onChange, wrapperCard = true }) => {
const [editing, setEditing] = useState(false);
const renderTextWithLineBreaks = (text: string) => {
const unescapedText = text.replace(/\\n/g, '\n');
return unescapedText.split('\n').map((line, index, array) => (
<span key={index}>
{line}
{index < array.length - 1 && <br />}
</span>
));
};
const promptEditTsx = (
<div className="flex justify-between items-start gap-4">
{editing ? (
<AutoExpandingTextArea
className="flex-1 p-3 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:outline-none min-h-[100px]"
value={value}
onChange={onChange}
onBlur={() => setEditing(false)}
/>
) : (
<div className="flex-1">
<h3 className="font-medium text-gray-800 mb-2">
Question/Instructions displayed to the student:
</h3>
<p className="text-gray-600">
{renderTextWithLineBreaks(value)}
</p>
</div>
)}
<button
onClick={() => setEditing(!editing)}
className="p-2 hover:bg-gray-100 rounded-lg transition-colors"
>
{editing ? (
<MdEditOff size={20} className="text-gray-500" />
) : (
<MdEdit size={20} className="text-gray-500" />
)}
</button>
</div>
);
if (!wrapperCard) {
return promptEditTsx;
}
return (
<Card className="mb-6">
<CardContent className="p-4">
{promptEditTsx}
</CardContent>
</Card>
);
};
export default PromptEdit;

View File

@@ -1,34 +0,0 @@
import { closestCenter, DndContext, PointerSensor, useSensor, useSensors } from "@dnd-kit/core";
import { SortableContext, verticalListSortingStrategy } from "@dnd-kit/sortable";
import { ReactNode } from "react";
interface Props {
ids: string[];
handleDragEnd: (event: any) => void;
children: ReactNode;
}
const QuestionsList: React.FC<Props> = ({ ids, handleDragEnd, children }) => {
const sensors = useSensors(
useSensor(PointerSensor),
);
return (
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
>
<SortableContext
items={ids}
strategy={verticalListSortingStrategy}
>
<div className="space-y-4">
{children}
</div>
</SortableContext>
</DndContext>
);
};
export default QuestionsList;

View File

@@ -1,155 +0,0 @@
import React, { ReactNode, useState } from 'react';
import { Card, CardContent } from '@/components/ui/card';
import { MdDragIndicator, MdDelete, MdEdit, MdEditOff } from 'react-icons/md';
import { useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import clsx from 'clsx';
interface Props {
id: string;
index: number;
deleteQuestion: (index: any) => void;
onFocus?: () => void;
extra?: ReactNode;
children: ReactNode;
variant?: 'default' | 'writeBlanks' | 'del-up';
title?: string;
onQuestionChange?: (value: string) => void;
questionText?: string;
}
const SortableQuestion: React.FC<Props> = ({
id,
index,
deleteQuestion,
children,
extra,
onFocus,
variant = 'default',
questionText = "",
onQuestionChange
}) => {
const [isEditingQuestion, setIsEditingQuestion] = useState(false);
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id });
const style = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.5 : 1,
};
if (variant === 'writeBlanks') {
return (
<Card ref={setNodeRef} style={style} onFocus={onFocus}>
<CardContent className="p-4">
<div className="flex items-stretch gap-4">
<div className='flex flex-col flex-none w-12'>
<div className="flex-none">
<span className="text-sm font-medium text-gray-500">ID: {id}</span>
</div>
<div
className='flex-1 flex items-center justify-center group'
{...attributes}
{...listeners}
>
<div className="p-2 rounded-lg group-hover:bg-gray-100 cursor-grab active:cursor-grabbing transition-colors">
<MdDragIndicator size={24} className="text-gray-400" />
</div>
</div>
</div>
<div className="flex-1">
<div className="flex items-start justify-between gap-4">
{isEditingQuestion ? (
<input
type="text"
value={questionText}
onChange={(e) => onQuestionChange?.(e.target.value)}
className="flex-1 p-2 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:outline-none"
autoFocus
onBlur={() => setIsEditingQuestion(false)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
setIsEditingQuestion(false);
}
}}
/>
) : (
<span className="flex-1 font-bold text-gray-800">{questionText}</span>
)}
<div className="flex items-center gap-2 flex-none">
<button
onClick={() => setIsEditingQuestion(!isEditingQuestion)}
className="p-2 hover:bg-gray-100 rounded-lg transition-colors"
>
{isEditingQuestion ?
<MdEditOff size={20} className="text-gray-500" /> :
<MdEdit size={20} className="text-gray-500" />
}
</button>
<button
onClick={() => deleteQuestion(index)}
className="p-2 text-red-500 hover:bg-red-50 rounded-lg transition-colors"
title="Delete question"
>
<MdDelete size={20} />
</button>
</div>
</div>
<div className="mt-4 space-y-3">
{children}
</div>
</div>
</div>
{extra && <div className="mt-4">{extra}</div>}
</CardContent>
</Card>
);
}
return (
<Card ref={setNodeRef} style={style} onFocus={onFocus}>
<CardContent className="p-4">
<div className="flex items-stretch gap-4">
<div className='flex flex-col flex-none w-12'>
<div className="flex-none">
<span className="text-sm font-medium text-gray-500">ID: {id}</span>
</div>
<div className='flex-1 flex items-center justify-center group'>
<div
{...attributes}
{...listeners}
className="p-2 rounded-lg group-hover:bg-gray-100 cursor-grab active:cursor-grabbing transition-colors"
>
<MdDragIndicator size={24} className="text-gray-400" />
</div>
</div>
</div>
<div className="flex-1 space-y-3">
{children}
</div>
<div className={clsx('flex flex-col gap-4', variant !== "del-up" ? "justify-center": "mt-1.5")}>
<button
onClick={() => deleteQuestion(index)}
className="p-2 text-red-500 hover:bg-red-50 rounded-lg transition-colors"
title="Delete question"
>
<MdDelete size={variant !== "del-up" ? 20 : 24} />
</button>
{extra}
</div>
</div>
</CardContent>
</Card>
);
};
export default SortableQuestion;

View File

@@ -1,21 +0,0 @@
import { AlertItem } from "./Alert";
const setEditingAlert = (editing: boolean, setAlerts: React.Dispatch<React.SetStateAction<AlertItem[]>>) => {
if (editing) {
setAlerts(prev => {
if (!prev.some(alert => alert.variant === "info")) {
return [...prev, {
variant: "info",
description: "You have unsaved changes. Don't forget to save your work!",
tag: "editing"
}];
}
return prev;
});
} else {
setAlerts([]);
}
}
export default setEditingAlert;

View File

@@ -1,480 +0,0 @@
import AutoExpandingTextArea from "@/components/Low/AutoExpandingTextarea";
import { Card, CardContent } from "@/components/ui/card";
import { BiQuestionMark } from 'react-icons/bi';
import { AiOutlineUnorderedList, AiOutlinePlus, AiOutlineDelete } from 'react-icons/ai';
import { Tooltip } from "react-tooltip";
import Header from "../../Shared/Header";
import GenLoader from "../Shared/GenLoader";
import { useCallback, useEffect, useState } from "react";
import useSectionEdit from "../../Hooks/useSectionEdit";
import useExamEditorStore from "@/stores/examEditor";
import { Difficulty, InteractiveSpeakingExercise, LevelPart } from "@/interfaces/exam";
import { BsFileText } from "react-icons/bs";
import { FaChevronLeft, FaChevronRight } from "react-icons/fa6";
import { RiVideoLine } from "react-icons/ri";
import { Module } from "@/interfaces";
interface Props {
sectionId: number;
exercise: InteractiveSpeakingExercise;
module?: Module;
}
const InteractiveSpeaking: React.FC<Props> = ({ sectionId, exercise, module = "speaking" }) => {
const { currentModule, dispatch } = useExamEditorStore();
const difficulty = useExamEditorStore((state) => state.modules[currentModule].difficulty);
const [local, setLocal] = useState(exercise);
const [currentVideoIndex, setCurrentVideoIndex] = useState(0);
const { generating, genResult, state, levelGenResults, levelGenerating } = useExamEditorStore(
(state) => state.modules[module].sections.find((section) => section.sectionId === sectionId)!
);
const { editing, setEditing, handleSave, handleDiscard, handleEdit, handlePractice } = useSectionEdit({
sectionId,
onSave: () => {
setEditing(false);
if (module === "level") {
const updatedState = {
...state,
exercises: (state as LevelPart).exercises.map((ex) =>
ex.id === local.id ? local : ex
)
};
dispatch({
type: "UPDATE_SECTION_STATE",
payload: { sectionId, update: updatedState, module }
});
} else {
dispatch({
type: "UPDATE_SECTION_STATE",
payload: { sectionId, update: local, module }
});
}
if (genResult) {
dispatch({
type: "UPDATE_SECTION_SINGLE_FIELD",
payload: {
sectionId,
module,
field: "genResult",
value: undefined
}
});
}
const speakingScript = levelGenResults?.find((res) => res.generating === `${local.id}-speakingScript`);
if (module === "level" && speakingScript) {
dispatch({
type: "UPDATE_SECTION_SINGLE_FIELD", payload: {
sectionId,
field: "levelGenResults",
value: levelGenResults.filter((res) => res.generating !== `${local.id}-speakingScript`),
module
}
});
}
},
onDiscard: () => {
setLocal(exercise);
},
onPractice: () => {
const updatedLocal = { ...local, isPractice: !local.isPractice };
setLocal(updatedLocal);
if (module === "level") {
const updatedState = {
...state,
exercises: (state as LevelPart).exercises.map((ex) =>
ex.id === local.id ? updatedLocal : ex
)
};
dispatch({
type: "UPDATE_SECTION_STATE",
payload: { sectionId, update: updatedState, module }
});
} else {
dispatch({
type: "UPDATE_SECTION_STATE",
payload: { sectionId, update: updatedLocal, module }
});
}
},
});
useEffect(() => {
if (genResult && generating === "speakingScript") {
if (!difficulty.includes(genResult.result[0].difficulty)) {
dispatch({ type: 'UPDATE_MODULE', payload: { updates: { difficulty: [...difficulty, genResult.result[0].difficulty]} } });
}
const updatedLocal = {
...local,
title: genResult.result[0].title,
prompts: genResult.result[0].prompts.map((item: any) => ({
text: item || "",
video_url: ""
})),
difficulty: genResult.result[0].difficulty
};
setEditing(true);
setLocal(updatedLocal);
dispatch({
type: "UPDATE_SECTION_SINGLE_FIELD",
payload: {
sectionId,
module,
field: "generating",
value: undefined
}
});
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [genResult, generating]);
useEffect(() => {
if (genResult && generating === "video") {
const updatedLocal = { ...local, prompts: genResult.result[0].prompts };
setLocal(updatedLocal);
dispatch({
type: "UPDATE_SECTION_STATE",
payload: { sectionId, update: updatedLocal, module }
});
dispatch({
type: "UPDATE_SECTION_SINGLE_FIELD",
payload: {
sectionId,
module,
field: "generating",
value: undefined
}
});
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [genResult, generating]);
useEffect(() => {
const speakingScript = levelGenResults?.find((res) => res.generating === `${local.id}-speakingScript`);
const isGenerating = levelGenerating?.includes(`${local.id}-speakingScript`);
if (speakingScript && isGenerating) {
if (!difficulty.includes(speakingScript.result[0].difficulty)) {
dispatch({ type: 'UPDATE_MODULE', payload: { updates: { difficulty: [...difficulty, speakingScript.result[0].difficulty]} } });
}
const updatedLocal = {
...local,
title: speakingScript.result[0].title,
prompts: speakingScript.result[0].prompts.map((item: any) => ({
text: item || "",
video_url: ""
})),
difficulty: speakingScript.result[0].difficulty
};
setEditing(true);
setLocal(updatedLocal);
dispatch({
type: "UPDATE_SECTION_SINGLE_FIELD", payload: {
sectionId,
field: "levelGenerating",
value: levelGenerating.filter((g) => g !== `${local.id}-speakingScript`),
module
}
});
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [levelGenResults, levelGenerating]);
useEffect(() => {
const speakingVideo = levelGenResults?.find((res) => res.generating === `${local.id}-video`);
const isGenerating = levelGenerating?.includes(`${local.id}-video`);
if (speakingVideo && isGenerating) {
const updatedLocal = { ...local, prompts: speakingVideo.result[0].prompts };
setLocal(updatedLocal);
const updatedState = {
...state,
exercises: (state as LevelPart).exercises.map((ex) =>
ex.id === local.id ? updatedLocal : ex
)
};
dispatch({
type: "UPDATE_SECTION_STATE",
payload: { sectionId, update: updatedState, module }
});
dispatch({
type: "UPDATE_SECTION_SINGLE_FIELD", payload: {
sectionId,
field: "levelGenerating",
value: levelGenerating.filter((g) => g !== `${local.id}-video`),
module
}
});
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [levelGenResults, levelGenerating]);
const addPrompt = () => {
setLocal(prev => ({
...prev,
prompts: [...prev.prompts, { text: "", video_url: "" }]
}));
};
const removePrompt = (index: number) => {
setLocal(prev => ({
...prev,
prompts: prev.prompts.filter((_, i) => i !== index)
}));
};
const updatePrompt = (index: number, text: string) => {
setLocal(prev => {
const newPrompts = [...prev.prompts];
newPrompts[index] = { ...newPrompts[index], text };
return { ...prev, prompts: newPrompts };
});
};
const isUnedited = local.prompts.length === 0;
useEffect(() => {
if (genResult && generating === "video") {
setLocal({ ...local, prompts: genResult.result[0].prompts });
dispatch({ type: "UPDATE_SECTION_STATE", payload: { sectionId, update: { ...local, prompts: genResult.result[0].prompts }, module: module } });
dispatch({
type: "UPDATE_SECTION_SINGLE_FIELD",
payload: {
sectionId,
module: module,
field: "generating",
value: undefined
}
});
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [genResult, generating]);
const handlePrevVideo = () => {
setCurrentVideoIndex((prev) => (prev > 0 ? prev - 1 : prev));
};
const handleNextVideo = () => {
setCurrentVideoIndex((prev) =>
(prev < local.prompts.length - 1 ? prev + 1 : prev)
);
};
const saveDifficulty = useCallback((diff: Difficulty)=> {
if (!difficulty.includes(diff)) {
dispatch({ type: 'UPDATE_MODULE', payload: { updates: { difficulty: [...difficulty, diff]} } });
}
if (module !== "level") {
const updatedExercise = { ...exercise, difficulty: diff };
dispatch({ type: 'UPDATE_SECTION_STATE', payload: { sectionId, update: updatedExercise, module: currentModule } });
} else {
const updatedExercise = { ...exercise, difficulty: diff };
const newState = { ...state as LevelPart };
newState.exercises = (newState as LevelPart).exercises.map((ex) => ex.id === exercise.id ? updatedExercise : ex );
dispatch({ type: 'UPDATE_SECTION_STATE', payload: { sectionId, update: newState, module: currentModule } });
}
}, [currentModule, difficulty, dispatch, exercise, module, sectionId, state]);
return (
<>
<div className='relative pb-4'>
<Header
title={`Interactive Speaking Script`}
description='Generate or write the scripts for the videos.'
editing={editing}
difficulty={exercise.difficulty}
saveDifficulty={saveDifficulty}
handleSave={handleSave}
handleEdit={handleEdit}
handleDiscard={handleDiscard}
handlePractice={handlePractice}
isEvaluationEnabled={!local.isPractice}
module="speaking"
/>
</div>
{(generating && generating === "speakingScript") || (levelGenerating.find((g) => g === `${local.id}-speakingScript`)) ? (
<GenLoader module={module} />
) : (
<>
{editing ? (
<>
{local.prompts.every((p) => p.video_url !== "") && (
<Card>
<CardContent className="pt-6">
<div className="flex flex-col items-start gap-3">
<div className="flex flex-row mb-3 gap-4 w-full justify-between items-center">
<div className="flex flex-row gap-4">
<RiVideoLine className="h-5 w-5 text-amber-500 mt-1" />
<h3 className="font-semibold text-xl">Videos</h3>
</div>
<div className="flex items-center gap-4">
<button
onClick={handlePrevVideo}
disabled={currentVideoIndex === 0}
className={`p-2 rounded-full ${currentVideoIndex === 0
? 'text-gray-400 cursor-not-allowed'
: 'text-gray-600 hover:bg-gray-100'
}`}
>
<FaChevronLeft className="w-4 h-4" />
</button>
<span className="text-sm text-gray-600">
{currentVideoIndex + 1} / {local.prompts.length}
</span>
<button
onClick={handleNextVideo}
disabled={currentVideoIndex === local.prompts.length - 1}
className={`p-2 rounded-full ${currentVideoIndex === local.prompts.length - 1
? 'text-gray-400 cursor-not-allowed'
: 'text-gray-600 hover:bg-gray-100'
}`}
>
<FaChevronRight className="w-4 h-4" />
</button>
</div>
</div>
<div className="flex flex-col gap-4 w-full items-center">
<div className="w-full">
<video
key={local.prompts[currentVideoIndex].video_url}
controls
className="w-full rounded-xl"
>
<source src={local.prompts[currentVideoIndex].video_url} />
</video>
</div>
</div>
</div>
</CardContent>
</Card>
)}
{(generating && generating === "video") || levelGenerating.find((g) => g === `${local.id}-video`) &&
<GenLoader module={module} custom="Generating the videos ... This may take a while ..." />
}
<Card>
<CardContent>
<div className="flex flex-col py-2 mt-2">
<h2 className="font-semibold text-xl mb-2">Title</h2>
<AutoExpandingTextArea
value={local.title || ''}
onChange={(text) => setLocal(prev => ({ ...prev, title: text }))}
className="w-full p-3 border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-transparent min-h-[80px] transition-all"
placeholder="Enter the title"
/>
</div>
</CardContent>
</Card>
<Card>
<CardContent>
<div className="flex items-center mb-4 mt-6">
<h2 className="font-semibold text-xl">Questions</h2>
</div>
<div className="space-y-5">
{local.prompts.length === 0 ? (
<div className="py-12 text-center bg-gray-200 rounded-lg border-2 border-dashed border-gray-400">
<p className="text-gray-600">No questions added yet</p>
</div>
) : (
local.prompts.map((prompt, index) => (
<Card key={index}>
<CardContent>
<div className="bg-gray-50 rounded-lg pt-4">
<div className="flex justify-between items-center mb-3">
<h3 className="font-medium text-gray-700">Question {index + 1}</h3>
<button
type="button"
className="p-1.5 text-gray-400 hover:text-red-500 hover:bg-red-50 rounded-full transition-colors"
onClick={() => removePrompt(index)}
>
<AiOutlineDelete className="h-5 w-5" />
</button>
</div>
<AutoExpandingTextArea
value={prompt.text}
onChange={(text) => updatePrompt(index, text)}
className="w-full p-3 border border-gray-200 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-transparent min-h-[80px] transition-all bg-white"
placeholder={`Enter question ${index + 1}`}
/>
</div>
</CardContent>
</Card>
))
)}
</div>
<div className="mt-6">
<button
type="button"
onClick={addPrompt}
className="w-full py-3 px-4 bg-gray-50 border border-gray-200 rounded-lg hover:bg-gray-100 transition-colors flex items-center justify-center gap-2 text-gray-600 font-medium"
>
<AiOutlinePlus className="h-5 w-5" />
Add Question
</button>
</div>
</CardContent>
</Card>
</>
) : isUnedited ? (
<p className="w-full text-gray-600 px-7 py-8 border-2 bg-white rounded-3xl whitespace-pre-line">
Generate or edit the questions!
</p>
) : (
<div className="space-y-6">
<Card>
<CardContent className="pt-6">
<div className="flex flex-col items-start gap-3">
<div className="flex flex-row mb-3 gap-4">
<BsFileText className="h-5 w-5 text-blue-500 mt-1" />
<h3 className="font-semibold text-xl">Title</h3>
</div>
<div className="w-full px-4 py-3 bg-white shadow-inner rounded-lg border border-gray-100">
<p className="text-lg">{local.title || 'Untitled'}</p>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="pt-6">
<div className="flex flex-col items-start gap-3">
<div className="flex flex-row mb-3 gap-4">
<AiOutlineUnorderedList className="h-5 w-5 text-purple-500 mt-1" />
<h3 className="font-semibold text-xl">Questions</h3>
</div>
<div className="w-full space-y-4">
{local.prompts
.filter(prompt => prompt.text !== "")
.map((prompt, index) => (
<div key={index} className="bg-white shadow-inner rounded-lg border border-gray-100 p-4">
<h4 className="font-medium text-gray-700 mb-2">Question {index + 1}</h4>
<p className="text-gray-700">{prompt.text}</p>
</div>
))
}
</div>
</div>
</CardContent>
</Card>
</div>
)}
</>
)}
</>
);
};
export default InteractiveSpeaking;

View File

@@ -1,544 +0,0 @@
import React, { useCallback, useEffect, useState } from 'react';
import AutoExpandingTextArea from "@/components/Low/AutoExpandingTextarea";
import { Card, CardContent } from "@/components/ui/card";
import { AiOutlineUnorderedList, AiOutlinePlus, AiOutlineDelete } from 'react-icons/ai';
import Header from "../../Shared/Header";
import GenLoader from "../Shared/GenLoader";
import useSectionEdit from "../../Hooks/useSectionEdit";
import useExamEditorStore from "@/stores/examEditor";
import { Difficulty, InteractiveSpeakingExercise, LevelPart } from "@/interfaces/exam";
import { BsFileText } from "react-icons/bs";
import { RiVideoLine } from 'react-icons/ri';
import { FaChevronLeft, FaChevronRight } from 'react-icons/fa6';
import { Module } from '@/interfaces';
interface Props {
sectionId: number;
exercise: InteractiveSpeakingExercise;
module?: Module;
}
const Speaking1: React.FC<Props> = ({ sectionId, exercise, module = "speaking" }) => {
const { currentModule, dispatch } = useExamEditorStore();
const difficulty = useExamEditorStore((state) => state.modules[currentModule].difficulty);
const [local, setLocal] = useState(() => {
const defaultPrompts = [
{ text: "Hello my name is {avatar}, what is yours?", video_url: "" },
{ text: "Do you work or do you study?", video_url: "" },
...exercise.prompts.slice(2)
];
return { ...exercise, prompts: defaultPrompts };
});
const [currentVideoIndex, setCurrentVideoIndex] = useState(0);
const { generating, genResult, state, levelGenResults, levelGenerating } = useExamEditorStore(
(state) => state.modules[module].sections.find((section) => section.sectionId === sectionId)!
);
const { editing, setEditing, handleSave, handleDiscard, handleEdit, handlePractice } = useSectionEdit({
sectionId,
onSave: () => {
setEditing(false);
if (module === "level") {
const updatedState = {
...state,
exercises: (state as LevelPart).exercises.map((ex) =>
ex.id === local.id ? local : ex
)
};
dispatch({
type: "UPDATE_SECTION_STATE",
payload: { sectionId, update: updatedState, module }
});
} else {
dispatch({
type: "UPDATE_SECTION_STATE",
payload: { sectionId, update: local, module }
});
}
if (genResult) {
dispatch({
type: "UPDATE_SECTION_SINGLE_FIELD",
payload: {
sectionId,
module,
field: "genResult",
value: undefined
}
});
}
const speakingScript = levelGenResults?.find((res) => res.generating === `${local.id}-speakingScript`);
if (module === "level" && speakingScript) {
dispatch({
type: "UPDATE_SECTION_SINGLE_FIELD", payload: {
sectionId,
field: "levelGenResults",
value: levelGenResults.filter((res) => res.generating !== `${local.id}-speakingScript`),
module
}
});
}
},
onDiscard: () => {
setLocal({
...exercise,
prompts: [
{ text: "Hello my name is {avatar}, what is yours?", video_url: "" },
{ text: "Do you work or do you study?", video_url: "" },
...exercise.prompts.slice(2)
]
});
},
onPractice: () => {
const updatedLocal = { ...local, isPractice: !local.isPractice };
setLocal(updatedLocal);
if (module === "level") {
const updatedState = {
...state,
exercises: (state as LevelPart).exercises.map((ex) =>
ex.id === local.id ? updatedLocal : ex
)
};
dispatch({
type: "UPDATE_SECTION_STATE",
payload: { sectionId, update: updatedState, module }
});
} else {
dispatch({
type: "UPDATE_SECTION_STATE",
payload: { sectionId, update: updatedLocal, module }
});
}
},
});
useEffect(() => {
if (genResult && generating === "speakingScript") {
if (!difficulty.includes(genResult.result[0].difficulty)) {
dispatch({ type: 'UPDATE_MODULE', payload: { updates: { difficulty: [...difficulty, genResult.result[0].difficulty]} } });
}
const updatedLocal = {
...local,
first_title: genResult.result[0].first_topic,
second_title: genResult.result[0].second_topic,
prompts: [
local.prompts[0],
local.prompts[1],
...genResult.result[0].prompts.map((item: any) => ({
text: item,
video_url: ""
}))
],
difficulty: genResult.result[0].difficulty
};
setEditing(true);
setLocal(updatedLocal);
dispatch({
type: "UPDATE_SECTION_SINGLE_FIELD",
payload: {
sectionId,
module,
field: "generating",
value: undefined
}
});
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [genResult, generating]);
useEffect(() => {
if (genResult && generating === "video") {
const updatedLocal = { ...local, prompts: genResult.result[0].prompts };
setLocal(updatedLocal);
dispatch({
type: "UPDATE_SECTION_STATE",
payload: { sectionId, update: updatedLocal, module }
});
dispatch({
type: "UPDATE_SECTION_SINGLE_FIELD",
payload: {
sectionId,
module,
field: "generating",
value: undefined
}
});
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [genResult, generating]);
useEffect(() => {
const speakingScript = levelGenResults?.find((res) => res.generating === `${local.id}-speakingScript`);
const isGenerating = levelGenerating?.includes(`${local.id}-speakingScript`);
if (speakingScript && isGenerating) {
if (!difficulty.includes(speakingScript.result[0].difficulty)) {
dispatch({ type: 'UPDATE_MODULE', payload: { updates: { difficulty: [...difficulty, speakingScript.result[0].difficulty]} } });
}
const updatedLocal = {
...local,
first_title: speakingScript.result[0].first_topic,
second_title: speakingScript.result[0].second_topic,
difficulty: speakingScript.result[0].difficulty,
prompts: [
local.prompts[0],
local.prompts[1],
...speakingScript.result[0].prompts.map((item: any) => ({
text: item,
video_url: ""
}))
]
};
setEditing(true);
setLocal(updatedLocal);
dispatch({
type: "UPDATE_SECTION_SINGLE_FIELD", payload: {
sectionId,
field: "levelGenerating",
value: levelGenerating.filter((g) => g !== `${local.id}-speakingScript`),
module
}
});
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [levelGenResults, levelGenerating]);
useEffect(() => {
const speakingVideo = levelGenResults?.find((res) => res.generating === `${local.id}-video`);
const isGenerating = levelGenerating?.includes(`${local.id}-video`);
if (speakingVideo && isGenerating) {
const updatedLocal = { ...local, video_url: speakingVideo.result[0].video_url };
setLocal(updatedLocal);
const updatedState = {
...state,
exercises: (state as LevelPart).exercises.map((ex) =>
ex.id === local.id ? updatedLocal : ex
)
};
dispatch({
type: "UPDATE_SECTION_STATE",
payload: { sectionId, update: updatedState, module }
});
dispatch({
type: "UPDATE_SECTION_SINGLE_FIELD", payload: {
sectionId,
field: "levelGenerating",
value: levelGenerating.filter((g) => g !== `${local.id}-video`),
module
}
});
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [levelGenResults, levelGenerating]);
const addPrompt = () => {
setLocal(prev => ({
...prev,
prompts: [...prev.prompts, { text: "", video_url: "" }]
}));
};
const removePrompt = (index: number) => {
if (index < 2) return;
setLocal(prev => ({
...prev,
prompts: prev.prompts.filter((_, i) => i !== index)
}));
};
const updatePrompt = (index: number, text: string) => {
if (index < 2) return;
setLocal(prev => {
const newPrompts = [...prev.prompts];
newPrompts[index] = { ...newPrompts[index], text };
return { ...prev, prompts: newPrompts };
});
};
const isUnedited = local.prompts.length === 2;
useEffect(() => {
if (genResult && generating === "video") {
setLocal({ ...local, prompts: genResult.result[0].prompts });
dispatch({ type: "UPDATE_SECTION_STATE", payload: { sectionId, update: { ...local, prompts: genResult.result[0].prompts }, module } });
dispatch({
type: "UPDATE_SECTION_SINGLE_FIELD",
payload: {
sectionId,
module: module,
field: "generating",
value: undefined
}
});
dispatch({
type: "UPDATE_SECTION_SINGLE_FIELD",
payload: {
sectionId,
module: module,
field: "genResult",
value: undefined
}
});
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [genResult, generating]);
const handlePrevVideo = () => {
setCurrentVideoIndex((prev) => (prev > 0 ? prev - 1 : prev));
};
const handleNextVideo = () => {
setCurrentVideoIndex((prev) =>
(prev < local.prompts.length - 1 ? prev + 1 : prev)
);
};
const saveDifficulty = useCallback((diff: Difficulty)=> {
if (!difficulty.includes(diff)) {
dispatch({ type: 'UPDATE_MODULE', payload: { updates: { difficulty: [...difficulty, diff]} } });
}
if (module !== "level") {
const updatedExercise = { ...exercise, difficulty: diff };
dispatch({ type: 'UPDATE_SECTION_STATE', payload: { sectionId, update: updatedExercise, module: currentModule } });
} else {
const updatedExercise = { ...exercise, difficulty: diff };
const newState = { ...state as LevelPart };
newState.exercises = (newState as LevelPart).exercises.map((ex) => ex.id === exercise.id ? updatedExercise : ex );
dispatch({ type: 'UPDATE_SECTION_STATE', payload: { sectionId, update: newState, module: currentModule } });
}
}, [currentModule, difficulty, dispatch, exercise, module, sectionId, state]);
return (
<>
<div className='relative pb-4'>
<Header
title={`Speaking 1 Script`}
description='Generate or write the scripts for the videos.'
editing={editing}
difficulty={exercise.difficulty}
saveDifficulty={saveDifficulty}
handleSave={handleSave}
handleEdit={handleEdit}
handleDiscard={handleDiscard}
handlePractice={handlePractice}
isEvaluationEnabled={!local.isPractice}
module="speaking"
/>
</div>
{(generating && generating === "speakingScript") || (levelGenerating.find((g) => g === `${local.id}-speakingScript`)) ? (
<GenLoader module={module} />
) : (
<>
{editing ? (
<>
<Card>
<CardContent>
<div className="py-2 mt-2">
<div className="flex flex-row mb-3 gap-4">
<BsFileText className="h-5 w-5 text-blue-500 mt-1" />
<h3 className="font-semibold text-xl">Titles</h3>
</div>
<div className="flex gap-6 mt-6">
<div className="flex-1">
<h2 className="font-semibold text-lg mb-2">First Title</h2>
<AutoExpandingTextArea
value={local.first_title || ''}
onChange={(text) => setLocal(prev => ({ ...prev, first_title: text }))}
className="w-full p-3 border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-transparent min-h-[80px] transition-all"
placeholder="Enter the first title"
/>
</div>
<div className="flex-1">
<h2 className="font-semibold text-lg mb-2">Second Title</h2>
<AutoExpandingTextArea
value={local.second_title || ''}
onChange={(text) => setLocal(prev => ({ ...prev, second_title: text }))}
className="w-full p-3 border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-transparent min-h-[80px] transition-all"
placeholder="Enter the second title"
/>
</div>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardContent>
<div className="flex items-center justify-between mb-4 mt-6">
<h2 className="font-semibold text-xl">Questions</h2>
</div>
<div className="space-y-5">
{local.prompts.length === 2 ? (
<div className="py-12 text-center bg-gray-200 rounded-lg border-2 border-dashed border-gray-400">
<p className="text-gray-600">No questions added yet</p>
</div>
) : (
local.prompts.slice(2).map((prompt, index) => (
<Card key={index}>
<CardContent>
<div className="bg-gray-50 rounded-lg pt-4">
<div className="flex justify-between items-center mb-3">
<h3 className="font-medium text-gray-700">Question {index + 1}</h3>
<button
type="button"
className="p-1.5 text-gray-400 hover:text-red-500 hover:bg-red-50 rounded-full transition-colors"
onClick={() => removePrompt(index + 2)}
>
<AiOutlineDelete className="h-5 w-5" />
</button>
</div>
<AutoExpandingTextArea
value={prompt.text}
onChange={(text) => updatePrompt(index + 2, text)}
className="w-full p-3 border border-gray-200 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-transparent min-h-[80px] transition-all bg-white"
placeholder={`Enter question ${index + 1}`}
/>
</div>
</CardContent>
</Card>
))
)}
</div>
<div className="mt-6">
<button
type="button"
onClick={addPrompt}
className="w-full py-3 px-4 bg-gray-50 border border-gray-200 rounded-lg hover:bg-gray-100 transition-colors flex items-center justify-center gap-2 text-gray-600 font-medium"
>
<AiOutlinePlus className="h-5 w-5" />
Add Question
</button>
</div>
</CardContent>
</Card>
</>
) : isUnedited ? (
<p className="w-full text-gray-600 px-7 py-8 border-2 bg-white rounded-3xl whitespace-pre-line">
Generate or edit the questions!
</p>
) : (
<div className="space-y-6">
{local.prompts.every((p) => p.video_url !== "") && (
<Card>
<CardContent className="pt-6">
<div className="flex flex-col items-start gap-3">
<div className="flex flex-row mb-3 gap-4 w-full justify-between items-center">
<div className="flex flex-row gap-4">
<RiVideoLine className="h-5 w-5 text-amber-500 mt-1" />
<h3 className="font-semibold text-xl">Videos</h3>
</div>
<div className="flex items-center gap-4">
<button
onClick={handlePrevVideo}
disabled={currentVideoIndex === 0}
className={`p-2 rounded-full ${currentVideoIndex === 0
? 'text-gray-400 cursor-not-allowed'
: 'text-gray-600 hover:bg-gray-100'
}`}
>
<FaChevronLeft className="w-4 h-4" />
</button>
<span className="text-sm text-gray-600">
{currentVideoIndex + 1} / {local.prompts.length}
</span>
<button
onClick={handleNextVideo}
disabled={currentVideoIndex === local.prompts.length - 1}
className={`p-2 rounded-full ${currentVideoIndex === local.prompts.length - 1
? 'text-gray-400 cursor-not-allowed'
: 'text-gray-600 hover:bg-gray-100'
}`}
>
<FaChevronRight className="w-4 h-4" />
</button>
</div>
</div>
<div className="flex flex-col gap-4 w-full items-center">
<div className="w-full">
<video
key={local.prompts[currentVideoIndex].video_url}
controls
className="w-full rounded-xl"
>
<source src={local.prompts[currentVideoIndex].video_url} />
</video>
</div>
</div>
</div>
</CardContent>
</Card>
)}
{(generating && generating === "video") || levelGenerating.find((g) => g === `${local.id}-video`) &&
<GenLoader module={module} custom="Generating the videos ... This may take a while ..." />
}
<Card>
<CardContent className="pt-6">
<div className="flex flex-col items-start">
<div className="flex flex-row mb-4 gap-4">
<BsFileText className="h-5 w-5 text-blue-500 mt-1" />
<h3 className="font-semibold text-xl">Titles</h3>
</div>
<div className="w-full flex gap-6 mt-6">
<div className="flex-1">
<h4 className="font-medium text-gray-700 mb-2">First Title</h4>
<div className="w-full px-4 py-3 bg-white shadow-inner rounded-lg border border-gray-100">
<p className="text-lg">{local.first_title || 'No first title'}</p>
</div>
</div>
<div className="flex-1">
<h4 className="font-medium text-gray-700 mb-2">Second Title</h4>
<div className="w-full px-4 py-3 bg-white shadow-inner rounded-lg border border-gray-100">
<p className="text-lg">{local.second_title || 'No second title'}</p>
</div>
</div>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="pt-6">
<div className="flex flex-col items-start gap-3">
<div className="flex flex-row mb-3 gap-4">
<AiOutlineUnorderedList className="h-5 w-5 text-purple-500 mt-1" />
<h3 className="font-semibold text-xl">Questions</h3>
</div>
<div className="w-full space-y-4">
{local.prompts.slice(2)
.filter(prompt => prompt.text !== "")
.map((prompt, index) => (
<div key={index} className="bg-white shadow-inner rounded-lg border border-gray-100 p-4">
<h4 className="font-medium text-gray-700 mb-2">Question {index + 1}</h4>
<p className="text-gray-700">{prompt.text}</p>
</div>
))
}
</div>
</div>
</CardContent>
</Card>
</div>
)}
</>
)}
</>
);
};
export default Speaking1;

View File

@@ -1,462 +0,0 @@
import AutoExpandingTextArea from "@/components/Low/AutoExpandingTextarea";
import { Card, CardContent } from "@/components/ui/card";
import { AiOutlinePlus, AiOutlineDelete } from 'react-icons/ai';
import { Difficulty, LevelPart, SpeakingExercise } from "@/interfaces/exam";
import useExamEditorStore from "@/stores/examEditor";
import { useCallback, useEffect, useState } from "react";
import useSectionEdit from "../../Hooks/useSectionEdit";
import Header from "../../Shared/Header";
import { Tooltip } from "react-tooltip";
import { BsFileText } from 'react-icons/bs';
import { AiOutlineUnorderedList } from 'react-icons/ai';
import { BiQuestionMark, BiMessageRoundedDetail } from "react-icons/bi";
import GenLoader from "../Shared/GenLoader";
import { RiVideoLine } from 'react-icons/ri';
import { Module } from "@/interfaces";
interface Props {
sectionId: number;
exercise: SpeakingExercise;
module?: Module;
}
const Speaking2: React.FC<Props> = ({ sectionId, exercise, module = "speaking" }) => {
const { currentModule, dispatch } = useExamEditorStore();
const difficulty = useExamEditorStore((state) => state.modules[currentModule].difficulty);
const [local, setLocal] = useState(exercise);
const { sections } = useExamEditorStore((store) => store.modules[module]);
const section = sections.find((section) => section.sectionId === sectionId)!;
const { generating, genResult, state, levelGenResults, levelGenerating } = section;
const { editing, setEditing, handleSave, handleDiscard, handleEdit, handlePractice } = useSectionEdit({
sectionId,
onSave: () => {
setEditing(false);
if (module === "level") {
const updatedState = {
...state,
exercises: (state as LevelPart).exercises.map((ex) =>
ex.id === local.id ? local : ex
)
};
dispatch({
type: "UPDATE_SECTION_STATE",
payload: { sectionId, update: updatedState, module }
});
} else {
dispatch({
type: "UPDATE_SECTION_STATE",
payload: { sectionId, update: local, module }
});
}
if (genResult) {
dispatch({
type: "UPDATE_SECTION_SINGLE_FIELD",
payload: {
sectionId,
module,
field: "genResult",
value: undefined
}
});
}
const speakingScript = levelGenResults.find((res) => res.generating === `${local.id}-speakingScript`)
if (module === "level" && speakingScript) {
dispatch({
type: "UPDATE_SECTION_SINGLE_FIELD", payload: {
sectionId,
field: "levelGenResults",
value: section!.levelGenResults.filter((res) => res.generating !== `${local.id ? `${local.id}-` : ''}speakingScript`),
module
}
})
}
},
onDiscard: () => {
setLocal(exercise);
},
onPractice: () => {
const updatedLocal = { ...local, isPractice: !local.isPractice };
setLocal(updatedLocal);
if (module === "level") {
const updatedState = {
...state,
exercises: (state as LevelPart).exercises.map((ex) =>
ex.id === local.id ? updatedLocal : ex
)
};
dispatch({
type: "UPDATE_SECTION_STATE",
payload: { sectionId, update: updatedState, module }
});
} else {
dispatch({
type: "UPDATE_SECTION_STATE",
payload: { sectionId, update: updatedLocal, module }
});
}
},
});
useEffect(() => {
if (genResult && generating === "speakingScript") {
if (!difficulty.includes(genResult.result[0].difficulty)) {
dispatch({ type: 'UPDATE_MODULE', payload: { updates: { difficulty: [...difficulty, genResult.result[0].difficulty]} } });
}
const updatedLocal = {
...local,
title: genResult.result[0].topic,
text: genResult.result[0].question,
prompts: genResult.result[0].prompts,
difficulty: genResult.result[0].difficulty
};
setEditing(true);
setLocal(updatedLocal);
dispatch({
type: "UPDATE_SECTION_SINGLE_FIELD",
payload: {
sectionId,
module,
field: "generating",
value: undefined
}
});
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [genResult, generating]);
useEffect(() => {
if (genResult && generating === "video") {
const updatedLocal = { ...local, video_url: genResult.result[0].video_url };
setLocal(updatedLocal);
dispatch({
type: "UPDATE_SECTION_STATE",
payload: { sectionId, update: updatedLocal, module }
});
dispatch({
type: "UPDATE_SECTION_SINGLE_FIELD",
payload: {
sectionId,
module,
field: "generating",
value: undefined
}
});
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [genResult, generating]);
useEffect(() => {
const speakingScript = levelGenResults.find((res) => res.generating === `${local.id}-speakingScript`);
const generating = levelGenerating.find((res) => res === `${local.id}-speakingScript`);
if (speakingScript && generating) {
if (!difficulty.includes(speakingScript.result[0].difficulty)) {
dispatch({ type: 'UPDATE_MODULE', payload: { updates: { difficulty: [...difficulty, speakingScript.result[0].difficulty]} } });
}
const updatedLocal = {
...local,
title: speakingScript.result[0].topic,
text: speakingScript.result[0].question,
prompts: speakingScript.result[0].prompts,
difficulty: speakingScript.result[0].difficulty
};
setEditing(true);
setLocal(updatedLocal);
dispatch({
type: "UPDATE_SECTION_SINGLE_FIELD", payload: {
sectionId,
field: "levelGenerating",
value: section!.levelGenerating.filter((g) => g !== `${local.id ? `${local.id}-` : ''}speakingScript`),
module
}
})
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [levelGenResults, levelGenerating]);
useEffect(() => {
const speakingVideo = levelGenResults.find((res) => res.generating === `${local.id}-video`);
const generating = levelGenerating.find((res) => res === `${local.id}-video`);
if (speakingVideo && generating) {
const updatedLocal = { ...local, video_url: speakingVideo.result[0].video_url };
setLocal(updatedLocal);
const updatedState = {
...state,
exercises: (state as LevelPart).exercises.map((ex) =>
ex.id === local.id ? updatedLocal : ex
)
};
dispatch({
type: "UPDATE_SECTION_STATE",
payload: { sectionId, update: updatedState, module }
});
dispatch({
type: "UPDATE_SECTION_SINGLE_FIELD", payload: {
sectionId,
field: "levelGenerating",
value: section!.levelGenerating.filter((g) => g !== `${local.id ? `${local.id}-` : ''}video`),
module
}
});
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [levelGenResults, levelGenerating]);
const addPrompt = () => {
setLocal(prev => ({
...prev,
prompts: [...prev.prompts, ""]
}));
};
const removePrompt = (index: number) => {
setLocal(prev => ({
...prev,
prompts: prev.prompts.filter((_, i) => i !== index)
}));
};
const updatePrompt = (index: number, text: string) => {
setLocal(prev => {
const newPrompts = [...prev.prompts];
newPrompts[index] = text;
return { ...prev, prompts: newPrompts };
});
};
const isUnedited = local.text === "" ||
(local.title === undefined || local.title === "") ||
local.prompts.length === 0;
const tooltipContent = `
<div class='p-2 max-w-xs'>
<p class='text-sm text-white'>
Prompts are guiding points that help candidates structure their talk. They typically include aspects like:
<ul class='list-disc pl-4 mt-1'>
<li>Describing what/who/where</li>
<li>Explaining why</li>
<li>Sharing feelings or preferences</li>
</ul>
</p>
</div>
`;
const saveDifficulty = useCallback((diff: Difficulty)=> {
if (!difficulty.includes(diff)) {
dispatch({ type: 'UPDATE_MODULE', payload: { updates: { difficulty: [...difficulty, diff]} } });
}
if (module !== "level") {
const updatedExercise = { ...exercise, difficulty: diff };
dispatch({ type: 'UPDATE_SECTION_STATE', payload: { sectionId, update: updatedExercise, module: currentModule } });
} else {
const updatedExercise = { ...exercise, difficulty: diff };
const newState = { ...state as LevelPart };
newState.exercises = (newState as LevelPart).exercises.map((ex) => ex.id === exercise.id ? updatedExercise : ex );
dispatch({ type: 'UPDATE_SECTION_STATE', payload: { sectionId, update: newState, module: currentModule } });
}
}, [currentModule, difficulty, dispatch, exercise, module, sectionId, state]);
return (
<>
<div className='relative pb-4'>
<Header
title={`Speaking ${module === "level" ? local.sectionId : sectionId} Script`}
description='Generate or write the script for the video.'
editing={editing}
difficulty={exercise.difficulty}
saveDifficulty={saveDifficulty}
handleSave={handleSave}
handleEdit={handleEdit}
handleDiscard={handleDiscard}
handlePractice={handlePractice}
isEvaluationEnabled={!local.isPractice}
module="speaking"
/>
</div>
{((generating && generating === "speakingScript") || (levelGenerating.find((g) => g === `${local.id}-speakingScript`))) ? (
<GenLoader module={module} />
) : (
<>
{editing ? (
<>
<Card>
<CardContent>
<div className="flex flex-col py-2 mt-2">
<h2 className="font-semibold text-xl mb-2">Title</h2>
<AutoExpandingTextArea
value={local.title || ''}
onChange={(text) => setLocal(prev => ({ ...prev, title: text }))}
className="w-full p-3 border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-transparent min-h-[80px] transition-all"
placeholder="Enter the topic"
/>
</div>
</CardContent>
</Card>
<Card>
<CardContent>
<div className="flex flex-col py-2 mt-2">
<h2 className="font-semibold text-xl mb-2">Question</h2>
<AutoExpandingTextArea
value={local.text}
onChange={(text) => setLocal(prev => ({ ...prev, text: text }))}
className="w-full p-3 border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-transparent min-h-[80px] transition-all"
placeholder="Enter the main question"
/>
</div>
</CardContent>
</Card>
<Card>
<CardContent>
<div className="flex items-center justify-between mb-4 mt-6">
<h2 className="font-semibold text-xl">Prompts</h2>
<Tooltip id="prompt-tp" />
<a
data-tooltip-id="prompt-tp"
data-tooltip-html={tooltipContent}
className='ml-1 w-6 h-6 flex items-center justify-center rounded-full hover:bg-gray-200 border bg-gray-100'
>
<BiQuestionMark
className="w-5 h-5 text-gray-500"
/>
</a>
</div>
<div className="space-y-5">
{local.prompts.length === 0 ? (
<div className="py-12 text-center bg-gray-200 rounded-lg border-2 border-dashed border-gray-400">
<p className="text-gray-600">No prompts added yet</p>
</div>
) : (
local.prompts.map((prompt, index) => (
<Card key={index}>
<CardContent>
<div className="bg-gray-50 rounded-lg pt-4">
<div className="flex justify-between items-center mb-3">
<h3 className="font-medium text-gray-700">Prompt {index + 1}</h3>
<button
type="button"
className="p-1.5 text-gray-400 hover:text-red-500 hover:bg-red-50 rounded-full transition-colors"
onClick={() => removePrompt(index)}
>
<AiOutlineDelete className="h-5 w-5" />
</button>
</div>
<AutoExpandingTextArea
value={prompt}
onChange={(text) => updatePrompt(index, text)}
className="w-full p-3 border border-gray-200 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-transparent min-h-[80px] transition-all bg-white"
placeholder={`Enter prompt ${index + 1}`}
/>
</div>
</CardContent>
</Card>
))
)}
</div>
<div className="mt-6">
<button
type="button"
onClick={addPrompt}
className="w-full py-3 px-4 bg-gray-50 border border-gray-200 rounded-lg hover:bg-gray-100 transition-colors flex items-center justify-center gap-2 text-gray-600 font-medium"
>
<AiOutlinePlus className="h-5 w-5" />
Add Prompt
</button>
</div>
</CardContent>
</Card>
</>
) : isUnedited ? (
<p className="w-full text-gray-600 px-7 py-8 border-2 bg-white rounded-3xl whitespace-pre-line">
Generate or edit the script!
</p>
) : (
<div className="space-y-6">
{local.video_url && <Card>
<CardContent className="pt-6">
<div className="flex flex-col items-start gap-3">
<div className="flex flex-row mb-3 gap-4">
<RiVideoLine className="h-5 w-5 text-amber-500 mt-1" />
<h3 className="font-semibold text-xl">Video</h3>
</div>
<div className="flex flex-col gap-4 w-full items-center">
<video controls className="w-full rounded-xl">
<source src={local.video_url} />
</video>
</div>
</div>
</CardContent>
</Card>
}
{((generating === "video") || (levelGenerating.find((g) => g === `${local.id}-video`) !== undefined)) &&
<GenLoader module={module} custom="Generating the video ... This may take a while ..." />
}
<Card>
<CardContent className="pt-6">
<div className="flex flex-col items-start gap-3">
<div className="flex flex-row mb-3 gap-4">
<BsFileText className="h-5 w-5 text-blue-500 mt-1" />
<h3 className="font-semibold text-xl">Title</h3>
</div>
<div className="w-full px-4 py-3 bg-white shadow-inner rounded-lg border border-gray-100">
<p className="text-lg">{local.title || 'Untitled'}</p>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="pt-6">
<div className="flex flex-col items-start gap-3">
<div className="flex flex-row mb-3 gap-4">
<BiMessageRoundedDetail className="h-5 w-5 text-green-500 mt-1" />
<h3 className="font-semibold text-xl">Question</h3>
</div>
<div className="w-full px-4 py-3 bg-white shadow-inner rounded-lg border border-gray-100">
<p className="text-lg">{local.text || 'No question provided'}</p>
</div>
</div>
</CardContent>
</Card>
{local.prompts && local.prompts.length > 0 && (
<Card>
<CardContent className="pt-6">
<div className="flex flex-col items-start gap-3">
<div className="flex flex-row mb-3 gap-4">
<AiOutlineUnorderedList className="h-5 w-5 text-purple-500 mt-1" />
<h3 className="font-semibold text-xl">Prompts</h3>
</div>
<div className="w-full p-4 bg-gray-50 shadow-inner rounded-lg border border-gray-100">
<div className="flex flex-col gap-3">
{local.prompts.map((prompt, index) => (
<div key={index} className="px-4 py-3 bg-white shadow rounded-lg border border-gray-100">
<p className="text-gray-700">{prompt}</p>
</div>
))}
</div>
</div>
</div>
</CardContent>
</Card>
)}
</div>
)}
</>
)}
</>
);
}
export default Speaking2;

View File

@@ -1,34 +0,0 @@
import useExamEditorStore from "@/stores/examEditor";
import { SpeakingExercise, InteractiveSpeakingExercise } from "@/interfaces/exam";
import Speaking2 from "./Speaking2";
import InteractiveSpeaking from "./InteractiveSpeaking";
import Speaking1 from "./Speaking1";
import { Module } from "@/interfaces";
interface Props {
sectionId: number;
exercise: SpeakingExercise | InteractiveSpeakingExercise;
module: Module;
}
const Speaking: React.FC<Props> = ({ sectionId, module = "speaking" }) => {
const { state } = useExamEditorStore(
(state) => state.modules[module].sections.find((section) => section.sectionId === sectionId)!
);
return (
<>
<div className="mx-auto p-3 space-y-6">
<div className="p-4">
<div className="flex flex-col space-y-6">
{sectionId === 1 && <Speaking1 sectionId={sectionId} exercise={state as InteractiveSpeakingExercise } />}
{sectionId === 2 && <Speaking2 sectionId={sectionId} exercise={state as SpeakingExercise} />}
{sectionId === 3 && <InteractiveSpeaking sectionId={sectionId} exercise={state as InteractiveSpeakingExercise} />}
</div>
</div>
</div>
</>
);
};
export default Speaking;

View File

@@ -1,230 +0,0 @@
import React, { useCallback, useEffect, useState } from 'react';
import {
MdAdd,
} from 'react-icons/md';
import Alert, { AlertItem } from '../Shared/Alert';
import { Difficulty, ReadingPart, TrueFalseExercise } from '@/interfaces/exam';
import QuestionsList from '../Shared/QuestionsList';
import Header from '../../Shared/Header';
import SortableQuestion from '../Shared/SortableQuestion';
import clsx from 'clsx';
import useExamEditorStore from '@/stores/examEditor';
import useSectionEdit from '../../Hooks/useSectionEdit';
import { toast } from 'react-toastify';
import validateTrueFalseQuestions from './validation';
import setEditingAlert from '../Shared/setEditingAlert';
import { DragEndEvent } from '@dnd-kit/core';
import { handleTrueFalseReorder } from '@/stores/examEditor/reorder/local';
import PromptEdit from '../Shared/PromptEdit';
const TrueFalse: React.FC<{ exercise: TrueFalseExercise, sectionId: number }> = ({ exercise, sectionId }) => {
const { currentModule, dispatch } = useExamEditorStore();
const difficulty = useExamEditorStore((state) => state.modules[currentModule].difficulty);
const { state } = useExamEditorStore(
(state) => state.modules[currentModule].sections.find((section) => section.sectionId === sectionId)!
);
const section = state as ReadingPart;
const [local, setLocal] = useState(exercise);
const [alerts, setAlerts] = useState<AlertItem[]>([]);
const updateLocal = (exercise: TrueFalseExercise) => {
setLocal(exercise);
setEditing(true);
};
const updateQuestion = (index: number, field: string, value: string) => {
const newQuestions = [...local.questions];
newQuestions[index] = { ...newQuestions[index], [field]: value };
updateLocal({ ...local, questions: newQuestions });
};
const addQuestion = () => {
const newId = (parseInt(local.questions[local.questions.length - 1].id) + 1).toString();
updateLocal({
...local,
questions: [
...local.questions,
{
prompt: "",
solution: undefined,
id: newId
}
]
});
};
const deleteQuestion = (index: number) => {
if (local.questions.length == 1) {
toast.error("There needs to be at least one question!");
return;
}
const newQuestions = local.questions.filter((_, i) => i !== index);
const minId = Math.min(...newQuestions.map(q => parseInt(q.id)));
const updatedQuestions = newQuestions.map((question, i) => ({
...question,
id: String(minId + i)
}));
updateLocal({ ...local, questions: updatedQuestions });
};
const { editing, handleSave, handleDiscard, handleDelete, handlePractice, setEditing } = useSectionEdit({
sectionId,
onSave: () => {
const isValid = validateTrueFalseQuestions(
local.questions,
setAlerts
);
if (!isValid) {
toast.error("Please fix the errors before saving!");
return;
}
setEditing(false);
setAlerts([]);
//dispatch({ type: 'UPDATE_ROOT', payload: { updates: { globalEdit: globalEdit.filter(id => id !== sectionId) } } });
const newSection = {
...section,
exercises: section.exercises.map((ex) => ex.id === local.id ? local : ex)
};
dispatch({ type: "UPDATE_SECTION_STATE", payload: { sectionId, update: newSection, module: currentModule } });
},
onDiscard: () => {
setLocal(exercise);
},
onDelete: () => {
const newSection = {
...section,
exercises: section.exercises.filter((ex) => ex.id !== local.id)
};
dispatch({ type: "UPDATE_SECTION_STATE", payload: { sectionId, update: newSection, module: currentModule } });
},
onPractice: () => {
const updatedExercise = {
...local,
isPractice: false
};
const newState = { ...section };
newState.exercises = newState.exercises.map((ex) =>
ex.id === exercise.id ? updatedExercise : ex
);
updateLocal({...local, isPractice: !local.isPractice})
dispatch({ type: 'UPDATE_SECTION_STATE', payload: { sectionId, update: newState, module: currentModule } });
}
});
useEffect(() => {
validateTrueFalseQuestions(local.questions, setAlerts);
}, [local.questions]);
useEffect(() => {
setEditingAlert(editing, setAlerts);
}, [editing]);
const handleDragEnd = (event: DragEndEvent) => {
setEditing(true);
setLocal(handleTrueFalseReorder(event, local));
}
const saveDifficulty = useCallback((diff: Difficulty) => {
if (!difficulty.includes(diff)) {
dispatch({ type: 'UPDATE_MODULE', payload: { updates: { difficulty: [...difficulty, diff]} } });
}
const updatedExercise = { ...exercise, difficulty: diff };
const newState = { ...section };
newState.exercises = newState.exercises.map((ex) => ex.id === exercise.id ? updatedExercise : ex );
dispatch({ type: 'UPDATE_SECTION_STATE', payload: { sectionId, update: newState, module: currentModule } });
}, [currentModule, difficulty, dispatch, exercise, section, sectionId]);
return (
<div className="p-4">
<Header
title='True/False/Not Given Exercise'
description='Edit questions and their solutions'
editing={editing}
difficulty={exercise.difficulty}
saveDifficulty={saveDifficulty}
handleSave={handleSave}
handleDelete={handleDelete}
handleDiscard={handleDiscard}
handlePractice={handlePractice}
isEvaluationEnabled={!local.isPractice}
/>
{alerts.length > 0 && <Alert className="mb-6" alerts={alerts} />}
<PromptEdit
value={local.prompt}
onChange={(text) => updateLocal({ ...local, prompt: text })}
/>
<div className="space-y-4">
<QuestionsList
ids={local.questions.map(q => q.id)}
handleDragEnd={handleDragEnd}
>
{local.questions.map((question, index) => (
<SortableQuestion
key={question.id}
id={question.id}
index={index}
deleteQuestion={deleteQuestion}
>
<>
<input
type="text"
value={question.prompt}
onChange={(e) => updateQuestion(index, 'prompt', e.target.value)}
className="w-full p-3 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:outline-none"
placeholder="Enter question..."
/>
<div className="flex gap-3">
{['true', 'false', 'not_given'].map((value) => (
<label
key={value}
className="flex-1 cursor-pointer"
>
<div
className={clsx(
"p-3 text-center rounded-lg border-2 transition-all flex items-center justify-center gap-2",
question.solution === value
? 'border-blue-500 bg-blue-50 text-blue-700'
: 'border-gray-200 hover:border-gray-300'
)}
>
<input
type="radio"
name={`solution-${question.id}`}
value={value}
checked={question.solution === value}
onChange={(e) => updateQuestion(index, 'solution', e.target.value)}
className="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 focus:ring-blue-500 sr-only"
/>
<span>
{value.replace('_', ' ').charAt(0).toUpperCase() + value.slice(1).replace('_', ' ')}
</span>
</div>
</label>
))}
</div>
</>
</SortableQuestion>
))}
</QuestionsList>
<button
onClick={addQuestion}
className="w-full p-4 border-2 border-dashed border-gray-300 rounded-lg hover:border-blue-500 hover:bg-blue-50 transition-colors flex items-center justify-center gap-2 text-gray-600 hover:text-blue-600"
>
<MdAdd size={18} />
Add New Question
</button>
</div>
</div>
);
};
export default TrueFalse;

Some files were not shown because too many files have changed in this diff Show More