Compare commits

..

3 Commits

Author SHA1 Message Date
carlos.mesquita
22209ee1c1 Merged main into feature/training-content 2024-09-09 00:40:36 +00:00
Carlos Mesquita
0e2f53db0a Search on user list with mongo search query 2024-09-09 01:38:11 +01:00
Carlos Mesquita
9177a6b2ac Pagination on UserList 2024-09-09 01:22:13 +01:00
470 changed files with 43381 additions and 51157 deletions

2
.gitignore vendored
View File

@@ -41,5 +41,3 @@ next-env.d.ts
.yarn/* .yarn/*
.history* .history*
__ENV.js __ENV.js
settings.json

20566
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -12,7 +12,6 @@
"dependencies": { "dependencies": {
"@beam-australia/react-env": "^3.1.1", "@beam-australia/react-env": "^3.1.1",
"@dnd-kit/core": "^6.1.0", "@dnd-kit/core": "^6.1.0",
"@dnd-kit/modifiers": "^7.0.0",
"@dnd-kit/sortable": "^8.0.0", "@dnd-kit/sortable": "^8.0.0",
"@firebase/util": "^1.9.7", "@firebase/util": "^1.9.7",
"@headlessui/react": "^2.1.2", "@headlessui/react": "^2.1.2",
@@ -39,7 +38,6 @@
"country-codes-list": "^1.6.11", "country-codes-list": "^1.6.11",
"currency-symbol-map": "^5.1.0", "currency-symbol-map": "^5.1.0",
"daisyui": "^3.1.5", "daisyui": "^3.1.5",
"deep-diff": "^1.0.2",
"eslint": "8.33.0", "eslint": "8.33.0",
"eslint-config-next": "13.1.6", "eslint-config-next": "13.1.6",
"exceljs": "^4.4.0", "exceljs": "^4.4.0",
@@ -51,7 +49,6 @@
"formidable-serverless": "^1.1.1", "formidable-serverless": "^1.1.1",
"framer-motion": "^9.0.2", "framer-motion": "^9.0.2",
"howler": "^2.2.4", "howler": "^2.2.4",
"immer": "^10.1.1",
"iron-session": "^6.3.1", "iron-session": "^6.3.1",
"lodash": "^4.17.21", "lodash": "^4.17.21",
"moment": "^2.29.4", "moment": "^2.29.4",
@@ -98,7 +95,6 @@
"devDependencies": { "devDependencies": {
"@simbathesailor/use-what-changed": "^2.0.0", "@simbathesailor/use-what-changed": "^2.0.0",
"@types/blob-stream": "^0.1.33", "@types/blob-stream": "^0.1.33",
"@types/deep-diff": "^1.0.5",
"@types/formidable": "^3.4.0", "@types/formidable": "^3.4.0",
"@types/howler": "^2.2.11", "@types/howler": "^2.2.11",
"@types/lodash": "^4.14.191", "@types/lodash": "^4.14.191",

Binary file not shown.

Before

Width:  |  Height:  |  Size: 419 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 418 KiB

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,12 +1,17 @@
import {infoButtonStyle} from "@/constants/buttonStyles";
import {Module} from "@/interfaces";
import {User} from "@/interfaces/user"; import {User} from "@/interfaces/user";
import useExamStore from "@/stores/exam"; import useExamStore from "@/stores/examStore";
import {getExam} from "@/utils/exams"; import {getExam, getExamById} from "@/utils/exams";
import {MODULE_ARRAY} from "@/utils/moduleUtils"; import {MODULE_ARRAY} from "@/utils/moduleUtils";
import {writingMarking} from "@/utils/score";
import {Menu} from "@headlessui/react";
import axios from "axios"; import axios from "axios";
import clsx from "clsx"; import clsx from "clsx";
import {capitalize} from "lodash";
import {useRouter} from "next/router"; import {useRouter} from "next/router";
import { useState} from "react"; import {useEffect, useState} from "react";
import { BsQuestionSquare} from "react-icons/bs"; import {BsBook, BsChevronDown, BsHeadphones, BsMegaphone, BsPen, BsQuestionSquare} from "react-icons/bs";
import {toast} from "react-toastify"; import {toast} from "react-toastify";
import Button from "./Low/Button"; import Button from "./Low/Button";
import ModuleLevelSelector from "./Medium/ModuleLevelSelector"; import ModuleLevelSelector from "./Medium/ModuleLevelSelector";
@@ -23,7 +28,8 @@ export default function Diagnostic({onFinish}: Props) {
const router = useRouter(); const router = useRouter();
const dispatch = useExamStore((state) => state.dispatch); const setExams = useExamStore((state) => state.setExams);
const setSelectedModules = useExamStore((state) => state.setSelectedModules);
const isNextDisabled = () => { const isNextDisabled = () => {
if (!focus) return true; if (!focus) return true;
@@ -35,8 +41,9 @@ export default function Diagnostic({onFinish}: Props) {
Promise.all(examPromises).then((exams) => { Promise.all(examPromises).then((exams) => {
if (exams.every((x) => !!x)) { 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!));
router.push("/exam"); setSelectedModules(exams.map((x) => x!.module));
router.push("/exercises");
} }
}); });
}; };

View File

@@ -2,36 +2,23 @@ import React, { useState, ReactNode, useRef, useEffect } from 'react';
import { animated, useSpring } from '@react-spring/web'; import { animated, useSpring } from '@react-spring/web';
interface DropdownProps { interface DropdownProps {
title?: ReactNode; title: ReactNode;
open?: boolean; open?: boolean;
setIsOpen?: React.Dispatch<React.SetStateAction<boolean>> | ((isOpen: boolean) => void);
className?: string; className?: string;
contentWrapperClassName?: string; contentWrapperClassName?: string;
titleClassName?: string;
bottomPadding?: number; bottomPadding?: number;
disabled?: boolean,
wrapperClassName?: string;
customTitle?: ReactNode;
children: ReactNode; children: ReactNode;
} }
const Dropdown: React.FC<DropdownProps> = ({ const Dropdown: React.FC<DropdownProps> = ({
title, title,
open = false, open = false,
titleClassName = "",
setIsOpen: externalSetIsOpen,
className = "w-full text-left font-semibold flex justify-between items-center p-4", className = "w-full text-left font-semibold flex justify-between items-center p-4",
contentWrapperClassName = "px-6", contentWrapperClassName = "px-6",
bottomPadding = 12, bottomPadding = 12,
disabled = false,
customTitle = undefined,
wrapperClassName,
children children
}) => { }) => {
const [internalIsOpen, setInternalIsOpen] = useState<boolean>(open); const [isOpen, setIsOpen] = useState<boolean>(open);
const isOpen = externalSetIsOpen !== undefined ? open : internalIsOpen;
const toggleOpen = externalSetIsOpen !== undefined ? externalSetIsOpen : setInternalIsOpen;
const contentRef = useRef<HTMLDivElement>(null); const contentRef = useRef<HTMLDivElement>(null);
const [contentHeight, setContentHeight] = useState<number>(0); const [contentHeight, setContentHeight] = useState<number>(0);
@@ -69,18 +56,12 @@ const Dropdown: React.FC<DropdownProps> = ({
}); });
return ( return (
<div className={wrapperClassName}> <>
<button <button
onClick={() => toggleOpen(!isOpen)} onClick={() => setIsOpen(!isOpen)}
className={className} className={className}
disabled={disabled}
> >
<div className='flex flex-row w-full justify-between items-center'> {title}
{customTitle ? (
customTitle
) : (
<p className={titleClassName}>{title}</p>
)}
<svg <svg
className={`w-4 h-4 transform transition-transform ${isOpen ? 'rotate-180' : ''}`} className={`w-4 h-4 transform transition-transform ${isOpen ? 'rotate-180' : ''}`}
fill="none" fill="none"
@@ -90,14 +71,13 @@ const Dropdown: React.FC<DropdownProps> = ({
> >
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg> </svg>
</div>
</button> </button>
<animated.div style={springProps} className="overflow-hidden"> <animated.div style={springProps} className="overflow-hidden">
<div ref={contentRef} className={contentWrapperClassName} style={{paddingBottom: bottomPadding}}> <div ref={contentRef} className={contentWrapperClassName} style={{paddingBottom: bottomPadding}}>
{children} {children}
</div> </div>
</animated.div> </animated.div>
</div> </>
); );
}; };

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,46 +0,0 @@
import { AlertItem } from "../Shared/Alert";
const validateTrueFalseQuestions = (
questions: {
id: string;
prompt: string;
solution?: string;
}[],
setAlerts: React.Dispatch<React.SetStateAction<AlertItem[]>>
): boolean => {
let hasErrors = false;
const emptyPrompts = questions.filter(q => !q.prompt.trim());
if (emptyPrompts.length > 0) {
hasErrors = true;
setAlerts(prev => {
const filteredAlerts = prev.filter(alert => !alert.tag?.startsWith('empty-prompt'));
return [...filteredAlerts, ...emptyPrompts.map(q => ({
variant: "error" as const,
tag: `empty-prompt-${q.id}`,
description: `Question ${q.id} has an empty prompt`
}))];
});
} else {
setAlerts(prev => prev.filter(alert => !alert.tag?.startsWith('empty-prompt')));
}
const missingSolutions = questions.filter(q => q.solution === undefined);
if (missingSolutions.length > 0) {
hasErrors = true;
setAlerts(prev => {
const filteredAlerts = prev.filter(alert => !alert.tag?.startsWith('missing-solution'));
return [...filteredAlerts, ...missingSolutions.map(q => ({
variant: "error" as const,
tag: `missing-solution-${q.id}`,
description: `Question ${q.id} is missing a solution`
}))];
});
} else {
setAlerts(prev => prev.filter(alert => !alert.tag?.startsWith('missing-solution')));
}
return !hasErrors;
};
export default validateTrueFalseQuestions;

View File

@@ -1,344 +0,0 @@
import React, { useState, useEffect, useCallback } from 'react';
import { Card, CardContent } from '@/components/ui/card';
import {
MdAdd,
MdEdit,
MdEditOff,
MdDelete,
} from 'react-icons/md';
import QuestionsList from '../Shared/QuestionsList';
import SortableQuestion from '../Shared/SortableQuestion';
import { DragEndEvent } from '@dnd-kit/core';
import Header from '../../Shared/Header';
import clsx from 'clsx';
import Alert, { AlertItem } from '../Shared/Alert';
import AutoExpandingTextArea from '@/components/Low/AutoExpandingTextarea';
import { Difficulty, ReadingPart, WriteBlanksExercise } from '@/interfaces/exam';
import useExamEditorStore from '@/stores/examEditor';
import useSectionEdit from '../../Hooks/useSectionEdit';
import setEditingAlert from '../Shared/setEditingAlert';
import { toast } from 'react-toastify';
import { validateEmptySolutions, validateQuestionText, validateWordCount } from './validation';
import { handleWriteBlanksReorder } from '@/stores/examEditor/reorder/local';
import { ParsedQuestion, parseText, reconstructText } from './parsing';
import PromptEdit from '../Shared/PromptEdit';
const WriteBlanks: React.FC<{ sectionId: number; exercise: WriteBlanksExercise; }> = ({ sectionId, exercise }) => {
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 [editingPrompt, setEditingPrompt] = useState(false);
const [errors, setErrors] = useState<{ [key: string]: string[] }>({});
const [parsedQuestions, setParsedQuestions] = useState<ParsedQuestion[]>([]);
const { editing, handleSave, handleDiscard, handleDelete, handlePractice, setEditing } = useSectionEdit({
sectionId,
onSave: () => {
const isQuestionTextValid = validateQuestionText(
parsedQuestions,
setAlerts
);
const isSolutionsValid = validateEmptySolutions(
local.solutions,
setAlerts
);
if (!isQuestionTextValid || !isSolutionsValid) {
toast.error("Please fix the errors before saving!");
return;
}
setEditing(false);
setAlerts([]);
//dispatch({ type: 'UPDATE_ROOT', payload: { updates: {globalEdit: globalEdit.filter(id => id !== sectionId)} } });
const newSection = {
...section,
exercises: section.exercises.map((ex) => ex.id === local.id ? local : ex)
};
dispatch({ type: "UPDATE_SECTION_STATE", payload: { sectionId, update: newSection, module: currentModule } });
},
onDiscard: () => {
setLocal(exercise);
},
onDelete: () => {
const newSection = {
...section,
exercises: section.exercises.filter((ex) => ex.id !== local.id)
};
dispatch({ type: "UPDATE_SECTION_STATE", payload: { sectionId, update: newSection, module: currentModule } });
},
onPractice: () => {
const updatedExercise = {
...local,
isPractice: !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(() => {
setParsedQuestions(parseText(local.text));
}, [local.text]);
const updateLocal = (exercise: WriteBlanksExercise) => {
setLocal(exercise);
setEditing(true);
};
const addQuestion = () => {
const existingIds = parsedQuestions.map(q => parseInt(q.id));
const newId = (Math.max(...existingIds, 0) + 1).toString();
const newQuestion = {
id: newId,
questionText: "New question"
};
const updatedQuestions = [...parsedQuestions, newQuestion];
const updatedText = reconstructText(updatedQuestions);
const updatedSolutions = [...local.solutions, {
id: newId,
solution: [""]
}];
updateLocal({
...local,
text: updatedText,
solutions: updatedSolutions
});
};
const updateQuestionText = (id: string, newText: string) => {
const updatedQuestions = parsedQuestions.map(q =>
q.id === id ? { ...q, questionText: newText } : q
);
const updatedText = reconstructText(updatedQuestions);
updateLocal({ ...local, text: updatedText });
};
const deleteQuestion = (id: string) => {
if (parsedQuestions.length == 1) {
toast.error("There needs to be at least one question!");
return;
}
const updatedQuestions = parsedQuestions.filter(q => q.id !== id);
const updatedText = reconstructText(updatedQuestions);
const updatedSolutions = local.solutions.filter(s => s.id !== id);
updateLocal({
...local,
text: updatedText,
solutions: updatedSolutions
});
};
const addSolutionToQuestion = (questionId: string) => {
const newSolutions = [...local.solutions];
const questionIndex = newSolutions.findIndex(s => s.id === questionId);
if (questionIndex !== -1) {
newSolutions[questionIndex] = {
...newSolutions[questionIndex],
solution: [...newSolutions[questionIndex].solution, ""]
};
updateLocal({ ...local, solutions: newSolutions });
}
};
const updateSolution = (questionId: string, solutionIndex: number, value: string) => {
const wordCount = value.trim().split(/\s+/).length;
const newSolutions = [...local.solutions];
const questionIndex = newSolutions.findIndex(s => s.id === questionId);
if (questionIndex !== -1) {
const newSolutionArray = [...newSolutions[questionIndex].solution];
newSolutionArray[solutionIndex] = value;
newSolutions[questionIndex] = {
...newSolutions[questionIndex],
solution: newSolutionArray
};
updateLocal({ ...local, solutions: newSolutions });
}
if (wordCount > local.maxWords) {
setAlerts(prev => {
const filteredAlerts = prev.filter(alert => alert.tag !== `solution-error-${questionId}-${solutionIndex}`);
return [...filteredAlerts, {
variant: "error",
tag: `solution-error-${questionId}-${solutionIndex}`,
description: `Alternative solution ${solutionIndex + 1} for question ${questionId} exceeds maximum of ${local.maxWords} words (current: ${wordCount} words)`
}];
});
} else {
setAlerts(prev => prev.filter(alert => alert.tag !== `solution-error-${questionId}-${solutionIndex}`));
}
};
const deleteSolution = (questionId: string, solutionIndex: number) => {
const newSolutions = [...local.solutions];
const questionIndex = newSolutions.findIndex(s => s.id === questionId);
if (questionIndex !== -1) {
if (newSolutions[questionIndex].solution.length == 1) {
toast.error("There needs to be at least one solution!");
return;
}
const newSolutionArray = newSolutions[questionIndex].solution.filter((_, i) => i !== solutionIndex);
newSolutions[questionIndex] = {
...newSolutions[questionIndex],
solution: newSolutionArray
};
updateLocal({ ...local, solutions: newSolutions });
}
};
const handleDragEnd = (event: DragEndEvent) => {
setEditing(true);
setLocal(handleWriteBlanksReorder(event, local));
}
useEffect(() => {
setEditingAlert(editing, setAlerts);
}, [editing]);
useEffect(() => {
validateWordCount(local.solutions, local.maxWords, setAlerts);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [local.maxWords, local.solutions]);
useEffect(() => {
validateQuestionText(parsedQuestions, setAlerts);
}, [parsedQuestions]);
useEffect(() => {
validateEmptySolutions(local.solutions, setAlerts);
}, [local.solutions]);
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={"Write Blanks: Questions"}
description="Edit questions and their solutions"
editing={editing}
difficulty={exercise.difficulty}
saveDifficulty={saveDifficulty}
handleSave={handleSave}
handleDiscard={handleDiscard}
handleDelete={handleDelete}
handlePractice={handlePractice}
isEvaluationEnabled={!local.isPractice}
/>
<div className="space-y-4">
{alerts.length > 0 && <Alert alerts={alerts} />}
<Card className="mb-6">
<CardContent className="p-4 space-y-4">
<PromptEdit value={local.prompt} onChange={(prompt: string) => updateLocal({ ...local, prompt })} wrapperCard={false}/>
<div className="flex justify-between items-start gap-4">
<div className="flex items-center gap-4">
<label className="flex items-center gap-2">
<span className="font-medium text-gray-800">Maximum words per solution:</span>
<input
type="number"
value={local.maxWords}
onChange={(e) => updateLocal({ ...local, maxWords: parseInt(e.target.value) })}
className="w-20 p-2 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:outline-none"
min="1"
/>
</label>
</div>
</div>
</CardContent>
</Card>
<div className="space-y-4">
<QuestionsList
ids={parsedQuestions.map(q => q.id)}
handleDragEnd={handleDragEnd}
>
{parsedQuestions.map((question) => {
const questionSolutions = local.solutions.find(s => s.id === question.id)?.solution || [];
return (
<SortableQuestion
key={question.id}
id={question.id}
index={parseInt(question.id)}
deleteQuestion={() => deleteQuestion(question.id)}
variant="writeBlanks"
questionText={question.questionText}
onQuestionChange={(value) => updateQuestionText(question.id, value)}
>
<div className="space-y-4">
{questionSolutions.map((solution, solutionIndex) => (
<div key={solutionIndex} className="flex gap-2">
<input
type="text"
value={solution}
onChange={(e) => updateSolution(question.id, solutionIndex, e.target.value)}
className={clsx(
"flex-1 p-3 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:outline-none",
errors[question.id]?.[solutionIndex] && "border-red-500"
)}
placeholder="Enter solution..."
/>
<button
onClick={() => deleteSolution(question.id, solutionIndex)}
className="p-2 hover:bg-gray-100 rounded-lg transition-colors"
>
<MdDelete size={20} className="text-gray-500" />
</button>
</div>
))}
<button
onClick={() => addSolutionToQuestion(question.id)}
className="w-full 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>
</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>
</div>
);
};
export default WriteBlanks;

View File

@@ -1,27 +0,0 @@
export interface ParsedQuestion {
id: string;
questionText: string;
}
const parseText = (text: string): ParsedQuestion[] => {
const lines = text.split('\\n').filter(line => line.trim());
return lines.map(line => {
const match = line.match(/(.*?)\{\{(\d+)\}\}/);
if (match) {
return {
questionText: match[1],
id: match[2]
};
}
return { questionText: line, id: '' };
}).filter(q => q.id);
};
const reconstructText = (questions: ParsedQuestion[]): string => {
return questions.map(q => `${q.questionText}{{${q.id}}}`).join('\\n') + '\\n';
};
export {
parseText,
reconstructText
}

View File

@@ -1,84 +0,0 @@
import { AlertItem } from "../Shared/Alert";
import { ParsedQuestion } from "./parsing";
export const validateQuestionText = (
parsedQuestions: ParsedQuestion[],
setAlerts: React.Dispatch<React.SetStateAction<AlertItem[]>>
): boolean => {
const unmodifiedQuestions = parsedQuestions.filter(q => q.questionText === "New question");
if (unmodifiedQuestions.length > 0) {
setAlerts(prev => {
const filteredAlerts = prev.filter(alert => !alert.tag?.startsWith('unmodified-question'));
return [...filteredAlerts, ...unmodifiedQuestions.map(q => ({
variant: "error" as const,
tag: `unmodified-question-${q.id}`,
description: `Question ${q.id} is unmodified`
}))];
});
return false;
}
setAlerts(prev => prev.filter(alert => !alert.tag?.startsWith('unmodified-question')));
return true;
};
export const validateEmptySolutions = (
solutions: Array<{ id: string; solution: string[] }>,
setAlerts: React.Dispatch<React.SetStateAction<AlertItem[]>>
): boolean => {
const questionsWithEmptySolutions = solutions.flatMap(solution =>
solution.solution.map((sol, index) => ({
questionId: solution.id,
solutionIndex: index,
isEmpty: !sol.trim()
})).filter(({ isEmpty }) => isEmpty)
);
if (questionsWithEmptySolutions.length > 0) {
setAlerts(prev => {
const filteredAlerts = prev.filter(alert => !alert.tag?.startsWith('empty-solution'));
return [...filteredAlerts, ...questionsWithEmptySolutions.map(({ questionId, solutionIndex }) => ({
variant: "error" as const,
tag: `empty-solution-${questionId}-${solutionIndex}`,
description: `Solution ${solutionIndex + 1} for question ${questionId} cannot be empty`
}))];
});
return false;
}
setAlerts(prev => prev.filter(alert => !alert.tag?.startsWith('empty-solution')));
return true;
};
export const validateWordCount = (
solutions: Array<{ id: string; solution: string[] }>,
maxWords: number,
setAlerts: React.Dispatch<React.SetStateAction<AlertItem[]>>
): boolean => {
let isValid = true;
solutions.forEach((solution) => {
solution.solution.forEach((value, solutionIndex) => {
const wordCount = value.trim().split(/\s+/).length;
if (wordCount > maxWords) {
isValid = false;
setAlerts(prev => {
const filteredAlerts = prev.filter(alert =>
alert.tag !== `solution-error-${solution.id}-${solutionIndex}`
);
return [...filteredAlerts, {
variant: "error",
tag: `solution-error-${solution.id}-${solutionIndex}`,
description: `Solution ${solutionIndex + 1} for question ${solution.id} exceeds maximum of ${maxWords} words (current: ${wordCount} words)`
}];
});
} else {
setAlerts(prev =>
prev.filter(alert =>
alert.tag !== `solution-error-${solution.id}-${solutionIndex}`
)
);
}
});
});
return isValid;
};

View File

@@ -1,160 +0,0 @@
import { useSensors, useSensor, PointerSensor, KeyboardSensor, DragEndEvent, DndContext, closestCenter } from "@dnd-kit/core";
import { sortableKeyboardCoordinates, arrayMove, SortableContext, horizontalListSortingStrategy } from "@dnd-kit/sortable";
import { useState } from "react";
import { BsCursorText } from "react-icons/bs";
import { MdSpaceBar } from "react-icons/md";
import { toast } from "react-toastify";
import { formatDisplayContent, formatStorageContent, PromptPart, reconstructLine } from "./parsing";
import SortableBlank from "./SortableBlank";
import { validatePlaceholders } from "./validation";
interface Props {
parts: PromptPart[];
onUpdate: (newText: string) => void;
}
interface EditingState {
text: string;
isPlaceholderMode: boolean;
}
const BlanksFormEditor: React.FC<Props> = ({ parts, onUpdate }) => {
const sensors = useSensors(
useSensor(PointerSensor),
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates })
);
const [editingState, setEditingState] = useState<EditingState>({
text: formatDisplayContent(reconstructLine(parts)),
isPlaceholderMode: true
});
const handleTextChange = (newText: string) => {
const placeholder = parts.find(p => p.isPlaceholder);
if (!placeholder) return;
const displayPlaceholder = formatDisplayContent(placeholder.content);
if (!newText.includes(displayPlaceholder)) {
const placeholderIndex = editingState.text.indexOf(displayPlaceholder);
if (placeholderIndex >= 0) {
const beforePlaceholder = newText.slice(0, Math.min(placeholderIndex, newText.length));
const afterPlaceholder = newText.slice(Math.min(placeholderIndex, newText.length));
newText = beforePlaceholder + displayPlaceholder + afterPlaceholder;
} else {
newText = newText + ' ' + displayPlaceholder;
}
}
setEditingState(prev => ({
...prev,
text: newText
}));
};
const handleDragEnd = (event: DragEndEvent) => {
const { active, over } = event;
if (!over || active.id === over.id) return;
const oldIndex = parts.findIndex(part => part.id === active.id);
const newIndex = parts.findIndex(part => part.id === over.id);
const newParts = [...parts];
const [movedPart] = newParts.splice(oldIndex, 1);
newParts.splice(newIndex, 0, movedPart);
onUpdate(reconstructLine(newParts));
setEditingState(prev => ({
...prev,
text: formatDisplayContent(reconstructLine(newParts))
}));
};
const toggleEditMode = () => {
setEditingState(prev => ({
...prev,
isPlaceholderMode: !prev.isPlaceholderMode
}));
};
const saveTextChanges = () => {
const placeholderId = parts.find(p => p.isPlaceholder)?.id;
if (!placeholderId) return;
const validation = validatePlaceholders(editingState.text, placeholderId);
if (!validation.isValid) {
toast.error(validation.message);
setEditingState(prev => ({
...prev,
text: formatDisplayContent(reconstructLine(parts))
}));
return;
}
onUpdate(formatStorageContent(editingState.text));
};
return (
<div className="flex flex-row items-center gap-2">
<div className="flex-grow">
{editingState.isPlaceholderMode ? (
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
>
<SortableContext
items={parts.map(part => part.id)}
strategy={horizontalListSortingStrategy}
>
<div className="flex flex-wrap items-center gap-1 min-h-[40px] p-2 border rounded-lg bg-white">
{parts.map((part) => (
<SortableBlank
key={part.id}
id={part.id}
isPlaceholder={part.isPlaceholder}
>
{part.isPlaceholder ? (
<div className="bg-blue-200 px-2 py-1 rounded cursor-move">
{formatDisplayContent(part.content)}
</div>
) : /^\s+$/.test(part.content) ? (
<div className="px-1 border-l-2 border-r-2 border-transparent">
&nbsp;
</div>
) : (
<div className="px-1">
{part.content}
</div>
)}
</SortableBlank>
))}
</div>
</SortableContext>
</DndContext>
) : (
<input
type="text"
value={editingState.text}
onChange={(e) => handleTextChange(e.target.value)}
onPaste={(e) => e.preventDefault()}
onBlur={saveTextChanges}
className="w-full p-2 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:outline-none"
/>
)}
</div>
<button
className={`p-2 rounded ${editingState.isPlaceholderMode ? 'bg-blue-500 text-white' : 'bg-gray-200'}`}
onClick={toggleEditMode}
title={editingState.isPlaceholderMode ? "Switch to text editing" : "Switch to placeholder editing"}
>
{editingState.isPlaceholderMode ? <BsCursorText size={20} /> : <MdSpaceBar size={20} />}
</button>
</div>
);
};
export default BlanksFormEditor;

View File

@@ -1,41 +0,0 @@
import React from 'react';
import { useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
interface SortableBlankProps {
id: string;
isPlaceholder?: boolean;
children: React.ReactNode;
}
const SortableBlank: React.FC<SortableBlankProps> = ({ id, isPlaceholder, children }) => {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id });
const style: React.CSSProperties = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.5 : undefined,
cursor: isPlaceholder ? 'move' : 'default',
};
const draggableProps = isPlaceholder ? { ...attributes, ...listeners } : {};
return (
<div
ref={setNodeRef}
style={style}
{...draggableProps}
>
{children}
</div>
);
};
export default SortableBlank;

View File

@@ -1,302 +0,0 @@
import AutoExpandingTextArea from "@/components/Low/AutoExpandingTextarea";
import { Card, CardContent } from "@/components/ui/card";
import { WriteBlanksExercise, ReadingPart, Difficulty } from "@/interfaces/exam";
import useExamEditorStore from "@/stores/examEditor";
import { DragEndEvent } from "@dnd-kit/core";
import { arrayMove } from "@dnd-kit/sortable";
import { useState, useEffect, useCallback } from "react";
import { MdEditOff, MdEdit, MdDelete, MdAdd } from "react-icons/md";
import { toast } from "react-toastify";
import useSectionEdit from "../../Hooks/useSectionEdit";
import Alert, { AlertItem } from "../Shared/Alert";
import QuestionsList from "../Shared/QuestionsList";
import setEditingAlert from "../Shared/setEditingAlert";
import SortableQuestion from "../Shared/SortableQuestion";
import { ParsedQuestion, parseLine, reconstructLine } from "./parsing";
import { validateQuestions, validateEmptySolutions, validateWordCount } from "./validation";
import Header from "../../Shared/Header";
import BlanksFormEditor from "./BlanksFormEditor";
import PromptEdit from "../Shared/PromptEdit";
const WriteBlanksForm: React.FC<{ sectionId: number; exercise: WriteBlanksExercise }> = ({ sectionId, exercise }) => {
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 [editingPrompt, setEditingPrompt] = useState(false);
const [parsedQuestions, setParsedQuestions] = useState<ParsedQuestion[]>([]);
const { editing, handleSave, handleDiscard, handleDelete, handlePractice, setEditing } = useSectionEdit({
sectionId,
onSave: () => {
const isQuestionsValid = validateQuestions(parsedQuestions, setAlerts);
const isSolutionsValid = validateEmptySolutions(local.solutions, setAlerts);
if (!isQuestionsValid || !isSolutionsValid) {
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: () => {
setLocal(exercise);
setParsedQuestions([]);
},
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(() => {
const questions = local.text.split('\\n')
.filter(line => line.trim())
.map(line => {
const match = line.match(/{{(\d+)}}/);
return {
id: match ? match[1] : `unknown-${Date.now()}`,
parts: parseLine(line),
editingPlaceholders: true
};
});
setParsedQuestions(questions);
}, [local.text]);
useEffect(() => {
setEditingAlert(editing, setAlerts);
}, [editing]);
useEffect(() => {
validateWordCount(local.solutions, local.maxWords, setAlerts);
}, [local.maxWords, local.solutions]);
const updateLocal = (exercise: WriteBlanksExercise) => {
setLocal(exercise);
setEditing(true);
};
const addQuestion = () => {
const existingIds = parsedQuestions.map(q => parseInt(q.id));
const newId = (Math.max(...existingIds, 0) + 1).toString();
const newLine = `New question with blank {{${newId}}}`;
const updatedQuestions = [...parsedQuestions, {
id: newId,
parts: parseLine(newLine),
editingPlaceholders: true
}];
const newText = updatedQuestions
.map(q => reconstructLine(q.parts))
.join('\\n') + '\\n';
const updatedSolutions = [...local.solutions, {
id: newId,
solution: [""]
}];
updateLocal({
...local,
text: newText,
solutions: updatedSolutions
});
};
const deleteQuestion = (id: string) => {
if (parsedQuestions.length === 1) {
toast.error("There needs to be at least one question!");
return;
}
const updatedQuestions = parsedQuestions.filter(q => q.id !== id);
const newText = updatedQuestions
.map(q => reconstructLine(q.parts))
.join('\\n') + '\\n';
const updatedSolutions = local.solutions.filter(s => s.id !== id);
updateLocal({
...local,
text: newText,
solutions: updatedSolutions
});
};
const handleQuestionUpdate = (questionId: string, newText: string) => {
const updatedQuestions = parsedQuestions.map(q =>
q.id === questionId ? { ...q, parts: parseLine(newText) } : q
);
const updatedText = updatedQuestions
.map(q => reconstructLine(q.parts))
.join('\\n') + '\\n';
updateLocal({ ...local, text: updatedText });
};
const addSolution = (questionId: string) => {
const newSolutions = local.solutions.map(s =>
s.id === questionId
? { ...s, solution: [...s.solution, ""] }
: s
);
updateLocal({ ...local, solutions: newSolutions });
};
const updateSolution = (questionId: string, index: number, value: string) => {
const newSolutions = local.solutions.map(s =>
s.id === questionId
? { ...s, solution: s.solution.map((sol, i) => i === index ? value : sol) }
: s
);
updateLocal({ ...local, solutions: newSolutions });
};
const deleteSolution = (questionId: string, index: number) => {
const solutions = local.solutions.find(s => s.id === questionId);
if (solutions && solutions.solution.length <= 1) {
toast.error("Each question must have at least one solution!");
return;
}
const newSolutions = local.solutions.map(s =>
s.id === questionId
? { ...s, solution: s.solution.filter((_, i) => i !== index) }
: s
);
updateLocal({ ...local, solutions: newSolutions });
};
const handleQuestionsReorder = (event: DragEndEvent) => {
const { active, over } = event;
if (!over || active.id === over.id) return;
const oldIndex = parsedQuestions.findIndex(q => q.id === active.id);
const newIndex = parsedQuestions.findIndex(q => q.id === over.id);
const reorderedQuestions = arrayMove(parsedQuestions, oldIndex, newIndex);
const newText = reorderedQuestions
.map(q => reconstructLine(q.parts))
.join('\\n') + '\\n';
updateLocal({ ...local, text: newText });
};
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="Write Blanks: Form Exercise"
description="Edit questions and their solutions"
editing={editing}
difficulty={exercise.difficulty}
saveDifficulty={saveDifficulty}
handleSave={handleSave}
handleDiscard={handleDiscard}
handleDelete={handleDelete}
handlePractice={handlePractice}
/>
<div className="space-y-4">
{alerts.length > 0 && <Alert alerts={alerts} />}
<PromptEdit value={local.prompt} onChange={(prompt: string) => updateLocal({ ...local, prompt })}/>
<div className="space-y-4">
<QuestionsList
ids={parsedQuestions.map(q => q.id)}
handleDragEnd={handleQuestionsReorder}
>
{parsedQuestions.map((question, index) => (
<SortableQuestion
key={question.id}
id={question.id}
index={index}
deleteQuestion={() => deleteQuestion(question.id)}
variant="del-up"
>
<div className="space-y-4">
<BlanksFormEditor
parts={question.parts}
onUpdate={(newText) => handleQuestionUpdate(question.id, newText)}
/>
<div className="space-y-2">
<h4 className="text-sm font-medium text-gray-700">Solutions:</h4>
{local.solutions.find(s => s.id === question.id)?.solution.map((solution, index) => (
<div key={index} className="flex gap-2 items-center">
<input
type="text"
value={solution}
onChange={(e) => updateSolution(question.id, 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={() => deleteSolution(question.id, 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={() => addSolution(question.id)}
className="w-full 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>
</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>
</div>
);
};
export default WriteBlanksForm;

View File

@@ -1,79 +0,0 @@
export interface PromptPart {
id: string;
content: string;
isPlaceholder?: boolean;
}
export interface ParsedQuestion {
id: string;
parts: PromptPart[];
editingPlaceholders: boolean;
}
const parseLine = (line: string): PromptPart[] => {
const parts: PromptPart[] = [];
let lastIndex = 0;
const regex = /{{(\d+)}}/g;
let match;
while ((match = regex.exec(line)) !== null) {
if (match.index > lastIndex) {
const textBefore = line.slice(lastIndex, match.index);
const words = textBefore.split(/(\s+)/).filter(Boolean);
words.forEach(word => {
parts.push({
id: `text-${Date.now()}-${parts.length}`,
content: word
});
});
}
const placeholderId = match[1];
parts.push({
id: placeholderId,
content: match[0],
isPlaceholder: true
});
lastIndex = match.index + match[0].length;
}
if (lastIndex < line.length) {
const textAfter = line.slice(lastIndex);
const words = textAfter.split(/(\s+)/).filter(Boolean);
words.forEach(word => {
parts.push({
id: `text-${Date.now()}-${parts.length}`,
content: word
});
});
}
return parts;
};
const reconstructLine = (parts: PromptPart[]): string => {
const text = parts
.map(part => part.content)
.join(' ')
.replace(/\s+/g, ' ')
.trim();
return text;
};
const formatDisplayContent = (content: string): string => {
return content.replace(/{{(\d+)}}/g, '[$1]');
};
const formatStorageContent = (content: string): string => {
return content.replace(/\[(\d+)\]/g, '{{$1}}');
};
export {
parseLine,
reconstructLine,
formatDisplayContent,
formatStorageContent
}

View File

@@ -1,117 +0,0 @@
import { AlertItem } from "../Shared/Alert";
import { ParsedQuestion, reconstructLine } from "./parsing";
const validatePlaceholders = (text: string, originalId: string): { isValid: boolean; message?: string } => {
const matches = text.match(/\[(\d+)\]/g) || [];
if (matches.length === 0) {
return {
isValid: false,
message: "Each question must have exactly one blank"
};
}
if (matches.length > 1) {
return {
isValid: false,
message: "Only one blank is allowed per question"
};
}
const idMatch = matches[0]?.match(/\[(\d+)\]/);
if (!idMatch || idMatch[1] !== originalId) {
return {
isValid: false,
message: "The blank ID cannot be changed"
};
}
return { isValid: true };
};
const validateQuestions = (
parsedQuestions: ParsedQuestion[],
setAlerts: React.Dispatch<React.SetStateAction<AlertItem[]>>
): boolean => {
const emptyQuestions = parsedQuestions.filter(q => reconstructLine(q.parts).trim() === '');
if (emptyQuestions.length > 0) {
setAlerts(prev => {
const filteredAlerts = prev.filter(alert => !alert.tag?.startsWith('empty-question'));
return [...filteredAlerts, ...emptyQuestions.map(q => ({
variant: "error" as const,
tag: `empty-question-${q.id}`,
description: `Question ${q.id} is empty`
}))];
});
return false;
}
setAlerts(prev => prev.filter(alert => !alert.tag?.startsWith('empty-question')));
return true;
};
const validateEmptySolutions = (
solutions: Array<{ id: string; solution: string[] }>,
setAlerts: React.Dispatch<React.SetStateAction<AlertItem[]>>
): boolean => {
const questionsWithEmptySolutions = solutions.flatMap(solution =>
solution.solution.map((sol, index) => ({
questionId: solution.id,
solutionIndex: index,
isEmpty: !sol.trim()
})).filter(({ isEmpty }) => isEmpty)
);
if (questionsWithEmptySolutions.length > 0) {
setAlerts(prev => {
const filteredAlerts = prev.filter(alert => !alert.tag?.startsWith('empty-solution'));
return [...filteredAlerts, ...questionsWithEmptySolutions.map(({ questionId, solutionIndex }) => ({
variant: "error" as const,
tag: `empty-solution-${questionId}-${solutionIndex}`,
description: `Solution ${solutionIndex + 1} for question ${questionId} cannot be empty`
}))];
});
return false;
}
setAlerts(prev => prev.filter(alert => !alert.tag?.startsWith('empty-solution')));
return true;
};
const validateWordCount = (
solutions: Array<{ id: string; solution: string[] }>,
maxWords: number,
setAlerts: React.Dispatch<React.SetStateAction<AlertItem[]>>
): boolean => {
let isValid = true;
solutions.forEach((solution) => {
solution.solution.forEach((value, solutionIndex) => {
const wordCount = value.trim().split(/\s+/).length;
if (wordCount > maxWords) {
isValid = false;
setAlerts(prev => {
const filteredAlerts = prev.filter(alert =>
alert.tag !== `solution-error-${solution.id}-${solutionIndex}`
);
return [...filteredAlerts, {
variant: "error",
tag: `solution-error-${solution.id}-${solutionIndex}`,
description: `Solution ${solutionIndex + 1} for question ${solution.id} exceeds maximum of ${maxWords} words (current: ${wordCount} words)`
}];
});
} else {
setAlerts(prev =>
prev.filter(alert =>
alert.tag !== `solution-error-${solution.id}-${solutionIndex}`
)
);
}
});
});
return isValid;
};
export {
validateQuestions,
validateEmptySolutions,
validateWordCount,
validatePlaceholders
}

View File

@@ -1,178 +0,0 @@
import { useCallback, useEffect, useState } from "react";
import useExamEditorStore from "@/stores/examEditor";
import ExamEditorStore, { ModuleState } from "@/stores/examEditor/types";
import AutoExpandingTextArea from "@/components/Low/AutoExpandingTextarea";
import { Difficulty, LevelPart, WritingExercise } from "@/interfaces/exam";
import Header from "../../Shared/Header";
import Alert, { AlertItem } from "../Shared/Alert";
import clsx from "clsx";
import useSectionEdit from "../../Hooks/useSectionEdit";
import GenLoader from "../Shared/GenLoader";
import setEditingAlert from "../Shared/setEditingAlert";
import { Module } from "@/interfaces";
interface Props {
sectionId: number;
exercise: WritingExercise;
module: Module;
index?: number;
}
const Writing: React.FC<Props> = ({ sectionId, exercise, module, index }) => {
const { currentModule, dispatch } = useExamEditorStore();
const difficulty = useExamEditorStore((state) => state.modules[currentModule].difficulty);
const { type, academic_url } = useExamEditorStore(
(state) => state.modules[currentModule]
);
const { generating, genResult, state } = useExamEditorStore(
(state) => state.modules[currentModule].sections.find((section) => section.sectionId === sectionId)!
);
const [local, setLocal] = useState(exercise);
const [prompt, setPrompt] = useState(exercise.prompt);
const [loading, setLoading] = useState(generating && generating == "exercises");
const [alerts, setAlerts] = useState<AlertItem[]>([]);
const level = module === "level";
const { editing, handleSave, handleDiscard, handleDelete, handlePractice, handleEdit, setEditing } = useSectionEdit({
sectionId,
onSave: () => {
const newExercise = { ...local } as WritingExercise;
newExercise.prompt = prompt;
newExercise.difficulty = exercise.difficulty;
setAlerts([]);
setEditing(false);
if (!level) {
dispatch({ type: "UPDATE_SECTION_STATE", payload: { sectionId: sectionId, update: newExercise, module } });
}
if (genResult) {
dispatch({ type: "UPDATE_SECTION_SINGLE_FIELD", payload: { sectionId, module: currentModule, field: "genResult", value: undefined } })
}
},
onDiscard: () => {
setEditing(false);
setLocal(exercise);
setPrompt(exercise.prompt);
},
onDelete: () => {
if (level) {
dispatch({
type: "UPDATE_SECTION_STATE", payload: {
sectionId: sectionId,
update: {
exercises: (state as LevelPart).exercises.filter((_, i) => i !== index)
},
module
}
});
}
},
onPractice: () => {
const newState = {
...state,
isPractice: !local.isPractice
};
setLocal((prev) => ({ ...prev, isPractice: !local.isPractice }))
dispatch({ type: 'UPDATE_SECTION_STATE', payload: { sectionId, update: newState, module: currentModule } });
}
});
useEffect(() => {
const loading = generating && generating == "writing";
setLoading(loading);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [generating]);
useEffect(() => {
if (genResult) {
setEditing(true);
setPrompt(genResult.result[0].prompt);
if (!difficulty.includes(genResult.result[0].difficulty)) {
dispatch({ type: 'UPDATE_MODULE', payload: { updates: { difficulty: [...difficulty, genResult.result[0].difficulty]} } });
}
const updatedExercise = { ...exercise, difficulty: genResult.result[0].difficulty };
dispatch({ type: 'UPDATE_SECTION_STATE', payload: { sectionId, update: updatedExercise, module: currentModule } });
dispatch({ type: "UPDATE_SECTION_SINGLE_FIELD", payload: { sectionId, module: currentModule, field: "generating", value: undefined } })
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [genResult, dispatch, sectionId, setEditing, currentModule]);
useEffect(() => {
setEditingAlert(prompt !== local.prompt, setAlerts);
}, [prompt, local.prompt]);
const saveDifficulty = useCallback((diff: Difficulty)=> {
if (!difficulty.includes(diff)) {
dispatch({ type: 'UPDATE_MODULE', payload: { updates: { difficulty: [...difficulty, diff]} } });
}
if (!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, level, sectionId, state]);
return (
<>
<div className={clsx('relative', level ? "px-4 mt-2" : "pb-2")}>
<Header
title={`${sectionId === 1 ? (type === "academic" ? "Visual Information" : "Letter") : "Essay"} Instructions`}
description='Generate or edit the instructions for the task'
editing={editing}
difficulty={exercise.difficulty}
saveDifficulty={saveDifficulty}
handleSave={handleSave}
handleDelete={module == "level" ? handleDelete : undefined}
handleEdit={handleEdit}
handleDiscard={handleDiscard}
handlePractice={handlePractice}
isEvaluationEnabled={!local.isPractice}
module={"writing"}
/>
{alerts.length !== 0 && <Alert alerts={alerts} />}
</div>
<div className={clsx(level ? "mt-2 px-4" : "mt-4")}>
{loading ?
<GenLoader module={currentModule} /> :
<>
{
editing ? (
<div className="text-gray-600 p-4">
<AutoExpandingTextArea
value={prompt}
onChange={(text) => setPrompt(text)}
placeholder="Instructions ..."
/>
</div>
) : (
<p className={
clsx("w-full px-7 py-8 border-2 bg-white rounded-3xl whitespace-pre-line",
prompt === "" ? "text-gray-600/50" : "text-gray-600"
)
}>
{prompt === "" ? "Instructions ..." : prompt}
</p>
)
}
{academic_url && sectionId == 1 && (
<div className="flex items-center justify-center mt-8">
<div className="max-w-lg self-center rounded-xl cursor-pointer">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={academic_url} alt="Visual Information" />
</div>
</div>
)}
</>
}
</div>
</>
);
};
export default Writing;

View File

@@ -1,84 +0,0 @@
import useExamEditorStore from '@/stores/examEditor';
import ExamEditorStore from '@/stores/examEditor/types';
import { useCallback, useState } from 'react';
interface Props {
sectionId: number;
editing?: boolean;
setEditing?: React.Dispatch<React.SetStateAction<boolean>>;
onSave?: () => void;
onDiscard?: () => void;
onDelete?: () => void;
onPractice?: () => void;
onEdit?: () => void;
}
const useSectionEdit = ({
sectionId,
editing: externalEditing = false,
setEditing: externalSetEditing,
onSave,
onDiscard,
onDelete,
onPractice,
onEdit
}: Props) => {
const { dispatch } = useExamEditorStore();
const [internalEditing, setInternalEditing] = useState<boolean>(externalEditing);
const editing = externalSetEditing !== undefined ? externalEditing : internalEditing;
const setEditing = externalSetEditing !== undefined ? externalSetEditing : setInternalEditing;
const updateRoot = useCallback((updates: Partial<ExamEditorStore>) => {
dispatch({ type: 'UPDATE_ROOT', payload: { updates } });
}, [dispatch]);
const handleEdit = useCallback(() => {
setEditing(!editing);
if (onEdit) {
onEdit();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [sectionId, editing, setEditing, updateRoot]);
const handleSave = useCallback(() => {
if (onSave) {
onSave();
} else {
setEditing(false);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [setEditing, updateRoot, onSave, sectionId]);
const handleDiscard = useCallback(() => {
setEditing(false);
onDiscard?.();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [setEditing, updateRoot, onDiscard, sectionId]);
const handleDelete = useCallback(() => {
setEditing(!editing);
onDelete?.();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [setEditing, editing, updateRoot, onDelete, sectionId]);
const handlePractice = useCallback(() => {
onPractice?.();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [setEditing, editing, updateRoot, onPractice, sectionId]);
return {
editing,
setEditing,
handleEdit,
handleSave,
handleDiscard,
handleDelete,
handlePractice,
};
};
export default useSectionEdit;

View File

@@ -1,84 +0,0 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import useExamEditorStore from "@/stores/examEditor";
import { Module } from "@/interfaces";
import { debounce } from "lodash";
import { SectionSettings } from "@/stores/examEditor/types";
// Since all the other components have a local state
// that then gets updated all at once, if the keydowns
// aren't here aren't throttled things can get messy
const useSettingsState = <T extends SectionSettings>(
module: Module,
sectionId: number,
) => {
const globalSettings = useExamEditorStore((state) => {
const settings = state.modules[module].sections.find(
(section) => section.sectionId === sectionId
)?.settings;
return settings as T;
});
const dispatch = useExamEditorStore((state) => state.dispatch);
const [localSettings, setLocalSettings] = useState<T>(() =>
globalSettings || {} as T
);
const pendingUpdatesRef = useRef<Partial<T>>({});
useEffect(() => {
if (globalSettings) {
setLocalSettings(globalSettings);
}
}, [globalSettings]);
const debouncedUpdateGlobal = useMemo(() => {
const debouncedFn = debounce(() => {
if (Object.keys(pendingUpdatesRef.current).length > 0) {
dispatch({
type: 'UPDATE_SECTION_SETTINGS',
payload: { sectionId, update: pendingUpdatesRef.current, module}
});
pendingUpdatesRef.current = {};
}
}, 1000);
return debouncedFn;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [dispatch, sectionId]);
useEffect(() => {
return () => {
if (Object.keys(pendingUpdatesRef.current).length > 0) {
dispatch({
type: 'UPDATE_SECTION_SETTINGS',
payload: {sectionId, update: pendingUpdatesRef.current, module}
});
}
};
}, [dispatch, module, sectionId]);
const updateLocalAndScheduleGlobal = useCallback((updates: Partial<T>, schedule: boolean = true) => {
setLocalSettings(prev => ({
...prev,
...updates
}));
pendingUpdatesRef.current = {
...pendingUpdatesRef.current,
...updates
};
if (schedule) {
debouncedUpdateGlobal();
}
}, [debouncedUpdateGlobal]);
return {
localSettings,
updateLocalAndScheduleGlobal
};
};
export default useSettingsState;

View File

@@ -1,59 +0,0 @@
import React from 'react';
import { FaPencilAlt } from 'react-icons/fa';
import { Module } from '@/interfaces';
import clsx from 'clsx';
import WordUploader from './WordUploader';
import GenLoader from '../Exercises/Shared/GenLoader';
import useExamEditorStore from '@/stores/examEditor';
const ImportOrFromScratch: React.FC<{
module: Module;
setNumberOfLevelParts: (parts: number) => void;
}> = ({ module, setNumberOfLevelParts }) => {
const { currentModule, dispatch } = useExamEditorStore();
const { importing } = useExamEditorStore((store) => store.modules[currentModule])
const handleClick = () => {
dispatch({ type: "UPDATE_MODULE", payload: { updates: { importModule: false } } });
}
return (
<>
{importing ? (
<GenLoader module={module} custom={`Importing ${module} exam ...`} className='flex flex-grow justify-center bg-slate-200 ' />
) : (
<div className="grid grid-cols-2 w-full flex-1 gap-6">
<button
onClick={handleClick}
className={clsx(
"flex flex-col items-center flex-1 gap-6 justify-center p-8",
"border-2 border-gray-200 rounded-xl",
`bg-ielts-${module}/20 hover:bg-ielts-${module}/30`,
"transition-all duration-300",
"shadow-sm hover:shadow-md group")}
>
<div className="transform group-hover:scale-105 transition-transform duration-300">
<FaPencilAlt className={clsx("w-20 h-20 transition-colors duration-300",
module === "reading" && "text-indigo-800 group-hover:text-indigo-950",
module === "listening" && "text-purple-800 group-hover:text-purple-950",
module === "level" && "text-teal-700 group-hover:text-teal-900"
)} />
</div>
<span className={clsx("text-lg font-bold transition-colors duration-300",
module === "reading" && "text-indigo-800 group-hover:text-indigo-950",
module === "listening" && "text-purple-800 group-hover:text-purple-950",
module === "level" && "text-teal-700 group-hover:text-teal-900"
)}>
Start from Scratch
</span>
</button>
<div className='h-full'>
<WordUploader module={module} setNumberOfLevelParts={setNumberOfLevelParts} />
</div>
</div>
)}
</>
);
};
export default ImportOrFromScratch;

View File

@@ -1,213 +0,0 @@
import Button from "@/components/Low/Button";
import { Module } from "@/interfaces";
import { Dialog, DialogPanel, DialogTitle, Transition, TransitionChild } from "@headlessui/react";
import { capitalize } from "lodash";
import React, { Fragment, useCallback, useEffect, useState } from "react";
import { FaFileDownload } from "react-icons/fa";
import { HiOutlineDocumentText } from "react-icons/hi";
import { IoInformationCircleOutline } from "react-icons/io5";
interface Props {
module: Module;
state: { isOpen: boolean, type: "exam" | "solutions" };
setState: React.Dispatch<React.SetStateAction<{ isOpen: boolean, type: "exam" | "solutions" }>>;
}
const Templates: React.FC<Props> = ({ module, state, setState }) => {
const [isClosing, setIsClosing] = useState(false);
const [mounted, setMounted] = useState(false);
useEffect(() => {
if (state.isOpen) {
setMounted(true);
}
}, [state]);
useEffect(() => {
if (!state.isOpen && mounted) {
const timer = setTimeout(() => {
setMounted(false);
setIsClosing(false);
}, 300);
return () => clearTimeout(timer);
}
}, [state, mounted]);
const blockMultipleClicksClose = useCallback(() => {
if (isClosing) return;
setIsClosing(true);
setState({ isOpen: false, type: state.type });
const timer = setTimeout(() => {
setIsClosing(false);
}, 300);
return () => clearTimeout(timer);
}, [isClosing, setState, state]);
if (!mounted && !state.isOpen) return null;
const moduleExercises = {
"reading": [
"Multiple Choice",
"Write Blanks",
"True False",
"Paragraph Match",
"Idea Match"
],
"listening": [
"Multiple Choice",
"True False",
"Write Blanks: Questions",
"Write Blanks: Fill",
"Write Blanks: Form",
],
"level": [
"Fill Blanks: Multiple Choice",
"Multiple Choice: Blank Space",
"Multiple Choice: Underline",
"Multiple Choice: Reading Passage"
],
"writing": [],
"speaking": [],
}
const handleTemplateDownload = () => {
const fileName = `${capitalize(module)}${state.type === "exam" ? "Exam" : "Solutions"}Template`;
const url = `https://firebasestorage.googleapis.com/v0/b/encoach-staging.appspot.com/o/import_templates%2F${fileName}.docx?alt=media&token=b771a535-bf95-4060-889c-a086df65d480`;
const link = document.createElement('a');
link.href = url;
link.download = `${fileName}.docx`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
};
return (
<Transition
show={state.isOpen}
as={Fragment}
beforeEnter={() => setIsClosing(false)}
beforeLeave={() => setIsClosing(true)}
afterLeave={() => {
setIsClosing(false);
setMounted(false);
}}
>
<Dialog onClose={() => blockMultipleClicksClose()} className="relative z-50">
<TransitionChild
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" />
</TransitionChild>
<TransitionChild
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">
<DialogPanel className={`bg-ielts-${module}-light w-full max-w-6xl h-fit p-8 rounded-xl flex flex-col gap-4`}>
<DialogTitle className="flex font-bold text-xl justify-center text-gray-700"><span>{`${capitalize(module)} ${state.type === "exam" ? 'Exam' : 'Solutions'} Import`}</span></DialogTitle>
<div className="flex flex-col w-full mt-4 gap-6">
{state.type === "exam" ? (
<>
<div className="flex flex-col gap-3 bg-gray-50 rounded-lg p-4">
<div className="flex items-center gap-2">
<HiOutlineDocumentText className={`w-5 h-5 text-ielts-${module}`} />
<h2 className="text-lg font-semibold">
The {module} exam import accepts the following exercise types:
</h2>
</div>
<ul className="flex flex-col pl-10 gap-2">
{moduleExercises[module].map((item, index) => (
<li key={index} className="text-gray-700 list-disc">
{item}
</li>
))}
</ul>
</div>
<div className="flex flex-col gap-3 bg-gray-50 rounded-lg p-4">
<div className="flex items-center gap-2">
<IoInformationCircleOutline className={`w-5 h-5 text-ielts-${module}`} />
<h2 className="text-lg font-semibold">
The uploaded document must:
</h2>
</div>
<ul className="flex flex-col pl-10 gap-2">
<li className="text-gray-700 list-disc">
be a Word .docx document.
</li>
<li className="text-gray-700 list-disc">
have clear part and exercise delineation (e.g. Part 1, ... , Part X, Question 1 - 10, ... , Question y - x).
</li>
{["reading", "level"].includes(module) && (
<li className="text-gray-700 list-disc">
a part must only contain a single reading passage and it must be between the part delineator (e.g. Part 1) and the part exercises.
</li>
)}
<li className="text-gray-700 list-disc">
if solutions are going to be uploaded, the exercise numbers/id&apos;s must match the ones in the solutions.
</li>
</ul>
</div>
</>
) :
<>
<div className="flex flex-col gap-3 bg-gray-50 rounded-lg p-4">
<div className="flex items-center gap-2">
<IoInformationCircleOutline className={`w-5 h-5 text-ielts-${module}`} />
<h2 className="text-lg font-semibold">
The uploaded document must:
</h2>
</div>
<ul className="flex flex-col pl-10 gap-2">
<li className="text-gray-700 list-disc">
be a Word .docx document.
</li>
<li className="text-gray-700 list-disc">
match the exercise numbers/id&apos;s that are in the exam document.
</li>
</ul>
</div>
</>
}
<div className="bg-gray-50 rounded-lg p-4">
<p className="text-gray-600">
{`The downloadable template is an example of a file that can be imported. Your document doesn't need to be a carbon copy of the template - it can have different styling and formatting but it must adhere to the previous requirements${state.type === "exam" ? " and exercises of the same type should have the same formatting" : ""}.`}
</p>
</div>
<div className="w-full flex justify-between mt-4 gap-8">
<Button color="purple" onClick={() => blockMultipleClicksClose()} variant="outline" className="self-end w-full bg-white">
Close
</Button>
<Button color="purple" onClick={handleTemplateDownload} variant="solid" className="self-end w-full">
<div className="flex items-center gap-2">
<FaFileDownload size={24} />
Download Template
</div>
</Button>
</div>
</div>
</DialogPanel>
</div>
</TransitionChild>
</Dialog>
</Transition>
);
}
export default Templates;

View File

@@ -1,301 +0,0 @@
import React, { useCallback, useRef, useState } from 'react';
import Image from 'next/image';
import clsx from 'clsx';
import { FaFileUpload, FaCheckCircle, FaLock, FaTimes } from 'react-icons/fa';
import { capitalize } from 'lodash';
import { Module } from '@/interfaces';
import { toast } from 'react-toastify';
import useExamEditorStore from '@/stores/examEditor';
import { LevelPart, ListeningPart, ReadingPart } from '@/interfaces/exam';
import { defaultSectionSettings } from '@/stores/examEditor/defaults';
import Templates from './Templates';
import { IoInformationCircleOutline } from 'react-icons/io5';
const WordUploader: React.FC<{ module: Module, setNumberOfLevelParts: (parts: number) => void; }> = ({ module, setNumberOfLevelParts }) => {
const { currentModule, dispatch } = useExamEditorStore();
const examInputRef = useRef<HTMLInputElement>(null);
const solutionsInputRef = useRef<HTMLInputElement>(null);
const [showUploaders, setShowUploaders] = useState(false);
const [examFile, setExamFile] = useState<File | null>(null);
const [solutionsFile, setSolutionsFile] = useState<File | null>(null);
const [templateState, setTemplateState] = useState<{ isOpen: boolean, type: "exam" | "solutions" }>({ isOpen: false, type: "exam" });
const handleExamChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (file) {
if (file.type === 'application/msword' ||
file.type === 'application/vnd.openxmlformats-officedocument.wordprocessingml.document') {
setExamFile(file);
}
}
};
const handleSolutionsChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (file) {
if (file.type === 'application/msword' ||
file.type === 'application/vnd.openxmlformats-officedocument.wordprocessingml.document') {
setSolutionsFile(file);
}
}
};
const handleImport = useCallback(async () => {
try {
if (!examFile) {
toast.error('Exam file is required');
return;
}
dispatch({ type: "UPDATE_MODULE", payload: { updates: { importing: true }, module } })
const formData = new FormData();
formData.append('exercises', examFile);
if (solutionsFile) {
formData.append('solutions', solutionsFile);
}
const response = await fetch(`/api/exam/${module}/import/`, {
method: 'POST',
body: formData,
});
if (!response.ok) {
toast.error(`An unknown error has occured while import ${module} exam!`);
return;
}
const data = await response.json();
toast.success(`${capitalize(module)} exam imported successfully!`);
setExamFile(null);
setSolutionsFile(null);
setShowUploaders(false);
const newSectionsStates = data.parts.map(
(part: ReadingPart | ListeningPart | LevelPart, index: number) => defaultSectionSettings(module, index + 1, part)
);
if (module === "level") {
setNumberOfLevelParts(data.parts.length);
}
dispatch({
type: "UPDATE_MODULE", payload: {
updates: {
sections: newSectionsStates,
minTimer: data.minTimer,
importModule: false,
importing: false,
},
module
}
});
} catch (error) {
toast.error(`Make sure you've imported a valid word document (.docx)!`);
} finally {
dispatch({ type: "UPDATE_MODULE", payload: { updates: { importing: false }, module } })
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
examFile,
solutionsFile,
dispatch,
currentModule
]);
return (
<>
<Templates module={module} state={templateState} setState={setTemplateState} />
{!showUploaders ? (
<div
onClick={() => setShowUploaders(true)}
className="flex flex-col items-center gap-6 h-full justify-center p-8 border-2 border-blue-200 rounded-xl
bg-gradient-to-b from-blue-50 to-blue-100
hover:from-blue-100 hover:to-blue-200
cursor-pointer transition-all duration-300
shadow-sm hover:shadow-md group"
>
<div className="transform group-hover:scale-105 transition-transform duration-300">
<Image
src="/microsoft-word-icon.png"
width={200}
height={100}
alt="Upload Word"
className="drop-shadow-md"
/>
</div>
<span className="text-lg font-bold text-stone-600 group-hover:text-stone-800 transition-colors duration-300">
Upload {capitalize(module)} Exam
</span>
</div>
) : (
<div className="flex flex-col h-full gap-4 p-6 justify-between border-2 border-blue-200 rounded-xl bg-white shadow-md">
<div className='flex flex-col flex-1 justify-center gap-8'>
<div
onClick={() => examInputRef.current?.click()}
className={clsx(
"relative p-6 border-2 border-dashed rounded-lg cursor-pointer transition-all duration-300",
examFile ? "border-green-300 bg-green-50" : "border-gray-300 hover:border-blue-400 hover:bg-blue-50"
)}
>
<div className="flex items-center gap-3">
<FaFileUpload className={clsx(
"w-8 h-8",
examFile ? "text-green-500" : "text-gray-400"
)} />
<div className="flex-grow">
<h3 className="font-semibold text-gray-700">Exam Document</h3>
<p className="text-sm text-gray-500">Required</p>
</div>
{examFile ? (
<div className="flex items-center gap-2">
<FaCheckCircle className="w-6 h-6 text-green-500" />
<button
onClick={(e) => {
e.stopPropagation();
setExamFile(null);
}}
className="p-1.5 hover:bg-green-100 rounded-full transition-colors duration-200"
>
<FaTimes className="w-4 h-4 text-green-600" />
</button>
</div>
) : (
<button
onClick={(e) => {
e.stopPropagation();
setTemplateState({ isOpen: true, type: "exam" });
}}
className="p-1.5 hover:bg-gray-200 rounded-full transition-colors duration-200"
>
<IoInformationCircleOutline size={28} />
</button>
)}
</div>
{examFile && (
<div className="mt-2 text-sm text-green-600 font-medium">
{examFile.name}
</div>
)}
<input
type="file"
ref={examInputRef}
onChange={handleExamChange}
accept=".docx"
className="hidden"
/>
</div>
<div
onClick={() => solutionsInputRef.current?.click()}
className={clsx(
"relative p-6 border-2 border-dashed rounded-lg cursor-pointer transition-all duration-300",
solutionsFile ? "border-green-300 bg-green-50" : "border-gray-300 hover:border-blue-400 hover:bg-blue-50"
)}
>
<div className="flex items-center gap-3">
<FaFileUpload className={clsx(
"w-8 h-8",
solutionsFile ? "text-green-500" : "text-gray-400"
)} />
<div className="flex-grow">
<h3 className="font-semibold text-gray-700">Solutions Document</h3>
<p className="text-sm text-gray-500">Optional</p>
</div>
{solutionsFile ? (
<div className="flex items-center gap-2">
<FaCheckCircle className="w-6 h-6 text-green-500" />
<button
onClick={(e) => {
e.stopPropagation();
setSolutionsFile(null);
}}
className="p-1.5 hover:bg-green-100 rounded-full transition-colors duration-200"
>
<FaTimes className="w-4 h-4 text-green-600" />
</button>
</div>
) : (
<>
<span className="text-xs text-gray-400 font-medium px-2 py-1 bg-gray-100 rounded">
OPTIONAL
</span>
<button
onClick={(e) => {
e.stopPropagation();
setTemplateState({ isOpen: true, type: "solutions" });
}}
className="p-1.5 hover:bg-gray-200 rounded-full transition-colors duration-200"
>
<IoInformationCircleOutline size={28} />
</button>
</>
)}
</div>
{solutionsFile && (
<div className="mt-2 text-sm text-green-600 font-medium">
{solutionsFile.name}
</div>
)}
<input
type="file"
ref={solutionsInputRef}
onChange={handleSolutionsChange}
accept=".docx"
className="hidden"
/>
</div>
</div>
<div className="flex gap-4">
<button
onClick={() => setShowUploaders(false)}
className={
clsx("px-6 py-2.5 text-sm font-semibold text-gray-700 bg-white border-2 border-gray-200",
"rounded-lg hover:bg-gray-50 hover:border-gray-300",
"transition-all duration-300 min-w-[120px]",
"focus:outline-none focus:ring-2 focus:ring-gray-200 focus:ring-offset-2",
"active:scale-95")}
>
<span className="flex items-center justify-center gap-2">
<FaTimes className="w-4 h-4" />
Cancel
</span>
</button>
<button
onClick={handleImport}
disabled={!examFile}
className={clsx(
"flex-grow px-6 py-2.5 text-sm font-semibold rounded-lg",
"transition-all duration-300 min-w-[120px]",
"focus:outline-none focus:ring-2 focus:ring-offset-2",
"flex items-center justify-center gap-2",
examFile
? "bg-gradient-to-r from-blue-500 to-blue-600 text-white hover:from-blue-600 hover:to-blue-700 active:scale-95 focus:ring-blue-500"
: "bg-gradient-to-r from-gray-100 to-gray-200 text-gray-400 cursor-not-allowed border-2 border-gray-200"
)}
>
{examFile ? (
<>
<FaFileUpload className="w-4 h-4" />
Import Files
</>
) : (
<>
<FaLock className="w-4 h-4" />
Upload Exam First
</>
)}
</button>
</div>
</div>
)}
</>
);
};
export default WordUploader;

View File

@@ -1,63 +0,0 @@
import { useCallback, useEffect, useState } from "react";
import useExamEditorStore from "@/stores/examEditor";
import ExamEditorStore, { Generating } from "@/stores/examEditor/types";
import Header from "../../Shared/Header";
import { Module } from "@/interfaces";
import GenLoader from "../../Exercises/Shared/GenLoader";
interface Props {
sectionId: number;
title: string;
description: string;
editing: boolean;
renderContent: (editing: boolean, listeningSection?: number) => React.ReactNode;
mode?: "edit" | "delete";
onSave: () => void;
onDiscard: () => void;
onEdit?: () => void;
module: Module;
context: Generating;
}
const SectionContext: React.FC<Props> = ({ sectionId, title, description, renderContent, editing, onSave, onDiscard, onEdit, mode = "edit", module, context }) => {
const { currentModule } = useExamEditorStore();
const { generating, levelGenerating } = useExamEditorStore(
(state) => state.modules[currentModule].sections.find((section) => section.sectionId === sectionId)!
);
const [loading, setLoading] = useState(generating && generating === context);
useEffect(() => {
const gen = module === "level" ? levelGenerating.find(g => g === context) !== undefined : generating && generating === context;
if (loading !== gen) {
setLoading(gen);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [generating, levelGenerating]);
return (
<div className="p-8 shadow-inner border border-gray-200 bg-gray-50 rounded-xl">
<div className='relative pb-4'>
<Header
title={title}
description={description}
editing={editing}
handleSave={onSave}
handleDiscard={onDiscard}
handleEdit={onEdit}
module={module}
/>
</div>
<div className="mt-4">
{loading ? (
<GenLoader module={module} />
) : (
renderContent(editing)
)}
</div>
</div>
);
};
export default SectionContext;

View File

@@ -1,40 +0,0 @@
import useExamEditorStore from "@/stores/examEditor";
import ListeningContext from "./listening";
import ReadingContext from "./reading";
import GenLoader from "../../Exercises/Shared/GenLoader";
interface Props {
sectionId: number;
}
const LevelContext: React.FC<Props> = ({ sectionId }) => {
const { currentModule } = useExamEditorStore();
const { generating, readingSection, listeningSection, state } = useExamEditorStore(
(state) => state.modules[currentModule].sections.find((section) => section.sectionId === sectionId)!
);
const hasReadingContext =
'text' in state &&
state.text !== undefined &&
typeof state.text === 'object' &&
'content' in state.text &&
state.text.content !== undefined &&
state.text.content !== "";
return (
<>
{generating && (
(generating === "passage" && <GenLoader module="reading" />) ||
(generating === "listeningScript" && <GenLoader module="listening" />)
)}
{(readingSection || listeningSection || hasReadingContext) && (
<div className="space-y-4 mb-4">
{(readingSection !== undefined || hasReadingContext) && <ReadingContext sectionId={sectionId} module="level" />}
{listeningSection && <ListeningContext sectionId={sectionId} listeningSection={listeningSection} module="level" level={true} />}
</div>
)}
</>
);
};
export default LevelContext;

View File

@@ -1,194 +0,0 @@
import { useCallback, useEffect, useState } from "react";
import { LevelPart, ListeningPart, Script } from "@/interfaces/exam";
import SectionContext from ".";
import useExamEditorStore from "@/stores/examEditor";
import useSectionEdit from "../../Hooks/useSectionEdit";
import ScriptRender from "../../Exercises/Script";
import { Card, CardContent } from "@/components/ui/card";
import Dropdown from "@/components/Dropdown";
import AudioPlayer from "@/components/Low/AudioPlayer";
import { MdHeadphones } from "react-icons/md";
import clsx from "clsx";
import { Module } from "@/interfaces";
import GenLoader from "../../Exercises/Shared/GenLoader";
interface Props {
module: Module;
sectionId: number;
listeningSection?: number;
level?: boolean;
}
const ListeningContext: React.FC<Props> = ({ sectionId, module, listeningSection, level = false }) => {
const { dispatch } = useExamEditorStore();
const { genResult, state, generating, levelGenResults, levelGenerating, scriptLoading } = useExamEditorStore(
(state) => state.modules[module].sections.find((section) => section.sectionId === sectionId)!
);
const listeningPart = state as ListeningPart | LevelPart;
const [isDialogDropdownOpen, setIsDialogDropdownOpen] = useState(false);
const [scriptLocal, setScriptLocal] = useState(listeningPart.script);
const { editing, handleSave, handleDiscard, setEditing, handleEdit } = useSectionEdit({
sectionId,
onSave: () => {
const newState = { ...listeningPart };
newState.script = scriptLocal;
dispatch({ type: 'UPDATE_SECTION_STATE', payload: { sectionId, update: newState, module } })
setEditing(false);
if (genResult) {
dispatch({ type: "UPDATE_SECTION_SINGLE_FIELD", payload: { sectionId, module, field: "genResult", value: undefined } })
}
if (levelGenResults.find((res) => res.generating === "listeningScript")) {
dispatch({ type: "UPDATE_SECTION_SINGLE_FIELD", payload: { sectionId, module, field: "levelGenResults", value: levelGenResults.filter((res) => res.generating !== "listeningScript") } })
}
},
onDiscard: () => {
setScriptLocal(listeningPart.script);
},
});
useEffect(() => {
if (listeningPart.script == undefined) {
setScriptLocal(undefined);
} else {
setScriptLocal(listeningPart.script);
}
}, [listeningPart])
useEffect(() => {
if (genResult && generating === "listeningScript") {
setEditing(true);
setScriptLocal(genResult.result[0].script);
setIsDialogDropdownOpen(true);
dispatch({ type: "UPDATE_SECTION_SINGLE_FIELD", payload: { sectionId, module, field: "generating", value: undefined } })
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [genResult]);
useEffect(() => {
if (genResult && generating === "audio") {
dispatch({ type: "UPDATE_SECTION_SINGLE_FIELD", payload: { sectionId, module, field: "generating", value: undefined } })
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [genResult]);
useEffect(() => {
const scriptRes = levelGenResults.find((res) => res.generating === "listeningScript");
if (levelGenResults && scriptRes) {
setEditing(true);
setScriptLocal(scriptRes.result[0].script);
dispatch({ type: "UPDATE_SECTION_SINGLE_FIELD", payload: { sectionId, module, field: "levelGenerating", value: levelGenerating.filter(g => g !== "listeningScript") } })
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [levelGenResults]);
useEffect(() => {
const scriptRes = levelGenResults.find((res) => res.generating === "audio");
if (levelGenResults && scriptRes) {
dispatch({ type: "UPDATE_SECTION_SINGLE_FIELD", payload: { sectionId, module, field: "levelGenerating", value: levelGenerating.filter(g => g !== "audio") } })
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [levelGenResults]);
const memoizedRenderContent = useCallback(() => {
if (scriptLocal === undefined && !editing && !scriptLoading) {
return (
<Card>
<CardContent className="py-10">
<span>Edit, generate or import your own audio.</span>
</CardContent>
</Card>
);
}
return (
<>
{(generating === "audio" || scriptLoading) ? (
<GenLoader
module="listening"
custom={scriptLoading ? 'Transcribing Audio ...' : 'Generating audio ...'}
/>
) : (
<>
{listeningPart.audio?.source !== undefined && (
<AudioPlayer
key={`${sectionId}-${scriptLocal?.length}`}
src={listeningPart.audio?.source ?? ''}
color="listening"
/>
)}
</>
)}
{!scriptLoading && <Dropdown
className="mt-8 w-full flex items-center justify-between p-4 bg-white hover:bg-gray-50 transition-colors border rounded-xl border-gray-200"
contentWrapperClassName="rounded-xl mt-2"
customTitle={
<div className="flex items-center space-x-3">
<MdHeadphones className={clsx(
"h-5 w-5",
`text-ielts-${module}`
)} />
<span className="font-medium text-gray-900">
{listeningSection === undefined
? ([1, 3].includes(sectionId) ? "Conversation" : "Monologue")
: ([1, 3].includes(listeningSection) ? "Conversation" : "Monologue")}
</span>
</div>
}
open={isDialogDropdownOpen}
setIsOpen={setIsDialogDropdownOpen}
>
<ScriptRender
key={scriptLocal?.length}
local={scriptLocal}
setLocal={setScriptLocal}
section={level ? listeningSection! : sectionId}
editing={editing}
/>
</Dropdown>
}
</>
);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
scriptLoading,
generating,
listeningPart.audio?.source,
listeningPart.script,
sectionId,
module,
isDialogDropdownOpen,
setIsDialogDropdownOpen,
setScriptLocal,
level,
scriptLocal,
editing,
listeningSection
]);
return (
<SectionContext
sectionId={sectionId}
title={
listeningSection === undefined ?
([1, 3].includes(sectionId) ? "Conversation" : "Monologue") :
([1, 3].includes(listeningSection) ? "Conversation" : "Monologue")
}
description={`Enter the section's ${(sectionId === 1 || sectionId === 3) ? "conversation" : "monologue"} or import your own`}
renderContent={memoizedRenderContent}
editing={editing}
onSave={handleSave}
onEdit={handleEdit}
onDiscard={handleDiscard}
module={module}
context="listeningScript"
/>
);
};
export default ListeningContext;

View File

@@ -1,145 +0,0 @@
import { useEffect, useState } from "react";
import { LevelPart, ReadingPart } from "@/interfaces/exam";
import Input from "@/components/Low/Input";
import AutoExpandingTextArea from "@/components/Low/AutoExpandingTextarea";
import Passage from "../../Shared/Passage";
import SectionContext from ".";
import useExamEditorStore from "@/stores/examEditor";
import useSectionEdit from "../../Hooks/useSectionEdit";
import { Module } from "@/interfaces";
interface Props {
module: Module;
sectionId: number;
}
const ReadingContext: React.FC<Props> = ({ sectionId, module }) => {
const { dispatch } = useExamEditorStore();
const sectionState = useExamEditorStore(
(state) => state.modules[module].sections.find((section) => section.sectionId === sectionId)!
);
const { genResult, state, levelGenResults, levelGenerating } = sectionState;
const readingPart = state as ReadingPart | LevelPart;
const [title, setTitle] = useState(readingPart.text?.title || '');
const [content, setContent] = useState(readingPart.text?.content || '');
const [passageOpen, setPassageOpen] = useState(false);
const { editing, handleSave, handleDiscard, handleEdit, setEditing } = useSectionEdit({
sectionId,
onSave: () => {
let newState = { ...state } as ReadingPart | LevelPart;
newState.text = {
title, content
}
dispatch({ type: 'UPDATE_SECTION_STATE', payload: { sectionId, update: newState, module } })
setEditing(false);
if (genResult) {
dispatch({ type: "UPDATE_SECTION_SINGLE_FIELD", payload: { sectionId, module, field: "genResult", value: undefined } })
}
if (levelGenResults.find((res) => res.generating === "passage")) {
dispatch({ type: "UPDATE_SECTION_SINGLE_FIELD", payload: { sectionId, module, field: "levelGenResults", value: levelGenResults.filter((res) => res.generating !== "passage") } })
}
},
onDiscard: () => {
setTitle(readingPart.text?.title || '');
setContent(readingPart.text?.content || '');
},
onEdit: () => {
setPassageOpen(false);
}
});
useEffect(() => {
if (readingPart.text === undefined) {
setTitle('');
setContent('');
}
}, [readingPart])
useEffect(() => {
if (genResult && genResult.generating === "passage") {
setEditing(true);
setTitle(genResult.result[0].title);
setContent(genResult.result[0].text);
dispatch({ type: "UPDATE_SECTION_SINGLE_FIELD", payload: { sectionId, module, field: "generating", value: undefined } })
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [genResult]);
useEffect(() => {
const passageRes = [...levelGenResults].reverse()
.find((res) => res.generating === "passage");
if (levelGenResults && passageRes) {
setEditing(true);
setTitle(passageRes.result[0].title);
setContent(passageRes.result[0].text);
dispatch({ type: "UPDATE_SECTION_SINGLE_FIELD", payload: { sectionId, module, field: "levelGenerating", value: levelGenerating.filter(g => g !== "passage") } })
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [levelGenResults]);
const renderContent = (editing: boolean) => {
if (editing) {
return (
<div className="flex flex-col text-mti-gray-dim p-4 gap-4">
<Input
type="text"
placeholder="Insert a title here"
name="title"
label="Title"
onChange={setTitle}
roundness="xl"
defaultValue={title}
required
/>
<div className="flex flex-col gap-3">
<label className="font-normal text-base text-mti-gray-dim">Content *</label>
<AutoExpandingTextArea
value={content}
placeholder="Insert a passage here"
onChange={(text) => setContent(text)}
/>
</div>
</div>
);
}
return content === "" || title === "" ? (
<p className="w-full text-gray-600 px-7 py-8 border-2 bg-white rounded-3xl whitespace-pre-line">
Generate or edit the passage to add exercises!
</p>
) : (
<Passage
title={title}
content={content}
open={passageOpen}
setIsOpen={setPassageOpen}
/>
);
};
return (
<SectionContext
sectionId={sectionId}
title="Reading Passage"
description="The reading passage that the exercises will refer to."
renderContent={renderContent}
editing={editing}
onSave={handleSave}
onEdit={handleEdit}
module={module}
onDiscard={handleDiscard}
context="passage"
/>
);
};
export default ReadingContext;

View File

@@ -1,124 +0,0 @@
import { Exercise } from "@/interfaces/exam";
import ExerciseItem, { isExerciseItem } from "./types";
import MultipleChoice from "../../Exercises/MultipleChoice";
import ExerciseLabel from "../../Shared/ExerciseLabel";
import writeBlanks from "./writeBlanks";
import TrueFalse from "../../Exercises/TrueFalse";
import fillBlanks from "./fillBlanks";
import MatchSentences from "../../Exercises/MatchSentences";
import Writing from "../../Exercises/Writing";
import Speaking2 from "../../Exercises/Speaking/Speaking2";
import Speaking1 from "../../Exercises/Speaking/Speaking1";
import InteractiveSpeaking from "../../Exercises/Speaking/InteractiveSpeaking";
const getExerciseItems = (exercises: Exercise[], sectionId: number): ExerciseItem[] => {
const items: ExerciseItem[] = exercises.map((exercise, index) => {
let firstQuestionId, lastQuestionId;
switch (exercise.type) {
case "multipleChoice":
firstQuestionId = exercise.questions[0].id;
lastQuestionId = exercise.questions[exercise.questions.length - 1].id;
return {
id: index.toString(),
sectionId,
label: (
<ExerciseLabel
type='Multiple Choice Questions'
firstId={firstQuestionId}
lastId={lastQuestionId}
prompt={exercise.prompt}
/>
),
content: <MultipleChoice exercise={exercise} sectionId={sectionId} />
};
case "trueFalse":
firstQuestionId = exercise.questions[0].id
lastQuestionId = exercise.questions[exercise.questions.length - 1].id;
return {
id: index.toString(),
sectionId,
label: (
<ExerciseLabel
type='True/False/Not Given'
firstId={firstQuestionId}
lastId={lastQuestionId}
prompt={exercise.prompt}
/>
),
content: <TrueFalse exercise={exercise} sectionId={sectionId} />
};
case "matchSentences":
firstQuestionId = exercise.sentences[0].id;
lastQuestionId = exercise.sentences[exercise.sentences.length - 1].id;
return {
id: index.toString(),
sectionId,
label: (
<ExerciseLabel
type={exercise.variant == "ideaMatch" ? "Idea Match" : "Paragraph Match"}
firstId={firstQuestionId}
lastId={lastQuestionId}
prompt={exercise.prompt}
/>
),
content: <MatchSentences exercise={exercise} sectionId={sectionId} />
};
case "fillBlanks":
return fillBlanks(exercise, index, sectionId);
case "writeBlanks":
return writeBlanks(exercise, index, sectionId);
case "writing":
return {
id: index.toString(),
sectionId,
label: (
<ExerciseLabel
type={`Writing Task: ${exercise.variant === "letter" ? "Letter" : "Essay"}`}
firstId={exercise.sectionId!.toString()}
lastId={exercise.sectionId!.toString()}
prompt={exercise.prompt}
/>
),
content: <Writing key={exercise.id} exercise={exercise} sectionId={sectionId} index={index} module="level" />
};
case "speaking":
return {
exerciseId: exercise.id,
id: index.toString(),
sectionId,
label: (
<ExerciseLabel
type={`Speaking Section 2: Question`}
firstId={(index+1).toString()}
lastId={(index+1).toString()}
prompt={exercise.prompts[2]}
/>
),
content: <Speaking2 key={exercise.id} exercise={exercise} sectionId={sectionId} module="level" />
};
case "interactiveSpeaking":
const content = exercise.sectionId === 1 ? <Speaking1 key={exercise.id} exercise={exercise} sectionId={sectionId} module="level" /> :
<InteractiveSpeaking key={exercise.id} exercise={exercise} sectionId={sectionId} module="level"/>
return {
exerciseId: exercise.id,
id: index.toString(),
sectionId,
label: (
<ExerciseLabel
type={`${exercise.sectionId === 1 ? 'Speaking Section 1': 'Interactive Speaking'}: Question`}
firstId={(index+1).toString()}
lastId={(index+1).toString()}
prompt={exercise.prompts[2].text}
/>
),
content: content
};
default:
return {} as unknown as ExerciseItem;
}
}).filter(isExerciseItem);
return items;
};
export default getExerciseItems;

View File

@@ -1,78 +0,0 @@
import { FillBlanksExercise, FillBlanksMCOption } from "@/interfaces/exam";
import ExerciseItem from "./types";
import ExerciseLabel from "../../Shared/ExerciseLabel";
import FillBlanksLetters from "../../Exercises/Blanks/Letters";
import FillBlanksMC from "../../Exercises/Blanks/MultipleChoice";
interface LetterWord {
letter: string;
word: string;
}
function isLetterWordArray(words: (string | LetterWord | FillBlanksMCOption)[]): words is LetterWord[] {
return words.length > 0 &&
words.every(item =>
typeof item === 'object' &&
'letter' in item &&
'word' in item &&
!('options' in item)
);
}
function isFillBlanksMCOptionArray(words: (string | LetterWord | FillBlanksMCOption)[]): words is FillBlanksMCOption[] {
return words.length > 0 &&
words.every(item =>
typeof item === 'object' &&
'id' in item &&
'options' in item &&
typeof (item as FillBlanksMCOption).options === 'object' &&
'A' in (item as FillBlanksMCOption).options &&
'B' in (item as FillBlanksMCOption).options &&
'C' in (item as FillBlanksMCOption).options &&
'D' in (item as FillBlanksMCOption).options
);
}
const fillBlanks = (exercise: FillBlanksExercise, index: number, sectionId: number): ExerciseItem => {
const firstWordId = exercise.solutions[0].id;
const lastWordId = exercise.solutions[exercise.solutions.length - 1].id;
if (isLetterWordArray(exercise.words)) {
return {
id: index.toString(),
sectionId,
label: (
<ExerciseLabel
type='Fill Blanks Question'
firstId={firstWordId}
lastId={lastWordId}
prompt={exercise.prompt}
/>
),
content: <FillBlanksLetters exercise={exercise} sectionId={sectionId} />
};
}
if (isFillBlanksMCOptionArray(exercise.words)) {
return {
id: index.toString(),
sectionId,
label: (
<ExerciseLabel
type='Fill Blanks: MC Question'
firstId={firstWordId}
lastId={lastWordId}
prompt={exercise.prompt}
/>
),
content: <FillBlanksMC exercise={exercise} sectionId={sectionId} />
};
}
// Don't know where the fillBlanks with words as string fits
throw new Error(`Unsupported Exercise`);
}
export default fillBlanks;

View File

@@ -1,329 +0,0 @@
import { SortableContext, verticalListSortingStrategy } from "@dnd-kit/sortable";
import SortableSection from "../../Shared/SortableSection";
import { Difficulty, Exercise, InteractiveSpeakingExercise, LevelPart, ListeningPart, ReadingPart, SpeakingExercise, WritingExercise } from "@/interfaces/exam";
import ExerciseItem from "./types";
import Dropdown from "@/components/Dropdown";
import useExamEditorStore from "@/stores/examEditor";
import Writing from "../../Exercises/Writing";
import Speaking from "../../Exercises/Speaking";
import { ReactNode, useEffect } from "react";
import {
DndContext,
PointerSensor,
useSensor,
useSensors,
closestCenter,
} from '@dnd-kit/core';
import GenLoader from "../../Exercises/Shared/GenLoader";
import { ExamPart, Generating } from "@/stores/examEditor/types";
import React from "react";
import getExerciseItems from "./exercises";
import { Action } from "@/stores/examEditor/reducers";
import { writingTask } from "@/stores/examEditor/sections";
import { createSpeakingExercise } from "./speaking";
interface QuestionItemsResult {
ids: string[];
items: ExerciseItem[];
}
const SectionExercises: React.FC<{ sectionId: number; }> = ({ sectionId }) => {
const dispatch = useExamEditorStore(state => state.dispatch);
const currentModule = useExamEditorStore(state => state.currentModule);
const {sections, expandedSections, difficulty} = useExamEditorStore(state => state.modules[currentModule]);
const section = useExamEditorStore(
state => state.modules[currentModule].sections.find(
section => section.sectionId === sectionId
)
);
const genResult = section?.genResult;
const generating = section?.generating;
const levelGenResults = section?.levelGenResults;
const levelGenerating = section?.levelGenerating;
const sectionState = section?.state;
useEffect(() => {
if (genResult && genResult.generating === "exercises" && genResult.module === currentModule) {
const newExercises = genResult.result[0].exercises;
const newDifficulties = newExercises
.map((ex: Exercise) => ex.difficulty)
.filter((diff: Difficulty) => !difficulty.includes(diff));
if (newDifficulties.length > 0) {
dispatch({ type: 'UPDATE_MODULE', payload: { updates: { difficulty: [...difficulty, ...newDifficulties]} } });
}
dispatch({
type: "UPDATE_SECTION_STATE", payload: {
sectionId,
module: genResult.module,
update: {
exercises: [...(sectionState as ExamPart).exercises, ...newExercises]
}
}
})
dispatch({ type: "UPDATE_SECTION_SINGLE_FIELD", payload: { sectionId, module: currentModule, field: "generating", value: undefined } })
dispatch({ type: "UPDATE_SECTION_SINGLE_FIELD", payload: { sectionId, module: currentModule, field: "genResult", value: undefined } })
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [genResult, dispatch, sectionId, currentModule]);
const handleExerciseGen = (
results: any[],
assignExercisesFn: (results: any[]) => any[],
{
sectionId,
currentModule,
sectionState,
levelGenerating,
levelGenResults
}: {
sectionId: number;
currentModule: string;
sectionState: ExamPart;
levelGenerating?: Generating[];
levelGenResults: any[];
}
) => {
const nonWritingOrSpeaking = results[0]?.generating.startsWith("exercises");
const newExercises = assignExercisesFn(results);
const newDifficulties = newExercises
.map((ex: Exercise) => ex.difficulty)
.filter((diff: Difficulty | undefined): diff is Difficulty =>
diff !== undefined && !difficulty.includes(diff)
);
if (newDifficulties.length > 0) {
dispatch({ type: 'UPDATE_MODULE', payload: { updates: { difficulty: [...difficulty, ...newDifficulties]} } });
}
const updates = [
{
type: "UPDATE_SECTION_STATE",
payload: {
sectionId,
module: "level",
update: {
exercises: [
...sectionState.exercises,
...newExercises
]
}
}
},
{
type: "UPDATE_SECTION_SINGLE_FIELD",
payload: {
sectionId,
module: currentModule,
field: "levelGenerating",
value: levelGenerating?.filter(g =>
nonWritingOrSpeaking
? !g?.startsWith("exercises")
: !results.flatMap(res => res.generating as Generating).includes(g)
)
}
},
{
type: "UPDATE_SECTION_SINGLE_FIELD",
payload: {
sectionId,
module: currentModule,
field: "levelGenResults",
value: levelGenResults.filter(res =>
nonWritingOrSpeaking
? !res.generating.startsWith("exercises")
: !results.flatMap(res => res.generating as Generating).includes(res.generating)
)
}
}
] as Action[];
updates.forEach(update => dispatch(update));
};
useEffect(() => {
if (levelGenResults && levelGenResults?.some(res => res.generating.startsWith("exercises"))) {
const results = levelGenResults.filter(res =>
res.generating.startsWith("exercises")
);
const assignExercises = (results: any[]) =>
results
.map(res => res.result[0].exercises)
.flat();
handleExerciseGen(
results,
assignExercises,
{
sectionId,
currentModule,
sectionState: sectionState as ExamPart,
levelGenerating,
levelGenResults
}
);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [levelGenResults, sectionState, levelGenerating, sectionId, currentModule]);
useEffect(() => {
if (levelGenResults && levelGenResults?.some(res =>
res.generating === "writing_letter" || res.generating === "writing_2"
)) {
const results = levelGenResults.filter(res =>
res.generating === "writing_letter" || res.generating === "writing_2"
);
const assignExercises = (results: any[]) =>
results.map(res => ({
...writingTask(res.generating === "writing_letter" ? 1 : 2),
prompt: res.result[0].prompt,
difficulty: res.result[0].difficulty,
variant: res.generating === "writing_letter" ? "letter" : "essay"
}) as WritingExercise);
handleExerciseGen(
results,
assignExercises,
{
sectionId,
currentModule,
sectionState: sectionState as ExamPart,
levelGenerating,
levelGenResults
}
);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [levelGenResults, sectionState, levelGenerating, sectionId, currentModule]);
useEffect(() => {
if (levelGenResults && levelGenResults?.some(res => res.generating.startsWith("speaking"))) {
const results = levelGenResults.filter(res =>
res.generating.startsWith("speaking")
);
const assignExercises = (results: any[]) =>
results.map(createSpeakingExercise);
handleExerciseGen(
results,
assignExercises,
{
sectionId,
currentModule,
sectionState: sectionState as ExamPart,
levelGenerating,
levelGenResults
}
);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [levelGenResults, sectionState, levelGenerating, sectionId, currentModule]);
const currentSection = sections.find((s) => s.sectionId === sectionId)!;
const sensors = useSensors(
useSensor(PointerSensor),
);
const questionItems = (): QuestionItemsResult => {
const part = currentSection.state as ReadingPart | ListeningPart | LevelPart;
const items = getExerciseItems(part.exercises, sectionId);
return {
items,
ids: items.map(item => item.id)
}
};
const background = (component: ReactNode) => {
return (
<div className="p-8 shadow-inner border border-gray-200 bg-gray-50 rounded-xl">
{component}
</div>
);
}
if (currentModule == "writing") return background(<Writing sectionId={sectionId} exercise={currentSection.state as WritingExercise} module="writing" />);
if (currentModule == "speaking") return background(<Speaking sectionId={sectionId} exercise={currentSection.state as SpeakingExercise} module="speaking" />);
const questions = questionItems();
// #############################################################################
// Typescript checks so that the compiler and builder don't freak out
const filteredIds = (questions.ids ?? []).filter(Boolean);
function isValidItem(item: ExerciseItem | undefined): item is ExerciseItem {
return item !== undefined &&
typeof item.id === 'string' &&
typeof item.sectionId === 'number' &&
React.isValidElement(item.label) &&
React.isValidElement(item.content);
}
const filteredItems = (questions.items ?? []).filter(isValidItem);
// #############################################################################
const onFocus = (questionId: string, id: string | undefined) => {
if (id) {
dispatch({ type: "UPDATE_SECTION_SINGLE_FIELD", payload: { module: currentModule, sectionId, field: "focusedExercise", value: { questionId, id } } })
}
}
return (
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={(e) => dispatch({ type: "REORDER_EXERCISES", payload: { event: e, sectionId, module: currentModule } })}
>
{expandedSections.includes(sectionId) &&
questions.items &&
questions.items.length > 0 &&
questions.ids &&
questions.ids.length > 0 && (
<div className="mt-4 p-6 rounded-xl shadow-inner border bg-gray-50">
<SortableContext
items={filteredIds}
strategy={verticalListSortingStrategy}
>
{filteredItems.map(item => (
<SortableSection key={item.id} id={item.id}>
<Dropdown
className={`w-full text-left p-4 mb-2 bg-gradient-to-r from-ielts-${currentModule}/60 to-ielts-${currentModule} text-white rounded-lg shadow-lg transition-transform transform hover:scale-102`}
customTitle={item.label}
contentWrapperClassName="rounded-xl"
>
<div tabIndex={4} className="p-4 shadow-inner border border-gray-200 bg-gray-50 rounded-xl" onFocus={() => onFocus(item.id, item.exerciseId)}>
{item.content}
</div>
</Dropdown>
</SortableSection>
))}
</SortableContext>
</div>
)
}
{generating === "exercises" && <GenLoader module={currentModule} className="mt-4" />}
{currentModule === "level" && (
<>
{
questions.ids?.length === 0 && !levelGenerating?.some((g) => g?.startsWith("exercises") || g?.startsWith("writing") || g?.startsWith("speaking")) && generating !== "exercises"
&& background(<span className="flex justify-center">Generated exercises will appear here!</span>)}
{levelGenerating?.some((g) => g?.startsWith("exercises") || g?.startsWith("writing") || g?.startsWith("speaking")) && <GenLoader module={currentModule} className="mt-4" />}
</>)
}
</DndContext >
);
}
export default SectionExercises;

View File

@@ -1,50 +0,0 @@
import { InteractiveSpeakingExercise, SpeakingExercise } from "@/interfaces/exam";
import { speakingTask } from "@/stores/examEditor/sections";
export const createSpeakingExercise = (res: any) => {
const taskNumber = Number(res.generating.split("_")[1]);
const baseExercise = speakingTask(taskNumber);
return {
...baseExercise,
...getSpeakingTaskData(taskNumber, res.result[0])
} as SpeakingExercise | InteractiveSpeakingExercise;
};
const getSpeakingTaskData = (taskNumber: number, data: any) => {
switch (taskNumber) {
case 1:
return {
first_title: data.first_topic,
second_title: data.second_topic,
prompts: [
...data.prompts.map((item: any) => ({
text: item,
video_url: ""
}))
],
difficulty: data.difficulty,
sectionId: 1,
};
case 2:
return {
title: data.topic,
text: data.question,
prompts: data.prompts,
difficulty: data.difficulty,
sectionId: 2,
type: "speaking"
};
case 3:
return {
title: data.topic,
prompts: data.questions.map((item: any) => ({
text: item || "",
video_url: ""
})),
difficulty: data.difficulty,
sectionId: 3,
};
default:
return data;
}
};

View File

@@ -1,16 +0,0 @@
export default interface ExerciseItem {
id: string;
sectionId: number;
label: React.ReactNode;
content: React.ReactNode;
exerciseId?: string;
}
export function isExerciseItem(item: unknown): item is ExerciseItem {
return item !== undefined &&
item !== null &&
typeof (item as ExerciseItem).id === 'string' &&
typeof (item as ExerciseItem).sectionId === 'number' &&
(item as ExerciseItem).label !== undefined &&
(item as ExerciseItem).content !== undefined;
}

View File

@@ -1,66 +0,0 @@
import { Action } from "@/stores/examEditor/reducers";
import { ExamPart, Generating } from "@/stores/examEditor/types";
import { createSpeakingExercise } from "./speaking";
import { writingTask } from "@/stores/examEditor/sections";
import { WritingExercise } from "@/interfaces/exam";
const getResults = (results: any[], type: 'writing' | 'speaking') => {
return results.map((res) => {
if (type === 'writing') {
return {
...writingTask(res.generating === "writing_letter" ? 1 : 2),
prompt: res.result[0].prompt,
variant: res.generating === "writing_letter" ? "letter" : "essay"
} as WritingExercise;
}
return createSpeakingExercise(res);
});
};
const updates = (
results: any[],
sectionState: ExamPart,
sectionId: number,
currentModule: string,
levelGenerating: any[],
levelGenResults: any[],
type: 'writing' | 'speaking'
): Action[] => {
return [
{
type: "UPDATE_SECTION_STATE",
payload: {
sectionId,
module: "level",
update: {
exercises: [
...(sectionState as ExamPart).exercises,
...getResults(results, type)
]
}
}
},
{
type: "UPDATE_SECTION_SINGLE_FIELD",
payload: {
sectionId,
module: currentModule,
field: "levelGenerating",
value: levelGenerating?.filter(g =>
!results.flatMap(res => res.generating as Generating).includes(g)
)
}
},
{
type: "UPDATE_SECTION_SINGLE_FIELD",
payload: {
sectionId,
module: currentModule,
field: "levelGenResults",
value: levelGenResults.filter(res =>
!results.flatMap(res => res.generating as Generating).includes(res.generating)
)
}
}
] as Action[];
};

View File

@@ -1,58 +0,0 @@
import { WriteBlanksExercise } from "@/interfaces/exam";
import ExerciseLabel from "../../Shared/ExerciseLabel";
import WriteBlanksForm from "../../Exercises/WriteBlanksForm";
import WriteBlanksFill from "../../Exercises/Blanks/WriteBlankFill";
import WriteBlanks from "../../Exercises/WriteBlanks";
import ExerciseItem from "./types";
const writeBlanks = (exercise: WriteBlanksExercise, index: number, sectionId: number): ExerciseItem => {
const firstQuestionId = exercise.solutions[0].id;
const lastQuestionId = exercise.solutions[exercise.solutions.length - 1].id;
switch (exercise.variant) {
case 'form':
return {
id: index.toString(),
sectionId,
label: (
<ExerciseLabel
type='Write Blanks: Form'
firstId={firstQuestionId}
lastId={lastQuestionId}
prompt={exercise.prompt}
/>
),
content: <WriteBlanksForm exercise={exercise} sectionId={sectionId} />
};
case 'fill':
return {
id: index.toString(),
sectionId,
label: (
<ExerciseLabel
type='Write Blanks: Fill'
firstId={firstQuestionId}
lastId={lastQuestionId}
prompt={exercise.prompt}
/>
),
content: <WriteBlanksFill exercise={exercise} sectionId={sectionId} />
};
default:
return {
id: index.toString(),
sectionId,
label: (
<ExerciseLabel
type='Write Blanks: Questions'
firstId={firstQuestionId}
lastId={lastQuestionId}
prompt={exercise.prompt}
/>
),
content: <WriteBlanks exercise={exercise} sectionId={sectionId} />
};
}
};
export default writeBlanks;

View File

@@ -1,102 +0,0 @@
import React, { useCallback } from 'react';
import clsx from 'clsx';
import { toast } from 'react-toastify';
import ReadingContext from './SectionContext/reading';
import SectionExercises from './SectionExercises';
import useExamEditorStore from '@/stores/examEditor';
import { ModuleState } from '@/stores/examEditor/types';
import ListeningContext from './SectionContext/listening';
import SectionDropdown from '../Shared/SectionDropdown';
import LevelContext from './SectionContext/level';
import { Module } from '@/interfaces';
const SectionRenderer: React.FC = () => {
const { currentModule, dispatch } = useExamEditorStore();
const {
focusedSection,
expandedSections,
sections,
sectionLabels,
edit,
} = useExamEditorStore(state => state.modules[currentModule]);
const updateModule = useCallback((updates: Partial<ModuleState>) => {
dispatch({ type: 'UPDATE_MODULE', payload: { updates } });
}, [dispatch]);
const toggleSection = (sectionId: number) => {
if (edit.includes(sectionId)) {
toast.info(`Save or discard your changes first!`);
} else {
if (!expandedSections.includes(sectionId)) {
updateModule({ focusedSection: sectionId });
}
updateModule({
expandedSections:
expandedSections.includes(sectionId) ?
expandedSections.filter(index => index !== sectionId) :
[...expandedSections, sectionId]
})
}
};
const ContextMap: Record<string, React.ComponentType<{ sectionId: number; module: Module }>> = {
reading: ReadingContext,
listening: ListeningContext,
level: LevelContext,
};
const SectionContext = ContextMap[currentModule];
return (
<>
<div className='flex flex-row'>
<div className={clsx(
"p-4 rounded-xl w-full",
currentModule && `bg-ielts-${currentModule}/20`
)}>
{sections.map((state, sectionIndex) => {
const id = state.sectionId;
const label = sectionLabels.find((sl) => sl.id == id)?.label;
return (
<div key={id}
className={
clsx("rounded-xl shadow",
sectionIndex !== sections.length - 1 && "mb-4"
)}>
<SectionDropdown
toggleOpen={() => toggleSection(id)}
open={expandedSections.includes(id)}
title={label}
className={clsx(
"w-full py-4 px-8 text-lg font-semibold leading-6 text-white",
"shadow-lg transform transition-all duration-300 hover:scale-102 hover:rounded-lg",
expandedSections.includes(id) ? "rounded-t-lg" : "rounded-lg",
focusedSection !== id ?
`bg-gradient-to-r from-ielts-${currentModule}/30 to-ielts-${currentModule}/60 hover:from-ielts-${currentModule}/60 hover:to-ielts-${currentModule}` :
`bg-ielts-${currentModule}`
)}
>
{expandedSections.includes(id) && (
<div
className="p-6 bg-white rounded-b-xl shadow-inner border-b"
onFocus={() => updateModule({ focusedSection: id })}
tabIndex={id + 1}
>
{currentModule in ContextMap && <SectionContext sectionId={id} module={currentModule} />}
<SectionExercises sectionId={id} />
</div>
)}
</SectionDropdown>
</div>);
})}
</div>
</div>
</>
);
};
export default SectionRenderer;

View File

@@ -1,19 +0,0 @@
import { Module } from "@/interfaces";
import { GeneratedExercises, GeneratorState } from "../ExercisePicker/generatedExercises";
import { SectionState } from "@/stores/examEditor/types";
export interface SectionRendererProps {
module: Module;
sectionLabel: string;
states: SectionState[];
globalEdit: number[];
generatedExercises: GeneratedExercises | undefined;
generating: GeneratorState | undefined;
focusedSection: number;
setGeneratedExercises: React.Dispatch<React.SetStateAction<GeneratedExercises | undefined>>;
setGenerating: React.Dispatch<React.SetStateAction<GeneratorState | undefined>>;
setGlobalEdit: React.Dispatch<React.SetStateAction<number[]>>;
setSectionStates: React.Dispatch<React.SetStateAction<SectionState[]>>;
setFocusedSection: React.Dispatch<React.SetStateAction<number>>;
}

View File

@@ -1,137 +0,0 @@
import axios from "axios";
import { playSound } from "@/utils/sound";
import { toast } from "react-toastify";
import { Generating } from "@/stores/examEditor/types";
import useExamEditorStore from "@/stores/examEditor";
import { Module } from "@/interfaces";
interface GeneratorConfig {
method: 'GET' | 'POST';
queryParams?: Record<string, string | string[]>;
files?: Record<string, string>;
body?: Record<string, any>;
}
export function generate(
sectionId: number,
module: Module,
type: Generating,
config: GeneratorConfig,
mapData: (data: any) => Record<string, any>[],
levelSectionId?: number,
level: boolean = false
) {
const setGenerating = (sectionId: number, generating: Generating, level: boolean, remove?: boolean) => {
const state = useExamEditorStore.getState();
const dispatch = state.dispatch;
let generatingUpdate;
if (level) {
if (remove) {
generatingUpdate = state.modules["level"].sections.find((s) => s.sectionId === levelSectionId)!.levelGenerating.filter(g => g === generating)
}
else {
generatingUpdate = [...state.modules["level"].sections.find((s) => s.sectionId === levelSectionId)!.levelGenerating, generating];
}
} else {
generatingUpdate = generating;
}
dispatch({
type: "UPDATE_SECTION_SINGLE_FIELD",
payload: { sectionId, module: level ? "level" : module, field: level ? "levelGenerating" : "generating", value: generatingUpdate }
});
};
const setGeneratedResult = (sectionId: number, generating: Generating, result: Record<string, any>[] | undefined, level: boolean) => {
const state = useExamEditorStore.getState();
const dispatch = state.dispatch;
let genResults;
if (level) {
genResults = [...state.modules["level"].sections.find((s) => s.sectionId === levelSectionId)!.levelGenResults, { generating, result, module }];
} else {
genResults = { generating, result, module };
}
dispatch({
type: "UPDATE_SECTION_SINGLE_FIELD",
payload: { sectionId: level ? levelSectionId! : sectionId, module: level ? "level" : module, field: level ? "levelGenResults" : "genResult", value: genResults }
});
};
setGenerating(level ? levelSectionId! : sectionId, type, level);
function buildQueryString(params: Record<string, string | string[]>): string {
const searchParams = new URLSearchParams();
Object.entries(params).forEach(([key, value]) => {
if (Array.isArray(value)) {
value.forEach(v => searchParams.append(key, v));
} else {
searchParams.append(key, value);
}
});
return searchParams.toString();
}
const queryString = config.queryParams ? buildQueryString(config.queryParams) : '';
const url = `/api/exam/generate/${module}/${sectionId}${queryString ? `?${queryString}` : ''}`;
let body = null;
if (config.files && Object.keys(config.files).length > 0 && config.method === 'POST') {
const formData = new FormData();
const buildForm = async () => {
await Promise.all(
Object.entries(config.files ?? {}).map(async ([key, blobUrl]) => {
const response = await fetch(blobUrl);
const blob = await response.blob();
const file = new File([blob], key, { type: blob.type });
formData.append(key, file);
})
);
if (config.body) {
Object.entries(config.body).forEach(([key, value]) => {
formData.append(key, value as string);
});
}
return formData;
};
buildForm().then(form => {
body = form;
const request = axios.post(url, body, { headers: { 'Content-Type': 'multipart/form-data' } });
request
.then((result) => {
playSound("check");
setGeneratedResult(level ? levelSectionId! : sectionId, type, mapData(result.data), level);
})
.catch((error) => {
setGenerating(sectionId, undefined, level, true);
playSound("error");
toast.error("Something went wrong! Try to generate again.");
});
});
} else {
body = config.body;
const request = config.method === 'POST'
? axios.post(url, body, { headers: { 'Content-Type': 'application/json' } })
: axios.get(url);
request
.then((result) => {
playSound("check");
setGeneratedResult(level ? levelSectionId! : sectionId, type, mapData(result.data), level);
})
.catch((error) => {
setGenerating(sectionId, undefined, level, true);
playSound("error");
toast.error("Something went wrong! Try to generate again.");
});
}
}

View File

@@ -1,63 +0,0 @@
import { Module } from "@/interfaces";
import useExamEditorStore from "@/stores/examEditor";
import { Generating } from "@/stores/examEditor/types";
import clsx from "clsx";
import { useEffect, useState } from "react";
import { BsArrowRepeat } from "react-icons/bs";
import { GiBrain } from "react-icons/gi";
interface Props {
module: Module;
sectionId: number;
genType: Generating;
generateFnc: (sectionId: number) => void
className?: string;
level?: boolean;
disabled?: boolean;
}
const GenerateBtn: React.FC<Props> = ({ module, sectionId, genType, generateFnc, className, level = false, disabled = false }) => {
const section = useExamEditorStore((store) => store.modules[level ? "level" : module].sections.find((s) => s.sectionId == sectionId));
const [loading, setLoading] = useState(false);
const generating = section?.generating;
const genResult = section?.genResult;
const levelGenerating = section?.levelGenerating;
const levelGenResults = section?.levelGenResults;
useEffect(() => {
const gen = level ? levelGenerating?.find(g => g === genType) !== undefined : (generating !== undefined && generating === genType);
if (loading !== gen) {
setLoading(gen);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [generating, levelGenerating, levelGenResults, genResult])
if (section === undefined) return <></>;
return (
<button
key={`section-${sectionId}`}
className={clsx(
"flex items-center w-[140px] justify-center px-4 py-2 text-white rounded-xl transition-colors duration-300 text-lg disabled:cursor-not-allowed",
`bg-ielts-${module}/70 border border-ielts-${module} hover:bg-ielts-${module} disabled:bg-ielts-${module}/40`,
className
)}
disabled={loading || disabled}
onClick={(loading || disabled) ? () => { } : () => generateFnc(sectionId)}
>
{loading ? (
<div key={`section-${sectionId}`} className="flex items-center justify-center">
<BsArrowRepeat className="text-white animate-spin" size={25} />
</div>
) : (
<div key={`section-${sectionId}`} className="flex flex-row">
<GiBrain className="mr-2" size={24} />
<span>Generate</span>
</div>
)}
</button>
);
}
export default GenerateBtn;

View File

@@ -1,129 +0,0 @@
import React from 'react';
import { Module } from "@/interfaces";
import useExamEditorStore from "@/stores/examEditor";
import Dropdown from "./SettingsDropdown";
import { LevelSectionSettings } from "@/stores/examEditor/types";
import { LevelPart } from '@/interfaces/exam';
interface Props {
module: Module;
sectionId: number;
localSettings: LevelSectionSettings;
updateLocalAndScheduleGlobal: (updates: Partial<LevelSectionSettings>, schedule?: boolean) => void;
}
const SectionPicker: React.FC<Props> = ({
module,
sectionId,
localSettings,
updateLocalAndScheduleGlobal
}) => {
const { dispatch } = useExamEditorStore();
const [selectedValue, setSelectedValue] = React.useState<number | undefined>(undefined);
const sectionState = useExamEditorStore(state =>
state.modules["level"].sections.find((s) => s.sectionId === sectionId)
);
const state = sectionState?.state as LevelPart;
if (sectionState === undefined) return null;
const { readingSection, listeningSection } = sectionState;
const currentValue = selectedValue ?? (module === "reading" ? readingSection : listeningSection);
const options = module === "reading" ? [1, 2, 3] : [1, 2, 3, 4];
const openPicker = module === "reading" ? "isReadingPickerOpen" : "isListeningPickerOpen";
const handleSectionChange = (value: number) => {
const newValue = currentValue === value ? undefined : value;
setSelectedValue(newValue);
let update = {};
if (module === "listening") {
if (state.audio?.source) {
URL.revokeObjectURL(state.audio.source)
}
update = {
audio: undefined,
script: undefined,
}
}
dispatch({
type: "UPDATE_SECTION_STATE",
payload: {
sectionId,
module: "level",
update: {
...state,
...update
}
}
})
setTimeout(() => {
dispatch({
type: "UPDATE_SECTION_SINGLE_FIELD",
payload: {
sectionId,
module: "level",
field: module === "reading" ? "readingSection" : "listeningSection",
value: newValue
}
});
}, 500);
};
const getTitle = () => {
const section = module === "reading" ? "Passage" : "Section";
if (!currentValue) return `Choose a ${section}`;
return `${section} ${currentValue}`;
};
return (
<Dropdown
title={getTitle()}
module={module}
open={localSettings[openPicker]}
setIsOpen={(isOpen: boolean) =>
updateLocalAndScheduleGlobal({ [openPicker]: isOpen }, false)
}
contentWrapperClassName={`pt-6 px-4 bg-gray-200 rounded-b-lg shadow-md transition-all duration-300 ease-in-out border border-ielts-${module}`}
>
<div className="space-y-2 pt-3 pb-3 px-2 border border-gray-200 rounded-lg shadow-inner">
{options.map((num) => (
<label
key={num}
className={`
flex items-center space-x-3 font-semibold cursor-pointer p-2 rounded
transition-colors duration-200
${currentValue === num
? `bg-ielts-${module}/90 text-white`
: `hover:bg-ielts-${module}/70 text-gray-700`}
`}
onClick={(e) => {
e.preventDefault();
handleSectionChange(num);
}}
>
<input
type="checkbox"
checked={currentValue === num}
onChange={() => { }}
className={`
h-5 w-5 cursor-pointer
accent-ielts-${module}
`}
/>
<div className="flex items-center space-x-2">
<span>
{module === "reading" ? `Passage ${num}` : `Section ${num}`}
</span>
</div>
</label>
))}
</div>
</Dropdown>
);
};
export default SectionPicker;

View File

@@ -1,35 +0,0 @@
import Dropdown from "@/components/Dropdown";
import clsx from "clsx";
import { ReactNode } from "react";
interface Props {
module: string;
title: string;
open: boolean;
disabled?: boolean;
setIsOpen: (isOpen: boolean) => void;
children: ReactNode;
center?: boolean;
contentWrapperClassName?: string;
}
const SettingsDropdown: React.FC<Props> = ({ module, title, open, setIsOpen, children, contentWrapperClassName = '', disabled = false, center = false}) => {
return (
<Dropdown
title={title}
className={clsx(
"w-full font-semibold flex justify-between items-center p-4 bg-gradient-to-r border text-white shadow-md transition-all duration-300 disabled:cursor-not-allowed",
`bg-ielts-${module}/70 border border-ielts-${module} hover:bg-ielts-${module} disabled:bg-ielts-${module}/30`,
open ? "rounded-t-lg" : "rounded-lg"
)}
contentWrapperClassName={`pt-6 px-2 bg-white rounded-b-lg shadow-md transition-all duration-300 ease-in-out ${center ? "flex justify-center" : ""} ${contentWrapperClassName}`}
open={open}
setIsOpen={setIsOpen}
disabled={disabled}
>
{children}
</Dropdown>
);
}
export default SettingsDropdown;

View File

@@ -1,125 +0,0 @@
import { InteractiveSpeakingExercise, SpeakingExercise } from "@/interfaces/exam";
import { Avatar } from "../speaking";
import axios from "axios";
interface VideoResponse {
status: 'STARTED' | 'ERROR' | 'COMPLETED' | 'IN_PROGRESS';
result: string;
}
interface VideoGeneration {
index: number;
text: string;
videoId?: string;
url?: string;
}
export async function generateVideos(section: InteractiveSpeakingExercise | SpeakingExercise, focusedSection: number, selectedAvatar: Avatar | null, speakingAvatars: Avatar[]) {
const abortController = new AbortController();
let activePollingIds: string[] = [];
const avatarToUse = selectedAvatar || speakingAvatars[Math.floor(Math.random() * speakingAvatars.length)];
const pollVideoGeneration = async (videoId: string): Promise<string> => {
while (true) {
try {
const { data } = await axios.get<VideoResponse>(`api/exam/media/poll?videoId=${videoId}`, {
signal: abortController.signal
});
if (data.status === 'ERROR') {
abortController.abort();
throw new Error('Video generation failed');
}
if (data.status === 'COMPLETED') {
const videoResponse = await axios.get(data.result, {
responseType: 'blob',
signal: abortController.signal
});
const videoUrl = URL.createObjectURL(
new Blob([videoResponse.data], { type: 'video/mp4' })
);
return videoUrl;
}
await new Promise(resolve => setTimeout(resolve, 10000)); // 10 secs
} catch (error: any) {
if (error.name === 'AbortError' || axios.isCancel(error)) {
throw new Error('Operation aborted');
}
throw error;
}
}
};
const generateSingleVideo = async (text: string, index: number): Promise<VideoGeneration> => {
try {
const { data } = await axios.post<VideoResponse>('/api/exam/media/speaking',
{ text, avatar: avatarToUse.name },
{
headers: {
'Content-Type': 'application/json',
},
signal: abortController.signal
}
);
if (data.status === 'ERROR') {
abortController.abort();
throw new Error('Initial video generation failed');
}
activePollingIds.push(data.result);
const videoUrl = await pollVideoGeneration(data.result);
return { index, text, videoId: data.result, url: videoUrl };
} catch (error) {
abortController.abort();
throw error;
}
};
try {
let videosToGenerate: { text: string; index: number }[] = [];
switch (focusedSection) {
case 1: {
const interactiveSection = section as InteractiveSpeakingExercise;
videosToGenerate = interactiveSection.prompts.map((prompt, index) => ({
text: index === 0 ? prompt.text.replace("{avatar}", avatarToUse.name) : prompt.text,
index
}));
break;
}
case 2: {
const speakingSection = section as SpeakingExercise;
videosToGenerate = [{ text: `${speakingSection.text}. You have 1 minute to take notes.`, index: 0 }];
break;
}
case 3: {
const interactiveSection = section as InteractiveSpeakingExercise;
videosToGenerate = interactiveSection.prompts.map((prompt, index) => ({
text: prompt.text,
index
}));
break;
}
}
// Generate all videos concurrently
const results = await Promise.all(
videosToGenerate.map(({ text, index }) => generateSingleVideo(text, index))
);
// by order which they came in
return results.sort((a, b) => a.index - b.index);
} catch (error) {
// Clean up any ongoing requests
abortController.abort();
// Clean up any created URLs
activePollingIds.forEach(id => {
if (id) URL.revokeObjectURL(id);
});
throw error;
}
}

View File

@@ -1,197 +0,0 @@
import React, { ReactNode, useCallback, useEffect, useMemo, useState, useRef } from "react";
import { FaEye, FaFileUpload } from "react-icons/fa";
import clsx from "clsx";
import Select from "@/components/Low/Select";
import Input from "@/components/Low/Input";
import AutoExpandingTextArea from "@/components/Low/AutoExpandingTextarea";
import Option from '@/interfaces/option'
import Dropdown from "./Shared/SettingsDropdown";
import useSettingsState from "../Hooks/useSettingsState";
import { Module } from "@/interfaces";
import { SectionSettings } from "@/stores/examEditor/types";
import useExamEditorStore from "@/stores/examEditor";
interface SettingsEditorProps {
sectionId: number,
sectionLabel: string;
module: Module,
introPresets: Option[];
children?: ReactNode;
canPreview: boolean;
canSubmit: boolean;
submitModule: (requiresApproval: boolean) => void;
preview: () => void;
}
const SettingsEditor: React.FC<SettingsEditorProps> = ({
sectionId,
sectionLabel,
module,
introPresets,
children,
preview,
submitModule,
canPreview,
canSubmit
}) => {
const { dispatch } = useExamEditorStore()
const examLabel = useExamEditorStore((state) => state.modules[module].examLabel) || '';
const type = useExamEditorStore((s) => s.modules[module].type);
const { localSettings, updateLocalAndScheduleGlobal } = useSettingsState<SectionSettings>(
module,
sectionId
);
const options = useMemo(() => [
{ value: 'None', label: 'None' },
...introPresets,
{ value: 'Custom', label: 'Custom' }
], [introPresets]);
const onCategoryChange = useCallback((text: string) => {
updateLocalAndScheduleGlobal({ category: text });
}, [updateLocalAndScheduleGlobal]);
const typeOptions = [
{ value: 'general', label: 'General' },
{ value: 'academic', label: 'Academic' }
];
const onTypeChange = useCallback((option: { value: string | null, label: string }) => {
dispatch({
type: 'UPDATE_MODULE',
payload: { module, updates: { type: option.value as "academic" | "general" | undefined } }
});
}, [dispatch, module]);
const onIntroOptionChange = useCallback((option: { value: string | null, label: string }) => {
let updates: Partial<SectionSettings> = { introOption: option };
switch (option.label) {
case 'None':
updates.currentIntro = undefined;
break;
case 'Custom':
updates.currentIntro = localSettings.customIntro;
break;
default:
const selectedPreset = introPresets.find(preset => preset.label === option.label);
if (selectedPreset) {
updates.currentIntro = selectedPreset.value!
.replace('{part}', sectionLabel)
.replace('{label}', examLabel);
}
}
updateLocalAndScheduleGlobal(updates);
}, [updateLocalAndScheduleGlobal, localSettings.customIntro, introPresets, sectionLabel, examLabel]);
const onCustomIntroChange = useCallback((text: string) => {
updateLocalAndScheduleGlobal({
introOption: { value: 'Custom', label: 'Custom' },
customIntro: text,
currentIntro: text
});
}, [updateLocalAndScheduleGlobal]);
return (
<div className={`flex flex-col gap-8 border bg-ielts-${module}/20 rounded-3xl p-8 w-1/3 h-fit -2xl:w-full`}>
<div className={`w-full flex justify-center text-ielts-${module} font-bold text-xl`}>{sectionLabel} Settings</div>
<div className="flex flex-col gap-4">
<Dropdown
title="Category"
module={module}
open={localSettings.isCategoryDropdownOpen}
setIsOpen={(isOpen: boolean) => updateLocalAndScheduleGlobal({ isCategoryDropdownOpen: isOpen }, false)}
>
<Input
key={`section-${sectionId}`}
type="text"
placeholder="Category"
name="category"
onChange={onCategoryChange}
roundness="full"
value={localSettings.category || ''}
/>
</Dropdown>
{["reading", "writing"].includes(module) && <Dropdown
title="Type"
module={module}
open={localSettings.isTypeDropdownOpen}
setIsOpen={(isOpen: boolean) => updateLocalAndScheduleGlobal({ isTypeDropdownOpen: isOpen }, false)}
>
<Select
options={typeOptions}
onChange={(o) => onTypeChange({ value: o!.value, label: o!.label })}
value={typeOptions.find(o => o.value === type)}
/>
</Dropdown>}
<Dropdown
title="Divider"
module={module}
open={localSettings.isIntroDropdownOpen}
setIsOpen={(isOpen: boolean) => updateLocalAndScheduleGlobal({ isIntroDropdownOpen: isOpen }, false)}
>
<div className="flex flex-col gap-3 w-full">
<Select
options={options}
onChange={(o) => onIntroOptionChange({ value: o!.value, label: o!.label })}
value={localSettings.introOption}
/>
{localSettings.introOption && localSettings.introOption.value !== "None" && (
<AutoExpandingTextArea
key={`section-${sectionId}`}
value={localSettings.currentIntro || ''}
onChange={onCustomIntroChange}
/>
)}
</div>
</Dropdown>
{children}
<div className="flex flex-col gap-3 mt-4">
<button
className={clsx(
"flex items-center justify-center px-4 py-2 text-white rounded-xl transition-colors duration-300",
`bg-ielts-${module}/70 border border-ielts-${module} hover:bg-ielts-${module} disabled:bg-ielts-${module}/30`,
"disabled:cursor-not-allowed disabled:text-gray-200"
)}
onClick={() => submitModule(true)}
disabled={!canSubmit}
>
<FaFileUpload className="mr-2" size={18} />
Submit module as exam for approval
</button>
<button
className={clsx(
"flex items-center justify-center px-4 py-2 text-white rounded-xl transition-colors duration-300",
`bg-ielts-${module}/70 border border-ielts-${module} hover:bg-ielts-${module} disabled:bg-ielts-${module}/30`,
"disabled:cursor-not-allowed disabled:text-gray-200"
)}
onClick={() => {
if (!confirm(`Are you sure you want to skip the approval process for this exam?`)) return;
submitModule(false);
}}
disabled={!canSubmit}
>
<FaFileUpload className="mr-2" size={18} />
Submit module as exam and skip approval process
</button>
<button
className={clsx(
"flex items-center justify-center px-4 py-2 text-white rounded-xl transition-colors duration-300",
`bg-ielts-${module}/70 border border-ielts-${module} hover:bg-ielts-${module} disabled:bg-ielts-${module}/30`,
"disabled:cursor-not-allowed disabled:text-gray-200"
)}
onClick={preview}
disabled={!canPreview}
>
<FaEye className="mr-2" size={18} />
Preview module
</button>
</div>
</div>
</div>
);
};
export default SettingsEditor;

View File

@@ -1,412 +0,0 @@
import { Exercise, InteractiveSpeakingExercise, LevelExam, LevelPart, SpeakingExercise } from "@/interfaces/exam";
import SettingsEditor from ".";
import Option from "@/interfaces/option";
import Dropdown from "@/components/Dropdown";
import clsx from "clsx";
import ExercisePicker from "../ExercisePicker";
import useExamEditorStore from "@/stores/examEditor";
import useSettingsState from "../Hooks/useSettingsState";
import { LevelSectionSettings } from "@/stores/examEditor/types";
import { toast } from "react-toastify";
import axios from "axios";
import { playSound } from "@/utils/sound";
import { useRouter } from "next/router";
import { usePersistentExamStore } from "@/stores/exam";
import openDetachedTab from "@/utils/popout";
import ListeningComponents from "./listening/components";
import ReadingComponents from "./reading/components";
import SpeakingComponents from "./speaking/components";
import SectionPicker from "./Shared/SectionPicker";
import { getExamById } from "@/utils/exams";
const LevelSettings: React.FC = () => {
const router = useRouter();
const {
setExam,
setExerciseIndex,
setPartIndex,
setQuestionIndex,
setBgColor,
} = usePersistentExamStore();
const { currentModule, title } = useExamEditorStore();
const {
focusedSection,
difficulty,
sections,
minTimer,
access,
} = useExamEditorStore(state => state.modules[currentModule]);
const { localSettings, updateLocalAndScheduleGlobal } = useSettingsState<LevelSectionSettings>(
currentModule,
focusedSection
);
const section = sections.find((section) => section.sectionId == focusedSection);
const focusedExercise = section?.focusedExercise;
if (section === undefined) return <></>;
const currentSection = section.state as LevelPart;
const readingSection = section.readingSection;
const listeningSection = section.listeningSection;
const canPreviewOrSubmit = sections.length > 0 && sections.some(s => {
const part = s.state as LevelPart;
return part.exercises.length > 0 && part.exercises.every((exercise) => {
if (exercise.type === 'speaking') {
return exercise.title !== '' &&
exercise.text !== '' &&
exercise.video_url !== '' &&
exercise.prompts.every(prompt => prompt !== '');
} else if (exercise.type === 'interactiveSpeaking') {
if ('first_title' in exercise && 'second_title' in exercise) {
return exercise.first_title !== '' &&
exercise.second_title !== '' &&
exercise.prompts.every(prompt => prompt.video_url !== '') &&
exercise.prompts.length > 2;
}
return exercise.title !== '' &&
exercise.prompts.every(prompt => prompt.video_url !== '');
}
return true;
});
});
const submitLevel = async (requiresApproval: boolean) => {
if (title === "") {
toast.error("Enter a title for the exam!");
return;
}
const partsWithMissingAudio = sections.some(s => {
const part = s.state as LevelPart;
return part.audio && !part.audio.source;
});
if (partsWithMissingAudio) {
toast.error("There are parts with missing audio recordings. Either generate them or remove the listening sections.");
return;
}
try {
const audioFormData = new FormData();
const videoFormData = new FormData();
const audioMap = new Map<number, string>();
const videoMap = new Map<string, string>();
const partsWithAudio = sections.filter(s => (s.state as LevelPart).audio?.source);
await Promise.all(
partsWithAudio.map(async (section) => {
const levelPart = section.state as LevelPart;
const blobUrl = levelPart.audio!.source;
const response = await fetch(blobUrl);
const blob = await response.blob();
audioFormData.append('file', blob, 'audio.mp3');
audioMap.set(section.sectionId, blobUrl);
})
);
await Promise.all(
sections.flatMap(async (section) => {
const levelPart = section.state as LevelPart;
return Promise.all(
levelPart.exercises.map(async (exercise, exerciseIndex) => {
if (exercise.type === "speaking") {
if (exercise.video_url) {
const response = await fetch(exercise.video_url);
const blob = await response.blob();
videoFormData.append('file', blob, 'video.mp4');
videoMap.set(`${section.sectionId}-${exerciseIndex}`, exercise.video_url);
}
} else if (exercise.type === "interactiveSpeaking") {
await Promise.all(
exercise.prompts.map(async (prompt, promptIndex) => {
if (prompt.video_url) {
const response = await fetch(prompt.video_url);
const blob = await response.blob();
videoFormData.append('file', blob, 'video.mp4');
videoMap.set(`${section.sectionId}-${exerciseIndex}-${promptIndex}`, prompt.video_url);
}
})
);
}
})
);
})
);
const [audioUrls, videoUrls] = await Promise.all([
audioMap.size > 0
? axios.post('/api/storage', audioFormData, {
params: { directory: 'listening_recordings' },
headers: { 'Content-Type': 'multipart/form-data' }
}).then(response => response.data.urls)
: [],
videoMap.size > 0
? axios.post('/api/storage', videoFormData, {
params: { directory: 'speaking_videos' },
headers: { 'Content-Type': 'multipart/form-data' }
}).then(response => response.data.urls)
: []
]);
const exam: LevelExam = {
parts: sections.map((s) => {
const part = s.state as LevelPart;
const audioIndex = Array.from(audioMap.entries())
.findIndex(([id]) => id === s.sectionId);
const updatedExercises = part.exercises.map((exercise, exerciseIndex) => {
if (exercise.type === "speaking") {
const videoIndex = Array.from(videoMap.entries())
.findIndex(([key]) => key === `${s.sectionId}-${exerciseIndex}`);
return {
...exercise,
video_url: videoIndex !== -1 ? videoUrls[videoIndex] : exercise.video_url
};
} else if (exercise.type === "interactiveSpeaking") {
const updatedPrompts = exercise.prompts.map((prompt, promptIndex) => {
const videoIndex = Array.from(videoMap.entries())
.findIndex(([key]) => key === `${s.sectionId}-${exerciseIndex}-${promptIndex}`);
return {
...prompt,
video_url: videoIndex !== -1 ? videoUrls[videoIndex] : prompt.video_url
};
});
return {
...exercise,
prompts: updatedPrompts
};
}
return exercise;
});
return {
...part,
audio: part.audio ? {
...part.audio,
source: audioIndex !== -1 ? audioUrls[audioIndex] : part.audio.source
} : undefined,
exercises: updatedExercises,
intro: s.settings.currentIntro,
category: s.settings.category
};
}).filter(part => part.exercises.length > 0),
requiresApproval: requiresApproval,
isDiagnostic: false,
minTimer,
module: "level",
id: title,
difficulty,
access,
};
const result = await axios.post('/api/exam/level', exam);
playSound("sent");
// Successfully submitted exam
if (result.status === 200) {
toast.success(result.data.message);
} else if (result.status === 207) {
toast.warning(result.data.message);
}
Array.from(audioMap.values()).forEach(url => {
URL.revokeObjectURL(url);
});
Array.from(videoMap.values()).forEach(url => {
URL.revokeObjectURL(url);
});
} catch (error: any) {
console.error('Error submitting exam:', error);
toast.error(
"Something went wrong while submitting, please try again later."
);
}
};
const preview = () => {
setExam({
parts: sections.map((s) => {
const part = s.state as LevelPart;
return {
...part,
intro: s.settings.currentIntro,
category: s.settings.category
};
}),
minTimer,
module: "level",
id: title,
isDiagnostic: false,
variant: undefined,
difficulty,
access,
} as LevelExam);
setExerciseIndex(0);
setQuestionIndex(0);
setPartIndex(0);
openDetachedTab("popout?type=Exam&module=level", router)
}
const speakingExercise = focusedExercise === undefined ? undefined : currentSection.exercises.find((ex) => ex.id === focusedExercise.id) as SpeakingExercise | InteractiveSpeakingExercise;
return (
<SettingsEditor
sectionLabel={`Part ${focusedSection}`}
sectionId={focusedSection}
module="level"
introPresets={[]}
preview={preview}
canPreview={canPreviewOrSubmit}
canSubmit={canPreviewOrSubmit}
submitModule={submitLevel}
>
<div>
<Dropdown title="Add Level Exercises" className={
clsx(
"w-full font-semibold flex justify-between items-center p-4 bg-gradient-to-r border",
"bg-ielts-level/70 border-ielts-level hover:bg-ielts-level",
"text-white shadow-md transition-all duration-300",
localSettings.isLevelDropdownOpen ? "rounded-t-lg" : "rounded-lg"
)
}
contentWrapperClassName={"pt-4 px-2 bg-white rounded-b-lg shadow-md transition-all duration-300 ease-in-out"}
open={localSettings.isLevelDropdownOpen}
setIsOpen={(isOpen: boolean) => updateLocalAndScheduleGlobal({ isLevelDropdownOpen: isOpen }, false)}
>
<ExercisePicker
module="level"
sectionId={focusedSection}
/>
</Dropdown>
</div>
<div>
<Dropdown title="Add Reading Exercises" className={
clsx(
"w-full font-semibold flex justify-between items-center p-4 bg-gradient-to-r border",
"bg-ielts-reading/70 border-ielts-reading hover:bg-ielts-reading",
"text-white shadow-md transition-all duration-300",
localSettings.isReadingDropdownOpen ? "rounded-t-lg" : "rounded-lg"
)
}
contentWrapperClassName={"pt-4 px-2 bg-white rounded-b-lg shadow-md transition-all duration-300 ease-in-out"}
open={localSettings.isReadingDropdownOpen}
setIsOpen={(isOpen: boolean) => updateLocalAndScheduleGlobal({ isReadingDropdownOpen: isOpen }, false)}
>
<div className="space-y-2 px-2 pb-2">
<SectionPicker {...{ module: "reading", sectionId: focusedSection, localSettings, updateLocalAndScheduleGlobal }} />
<ReadingComponents
{...{ localSettings, updateLocalAndScheduleGlobal, currentSection, generatePassageDisabled: readingSection === undefined, levelId: readingSection, level: true }}
/>
</div>
</Dropdown>
</div>
<div>
<Dropdown title="Add Listening Exercises" className={
clsx(
"w-full font-semibold flex justify-between items-center p-4 bg-gradient-to-r border",
"bg-ielts-listening/70 border-ielts-listening hover:bg-ielts-listening",
"text-white shadow-md transition-all duration-300",
localSettings.isListeningDropdownOpen ? "rounded-t-lg" : "rounded-lg"
)
}
contentWrapperClassName={"pt-4 px-2 bg-white rounded-b-lg shadow-md transition-all duration-300 ease-in-out"}
open={localSettings.isListeningDropdownOpen}
setIsOpen={(isOpen: boolean) => updateLocalAndScheduleGlobal({ isListeningDropdownOpen: isOpen }, false)}
>
<div className="space-y-2 px-2 pb-2">
<SectionPicker {...{ module: "listening", sectionId: focusedSection, localSettings, updateLocalAndScheduleGlobal }} />
<ListeningComponents
{...{ localSettings, updateLocalAndScheduleGlobal, currentSection, audioContextDisabled: listeningSection === undefined, levelId: listeningSection, level: true }}
/>
</div>
</Dropdown>
</div>
<div>
<Dropdown title="Add Writing Exercises" className={
clsx(
"w-full font-semibold flex justify-between items-center p-4 bg-gradient-to-r border",
"bg-ielts-writing/70 border-ielts-writing hover:bg-ielts-writing",
"text-white shadow-md transition-all duration-300",
localSettings.isWritingDropdownOpen ? "rounded-t-lg" : "rounded-lg"
)
}
contentWrapperClassName={"pt-4 px-2 bg-white rounded-b-lg shadow-md transition-all duration-300 ease-in-out"}
open={localSettings.isWritingDropdownOpen}
setIsOpen={(isOpen: boolean) => updateLocalAndScheduleGlobal({ isWritingDropdownOpen: isOpen }, false)}
>
<ExercisePicker
module="writing"
sectionId={focusedSection}
levelSectionId={focusedSection}
level
/>
</Dropdown>
</div >
<div>
<Dropdown title="Add Speaking Exercises" className={
clsx(
"w-full font-semibold flex justify-between items-center p-4 bg-gradient-to-r border",
"bg-ielts-speaking/70 border-ielts-speaking hover:bg-ielts-speaking",
"text-white shadow-md transition-all duration-300",
localSettings.isSpeakingDropdownOpen ? "rounded-t-lg" : "rounded-lg"
)
}
contentWrapperClassName={"pt-4 px-2 bg-white rounded-b-lg shadow-md transition-all duration-300 ease-in-out"}
open={localSettings.isSpeakingDropdownOpen}
setIsOpen={(isOpen: boolean) => updateLocalAndScheduleGlobal({ isSpeakingDropdownOpen: isOpen }, false)}
>
<div className="space-y-2 px-2 pb-2">
<Dropdown title="Exercises" className={
clsx(
"w-full font-semibold flex justify-between items-center p-4 bg-gradient-to-r border",
"bg-ielts-speaking/70 border-ielts-speaking hover:bg-ielts-speaking",
"text-white shadow-md transition-all duration-300",
localSettings.isSpeakingExercisesOpen ? "rounded-t-lg" : "rounded-lg"
)
}
contentWrapperClassName={"pt-4 px-2 bg-white rounded-b-lg shadow-md transition-all duration-300 ease-in-out border border-ielts-speaking"}
open={localSettings.isSpeakingExercisesOpen}
setIsOpen={(isOpen: boolean) => updateLocalAndScheduleGlobal({ isSpeakingExercisesOpen: isOpen }, false)}
>
<ExercisePicker
module="speaking"
sectionId={focusedSection}
levelSectionId={focusedSection}
level
/>
</Dropdown>
{speakingExercise !== undefined &&
<Dropdown title={`Configure Speaking Exercise #${Number(focusedExercise!.questionId) + 1}`} className={
clsx(
"w-full font-semibold flex justify-between items-center p-4 bg-gradient-to-r border",
"bg-ielts-speaking/70 border-ielts-speaking hover:bg-ielts-speaking",
"text-white shadow-md transition-all duration-300",
localSettings.isConfigureExercisesOpen ? "rounded-t-lg" : "rounded-lg"
)
}
contentWrapperClassName={"pt-4 px-2 bg-white rounded-b-lg shadow-md transition-all duration-300 ease-in-out border border-ielts-speaking"}
open={localSettings.isConfigureExercisesOpen}
setIsOpen={(isOpen: boolean) => updateLocalAndScheduleGlobal({ isConfigureExercisesOpen: isOpen }, false)}
>
<div className="space-y-2 px-2 pb-2">
<SpeakingComponents
{...{ localSettings, updateLocalAndScheduleGlobal, section: speakingExercise, id: speakingExercise.id, sectionId: focusedSection }}
level
/>
</div>
</Dropdown>
}
</div>
</Dropdown>
</div>
</SettingsEditor >
);
};
export default LevelSettings;

View File

@@ -1,148 +0,0 @@
import Button from '@/components/Low/Button';
import Modal from '@/components/Modal';
import dynamic from 'next/dynamic';
import React, { useCallback, useState } from 'react';
import { MdAudioFile, MdCloudUpload, MdDelete } from 'react-icons/md';
const Waveform = dynamic(() => import("@/components/Waveform"), { ssr: false });
interface AudioUploadProps {
isOpen: boolean;
audioFile: string | undefined;
setIsOpen: React.Dispatch<React.SetStateAction<boolean>>;
onFileSelect: (file: File | null) => void;
transcribeAudio: () => void;
setAudioUrl: React.Dispatch<React.SetStateAction<string | undefined>>;
}
const AudioUpload: React.FC<AudioUploadProps> = ({ isOpen, audioFile, setIsOpen, onFileSelect, transcribeAudio, setAudioUrl }) => {
const [isDragging, setIsDragging] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleDrag = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
}, []);
const handleDragIn = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(true);
}, []);
const handleDragOut = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(false);
}, []);
const validateFile = (file: File): boolean => {
if (!file.type.startsWith('audio/')) {
setError('Please upload an audio file');
return false;
}
setError(null);
return true;
};
const handleDrop = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(false);
setError(null);
const file = e.dataTransfer.files?.[0];
if (file && validateFile(file)) {
onFileSelect(file);
}
}, [onFileSelect]);
const handleFileUpload = (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (file && validateFile(file)) {
onFileSelect(file);
}
};
const handleRemoveAudio = () => {
onFileSelect(null);
};
return (
<Modal isOpen={isOpen} onClose={() => setIsOpen(false)}>
<div className="w-full space-y-4">
{!audioFile && (
<div
className={`relative border-2 border-dashed rounded-lg p-8 text-center
${isDragging
? 'border-blue-500 bg-blue-50'
: 'border-gray-300 hover:border-gray-400'
}
transition-all duration-200 ease-in-out`}
onDragEnter={handleDragIn}
onDragLeave={handleDragOut}
onDragOver={handleDrag}
onDrop={handleDrop}
>
<input
type="file"
accept="audio/*"
onChange={handleFileUpload}
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer"
title="Choose audio file"
/>
<div className="space-y-4">
<div className="flex justify-center">
{error ? (
<MdAudioFile className="w-16 h-16 text-red-500" />
) : (
<MdCloudUpload className="w-16 h-16 text-gray-400" />
)}
</div>
<div className="space-y-2">
<h3 className="text-lg font-medium text-gray-700">
{error ? error : 'Upload Audio File'}
</h3>
<p className="text-sm text-gray-500">
Drag and drop your audio file here, or click to select
</p>
</div>
</div>
</div>
)}
{audioFile && (
<div className="space-y-4">
<div className="flex justify-between items-center">
<h3 className="text-lg font-medium text-gray-700">Audio Upload</h3>
<button
onClick={handleRemoveAudio}
className="flex items-center gap-2 px-3 py-1.5 text-sm text-white bg-red-500 hover:bg-red-600 rounded-md transition-colors duration-200 w-36"
>
<MdDelete className="w-4 h-4" />
Remove Audio
</button>
</div>
<Waveform
variant='edit'
audio={audioFile}
waveColor="#ddd"
progressColor="#4a90e2"
setAudioUrl={setAudioUrl}
/>
<div className="flex w-full justify-between pt-8">
<Button color="purple" onClick={() => setIsOpen(false)} variant="outline" className="max-w-[200px] self-end w-full">
Cancel
</Button>
<Button color="purple" onClick={()=> { transcribeAudio(); setIsOpen(false);}} className="max-w-[200px] self-end w-full">
Upload
</Button>
</div>
</div>
)}
</div>
</Modal>
);
};
export default AudioUpload;

View File

@@ -1,330 +0,0 @@
import Dropdown from "../Shared/SettingsDropdown";
import ExercisePicker from "../../ExercisePicker";
import GenerateBtn from "../Shared/GenerateBtn";
import { useCallback, useState } from "react";
import { generate } from "../Shared/Generate";
import { LevelSectionSettings, ListeningSectionSettings } from "@/stores/examEditor/types";
import useExamEditorStore from "@/stores/examEditor";
import { LevelPart, ListeningPart, Script } from "@/interfaces/exam";
import Input from "@/components/Low/Input";
import axios from "axios";
import { toast } from "react-toastify";
import { playSound } from "@/utils/sound";
import { FaFileUpload } from "react-icons/fa";
import clsx from "clsx";
import AudioUpload from "./AudioUpload";
import { downloadBlob } from "@/utils/evaluation";
import { BsArrowRepeat } from "react-icons/bs";
interface Props {
localSettings: ListeningSectionSettings | LevelSectionSettings;
updateLocalAndScheduleGlobal: (updates: Partial<ListeningSectionSettings | LevelSectionSettings>, schedule?: boolean) => void;
currentSection: ListeningPart | LevelPart;
audioContextDisabled?: boolean;
levelId?: number;
level?: boolean;
}
const ListeningComponents: React.FC<Props> = ({ currentSection, localSettings, updateLocalAndScheduleGlobal, levelId, level = false, audioContextDisabled = false }) => {
const { currentModule, dispatch, modules } = useExamEditorStore();
const {
focusedSection,
difficulty,
} = useExamEditorStore(state => state.modules[currentModule]);
const [originalAudioUrl, setOriginalAudioUrl] = useState<string | undefined>();
const [audioUrl, setAudioUrl] = useState<string | undefined>();
const [isUploaderOpen, setIsUploaderOpen] = useState(false);
const [isUploading, setIsUploading] = useState(false);
const generateScript = useCallback(() => {
if (audioUrl) {
URL.revokeObjectURL(audioUrl);
setAudioUrl(undefined);
dispatch({
type: "UPDATE_SECTION_STATE",
payload: {
sectionId: focusedSection,
module: "listening",
update: {
audio: undefined
}
}
});
}
generate(
levelId ? levelId : focusedSection,
"listening",
"listeningScript",
{
method: 'GET',
queryParams: {
difficulty,
...(localSettings.listeningTopic && { topic: localSettings.listeningTopic })
}
},
(data: any) => [{
script: data.dialog
}],
level ? focusedSection : undefined,
level
);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [localSettings.listeningTopic, difficulty, focusedSection, levelId, level]);
const onTopicChange = useCallback((listeningTopic: string) => {
updateLocalAndScheduleGlobal({ listeningTopic });
}, [updateLocalAndScheduleGlobal]);
const generateAudio = useCallback(async (sectionId: number) => {
let body: any;
if ([1, 3].includes(levelId ? levelId : focusedSection)) {
body = { conversation: currentSection.script }
} else {
body = { monologue: currentSection.script }
}
try {
if (level) {
dispatch({
type: "UPDATE_SECTION_SINGLE_FIELD", payload: {
sectionId, module: "level", field: "levelGenerating", value:
[...modules["level"].sections.find((s) => s.sectionId === sectionId)!.levelGenerating, "audio"]
}
});
} else {
dispatch({ type: "UPDATE_SECTION_SINGLE_FIELD", payload: { sectionId, module: currentModule, field: "generating", value: "audio" } });
}
const response = await axios.post(
'/api/exam/media/listening',
body,
{
responseType: 'arraybuffer',
headers: {
'Accept': 'audio/mpeg'
},
}
);
const blob = new Blob([response.data], { type: 'audio/mpeg' });
const url = URL.createObjectURL(blob);
if (currentSection.audio?.source) {
URL.revokeObjectURL(currentSection.audio?.source)
}
dispatch({
type: "UPDATE_SECTION_STATE",
payload: {
sectionId,
module: level ? "level" : "listening",
update: {
audio: {
source: url,
repeatableTimes: 3
}
}
}
});
playSound("check");
toast.success('Audio generated successfully!');
} catch (error: any) {
toast.error('Failed to generate audio');
} finally {
if (level) {
dispatch({
type: "UPDATE_SECTION_SINGLE_FIELD", payload: {
sectionId, module: "level", field: "levelGenerating", value:
[...modules["level"].sections.find((s) => s.sectionId === sectionId)!.levelGenerating.filter(g => g !== "audio")]
}
});
} else {
dispatch({ type: "UPDATE_SECTION_SINGLE_FIELD", payload: { sectionId, module: currentModule, field: "generating", value: undefined } });
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [currentSection?.script, dispatch, level, levelId]);
const handleFileSelect = (file: File | null) => {
if (file) {
const url = URL.createObjectURL(file);
setOriginalAudioUrl(url);
setAudioUrl(url);
dispatch({
type: "UPDATE_SECTION_STATE",
payload: {
sectionId: focusedSection,
module: "listening",
update: {
audio: {
source: url,
repeatableTimes: 3
}
}
}
});
} else {
if (audioUrl) {
URL.revokeObjectURL(audioUrl);
URL.revokeObjectURL(originalAudioUrl!);
dispatch({
type: "UPDATE_SECTION_STATE",
payload: {
sectionId: focusedSection,
module: "listening",
update: { audio: undefined }
}
});
}
setAudioUrl(undefined);
setOriginalAudioUrl(undefined);
}
};
const transcribeAudio = async () => {
try {
setIsUploading(true);
dispatch({type: "UPDATE_SECTION_SINGLE_FIELD", payload: {module: "listening", sectionId: focusedSection, field: "scriptLoading", value: true}})
const formData = new FormData();
const audioBlob = await downloadBlob(audioUrl!);
const audioFile = new File([audioBlob], "audio");
formData.append("audio", audioFile);
const config = {
headers: {
"Content-Type": "multipart/form-data",
},
};
const response = await axios.post(`/api/transcribe`, formData, config);
dispatch({
type: "UPDATE_SECTION_STATE",
payload: {
sectionId: focusedSection,
module: "listening",
update: {
script: (response.data as any).dialog as Script,
audio: { source: audioUrl!, repeatableTimes: 3 }
}
}
});
} catch (error) {
toast.error("An unexpected error has occurred, try again later!");
} finally {
setIsUploading(false);
dispatch({type: "UPDATE_SECTION_SINGLE_FIELD", payload: {module: "listening", sectionId: focusedSection, field: "scriptLoading", value: false}})
}
};
return (
<>
<AudioUpload isOpen={isUploaderOpen} setIsOpen={setIsUploaderOpen} audioFile={originalAudioUrl} onFileSelect={handleFileSelect} transcribeAudio={transcribeAudio} setAudioUrl={setAudioUrl} />
<Dropdown
title="Audio Context"
module="listening"
open={localSettings.isAudioContextOpen}
disabled={audioContextDisabled}
setIsOpen={(isOpen: boolean) => updateLocalAndScheduleGlobal({ isAudioContextOpen: isOpen }, false)}
contentWrapperClassName={level ? `border border-ielts-listening` : ''}
>
<div className="flex flex-row flex-wrap gap-2 items-center px-2 pb-4">
<div className="flex flex-col flex-grow gap-4 px-2">
<label className="font-normal text-base text-mti-gray-dim">Topic (Optional)</label>
<Input
key={`section-${focusedSection}`}
type="text"
placeholder="Topic"
name="category"
onChange={onTopicChange}
roundness="full"
value={localSettings.listeningTopic}
/>
</div>
<div className="flex self-end h-16 mb-1">
<GenerateBtn
module="listening"
genType="listeningScript"
sectionId={focusedSection}
generateFnc={generateScript}
level={level}
disabled={isUploading}
/>
</div>
</div>
<div className="flex justify-center text-mti-gray-dim font-semibold">Or</div>
<div className="flex flex-col w-full gap-2 px-2 pb-4">
<div className="flex flex-row items-center text-mti-gray-dim justify-between w-full gap-4 py-2 pl-2">
<div className="flex-1 bg-gray-100 px-3.5 py-2.5 rounded-lg border border-gray-300">
Import your own audio file
</div>
<div className="flex self-end h-16 mb-1 flex-shrink-0">
<button
className={clsx(
"flex items-center w-[140px] justify-center px-4 py-2 text-white rounded-xl transition-colors duration-300 text-lg disabled:cursor-not-allowed",
"bg-ielts-listening/70 border border-ielts-listening hover:bg-ielts-listening disabled:bg-ielts-listening/40"
)}
onClick={() => setIsUploaderOpen(true)}
>
<div className="flex flex-row">
{isUploading ? (
<BsArrowRepeat className="mr-2 text-white animate-spin" size={25} />
) : (
<>
<FaFileUpload className="mr-2" size={24} />
<span>Upload</span>
</>
)}
</div>
</button>
</div>
</div>
</div >
</Dropdown >
<Dropdown
title="Add Exercises"
module="listening"
open={localSettings.isListeningTopicOpen}
setIsOpen={(isOpen: boolean) => updateLocalAndScheduleGlobal({ isListeningTopicOpen: isOpen }, false)}
disabled={currentSection === undefined || currentSection.script === undefined && currentSection.audio === undefined}
contentWrapperClassName={level ? `border border-ielts-listening` : ''}
>
<ExercisePicker
module="listening"
sectionId={levelId !== undefined ? levelId : focusedSection}
extraArgs={{ script: currentSection === undefined || currentSection.audio === undefined ? "" : currentSection.script }}
levelSectionId={focusedSection}
level={level}
/>
</Dropdown>
<Dropdown
title="Generate Audio"
module="listening"
open={localSettings.isAudioGenerationOpen}
setIsOpen={(isOpen: boolean) => updateLocalAndScheduleGlobal({ isAudioGenerationOpen: isOpen }, false)}
disabled={currentSection === undefined || currentSection.script === undefined || currentSection.exercises.length === 0 || audioUrl !== undefined}
contentWrapperClassName={level ? `border border-ielts-listening` : ''}
>
<div className="flex flex-row items-center text-mti-gray-dim justify-center mb-4 gap-4 p-2">
<span className="bg-gray-100 px-3.5 py-2.5 rounded-lg border border-gray-300">
Generate audio recording for this section
</span>
<GenerateBtn
module="listening"
genType="audio"
sectionId={levelId ? levelId : focusedSection}
generateFnc={generateAudio}
/>
</div>
</Dropdown>
</>
);
};
export default ListeningComponents;

View File

@@ -1,234 +0,0 @@
import Dropdown from "../Shared/SettingsDropdown";
import ExercisePicker from "../../ExercisePicker";
import SettingsEditor from "..";
import GenerateBtn from "../Shared/GenerateBtn";
import { useCallback, useState } from "react";
import { generate } from "../Shared/Generate";
import { Generating, LevelSectionSettings, ListeningSectionSettings } from "@/stores/examEditor/types";
import Option from "@/interfaces/option";
import useExamEditorStore from "@/stores/examEditor";
import useSettingsState from "../../Hooks/useSettingsState";
import { ListeningExam, ListeningPart } from "@/interfaces/exam";
import Input from "@/components/Low/Input";
import openDetachedTab from "@/utils/popout";
import { useRouter } from "next/router";
import axios from "axios";
import { usePersistentExamStore } from "@/stores/exam";
import { playSound } from "@/utils/sound";
import { toast } from "react-toastify";
import ListeningComponents from "./components";
import { getExamById } from "@/utils/exams";
const ListeningSettings: React.FC = () => {
const router = useRouter();
const { currentModule, title } = useExamEditorStore();
const {
focusedSection,
difficulty,
sections,
minTimer,
access,
instructionsState
} = useExamEditorStore(state => state.modules[currentModule]);
const {
setExam,
setExerciseIndex,
setPartIndex,
setQuestionIndex,
setBgColor,
} = usePersistentExamStore();
const { localSettings, updateLocalAndScheduleGlobal } = useSettingsState<ListeningSectionSettings>(
currentModule,
focusedSection
);
const currentSection = sections.find((section) => section.sectionId == focusedSection)?.state as ListeningPart;
const defaultPresets: Option[] = [
{
label: "Preset: Listening Section 1",
value: "Welcome to {part} of the {label}. You will hear a conversation between two people in an everyday social context. This may include topics such as making arrangements or bookings, inquiring about services, or handling basic transactions."
},
{
label: "Preset: Listening Section 2",
value: "Welcome to {part} of the {label}. You will hear a monologue set in an everyday social context. This may include a speech about local facilities, arrangements for social occasions, or general announcements."
},
{
label: "Preset: Listening Section 3",
value: "Welcome to {part} of the {label}. You will hear a conversation between up to four people in an educational or training context. This may include discussions about assignments, research projects, or course requirements."
},
{
label: "Preset: Listening Section 4",
value: "Welcome to {part} of the {label}. You will hear an academic lecture or talk on a specific subject."
}
];
const submitListening = async (requiresApproval: boolean) => {
if (title === "") {
toast.error("Enter a title for the exam!");
return;
}
try {
const sectionsWithAudio = sections.filter(s => (s.state as ListeningPart).audio?.source);
if (instructionsState.chosenOption.value === "Custom" && !instructionsState.currentInstructionsURL.startsWith("blob:")) {
toast.error("Generate the custom instructions audio first!");
return;
}
if (sectionsWithAudio.length > 0) {
let instructionsURL = instructionsState.currentInstructionsURL;
if (instructionsState.chosenOption.value === "Custom") {
const instructionsFormData = new FormData();
const instructionsResponse = await fetch(instructionsState.currentInstructionsURL);
const instructionsBlob = await instructionsResponse.blob();
instructionsFormData.append('file', instructionsBlob, 'audio.mp3');
const instructionsUploadResponse = await axios.post('/api/storage', instructionsFormData, {
params: {
directory: 'listening_instructions'
},
headers: {
'Content-Type': 'multipart/form-data'
}
});
instructionsURL = instructionsUploadResponse.data.urls[0];
}
const formData = new FormData();
const sectionMap = new Map<number, string>();
await Promise.all(
sectionsWithAudio.map(async (section) => {
const listeningPart = section.state as ListeningPart;
const blobUrl = listeningPart.audio!.source;
const response = await fetch(blobUrl);
const blob = await response.blob();
formData.append('file', blob, 'audio.mp3');
sectionMap.set(section.sectionId, blobUrl);
})
);
const response = await axios.post('/api/storage', formData, {
params: {
directory: 'listening_recordings'
},
headers: {
'Content-Type': 'multipart/form-data'
}
});
const { urls } = response.data;
const exam: ListeningExam = {
parts: sectionsWithAudio.map((s) => {
const part = s.state as ListeningPart;
const index = Array.from(sectionMap.entries())
.findIndex(([id]) => id === s.sectionId);
return {
...part,
audio: part.audio ? {
...part.audio,
source: index !== -1 ? urls[index] : part.audio.source
} : undefined,
intro: s.settings.currentIntro,
category: s.settings.category
};
}),
requiresApproval: requiresApproval,
isDiagnostic: false,
minTimer,
module: "listening",
id: title,
variant: sections.length === 4 ? "full" : "partial",
difficulty,
access,
instructions: instructionsURL
};
const result = await axios.post('/api/exam/listening', exam);
playSound("sent");
// Successfully submitted exam
if (result.status === 200) {
toast.success(result.data.message);
} else if (result.status === 207) {
toast.warning(result.data.message);
}
} else {
toast.error('No audio sections found in the exam! Please either import them or generate them.');
}
} catch (error: any) {
console.error('Error submitting exam:', error);
toast.error(
"Something went wrong while submitting, please try again later."
);
}
};
const preview = () => {
if (instructionsState.chosenOption.value === "Custom" && !instructionsState.currentInstructionsURL.startsWith("blob:")) {
toast.error("Generate the custom instructions audio first!");
return;
}
setExam({
parts: sections.map((s) => {
const exercise = s.state as ListeningPart;
return {
...exercise,
intro: s.settings.currentIntro,
category: s.settings.category
};
}),
minTimer,
module: "listening",
id: title,
isDiagnostic: false,
variant: sections.length === 4 ? "full" : "partial",
difficulty,
access,
instructions: instructionsState.currentInstructionsURL
} as ListeningExam);
setExerciseIndex(0);
setQuestionIndex(0);
setPartIndex(0);
setBgColor("bg-white");
openDetachedTab("popout?type=Exam&module=listening", router)
}
const canPreview = sections.some(
(s) => (s.state as ListeningPart).exercises && (s.state as ListeningPart).exercises.length > 0
);
const canSubmit = sections.every(
(s) => (s.state as ListeningPart).exercises &&
(s.state as ListeningPart).exercises.length > 0 &&
(s.state as ListeningPart).audio !== undefined
);
return (
<SettingsEditor
sectionLabel={`Section ${focusedSection}`}
sectionId={focusedSection}
module="listening"
introPresets={[defaultPresets[focusedSection - 1]]}
canPreview={canPreview}
canSubmit={canSubmit}
preview={preview}
submitModule={submitListening}
>
<ListeningComponents
{...{ localSettings, updateLocalAndScheduleGlobal, currentSection }}
/>
</SettingsEditor>
);
};
export default ListeningSettings;

View File

@@ -1,144 +0,0 @@
import React, { useCallback } from "react";
import Dropdown from "../Shared/SettingsDropdown";
import Input from "@/components/Low/Input";
import ExercisePicker from "../../ExercisePicker";
import { generate } from "../Shared/Generate";
import GenerateBtn from "../Shared/GenerateBtn";
import { LevelPart, ReadingPart } from "@/interfaces/exam";
import {
LevelSectionSettings,
ReadingSectionSettings,
} from "@/stores/examEditor/types";
import useExamEditorStore from "@/stores/examEditor";
interface Props {
localSettings: ReadingSectionSettings | LevelSectionSettings;
updateLocalAndScheduleGlobal: (
updates: Partial<ReadingSectionSettings | LevelSectionSettings>,
schedule?: boolean
) => void;
currentSection: ReadingPart | LevelPart;
generatePassageDisabled?: boolean;
levelId?: number;
level?: boolean;
}
const ReadingComponents: React.FC<Props> = ({
localSettings,
updateLocalAndScheduleGlobal,
currentSection,
levelId,
level = false,
generatePassageDisabled = false,
}) => {
const { currentModule } = useExamEditorStore();
const { focusedSection, difficulty } = useExamEditorStore(
(state) => state.modules[currentModule]
);
const generatePassage = useCallback(() => {
generate(
levelId ? levelId : focusedSection,
"reading",
"passage",
{
method: "GET",
queryParams: {
difficulty,
...(localSettings.readingTopic && {
topic: localSettings.readingTopic,
}),
},
},
(data: any) => [
{
title: data.title,
text: data.text,
},
],
level ? focusedSection : undefined,
level
);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [localSettings.readingTopic, difficulty, focusedSection, levelId]);
const onTopicChange = useCallback(
(readingTopic: string) => {
updateLocalAndScheduleGlobal({ readingTopic });
},
[updateLocalAndScheduleGlobal]
);
return (
<>
<Dropdown
title="Generate Passage"
module="reading"
open={localSettings.isPassageOpen}
setIsOpen={(isOpen: boolean) =>
updateLocalAndScheduleGlobal({ isPassageOpen: isOpen }, false)
}
contentWrapperClassName={level ? `border border-ielts-reading` : ""}
disabled={generatePassageDisabled}
>
<div
className="flex flex-row flex-wrap gap-2 items-center px-2 pb-4 "
>
<div className="flex flex-col flex-grow gap-4 px-2">
<label className="font-normal text-base text-mti-gray-dim">
Topic (Optional)
</label>
<Input
key={`section-${focusedSection}`}
type="text"
placeholder="Topic"
name="category"
onChange={onTopicChange}
roundness="full"
value={localSettings.readingTopic}
/>
</div>
<div className="flex self-end h-16 mb-1">
<GenerateBtn
module="reading"
genType="passage"
sectionId={focusedSection}
generateFnc={generatePassage}
level={level}
/>
</div>
</div>
</Dropdown>
<Dropdown
title="Add Exercises"
module="reading"
open={localSettings.isReadingTopicOpean}
setIsOpen={(isOpen: boolean) =>
updateLocalAndScheduleGlobal({ isReadingTopicOpean: isOpen })
}
contentWrapperClassName={level ? `border border-ielts-reading` : ""}
disabled={
currentSection === undefined ||
currentSection.text === undefined ||
currentSection.text.content === "" ||
currentSection.text.title === ""
}
>
<ExercisePicker
module="reading"
sectionId={levelId !== undefined ? levelId : focusedSection}
extraArgs={{
text:
currentSection === undefined || currentSection.text === undefined
? ""
: currentSection.text.content,
}}
levelSectionId={focusedSection}
level={level}
/>
</Dropdown>
</>
);
};
export default ReadingComponents;

View File

@@ -1,152 +0,0 @@
import React from "react";
import SettingsEditor from "..";
import Option from "@/interfaces/option";
import useSettingsState from "../../Hooks/useSettingsState";
import { ReadingExam, ReadingPart } from "@/interfaces/exam";
import { ReadingSectionSettings } from "@/stores/examEditor/types";
import useExamEditorStore from "@/stores/examEditor";
import openDetachedTab from "@/utils/popout";
import { useRouter } from "next/router";
import { usePersistentExamStore } from "@/stores/exam";
import axios from "axios";
import { playSound } from "@/utils/sound";
import { toast } from "react-toastify";
import ReadingComponents from "./components";
import { getExamById } from "@/utils/exams";
const ReadingSettings: React.FC = () => {
const router = useRouter();
const {
setExam,
setExerciseIndex,
setPartIndex,
setQuestionIndex,
setBgColor,
} = usePersistentExamStore();
const { currentModule, title } = useExamEditorStore();
const { focusedSection, difficulty, sections, minTimer, access, type } =
useExamEditorStore((state) => state.modules[currentModule]);
const { localSettings, updateLocalAndScheduleGlobal } =
useSettingsState<ReadingSectionSettings>(currentModule, focusedSection);
const currentSection = sections.find(
(section) => section.sectionId == focusedSection
)?.state as ReadingPart;
const defaultPresets: Option[] = [
{
label: "Preset: Reading Passage 1",
value:
"Welcome to {part} of the {label}. You will read texts relating to everyday topics and situations. These may include advertisements, brochures, manuals, or official documents. Answer questions that test your ability to locate specific information and understand main ideas.",
},
{
label: "Preset: Reading Passage 2",
value:
"Welcome to {part} of the {label}. You will read texts dealing with general interest topics that may include news articles, company policies, or workplace documents. Answer questions testing your understanding of main ideas, specific details, and the author's views.",
},
{
label: "Preset: Reading Passage 3",
value:
"Welcome to {part} of the {label}. You will read longer academic texts that may include journal articles, academic essays, or research papers. Answer questions testing your ability to understand complex arguments, identify key points, and follow the development of ideas.",
},
];
const canPreviewOrSubmit = sections.some(
(s) =>
(s.state as ReadingPart).exercises &&
(s.state as ReadingPart).exercises.length > 0
);
const submitReading = (requiresApproval: boolean) => {
if (title === "") {
toast.error("Enter a title for the exam!");
return;
}
const exam: ReadingExam = {
parts: sections.map((s) => {
const exercise = s.state as ReadingPart;
return {
...exercise,
intro: localSettings.currentIntro,
category: localSettings.category,
};
}),
requiresApproval: requiresApproval,
isDiagnostic: false,
minTimer,
module: "reading",
id: title,
variant: sections.length === 3 ? "full" : "partial",
difficulty,
access,
type: type!,
};
axios
.post(`/api/exam/reading`, exam)
.then((result) => {
playSound("sent");
// Successfully submitted exam
if (result.status === 200) {
toast.success(result.data.message);
} else if (result.status === 207) {
toast.warning(result.data.message);
}
})
.catch((error) => {
console.log(error);
toast.error(
error.response.data.error ||
"Something went wrong while submitting, please try again later."
);
});
};
const preview = () => {
setExam({
parts: sections.map((s) => {
const exercises = s.state as ReadingPart;
return {
...exercises,
intro: s.settings.currentIntro,
category: s.settings.category,
};
}),
minTimer,
module: "reading",
id: title,
isDiagnostic: false,
variant: undefined,
difficulty,
access: access,
type: type!,
} as ReadingExam);
setExerciseIndex(0);
setQuestionIndex(0);
setPartIndex(0);
setBgColor("bg-white");
openDetachedTab("popout?type=Exam&module=reading", router);
};
return (
<SettingsEditor
sectionLabel={`Passage ${focusedSection}`}
sectionId={focusedSection}
module="reading"
introPresets={[defaultPresets[focusedSection - 1]]}
preview={preview}
canPreview={canPreviewOrSubmit}
canSubmit={canPreviewOrSubmit}
submitModule={submitReading}
>
<ReadingComponents
{...{ localSettings, updateLocalAndScheduleGlobal, currentSection }}
/>
</SettingsEditor>
);
};
export default ReadingSettings;

View File

@@ -1,371 +0,0 @@
import useExamEditorStore from "@/stores/examEditor";
import { LevelSectionSettings, SpeakingSectionSettings } from "@/stores/examEditor/types";
import { useCallback, useEffect, useState } from "react";
import { generate } from "../Shared/Generate";
import Dropdown from "../Shared/SettingsDropdown";
import Input from "@/components/Low/Input";
import GenerateBtn from "../Shared/GenerateBtn";
import clsx from "clsx";
import { FaFemale, FaMale } from "react-icons/fa";
import { Difficulty, InteractiveSpeakingExercise, LevelPart, SpeakingExercise } from "@/interfaces/exam";
import { toast } from "react-toastify";
import { generateVideos } from "../Shared/generateVideos";
import { Module } from "@/interfaces";
import useCanGenerate from "./useCanGenerate";
import ReactSelect, { components } from "react-select";
import { capitalize } from "lodash";
import Option from "@/interfaces/option";
import { MdSignalCellularAlt } from "react-icons/md";
export interface Avatar {
name: string;
gender: string;
}
interface Props {
localSettings: SpeakingSectionSettings | LevelSectionSettings;
updateLocalAndScheduleGlobal: (updates: Partial<SpeakingSectionSettings | LevelSectionSettings>, schedule?: boolean) => void;
section: SpeakingExercise | InteractiveSpeakingExercise | LevelPart;
level?: boolean;
module?: Module;
id?: string;
sectionId?: number;
}
const SpeakingComponents: React.FC<Props> = ({ localSettings, updateLocalAndScheduleGlobal, section, level = false, module = "speaking", id, sectionId }) => {
const { currentModule, speakingAvatars, dispatch, modules } = useExamEditorStore();
const { focusedSection, difficulty, sections } = useExamEditorStore((store) => store.modules[level ? "level" : currentModule])
const state = sections.find((s) => s.sectionId === sectionId);
const [selectedAvatar, setSelectedAvatar] = useState<Avatar | null>(null);
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"];
const difficultyOptions: Option[] = DIFFICULTIES.map(level => ({
label: level,
value: level
}));
const [specificDiff, setSpecificDiff] = useState(randomDiff);
const generateScript = useCallback((scriptSectionId: number) => {
const queryParams: {
difficulty: string[];
first_topic?: string;
second_topic?: string;
topic?: string;
} = { difficulty };
if (scriptSectionId === 1) {
if (localSettings.speakingTopic) {
queryParams['first_topic'] = localSettings.speakingTopic;
}
if (localSettings.speakingSecondTopic) {
queryParams['second_topic'] = localSettings.speakingSecondTopic;
}
} else {
if (localSettings.speakingTopic) {
queryParams['topic'] = localSettings.speakingTopic;
}
}
generate(
level ? section.sectionId! : focusedSection,
"speaking",
`${id ? `${id}-` : ''}speakingScript`,
{
method: 'GET',
queryParams
},
(data: any) => {
switch (level ? section.sectionId! : focusedSection) {
case 1:
return [{
prompts: data.questions,
first_topic: data.first_topic,
second_topic: data.second_topic,
difficulty: specificDiff.length == 2 ? specificDiff : difficulty,
}];
case 2:
return [{
topic: data.topic,
question: data.question,
prompts: data.prompts,
suffix: data.suffix,
difficulty: specificDiff.length == 2 ? specificDiff : difficulty,
}];
case 3:
return [{
title: data.topic,
prompts: data.questions,
difficulty: specificDiff.length == 2 ? specificDiff : difficulty,
}];
default:
return [data];
}
},
sectionId,
level
);
}, [difficulty, level, section.sectionId, focusedSection, id, sectionId, localSettings.speakingTopic, localSettings.speakingSecondTopic, specificDiff]);
const onTopicChange = useCallback((speakingTopic: string) => {
updateLocalAndScheduleGlobal({ speakingTopic });
}, [updateLocalAndScheduleGlobal]);
const onSecondTopicChange = useCallback((speakingSecondTopic: string) => {
updateLocalAndScheduleGlobal({ speakingSecondTopic });
}, [updateLocalAndScheduleGlobal]);
const canGenerate = useCanGenerate({
section,
sections,
id,
focusedSection
});
useEffect(() => {
if (!canGenerate) {
updateLocalAndScheduleGlobal({ isGenerateVideoOpen: false }, false);
}
}, [canGenerate, updateLocalAndScheduleGlobal]);
const generateVideoCallback = useCallback((sectionId: number) => {
if (level) {
dispatch({ type: "UPDATE_SECTION_SINGLE_FIELD", payload: { sectionId, module: "level", field: "levelGenerating", value: [...state!.levelGenerating, `${id ? `${id}-` : ''}video`] } })
} else {
dispatch({ type: "UPDATE_SECTION_SINGLE_FIELD", payload: { sectionId: focusedSection, module: "speaking", field: "generating", value: "video" } })
}
generateVideos(
section as InteractiveSpeakingExercise | SpeakingExercise,
level ? section.sectionId! : focusedSection,
selectedAvatar,
speakingAvatars
).then((results) => {
switch (level ? section.sectionId! : focusedSection) {
case 1:
case 3: {
const interactiveSection = section as InteractiveSpeakingExercise;
const updatedPrompts = interactiveSection.prompts.map((prompt, index) => ({
...prompt,
video_url: results[index].url || ''
}));
if (level) {
dispatch({
type: "UPDATE_SECTION_SINGLE_FIELD", payload: {
sectionId, field: "levelGenResults", value: [...state!.levelGenResults,
{ generating: `${id ? `${id}-` : ''}video`, result: [{ prompts: updatedPrompts }] }], module: "level"
}
})
} else {
dispatch({
type: "UPDATE_SECTION_SINGLE_FIELD", payload: {
sectionId: focusedSection, module: "speaking", field: "genResult", value:
{ generating: "video", result: [{ prompts: updatedPrompts }], module: module }
}
})
}
break;
}
case 2: {
if (results[0]?.url) {
if (level) {
dispatch({
type: "UPDATE_SECTION_SINGLE_FIELD", payload: {
sectionId, field: "levelGenResults", value: [...state!.levelGenResults,
{ generating: `${id ? `${id}-` : ''}video`, result: [{ video_url: results[0].url }] }], module: "level"
}
})
} else {
dispatch({
type: "UPDATE_SECTION_SINGLE_FIELD", payload: {
sectionId: focusedSection, module, field: "genResult", value:
{ generating: 'video', result: [{ video_url: results[0].url }], module: "speaking" }
}
})
}
}
break;
}
}
}).catch((error) => {
toast.error("Failed to generate the video, try again later!")
});
}, [level, section, focusedSection, selectedAvatar, speakingAvatars, dispatch, module, state, id]);
const secId = level ? section.sectionId! : focusedSection;
return (
<>
<Dropdown
title="Generate Script"
module="speaking"
open={localSettings.isSpeakingTopicOpen}
setIsOpen={(isOpen: boolean) => updateLocalAndScheduleGlobal({ isSpeakingTopicOpen: isOpen }, false)}
contentWrapperClassName={level ? `border border-ielts-speaking` : ''}
>
<div className="gap-4 px-2 pb-4 flex flex-col w-full">
<div className="flex flex-col flex-grow gap-4 px-2">
<label className="font-normal text-base text-mti-gray-dim">{`${secId === 1 ? "First Topic" : "Topic"}`} (Optional)</label>
<Input
key={`section-${secId}`}
type="text"
placeholder="Topic"
name="category"
onChange={onTopicChange}
roundness="xl"
value={localSettings.speakingTopic}
thin
/>
</div>
{secId === 1 &&
<div className="flex flex-col flex-grow gap-4 px-2">
<label className="font-normal text-base text-mti-gray-dim">Second Topic (Optional)</label>
<Input
key={`section-${secId}`}
type="text"
placeholder="Topic"
name="category"
onChange={onSecondTopicChange}
roundness="xl"
value={localSettings.speakingSecondTopic}
thin
/>
</div>
}
<div className="flex flex-col gap-2 px-2">
<label className="block font-normal text-base text-mti-gray-dim mb-2">Difficulty (Optional)</label>
<ReactSelect
options={difficultyOptions}
value={difficultyOptions.find(opt => opt.value === specificDiff)}
onChange={(value) => setSpecificDiff(value!.value as Difficulty)}
menuPortalTarget={document?.body}
components={{
IndicatorSeparator: null,
ValueContainer: ({ children, ...props }) => (
<components.ValueContainer {...props}>
<div className="flex flex-row gap-2 items-center pl-4">
<MdSignalCellularAlt size={14} className="text-gray-600" />
{children}
</div>
</components.ValueContainer>
)
}}
styles={{
menuPortal: (base) => ({ ...base, zIndex: 9999 }),
control: (styles) => ({
...styles,
minHeight: '50px',
border: '1px solid #e5e7eb',
borderRadius: '0.5rem',
boxShadow: 'none',
backgroundColor: 'white',
cursor: 'pointer',
'&:hover': {
border: '1px solid #e5e7eb',
}
}),
valueContainer: (styles) => ({
...styles,
padding: '0 8px',
display: 'flex',
alignItems: 'center'
}),
input: (styles) => ({
...styles,
margin: '0',
padding: '0'
}),
dropdownIndicator: (styles) => ({
...styles,
padding: '8px'
}),
option: (styles, state) => ({
...styles,
backgroundColor: state.isFocused ? "#D5D9F0" : state.isSelected ? "#7872BF" : "white",
color: state.isFocused ? "black" : styles.color,
}),
}}
className="text-sm"
/>
</div>
<div className="flex h-16 mb-1 justify-center mt-4">
<GenerateBtn
module="speaking"
genType={`${id ? `${id}-` : ''}speakingScript`}
sectionId={focusedSection}
generateFnc={generateScript}
level={level}
/>
</div>
</div>
</Dropdown>
<Dropdown
title="Generate Video"
module="speaking"
open={localSettings.isGenerateVideoOpen}
disabled={!canGenerate}
setIsOpen={(isOpen: boolean) => updateLocalAndScheduleGlobal({ isGenerateVideoOpen: isOpen }, false)}
contentWrapperClassName={level ? `border border-ielts-speaking` : ''}
>
<div className={clsx("flex items-center justify-between gap-4 px-2 pb-4")}>
<div className="relative flex-1 max-w-xs">
<select
value={selectedAvatar ? `${selectedAvatar.name}-${selectedAvatar.gender}` : ""}
onChange={(e) => {
if (e.target.value === "") {
setSelectedAvatar(null);
} else {
const [name, gender] = e.target.value.split("-");
const avatar = speakingAvatars.find(a => a.name === name && a.gender === gender);
if (avatar) setSelectedAvatar(avatar);
}
}}
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="">Select an avatar (Optional)</option>
{speakingAvatars.map((avatar) => (
<option
key={`${avatar.name}-${avatar.gender}`}
value={`${avatar.name}-${avatar.gender}`}
>
{avatar.name}
</option>
))}
</select>
<div className="absolute right-2.5 top-2.5 pointer-events-none">
{selectedAvatar && (
selectedAvatar.gender === 'male' ? (
<FaMale className="w-5 h-5 text-blue-500" />
) : (
<FaFemale className="w-5 h-5 text-pink-500" />
)
)}
</div>
</div>
<GenerateBtn
module="speaking"
genType={`${id ? `${id}-` : ''}video`}
sectionId={focusedSection}
generateFnc={generateVideoCallback}
level={level}
/>
</div>
</Dropdown>
</>
);
};
export default SpeakingComponents;

View File

@@ -1,268 +0,0 @@
import useExamEditorStore from "@/stores/examEditor";
import useSettingsState from "../../Hooks/useSettingsState";
import { SpeakingSectionSettings } from "@/stores/examEditor/types";
import Option from "@/interfaces/option";
import SettingsEditor from "..";
import { InteractiveSpeakingExercise, SpeakingExam, SpeakingExercise } from "@/interfaces/exam";
import { toast } from "react-toastify";
import { usePersistentExamStore } from "@/stores/exam";
import { useRouter } from "next/router";
import openDetachedTab from "@/utils/popout";
import axios from "axios";
import { playSound } from "@/utils/sound";
import SpeakingComponents from "./components";
import { getExamById } from "@/utils/exams";
export interface Avatar {
name: string;
gender: string;
}
const SpeakingSettings: React.FC = () => {
const router = useRouter();
const {
setExam,
setExerciseIndex,
setQuestionIndex,
setBgColor,
} = usePersistentExamStore();
const { title, currentModule } = useExamEditorStore();
const { focusedSection, difficulty, sections, minTimer, access } = useExamEditorStore((store) => store.modules[currentModule])
const section = sections.find((section) => section.sectionId == focusedSection)?.state;
const { localSettings, updateLocalAndScheduleGlobal } = useSettingsState<SpeakingSectionSettings>(
currentModule,
focusedSection,
);
if (section === undefined) return <></>;
const currentSection = section as SpeakingExercise | InteractiveSpeakingExercise;
const defaultPresets: Option[] = [
{
label: "Preset: Speaking Part 1",
value: "Welcome to {part} of the {label}. You will engage in a conversation about yourself and familiar topics such as your home, family, work, studies, and interests. General questions will be asked."
},
{
label: "Preset: Speaking Part 2",
value: "Welcome to {part} of the {label}. You will be given a topic card describing a particular person, object, event, or experience."
},
{
label: "Preset: Speaking Part 3",
value: "Welcome to {part} of the {label}. You will engage in an in-depth discussion about abstract ideas and issues. The examiner will ask questions that require you to explain, analyze, and speculate about various aspects of the topic."
}
];
const canPreviewOrSubmit = (() => {
return sections.every((s) => {
const section = s.state as SpeakingExercise | InteractiveSpeakingExercise;
switch (section.type) {
case 'speaking':
return section.title !== '' &&
section.text !== '' &&
section.video_url !== '' &&
section.prompts.every(prompt => prompt !== '');
case 'interactiveSpeaking':
if ('first_title' in section && 'second_title' in section) {
return section.first_title !== '' &&
section.second_title !== '' &&
section.prompts.every(prompt => prompt.video_url !== '') &&
section.prompts.length > 2;
}
return section.title !== '' &&
section.prompts.every(prompt => prompt.video_url !== '');
default:
return false;
}
});
})();
const submitSpeaking = async (requiresApproval: boolean) => {
if (title === "") {
toast.error("Enter a title for the exam!");
return;
}
try {
const formData = new FormData();
const urlMap = new Map<string, string>();
const sectionsWithVideos = sections.filter(s => {
const exercise = s.state as SpeakingExercise | InteractiveSpeakingExercise;
if (exercise.type === "speaking") {
return exercise.video_url !== "";
}
if (exercise.type === "interactiveSpeaking") {
return exercise.prompts?.some(prompt => prompt.video_url !== "");
}
return false;
});
if (sectionsWithVideos.length === 0) {
toast.error('No video sections found in the exam! Please record or import videos.');
return;
}
await Promise.all(
sectionsWithVideos.map(async (section) => {
const exercise = section.state as SpeakingExercise | InteractiveSpeakingExercise;
if (exercise.type === "speaking") {
const response = await fetch(exercise.video_url);
const blob = await response.blob();
formData.append('file', blob, 'video.mp4');
urlMap.set(`${section.sectionId}`, exercise.video_url);
} else {
await Promise.all(
exercise.prompts.map(async (prompt, promptIndex) => {
if (prompt.video_url) {
const response = await fetch(prompt.video_url);
const blob = await response.blob();
formData.append('file', blob, 'video.mp4');
urlMap.set(`${section.sectionId}-${promptIndex}`, prompt.video_url);
}
})
);
}
})
);
const response = await axios.post('/api/storage', formData, {
params: {
directory: 'speaking_videos'
},
headers: {
'Content-Type': 'multipart/form-data'
}
});
const { urls } = response.data;
const exam: SpeakingExam = {
exercises: sections.map((s) => {
const exercise = s.state as SpeakingExercise | InteractiveSpeakingExercise;
if (exercise.type === "speaking") {
const videoIndex = Array.from(urlMap.entries())
.findIndex(([key]) => key === `${s.sectionId}`);
return {
...exercise,
video_url: videoIndex !== -1 ? urls[videoIndex] : exercise.video_url,
intro: s.settings.currentIntro,
category: s.settings.category
};
} else {
const updatedPrompts = exercise.prompts.map((prompt, promptIndex) => {
const videoIndex = Array.from(urlMap.entries())
.findIndex(([key]) => key === `${s.sectionId}-${promptIndex}`);
return {
...prompt,
video_url: videoIndex !== -1 ? urls[videoIndex] : prompt.video_url
};
});
return {
...exercise,
prompts: updatedPrompts,
intro: s.settings.currentIntro,
category: s.settings.category
};
}
}),
minTimer,
module: "speaking",
id: title,
requiresApproval: requiresApproval,
isDiagnostic: false,
variant: undefined,
difficulty,
instructorGender: "varied",
access,
};
const result = await axios.post('/api/exam/speaking', exam);
playSound("sent");
// Successfully submitted exam
if (result.status === 200) {
toast.success(result.data.message);
} else if (result.status === 207) {
toast.warning(result.data.message);
}
Array.from(urlMap.values()).forEach(url => {
URL.revokeObjectURL(url);
});
} catch (error: any) {
toast.error(
"Something went wrong while submitting, please try again later."
);
}
};
const preview = () => {
setExam({
exercises: sections
.filter((s) => {
const exercise = s.state as SpeakingExercise | InteractiveSpeakingExercise;
if (exercise.type === "speaking") {
return exercise.video_url !== "";
}
if (exercise.type === "interactiveSpeaking") {
return exercise.prompts?.every(prompt => prompt.video_url !== "");
}
return false;
})
.map((s) => {
const exercise = s.state as SpeakingExercise | InteractiveSpeakingExercise;
return {
...exercise,
intro: s.settings.currentIntro,
category: s.settings.category
};
}),
minTimer,
module: "speaking",
id: title,
isDiagnostic: false,
variant: undefined,
difficulty,
access,
} as SpeakingExam);
setExerciseIndex(0);
setQuestionIndex(0);
setBgColor("bg-white");
openDetachedTab("popout?type=Exam&module=speaking", router)
}
return (
<SettingsEditor
sectionLabel={`Speaking ${focusedSection}`}
sectionId={focusedSection}
module="speaking"
introPresets={[defaultPresets[focusedSection - 1]]}
preview={preview}
canPreview={canPreviewOrSubmit}
canSubmit={canPreviewOrSubmit}
submitModule={submitSpeaking}
>
<SpeakingComponents
{...{ localSettings, updateLocalAndScheduleGlobal, section: currentSection }}
/>
</SettingsEditor>
);
};
export default SpeakingSettings;

View File

@@ -1,62 +0,0 @@
import { useCallback, useEffect, useState } from 'react';
import { InteractiveSpeakingExercise, LevelPart, SpeakingExercise } from "@/interfaces/exam";
import { Section } from '@/stores/examEditor/types';
interface CheckGenerateProps {
section: Section | null;
sections: Array<{ sectionId: number; state: Section }>;
id?: string;
focusedSection: number;
}
const useCanGenerate = ({ section, sections, id, focusedSection }: CheckGenerateProps) => {
const checkCanGenerate = useCallback(() => {
if (!section) return false;
const exercise = id
? (sections.find(s => s.sectionId === 1)?.state as LevelPart)
?.exercises?.find(ex => ex.id === id) ?? section
: section;
const sectionId = id ? (exercise as SpeakingExercise | InteractiveSpeakingExercise).sectionId : focusedSection;
switch (sectionId) {
case 1: {
const currentSection = exercise as InteractiveSpeakingExercise;
return currentSection.first_title &&
currentSection.second_title &&
currentSection.prompts?.length > 2 &&
currentSection.prompts.every(prompt => prompt.text)
;
}
case 2: {
const currentSection = exercise as SpeakingExercise;
return currentSection.title &&
currentSection.text &&
currentSection.prompts?.length > 0 &&
currentSection.prompts.every(prompt => prompt)
;
}
case 3: {
const currentSection = exercise as InteractiveSpeakingExercise;
return currentSection.title &&
currentSection.prompts?.length > 0 &&
currentSection.prompts.every(prompt => prompt.text)
;
}
default:
return false;
}
}, [section, sections, id, focusedSection]);
const [canGenerate, setCanGenerate] = useState(checkCanGenerate());
useEffect(() => {
setCanGenerate(checkCanGenerate());
}, [checkCanGenerate, section]);
return canGenerate;
};
export default useCanGenerate;

View File

@@ -1,304 +0,0 @@
import React, { useCallback, useRef, useState } from "react";
import Dropdown from "../Shared/SettingsDropdown";
import Input from "@/components/Low/Input";
import { generate } from "../Shared/Generate";
import GenerateBtn from "../Shared/GenerateBtn";
import { LevelSectionSettings, WritingSectionSettings } from "@/stores/examEditor/types";
import useExamEditorStore from "@/stores/examEditor";
import { Difficulty, WritingExercise } from "@/interfaces/exam";
import clsx from "clsx";
import { FaFileUpload } from "react-icons/fa";
import ReactSelect, { components } from "react-select";
import Option from "@/interfaces/option"
import { MdSignalCellularAlt } from "react-icons/md";
import { capitalize } from "lodash";
interface Props {
localSettings: WritingSectionSettings | LevelSectionSettings;
updateLocalAndScheduleGlobal: (updates: Partial<WritingSectionSettings | LevelSectionSettings>, schedule?: boolean) => void;
currentSection?: WritingExercise;
level?: boolean;
}
const WritingComponents: React.FC<Props> = ({ localSettings, updateLocalAndScheduleGlobal, level }) => {
const { currentModule, dispatch } = useExamEditorStore();
const {
difficulty,
focusedSection,
type,
academic_url
} = useExamEditorStore((store) => store.modules["writing"]);
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"];
const difficultyOptions: Option[] = DIFFICULTIES.map(level => ({
label: level,
value: level
}));
const [specificDiff, setSpecificDiff] = useState(randomDiff);
const generatePassage = useCallback((sectionId: number) => {
if (type === "academic" && academic_url !== undefined && sectionId == 1) {
generate(
sectionId,
currentModule,
"writing",
{
method: 'POST',
queryParams: {
difficulty: specificDiff.length == 2 ? [specificDiff] : difficulty,
type: type!
},
files: {
file: academic_url!,
}
},
(data: any) => [{
prompt: data.question,
difficulty: data.difficulty
}]
)
} else {
generate(
sectionId,
currentModule,
"writing",
{
method: 'GET',
queryParams: {
difficulty: specificDiff.length == 2 ? [specificDiff] : difficulty,
type: type!,
...(localSettings.writingTopic && { topic: localSettings.writingTopic })
}
},
(data: any) => [{
prompt: data.question,
difficulty: data.difficulty
}]
);
}
}, [type, academic_url, currentModule, specificDiff, difficulty, localSettings.writingTopic]);
const onTopicChange = useCallback((writingTopic: string) => {
updateLocalAndScheduleGlobal({ writingTopic });
}, [updateLocalAndScheduleGlobal]);
const fileInputRef = useRef<HTMLInputElement>(null);
const triggerFileInput = () => {
fileInputRef.current?.click();
};
const handleFileUpload = (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (file) {
const blobUrl = URL.createObjectURL(file);
if (academic_url !== undefined) {
URL.revokeObjectURL(academic_url);
}
dispatch({ type: "UPDATE_MODULE", payload: { updates: { academic_url: blobUrl } } });
}
};
return (
<>
{type === "academic" && focusedSection === 1 && <Dropdown
title="Upload Image"
module={"writing"}
open={localSettings.isImageUploadOpen}
setIsOpen={(isOpen: boolean) => updateLocalAndScheduleGlobal({ isImageUploadOpen: isOpen }, false)}
contentWrapperClassName={level ? `border border-ielts-writing` : ''}
>
<div className="flex w-full flex-row gap-2 items-center px-2 pb-4">
<div className="flex w-full flex-col gap-4">
<div className="flex flex-col gap-2">
<label className="block font-normal text-base text-mti-gray-dim mb-2">Difficulty (Optional)</label>
<ReactSelect
options={difficultyOptions}
value={difficultyOptions.find(opt => opt.value === specificDiff)}
onChange={(value) => setSpecificDiff(value!.value as Difficulty)}
menuPortalTarget={document?.body}
components={{
IndicatorSeparator: null,
ValueContainer: ({ children, ...props }) => (
<components.ValueContainer {...props}>
<div className="flex flex-row gap-2 items-center pl-4">
<MdSignalCellularAlt size={14} className="text-gray-600" />
{children}
</div>
</components.ValueContainer>
)
}}
styles={{
menuPortal: (base) => ({ ...base, zIndex: 9999 }),
control: (styles) => ({
...styles,
minHeight: '50px',
border: '1px solid #e5e7eb',
borderRadius: '0.5rem',
boxShadow: 'none',
backgroundColor: 'white',
cursor: 'pointer',
'&:hover': {
border: '1px solid #e5e7eb',
}
}),
valueContainer: (styles) => ({
...styles,
padding: '0 8px',
display: 'flex',
alignItems: 'center'
}),
input: (styles) => ({
...styles,
margin: '0',
padding: '0'
}),
dropdownIndicator: (styles) => ({
...styles,
padding: '8px'
}),
option: (styles, state) => ({
...styles,
backgroundColor: state.isFocused ? "#D5D9F0" : state.isSelected ? "#7872BF" : "white",
color: state.isFocused ? "black" : styles.color,
}),
}}
className="text-sm"
/>
</div>
<div className="flex flex-row justify-between gap-4">
<span className="bg-gray-100 px-3.5 py-2.5 rounded-lg border border-gray-300 text-mti-gray-dim flex-grow">
Upload a graph, chart or diagram
</span>
<input
type="file"
accept="image/png, image/jpeg"
onChange={handleFileUpload}
style={{ display: 'none' }}
ref={fileInputRef}
/>
<button
key={`section-${focusedSection}`}
className={clsx(
"flex items-center w-[140px] px-4 py-2 text-white rounded-xl transition-colors duration-300 text-lg disabled:cursor-not-allowed",
`bg-ielts-writing/70 border border-ielts-writing hover:bg-ielts-writing disabled:bg-ielts-writing/40`,
)}
onClick={triggerFileInput}
>
<div className="flex flex-row">
<FaFileUpload className="mr-2" size={24} />
<span>Upload</span>
</div>
</button>
</div>
</div>
</div>
</Dropdown>}
{
(type !== "academic" || (type === "academic" && focusedSection == 2)) && <Dropdown
title="Generate Instructions"
module={"writing"}
open={localSettings.isWritingTopicOpen}
setIsOpen={(isOpen: boolean) => updateLocalAndScheduleGlobal({ isWritingTopicOpen: isOpen }, false)}
contentWrapperClassName={level ? `border border-ielts-writing` : ''}
>
<div className="px-2 pb-4 flex flex-col w-full">
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-2">
<label className="block font-normal text-base text-mti-gray-dim mb-2">Topic (Optional)</label>
<div className="flex gap-2 min-w-0">
<Input
key={`section-${focusedSection}`}
type="text"
placeholder="Topic"
name="category"
onChange={onTopicChange}
roundness="xl"
value={localSettings.writingTopic}
thin
/>
</div>
</div>
<div className="flex flex-col gap-2">
<label className="block font-normal text-base text-mti-gray-dim mb-2">Difficulty (Optional)</label>
<ReactSelect
options={difficultyOptions}
value={difficultyOptions.find(opt => opt.value === specificDiff)}
onChange={(value) => setSpecificDiff(value!.value as Difficulty)}
menuPortalTarget={document?.body}
components={{
IndicatorSeparator: null,
ValueContainer: ({ children, ...props }) => (
<components.ValueContainer {...props}>
<div className="flex flex-row gap-2 items-center pl-4">
<MdSignalCellularAlt size={14} className="text-gray-600" />
{children}
</div>
</components.ValueContainer>
)
}}
styles={{
menuPortal: (base) => ({ ...base, zIndex: 9999 }),
control: (styles) => ({
...styles,
minHeight: '50px',
border: '1px solid #e5e7eb',
borderRadius: '0.5rem',
boxShadow: 'none',
backgroundColor: 'white',
cursor: 'pointer',
'&:hover': {
border: '1px solid #e5e7eb',
}
}),
valueContainer: (styles) => ({
...styles,
padding: '0 8px',
display: 'flex',
alignItems: 'center'
}),
input: (styles) => ({
...styles,
margin: '0',
padding: '0'
}),
dropdownIndicator: (styles) => ({
...styles,
padding: '8px'
}),
option: (styles, state) => ({
...styles,
backgroundColor: state.isFocused ? "#D5D9F0" : state.isSelected ? "#7872BF" : "white",
color: state.isFocused ? "black" : styles.color,
}),
}}
className="text-sm"
/>
</div>
<div className="flex w-full h-full justify-center items-center mt-2">
<GenerateBtn
genType="writing"
module={"writing"}
sectionId={focusedSection}
generateFnc={generatePassage}
/>
</div>
</div>
</div>
</Dropdown>
}
</>
);
};
export default WritingComponents;

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