Compare commits
1 Commits
5e363e9951
...
feature-ge
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7962857a95 |
5
.gitignore
vendored
@@ -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
|
||||
@@ -1,4 +0,0 @@
|
||||
#!/usr/bin/env sh
|
||||
. "$(dirname -- "$0")/_/husky.sh"
|
||||
|
||||
yarn build
|
||||
28
.vscode/launch.json
vendored
@@ -1,28 +0,0 @@
|
||||
{
|
||||
"version": "0.2.0",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "Next.js: debug server-side",
|
||||
"type": "node-terminal",
|
||||
"request": "launch",
|
||||
"command": "npm run dev"
|
||||
},
|
||||
{
|
||||
"name": "Next.js: debug client-side",
|
||||
"type": "chrome",
|
||||
"request": "launch",
|
||||
"url": "http://localhost:3000"
|
||||
},
|
||||
{
|
||||
"name": "Next.js: debug full stack",
|
||||
"type": "node-terminal",
|
||||
"request": "launch",
|
||||
"command": "npm run dev",
|
||||
"serverReadyAction": {
|
||||
"pattern": "- Local:.+(https?://.+)",
|
||||
"uriFormat": "%s",
|
||||
"action": "debugWithChrome"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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"]
|
||||
@@ -1,17 +0,0 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "new-york",
|
||||
"rsc": false,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "tailwind.config.ts",
|
||||
"css": "src/styles/globals.css",
|
||||
"baseColor": "neutral",
|
||||
"cssVariables": false,
|
||||
"prefix": ""
|
||||
},
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils"
|
||||
}
|
||||
}
|
||||
@@ -1,57 +1,7 @@
|
||||
/** @type {import('next').NextConfig} */
|
||||
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
176
package.json
@@ -1,118 +1,62 @@
|
||||
{
|
||||
"name": "next-wind",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint",
|
||||
"prepare": "husky install"
|
||||
},
|
||||
"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",
|
||||
"@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",
|
||||
"@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",
|
||||
"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",
|
||||
"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",
|
||||
"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-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",
|
||||
"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"
|
||||
}
|
||||
"name": "next-wind",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint"
|
||||
},
|
||||
"dependencies": {
|
||||
"@headlessui/react": "^1.7.13",
|
||||
"@mdi/js": "^7.1.96",
|
||||
"@mdi/react": "^1.6.1",
|
||||
"@next/font": "13.1.6",
|
||||
"@types/node": "18.13.0",
|
||||
"@types/react": "18.0.27",
|
||||
"@types/react-dom": "18.0.10",
|
||||
"axios": "^1.3.5",
|
||||
"chart.js": "^4.2.1",
|
||||
"clsx": "^1.2.1",
|
||||
"daisyui": "^3.1.5",
|
||||
"eslint": "8.33.0",
|
||||
"eslint-config-next": "13.1.6",
|
||||
"firebase": "9.19.1",
|
||||
"formidable": "^3.5.0",
|
||||
"formidable-serverless": "^1.1.1",
|
||||
"framer-motion": "^9.0.2",
|
||||
"iron-session": "^6.3.1",
|
||||
"lodash": "^4.17.21",
|
||||
"moment": "^2.29.4",
|
||||
"next": "13.1.6",
|
||||
"primeicons": "^6.0.1",
|
||||
"primereact": "^9.2.3",
|
||||
"react": "18.2.0",
|
||||
"react-chartjs-2": "^5.2.0",
|
||||
"react-dom": "18.2.0",
|
||||
"react-firebase-hooks": "^5.1.1",
|
||||
"react-icons": "^4.8.0",
|
||||
"react-lineto": "^3.3.0",
|
||||
"react-media-recorder": "1.6.5",
|
||||
"react-player": "^2.12.0",
|
||||
"react-string-replace": "^1.1.0",
|
||||
"react-toastify": "^9.1.2",
|
||||
"react-xarrows": "^2.0.2",
|
||||
"swr": "^2.1.3",
|
||||
"typescript": "4.9.5",
|
||||
"uuid": "^9.0.0",
|
||||
"wavesurfer.js": "^6.6.4",
|
||||
"zustand": "^4.3.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/formidable": "^3.4.0",
|
||||
"@types/lodash": "^4.14.191",
|
||||
"@types/uuid": "^9.0.1",
|
||||
"@types/wavesurfer.js": "^6.0.6",
|
||||
"@wixc3/react-board": "^2.2.0",
|
||||
"autoprefixer": "^10.4.13",
|
||||
"postcss": "^8.4.21",
|
||||
"tailwindcss": "^3.2.4"
|
||||
}
|
||||
}
|
||||
|
Before Width: | Height: | Size: 419 KiB |
|
Before Width: | Height: | Size: 48 KiB After Width: | Height: | Size: 5.2 KiB |
|
Before Width: | Height: | Size: 5.5 KiB After Width: | Height: | Size: 13 KiB |
BIN
public/logo.png
|
Before Width: | Height: | Size: 6.4 KiB |
|
Before Width: | Height: | Size: 48 KiB |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="#e8eaed"><path d="M440-280h80v-240h-80v240Zm40-320q17 0 28.5-11.5T520-640q0-17-11.5-28.5T480-680q-17 0-28.5 11.5T440-640q0 17 11.5 28.5T480-600Zm0 520q-83 0-156-31.5T197-197q-54-54-85.5-127T80-480q0-83 31.5-156T197-763q54-54 127-85.5T480-880q83 0 156 31.5T763-763q54 54 85.5 127T880-480q0 83-31.5 156T763-197q-54 54-127 85.5T480-80Zm0-80q134 0 227-93t93-227q0-134-93-227t-227-93q-134 0-227 93t-93 227q0 134 93 227t227 93Zm0-320Z"/></svg>
|
||||
|
Before Width: | Height: | Size: 535 B |
|
Before Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 3.0 MiB |
|
Before Width: | Height: | Size: 418 KiB |
|
Before Width: | Height: | Size: 2.7 KiB |
|
Before Width: | Height: | Size: 3.1 KiB |
|
Before Width: | Height: | Size: 2.8 KiB |
|
Before Width: | Height: | Size: 3.2 KiB |
|
Before Width: | Height: | Size: 3.3 KiB |
|
Before Width: | Height: | Size: 3.5 KiB |
|
Before Width: | Height: | Size: 3.4 KiB |
|
Before Width: | Height: | Size: 3.5 KiB |
|
Before Width: | Height: | Size: 4.0 KiB |
|
Before Width: | Height: | Size: 3.9 KiB |
|
Before Width: | Height: | Size: 4.4 KiB |
|
Before Width: | Height: | Size: 2.9 KiB |
|
Before Width: | Height: | Size: 3.2 KiB |
|
Before Width: | Height: | Size: 2.9 KiB |
|
Before Width: | Height: | Size: 3.3 KiB |
|
Before Width: | Height: | Size: 3.4 KiB |
|
Before Width: | Height: | Size: 3.4 KiB |
|
Before Width: | Height: | Size: 3.4 KiB |
|
Before Width: | Height: | Size: 3.6 KiB |
|
Before Width: | Height: | Size: 4.0 KiB |
|
Before Width: | Height: | Size: 3.8 KiB |
|
Before Width: | Height: | Size: 4.2 KiB |
|
Before Width: | Height: | Size: 7.3 MiB |
@@ -1,51 +0,0 @@
|
||||
import dotenv from "dotenv";
|
||||
dotenv.config();
|
||||
import { MongoClient } from "mongodb";
|
||||
const uri = process.env.MONGODB_URI || "";
|
||||
const options = {
|
||||
maxPoolSize: 10,
|
||||
};
|
||||
const dbName = process.env.MONGODB_DB; // change this to prod db when needed
|
||||
async function migrateData() {
|
||||
const MODULE_ARRAY = ["reading", "listening", "writing", "speaking", "level"];
|
||||
const client = new MongoClient(uri, options);
|
||||
|
||||
try {
|
||||
await client.connect();
|
||||
console.log("Connected to MongoDB");
|
||||
if (!process.env.MONGODB_DB) {
|
||||
throw new Error("Missing env var: MONGODB_DB");
|
||||
}
|
||||
const db = client.db(dbName);
|
||||
for (const string of MODULE_ARRAY) {
|
||||
const collection = db.collection(string);
|
||||
const result = await collection.updateMany(
|
||||
{ private: { $exists: false } },
|
||||
{ $set: { access: "public" } }
|
||||
);
|
||||
const result2 = await collection.updateMany(
|
||||
{ private: true },
|
||||
{ $set: { access: "private" }, $unset: { private: "" } }
|
||||
);
|
||||
const result1 = await collection.updateMany(
|
||||
{ private: { $exists: true } },
|
||||
{ $set: { access: "public" } }
|
||||
);
|
||||
console.log(
|
||||
`Updated ${
|
||||
result.modifiedCount + result1.modifiedCount
|
||||
} documents to "access: public" in ${string}`
|
||||
);
|
||||
console.log(
|
||||
`Updated ${result2.modifiedCount} documents to "access: private" and removed private var in ${string}`
|
||||
);
|
||||
}
|
||||
console.log("Migration completed successfully!");
|
||||
} catch (error) {
|
||||
console.error("Migration failed:", error);
|
||||
} finally {
|
||||
await client.close();
|
||||
console.log("MongoDB connection closed.");
|
||||
}
|
||||
}
|
||||
//migrateData(); // uncomment to run the migration
|
||||
@@ -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;
|
||||
@@ -1,99 +1,62 @@
|
||||
import { Dialog, Transition } from "@headlessui/react";
|
||||
import { Fragment, useCallback, useEffect, useState } from "react";
|
||||
import {Dialog, Transition} from "@headlessui/react";
|
||||
import {Fragment} from "react";
|
||||
import Button from "./Low/Button";
|
||||
|
||||
interface Props {
|
||||
isOpen: boolean;
|
||||
isOpen: boolean;
|
||||
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);
|
||||
export default function AbandonPopup({
|
||||
isOpen,
|
||||
abandonPopupTitle,
|
||||
abandonPopupDescription,
|
||||
abandonConfirmButtonText,
|
||||
onAbandon,
|
||||
onCancel,
|
||||
}: Props) {
|
||||
return (
|
||||
<Transition show={isOpen} as={Fragment}>
|
||||
<Dialog onClose={onCancel} className="relative z-50">
|
||||
<Transition.Child
|
||||
as={Fragment}
|
||||
enter="ease-out duration-300"
|
||||
enterFrom="opacity-0"
|
||||
enterTo="opacity-100"
|
||||
leave="ease-in duration-200"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0">
|
||||
<div className="fixed inset-0 bg-black/30" />
|
||||
</Transition.Child>
|
||||
|
||||
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;
|
||||
|
||||
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.Child
|
||||
as={Fragment}
|
||||
enter="ease-out duration-300"
|
||||
enterFrom="opacity-0"
|
||||
enterTo="opacity-100"
|
||||
leave="ease-in duration-200"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0">
|
||||
<div className="fixed inset-0 bg-black/30" />
|
||||
</Transition.Child>
|
||||
|
||||
<Transition.Child
|
||||
as={Fragment}
|
||||
enter="ease-out duration-300"
|
||||
enterFrom="opacity-0 scale-95"
|
||||
enterTo="opacity-100 scale-100"
|
||||
leave="ease-in duration-200"
|
||||
leaveFrom="opacity-100 scale-100"
|
||||
leaveTo="opacity-0 scale-95">
|
||||
<div className="fixed inset-0 flex items-center justify-center p-4">
|
||||
<Dialog.Panel className="w-full max-w-2xl h-fit p-8 rounded-xl bg-white flex flex-col gap-4">
|
||||
<Dialog.Title className="font-bold text-xl">{abandonPopupTitle}</Dialog.Title>
|
||||
<span>{abandonPopupDescription}</span>
|
||||
<div className="w-full flex justify-between mt-8">
|
||||
<Button color="purple" onClick={() => blockMultipleClicksClose(true)} 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">
|
||||
{abandonConfirmButtonText}
|
||||
</Button>
|
||||
</div>
|
||||
</Dialog.Panel>
|
||||
</div>
|
||||
</Transition.Child>
|
||||
</Dialog>
|
||||
</Transition>
|
||||
);
|
||||
<Transition.Child
|
||||
as={Fragment}
|
||||
enter="ease-out duration-300"
|
||||
enterFrom="opacity-0 scale-95"
|
||||
enterTo="opacity-100 scale-100"
|
||||
leave="ease-in duration-200"
|
||||
leaveFrom="opacity-100 scale-100"
|
||||
leaveTo="opacity-0 scale-95">
|
||||
<div className="fixed inset-0 flex items-center justify-center p-4">
|
||||
<Dialog.Panel className="w-full max-w-2xl h-fit p-8 rounded-xl bg-white flex flex-col gap-4">
|
||||
<Dialog.Title className="font-bold text-xl">{abandonPopupTitle}</Dialog.Title>
|
||||
<span>{abandonPopupDescription}</span>
|
||||
<div className="w-full flex justify-between mt-8">
|
||||
<Button color="purple" onClick={onCancel} variant="outline" className="max-w-[200px] self-end w-full">
|
||||
Cancel
|
||||
</Button>
|
||||
<Button color="purple" onClick={onAbandon} className="max-w-[200px] self-end w-full">
|
||||
{abandonConfirmButtonText}
|
||||
</Button>
|
||||
</div>
|
||||
</Dialog.Panel>
|
||||
</div>
|
||||
</Transition.Child>
|
||||
</Dialog>
|
||||
</Transition>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
56
src/components/BlankQuestionsModal.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
import {Dialog, Transition} from "@headlessui/react";
|
||||
import {Fragment} from "react";
|
||||
import Button from "./Low/Button";
|
||||
|
||||
interface Props {
|
||||
isOpen: boolean;
|
||||
onClose: (next?: boolean) => void;
|
||||
}
|
||||
|
||||
export default function BlankQuestionsModal({isOpen, onClose}: Props) {
|
||||
return (
|
||||
<Transition show={isOpen} as={Fragment}>
|
||||
<Dialog onClose={() => onClose(false)} className="relative z-50">
|
||||
<Transition.Child
|
||||
as={Fragment}
|
||||
enter="ease-out duration-300"
|
||||
enterFrom="opacity-0"
|
||||
enterTo="opacity-100"
|
||||
leave="ease-in duration-200"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0">
|
||||
<div className="fixed inset-0 bg-black/30" />
|
||||
</Transition.Child>
|
||||
|
||||
<Transition.Child
|
||||
as={Fragment}
|
||||
enter="ease-out duration-300"
|
||||
enterFrom="opacity-0 scale-95"
|
||||
enterTo="opacity-100 scale-100"
|
||||
leave="ease-in duration-200"
|
||||
leaveFrom="opacity-100 scale-100"
|
||||
leaveTo="opacity-0 scale-95">
|
||||
<div className="fixed inset-0 flex items-center justify-center p-4">
|
||||
<Dialog.Panel className="w-full max-w-2xl h-fit p-8 rounded-xl bg-white flex flex-col gap-4">
|
||||
<Dialog.Title className="font-bold text-xl">Questions Unanswered</Dialog.Title>
|
||||
<span>
|
||||
Please note that you are finishing the current module and once you proceed to the next module, you will no longer be
|
||||
able to change the answers in the current one, including your unanswered questions. <br />
|
||||
<br />
|
||||
Are you sure you want to continue without completing those questions?
|
||||
</span>
|
||||
<div className="w-full flex justify-between mt-8">
|
||||
<Button color="purple" onClick={() => onClose(false)} variant="outline" className="max-w-[200px] self-end w-full">
|
||||
Go Back
|
||||
</Button>
|
||||
<Button color="purple" onClick={() => onClose(true)} className="max-w-[200px] self-end w-full">
|
||||
Continue
|
||||
</Button>
|
||||
</div>
|
||||
</Dialog.Panel>
|
||||
</div>
|
||||
</Transition.Child>
|
||||
</Dialog>
|
||||
</Transition>
|
||||
);
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
import clsx from "clsx";
|
||||
import {IconType} from "react-icons";
|
||||
import {MdSpaceDashboard} from "react-icons/md";
|
||||
import {BsFileEarmarkText, BsClockHistory, BsPencil, BsGraphUp} from "react-icons/bs";
|
||||
import {RiLogoutBoxFill} from "react-icons/ri";
|
||||
import {SlPencil} from "react-icons/sl";
|
||||
import {FaAward} from "react-icons/fa";
|
||||
import Link from "next/link";
|
||||
import {useRouter} from "next/router";
|
||||
import axios from "axios";
|
||||
import FocusLayer from "@/components/FocusLayer";
|
||||
import {preventNavigation} from "@/utils/navigation.disabled";
|
||||
interface Props {
|
||||
path: string;
|
||||
navDisabled?: boolean;
|
||||
focusMode?: boolean;
|
||||
onFocusLayerMouseEnter?: () => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
interface NavProps {
|
||||
Icon: IconType;
|
||||
label: string;
|
||||
path: string;
|
||||
keyPath: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const Nav = ({Icon, label, path, keyPath, disabled = false}: NavProps) => (
|
||||
<Link
|
||||
href={!disabled ? keyPath : ""}
|
||||
className={clsx(
|
||||
"p-4 rounded-full flex gap-4 items-center cursor-pointer text-gray-500 hover:bg-mti-purple-light hover:text-white transition duration-300 ease-in-out",
|
||||
path === keyPath && "bg-mti-purple-light text-white",
|
||||
)}>
|
||||
<Icon size={20} />
|
||||
</Link>
|
||||
);
|
||||
|
||||
export default function BottomBar({path, navDisabled = false, focusMode = false, onFocusLayerMouseEnter, className}: Props) {
|
||||
const router = useRouter();
|
||||
|
||||
const logout = async () => {
|
||||
axios.post("/api/logout").finally(() => {
|
||||
setTimeout(() => router.reload(), 500);
|
||||
});
|
||||
};
|
||||
|
||||
const disableNavigation = preventNavigation(navDisabled, focusMode);
|
||||
|
||||
return (
|
||||
<section className={clsx("w-full bg-white py-2 drop-shadow-2xl shadow-2xl rounded-t-2xl", className)}>
|
||||
<div className="flex justify-around gap-3">
|
||||
<Nav disabled={disableNavigation} Icon={MdSpaceDashboard} label="Dashboard" path={path} keyPath="/" />
|
||||
<Nav disabled={disableNavigation} Icon={BsFileEarmarkText} label="Exams" path={path} keyPath="/exam" />
|
||||
<Nav disabled={disableNavigation} Icon={BsPencil} label="Exercises" path={path} keyPath="/exercises" />
|
||||
<Nav disabled={disableNavigation} Icon={BsGraphUp} label="Stats" path={path} keyPath="/stats" />
|
||||
<Nav disabled={disableNavigation} Icon={BsClockHistory} label="Record" path={path} keyPath="/record" />
|
||||
</div>
|
||||
{focusMode && <FocusLayer onFocusLayerMouseEnter={onFocusLayerMouseEnter} />}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,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>
|
||||
);
|
||||
}
|
||||
@@ -1,29 +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} 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 { useState} from "react";
|
||||
import { BsQuestionSquare} from "react-icons/bs";
|
||||
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;
|
||||
@@ -31,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");
|
||||
}
|
||||
});
|
||||
@@ -45,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,
|
||||
})
|
||||
@@ -60,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(
|
||||
@@ -84,44 +97,131 @@ 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>
|
||||
<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 className="self-end flex justify-between w-full gap-8 absolute bottom-8 left-0 px-8">
|
||||
<Button
|
||||
onClick={() => updateUser(selectExam)}
|
||||
color="purple"
|
||||
variant="outline"
|
||||
className="group flex items-center justify-center gap-6 relative max-w-[400px] w-full"
|
||||
disabled={!focus}>
|
||||
<BsQuestionSquare
|
||||
className="text-mti-purple-light group-hover:text-white transition duration-300 ease-in-out"
|
||||
size={20}
|
||||
onClick={() => updateUser(selectExam)}
|
||||
/>
|
||||
<span onClick={() => updateUser(selectExam)}>Perform diagnostic test instead</span>
|
||||
</Button>
|
||||
<Button color="purple" className="max-w-[400px] w-full" onClick={() => updateUser(onFinish)} disabled={isNextDisabled()}>
|
||||
Next Step
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<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"
|
||||
disabled={!focus}>
|
||||
<BsQuestionSquare
|
||||
className="text-mti-purple-light group-hover:text-white transition duration-300 ease-in-out"
|
||||
size={20}
|
||||
onClick={() => updateUser(selectExam)}
|
||||
/>
|
||||
<span onClick={() => updateUser(selectExam)}>Perform diagnostic test instead</span>
|
||||
</Button>
|
||||
<Button color="purple" className="md:max-w-[400px] w-full" onClick={() => updateUser(onFinish)} disabled={isNextDisabled()}>
|
||||
Next Step
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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"
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -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;
|
||||
@@ -1,353 +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";
|
||||
import { uuidv4 } from "@firebase/util";
|
||||
|
||||
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]) => ({
|
||||
uuid: local.solutions.find(sol => sol.id === id)?.uuid || uuidv4(),
|
||||
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]) => ({
|
||||
uuid: prev.solutions.find(sol => sol.id === id)?.uuid || uuidv4(),
|
||||
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]) => ({
|
||||
uuid: prev.solutions.find(sol => sol.id === id)?.uuid || uuidv4(),
|
||||
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]) => ({
|
||||
uuid: prev.solutions.find(sol => sol.id === id)?.uuid || uuidv4(),
|
||||
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]) => ({
|
||||
uuid: prev.solutions.find(sol => sol.id === id)?.uuid || uuidv4(),
|
||||
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;
|
||||
@@ -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;
|
||||
@@ -1,345 +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";
|
||||
import { uuidv4 } from "@firebase/util";
|
||||
|
||||
|
||||
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]) => ({
|
||||
uuid: local.solutions.find(sol => sol.id === id)?.uuid || uuidv4(),
|
||||
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]) => ({
|
||||
uuid: prev.solutions.find(sol => sol.id === id)?.uuid || uuidv4(),
|
||||
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]) => ({
|
||||
uuid: prev.solutions.find(sol => sol.id === id)?.uuid || uuidv4(),
|
||||
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]) => ({
|
||||
uuid: prev.solutions.find(sol => sol.id === id)?.uuid || uuidv4(),
|
||||
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 = {
|
||||
uuid: uuidv4(),
|
||||
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]) => ({
|
||||
uuid: prev.solutions.find(sol => sol.id === id)?.uuid || uuidv4(),
|
||||
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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -1,263 +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';
|
||||
import { uuidv4 } from '@firebase/util';
|
||||
|
||||
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,
|
||||
{
|
||||
uuid: uuidv4(),
|
||||
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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -1,179 +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";
|
||||
import { uuidv4 } from "@firebase/util";
|
||||
|
||||
|
||||
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: "",
|
||||
uuid: uuidv4(),
|
||||
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;
|
||||
@@ -1,302 +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';
|
||||
import { uuidv4 } from '@firebase/util';
|
||||
|
||||
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: "",
|
||||
uuid: uuidv4(),
|
||||
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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||