Compare commits
257 Commits
22209ee1c1
...
feature/Ex
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1c75a0e59c | ||
|
|
e36b24ea3f | ||
|
|
4d788e13b4 | ||
|
|
ae9a49681e | ||
|
|
205449e1ae | ||
|
|
4724e98993 | ||
|
|
6f9be29cd8 | ||
|
|
d8fafa5cae | ||
|
|
ccbbf30058 | ||
|
|
387418b9bd | ||
|
|
715a841483 | ||
|
|
f6cd509aa4 | ||
|
|
393b1a6be9 | ||
|
|
bc89f4b9ce | ||
|
|
8f77f28aaa | ||
|
|
61e07dae95 | ||
|
|
0739e044a1 | ||
|
|
7f91a92962 | ||
|
|
af9f70880a | ||
|
|
26fa1691c4 | ||
|
|
548163d66c | ||
|
|
8ff0d16402 | ||
|
|
4c746b93bc | ||
|
|
502cc64f99 | ||
|
|
f64b50df9e | ||
|
|
17154be8bf | ||
|
|
b52259794e | ||
|
|
bd9e249704 | ||
|
|
f642e41bfa | ||
|
|
7b5d021bf3 | ||
|
|
958f74bd9c | ||
|
|
bac2a08748 | ||
|
|
770056e0c4 | ||
|
|
f8e9cfbeff | ||
|
|
408cfbb500 | ||
|
|
2146f57941 | ||
|
|
e9c961e633 | ||
|
|
9cf13e3f26 | ||
|
|
f1d97aa6c9 | ||
|
|
d938535d9f | ||
|
|
319da200c6 | ||
|
|
345b784daf | ||
|
|
1b15a035df | ||
|
|
8d7b47312e | ||
|
|
860f1295e5 | ||
|
|
0cbdba1ab8 | ||
|
|
4ee3724196 | ||
|
|
98a1636d0c | ||
|
|
020f65c566 | ||
|
|
f6d387ce2d | ||
|
|
d3d5e59aad | ||
|
|
84f66f0bbb | ||
|
|
8d0f98d186 | ||
|
|
ed9de74f28 | ||
|
|
bcf3cf0667 | ||
|
|
f3057c675f | ||
|
|
61d1bbbe13 | ||
|
|
6bb817f9af | ||
|
|
3b6836c15a | ||
|
|
858e29eb93 | ||
|
|
240e36f15a | ||
|
|
3e74827c47 | ||
|
|
578d29066f | ||
|
|
1a7d35317b | ||
|
|
ce35ba71f4 | ||
|
|
76cbf8dc41 | ||
|
|
71ed1013b7 | ||
|
|
fa0c257040 | ||
|
|
cf85b5a822 | ||
|
|
efba1939e5 | ||
|
|
eabfcd026b | ||
|
|
d074ec390c | ||
|
|
6a6c4661c4 | ||
|
|
7538392e44 | ||
|
|
8920eb8441 | ||
|
|
35d28fbff6 | ||
|
|
19b6626b71 | ||
|
|
8bd8b61041 | ||
|
|
4f32e3cf93 | ||
|
|
70530b3f6c | ||
|
|
7d08db28aa | ||
|
|
3b6fd2bc6b | ||
|
|
04b2cb3907 | ||
|
|
ec47d750bf | ||
|
|
98ed842932 | ||
|
|
5252faafb7 | ||
|
|
7801a7d05a | ||
|
|
a36be67c8b | ||
|
|
1b97f86a37 | ||
|
|
444fb15c29 | ||
|
|
490c5ad7d3 | ||
|
|
b6f61c6be1 | ||
|
|
2e5545f181 | ||
|
|
cd14ac537d | ||
|
|
24d613e9cd | ||
|
|
1f1c2b4aaf | ||
|
|
0faa908538 | ||
|
|
5cfd2c90b7 | ||
|
|
8efaa67574 | ||
|
|
c90bc271d3 | ||
|
|
09e97a28a5 | ||
|
|
a96d4c6e52 | ||
|
|
432c6ed6fd | ||
|
|
942b2c853c | ||
|
|
a2a513077f | ||
|
|
a0e79f7a5c | ||
|
|
ca5977e78b | ||
|
|
40d049ad36 | ||
|
|
dacab73265 | ||
|
|
af5ae2a687 | ||
|
|
44da859d30 | ||
|
|
6a1bc92270 | ||
|
|
48d3cfe5f8 | ||
|
|
4e94773861 | ||
|
|
f2941e2ba3 | ||
|
|
67909c1d7c | ||
|
|
efb153a33d | ||
|
|
2e96508205 | ||
|
|
61b6da749e | ||
|
|
c6e8d3527d | ||
|
|
9faf82ee9c | ||
|
|
1fc439cb25 | ||
|
|
de08164dd8 | ||
|
|
2ed4e6509e | ||
|
|
39ff336af5 | ||
|
|
bb5326a331 | ||
|
|
a1501d6c23 | ||
|
|
114da173be | ||
|
|
bce3a25dc2 | ||
|
|
47762544fc | ||
|
|
55a03b283f | ||
|
|
e5087d4d58 | ||
|
|
2160a42964 | ||
|
|
593d349617 | ||
|
|
a6bd3a9f3b | ||
|
|
f6fc701fb7 | ||
|
|
50bbb0dacf | ||
|
|
f301001ebe | ||
|
|
0eed8e4612 | ||
|
|
e6d77af53f | ||
|
|
bb24fe3c6d | ||
|
|
4f60819dcc | ||
|
|
501606233f | ||
|
|
d564d86feb | ||
|
|
c067e26e0c | ||
|
|
e40f880342 | ||
|
|
e9b7bd14cc | ||
|
|
2e5c4295dc | ||
|
|
ba71995ace | ||
|
|
a18bdfcef6 | ||
|
|
07e03c8981 | ||
|
|
8cb09e349f | ||
|
|
70a461bc69 | ||
|
|
6aa5385939 | ||
|
|
49749bfbe9 | ||
|
|
69d0b2bdf4 | ||
|
|
9000ca9001 | ||
|
|
153d7f5448 | ||
|
|
6f5f53d564 | ||
|
|
311036fe86 | ||
|
|
49c63544a1 | ||
|
|
36c3bb4392 | ||
|
|
1cc068db3e | ||
|
|
fdf411d133 | ||
|
|
b2dc9b2e31 | ||
|
|
1787e3ed53 | ||
|
|
711a0743c2 | ||
|
|
0a3a00cd3f | ||
|
|
696c968ebc | ||
|
|
af21ff2f20 | ||
|
|
4219341951 | ||
|
|
322d7905c3 | ||
|
|
a010a630ac | ||
|
|
7ac15fc767 | ||
|
|
042b07c267 | ||
|
|
c507eae507 | ||
|
|
f0a97d42a4 | ||
|
|
065497dfa3 | ||
|
|
7ae91d7bc1 | ||
|
|
78cf011bf7 | ||
|
|
fae7b729fe | ||
|
|
0325bb68f5 | ||
|
|
50481a836e | ||
|
|
bead6d98ae | ||
|
|
55737781bc | ||
|
|
4ce208281d | ||
|
|
0741c4c647 | ||
|
|
dc8f00c318 | ||
|
|
7045b4e3c7 | ||
|
|
a371b171bb | ||
|
|
5165b6ae6d | ||
|
|
817100b0e4 | ||
|
|
7de860ad44 | ||
|
|
27b72c162b | ||
|
|
b5ac908d09 | ||
|
|
b50913eda8 | ||
|
|
ffa2045a2d | ||
|
|
f686985c6e | ||
|
|
e8b56485ee | ||
|
|
df41611093 | ||
|
|
54c5b2063b | ||
|
|
225640ad8a | ||
|
|
7dacc69321 | ||
|
|
e6f9d9b79a | ||
|
|
b5a40ea220 | ||
|
|
15c9c4d4bd | ||
|
|
774f5e72c0 | ||
|
|
a502b2b863 | ||
|
|
dc4694eb17 | ||
|
|
ce353f34c7 | ||
|
|
a2bc997e8f | ||
|
|
f29daa0d94 | ||
|
|
28c5d13682 | ||
|
|
35ca933339 | ||
|
|
dd94f245eb | ||
|
|
ef857fee59 | ||
|
|
fa0c502467 | ||
|
|
0becd295b0 | ||
|
|
184a5fd820 | ||
|
|
70a027f85b | ||
|
|
87d7d6f12b | ||
|
|
4917583c67 | ||
|
|
a0a9402945 | ||
|
|
22b8aed127 | ||
|
|
532b49165c | ||
|
|
1fb7343aa7 | ||
|
|
de31f77181 | ||
|
|
a53ee79c0a | ||
|
|
55204e2ce1 | ||
|
|
c43ab9a911 | ||
|
|
1ef4efcacf | ||
|
|
b5200c88fc | ||
|
|
3d4a604aa2 | ||
|
|
9f1f564e25 | ||
|
|
8adacc51a6 | ||
|
|
564e6438cb | ||
|
|
3c1c4489f8 | ||
|
|
044ec8d966 | ||
|
|
bae02e5192 | ||
|
|
dd94228672 | ||
|
|
8c392f8b49 | ||
|
|
07c9074d15 | ||
|
|
71bac76c3a | ||
|
|
fb293dc98c | ||
|
|
3ce97b4dcd | ||
|
|
7bfd000213 | ||
|
|
2a10933206 | ||
|
|
33a46c227b | ||
|
|
5153c3d5f1 | ||
|
|
85c8f622ee | ||
|
|
b9c097d42c | ||
|
|
192132559b | ||
|
|
392eac2ef9 | ||
|
|
a073ca1cce | ||
|
|
7e30ca5750 | ||
|
|
2e065eddcb | ||
|
|
4e91b2f1fb |
20566
package-lock.json
generated
20566
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -12,6 +12,7 @@
|
||||
"dependencies": {
|
||||
"@beam-australia/react-env": "^3.1.1",
|
||||
"@dnd-kit/core": "^6.1.0",
|
||||
"@dnd-kit/modifiers": "^7.0.0",
|
||||
"@dnd-kit/sortable": "^8.0.0",
|
||||
"@firebase/util": "^1.9.7",
|
||||
"@headlessui/react": "^2.1.2",
|
||||
@@ -49,6 +50,7 @@
|
||||
"formidable-serverless": "^1.1.1",
|
||||
"framer-motion": "^9.0.2",
|
||||
"howler": "^2.2.4",
|
||||
"immer": "^10.1.1",
|
||||
"iron-session": "^6.3.1",
|
||||
"lodash": "^4.17.21",
|
||||
"moment": "^2.29.4",
|
||||
|
||||
BIN
public/blue-stock-photo.png
Normal file
BIN
public/blue-stock-photo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 419 KiB |
BIN
public/microsoft-word-icon.png
Normal file
BIN
public/microsoft-word-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 16 KiB |
BIN
public/purple-stock-photo.png
Normal file
BIN
public/purple-stock-photo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 418 KiB |
@@ -1,5 +1,5 @@
|
||||
import { Dialog, Transition } from "@headlessui/react";
|
||||
import {Fragment} from "react";
|
||||
import { Fragment, useCallback, useEffect, useState } from "react";
|
||||
import Button from "./Low/Button";
|
||||
|
||||
interface Props {
|
||||
@@ -12,9 +12,53 @@ interface 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 (
|
||||
<Transition show={isOpen} as={Fragment}>
|
||||
<Dialog onClose={onCancel} className="relative z-50">
|
||||
<Transition
|
||||
show={isOpen}
|
||||
as={Fragment}
|
||||
beforeEnter={() => setIsClosing(false)}
|
||||
beforeLeave={() => setIsClosing(true)}
|
||||
afterLeave={() => {
|
||||
setIsClosing(false);
|
||||
setMounted(false);
|
||||
}}
|
||||
>
|
||||
<Dialog onClose={() => blockMultipleClicksClose(true)} className="relative z-50">
|
||||
<Transition.Child
|
||||
as={Fragment}
|
||||
enter="ease-out duration-300"
|
||||
@@ -39,10 +83,10 @@ export default function AbandonPopup({isOpen, abandonPopupTitle, abandonPopupDes
|
||||
<Dialog.Title className="font-bold text-xl">{abandonPopupTitle}</Dialog.Title>
|
||||
<span>{abandonPopupDescription}</span>
|
||||
<div className="w-full flex justify-between mt-8">
|
||||
<Button color="purple" onClick={onCancel} variant="outline" className="max-w-[200px] self-end w-full">
|
||||
<Button color="purple" onClick={() => blockMultipleClicksClose(true)} variant="outline" className="max-w-[200px] self-end w-full">
|
||||
Cancel
|
||||
</Button>
|
||||
<Button color="purple" onClick={onAbandon} className="max-w-[200px] self-end w-full">
|
||||
<Button color="purple" onClick={() => blockMultipleClicksClose(false)} className="max-w-[200px] self-end w-full">
|
||||
{abandonConfirmButtonText}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -13,6 +13,7 @@ import {useAssignmentUnarchive} from "@/hooks/useAssignmentUnarchive";
|
||||
import { useAssignmentRelease } from "@/hooks/useAssignmentRelease";
|
||||
import { getUserName } from "@/utils/users";
|
||||
import { User } from "@/interfaces/user";
|
||||
import { EntityWithRoles } from "@/interfaces/entity";
|
||||
|
||||
interface Props {
|
||||
users: User[];
|
||||
@@ -22,6 +23,7 @@ interface Props {
|
||||
allowArchive?: boolean;
|
||||
allowUnarchive?: boolean;
|
||||
allowExcelDownload?: boolean;
|
||||
entityObj?: EntityWithRoles
|
||||
}
|
||||
|
||||
export default function AssignmentCard({
|
||||
@@ -30,6 +32,7 @@ export default function AssignmentCard({
|
||||
assigner,
|
||||
startDate,
|
||||
endDate,
|
||||
entityObj,
|
||||
assignees,
|
||||
results,
|
||||
exams,
|
||||
@@ -49,7 +52,6 @@ export default function AssignmentCard({
|
||||
const renderUnarchiveIcon = useAssignmentUnarchive(id, reload);
|
||||
const renderReleaseIcon = useAssignmentRelease(id, reload);
|
||||
|
||||
|
||||
const calculateAverageModuleScore = (module: Module) => {
|
||||
const resultModuleBandScores = results.map((r) => {
|
||||
const moduleStats = r.stats.filter((s) => s.module === module);
|
||||
@@ -69,22 +71,22 @@ export default function AssignmentCard({
|
||||
// in order to be downloadable, the assignment has to be released
|
||||
// the component should have the allowDownload prop
|
||||
// and the assignment should not have the level module
|
||||
return uniqModules.every(({ module }) => module !== 'level');
|
||||
return uniqModules.every(({ module }) => module !== "level");
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const shouldRenderExcel = () => {
|
||||
if (released && allowExcelDownload) {
|
||||
// in order to be downloadable, the assignment has to be released
|
||||
// the component should have the allowExcelDownload prop
|
||||
// and the assignment should have the level module
|
||||
return uniqModules.some(({ module }) => module === 'level');
|
||||
return uniqModules.some(({ module }) => module === "level");
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -116,6 +118,7 @@ export default function AssignmentCard({
|
||||
<span>{moment(endDate).format("DD/MM/YY, HH:mm")}</span>
|
||||
</span>
|
||||
<span>Assigner: {getUserName(users.find((x) => x.id === assigner))}</span>
|
||||
{entityObj && <span>Entity: {entityObj.label}</span>}
|
||||
</div>
|
||||
<div className="-md:mt-2 grid w-full grid-cols-4 place-items-start gap-2">
|
||||
{uniqModules.map(({ module }) => (
|
||||
339
src/components/AssignmentView.tsx
Normal file
339
src/components/AssignmentView.tsx
Normal file
@@ -0,0 +1,339 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import {infoButtonStyle} from "@/constants/buttonStyles";
|
||||
import {Module} from "@/interfaces";
|
||||
import {User} from "@/interfaces/user";
|
||||
import useExamStore from "@/stores/examStore";
|
||||
import useExamStore from "@/stores/exam";
|
||||
import {getExam, getExamById} from "@/utils/exams";
|
||||
import {MODULE_ARRAY} from "@/utils/moduleUtils";
|
||||
import {writingMarking} from "@/utils/score";
|
||||
@@ -28,8 +28,7 @@ export default function Diagnostic({onFinish}: Props) {
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const setExams = useExamStore((state) => state.setExams);
|
||||
const setSelectedModules = useExamStore((state) => state.setSelectedModules);
|
||||
const dispatch = useExamStore((state) => state.dispatch);
|
||||
|
||||
const isNextDisabled = () => {
|
||||
if (!focus) return true;
|
||||
@@ -41,9 +40,8 @@ export default function Diagnostic({onFinish}: Props) {
|
||||
|
||||
Promise.all(examPromises).then((exams) => {
|
||||
if (exams.every((x) => !!x)) {
|
||||
setExams(exams.map((x) => x!));
|
||||
setSelectedModules(exams.map((x) => x!.module));
|
||||
router.push("/exercises");
|
||||
dispatch({type: 'INIT_EXAM', payload: {exams: exams.map((x) => x!), modules: exams.map((x) => x!.module)}})
|
||||
router.push("/exam");
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
@@ -2,23 +2,36 @@ import React, { useState, ReactNode, useRef, useEffect } from 'react';
|
||||
import { animated, useSpring } from '@react-spring/web';
|
||||
|
||||
interface DropdownProps {
|
||||
title: ReactNode;
|
||||
title?: ReactNode;
|
||||
open?: boolean;
|
||||
setIsOpen?: React.Dispatch<React.SetStateAction<boolean>> | ((isOpen: boolean) => void);
|
||||
className?: string;
|
||||
contentWrapperClassName?: string;
|
||||
titleClassName?: string;
|
||||
bottomPadding?: number;
|
||||
disabled?: boolean,
|
||||
wrapperClassName?: string;
|
||||
customTitle?: ReactNode;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
const Dropdown: React.FC<DropdownProps> = ({
|
||||
title,
|
||||
open = false,
|
||||
titleClassName = "",
|
||||
setIsOpen: externalSetIsOpen,
|
||||
className = "w-full text-left font-semibold flex justify-between items-center p-4",
|
||||
contentWrapperClassName = "px-6",
|
||||
bottomPadding = 12,
|
||||
disabled = false,
|
||||
customTitle = undefined,
|
||||
wrapperClassName,
|
||||
children
|
||||
}) => {
|
||||
const [isOpen, setIsOpen] = useState<boolean>(open);
|
||||
const [internalIsOpen, setInternalIsOpen] = useState<boolean>(open);
|
||||
const isOpen = externalSetIsOpen !== undefined ? open : internalIsOpen;
|
||||
const toggleOpen = externalSetIsOpen !== undefined ? externalSetIsOpen : setInternalIsOpen;
|
||||
|
||||
const contentRef = useRef<HTMLDivElement>(null);
|
||||
const [contentHeight, setContentHeight] = useState<number>(0);
|
||||
|
||||
@@ -56,12 +69,18 @@ const Dropdown: React.FC<DropdownProps> = ({
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={wrapperClassName}>
|
||||
<button
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
onClick={() => toggleOpen(!isOpen)}
|
||||
className={className}
|
||||
disabled={disabled}
|
||||
>
|
||||
{title}
|
||||
<div className='flex flex-row w-full justify-between items-center'>
|
||||
{customTitle ? (
|
||||
customTitle
|
||||
) : (
|
||||
<p className={titleClassName}>{title}</p>
|
||||
)}
|
||||
<svg
|
||||
className={`w-4 h-4 transform transition-transform ${isOpen ? 'rotate-180' : ''}`}
|
||||
fill="none"
|
||||
@@ -71,13 +90,14 @@ const Dropdown: React.FC<DropdownProps> = ({
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</div>
|
||||
</button>
|
||||
<animated.div style={springProps} className="overflow-hidden">
|
||||
<div ref={contentRef} className={contentWrapperClassName} style={{ paddingBottom: bottomPadding }}>
|
||||
{children}
|
||||
</div>
|
||||
</animated.div>
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
307
src/components/ExamEditor/ExercisePicker/ExerciseWizard.tsx
Normal file
307
src/components/ExamEditor/ExercisePicker/ExerciseWizard.tsx
Normal file
@@ -0,0 +1,307 @@
|
||||
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;
|
||||
471
src/components/ExamEditor/ExercisePicker/exercises.ts
Normal file
471
src/components/ExamEditor/ExercisePicker/exercises.ts
Normal file
@@ -0,0 +1,471 @@
|
||||
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;
|
||||
@@ -0,0 +1,22 @@
|
||||
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
|
||||
}
|
||||
275
src/components/ExamEditor/ExercisePicker/index.tsx
Normal file
275
src/components/ExamEditor/ExercisePicker/index.tsx
Normal file
@@ -0,0 +1,275 @@
|
||||
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;
|
||||
247
src/components/ExamEditor/Exercises/Blanks/BlanksReducer.tsx
Normal file
247
src/components/ExamEditor/Exercises/Blanks/BlanksReducer.tsx
Normal file
@@ -0,0 +1,247 @@
|
||||
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;
|
||||
};
|
||||
129
src/components/ExamEditor/Exercises/Blanks/DragNDrop.tsx
Normal file
129
src/components/ExamEditor/Exercises/Blanks/DragNDrop.tsx
Normal file
@@ -0,0 +1,129 @@
|
||||
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"
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,58 @@
|
||||
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;
|
||||
347
src/components/ExamEditor/Exercises/Blanks/Letters/index.tsx
Normal file
347
src/components/ExamEditor/Exercises/Blanks/Letters/index.tsx
Normal file
@@ -0,0 +1,347 @@
|
||||
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;
|
||||
@@ -0,0 +1,67 @@
|
||||
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;
|
||||
@@ -0,0 +1,338 @@
|
||||
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;
|
||||
@@ -0,0 +1,47 @@
|
||||
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;
|
||||
@@ -0,0 +1,234 @@
|
||||
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;
|
||||
@@ -0,0 +1,58 @@
|
||||
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;
|
||||
};
|
||||
283
src/components/ExamEditor/Exercises/Blanks/index.tsx
Normal file
283
src/components/ExamEditor/Exercises/Blanks/index.tsx
Normal file
@@ -0,0 +1,283 @@
|
||||
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;
|
||||
38
src/components/ExamEditor/Exercises/Blanks/validateBlanks.ts
Normal file
38
src/components/ExamEditor/Exercises/Blanks/validateBlanks.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
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;
|
||||
@@ -0,0 +1,45 @@
|
||||
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;
|
||||
261
src/components/ExamEditor/Exercises/MatchSentences/index.tsx
Normal file
261
src/components/ExamEditor/Exercises/MatchSentences/index.tsx
Normal file
@@ -0,0 +1,261 @@
|
||||
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;
|
||||
@@ -0,0 +1,42 @@
|
||||
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;
|
||||
@@ -0,0 +1,191 @@
|
||||
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;
|
||||
@@ -0,0 +1,177 @@
|
||||
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;
|
||||
@@ -0,0 +1,300 @@
|
||||
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;
|
||||
17
src/components/ExamEditor/Exercises/MultipleChoice/index.tsx
Normal file
17
src/components/ExamEditor/Exercises/MultipleChoice/index.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
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;
|
||||
86
src/components/ExamEditor/Exercises/Script/Message.tsx
Normal file
86
src/components/ExamEditor/Exercises/Script/Message.tsx
Normal file
@@ -0,0 +1,86 @@
|
||||
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;
|
||||
360
src/components/ExamEditor/Exercises/Script/index.tsx
Normal file
360
src/components/ExamEditor/Exercises/Script/index.tsx
Normal file
@@ -0,0 +1,360 @@
|
||||
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;
|
||||
61
src/components/ExamEditor/Exercises/Shared/Alert.tsx
Normal file
61
src/components/ExamEditor/Exercises/Shared/Alert.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
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;
|
||||
14
src/components/ExamEditor/Exercises/Shared/GenLoader.tsx
Normal file
14
src/components/ExamEditor/Exercises/Shared/GenLoader.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
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;
|
||||
70
src/components/ExamEditor/Exercises/Shared/PromptEdit.tsx
Normal file
70
src/components/ExamEditor/Exercises/Shared/PromptEdit.tsx
Normal file
@@ -0,0 +1,70 @@
|
||||
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;
|
||||
34
src/components/ExamEditor/Exercises/Shared/QuestionsList.tsx
Normal file
34
src/components/ExamEditor/Exercises/Shared/QuestionsList.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
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;
|
||||
155
src/components/ExamEditor/Exercises/Shared/SortableQuestion.tsx
Normal file
155
src/components/ExamEditor/Exercises/Shared/SortableQuestion.tsx
Normal file
@@ -0,0 +1,155 @@
|
||||
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;
|
||||
@@ -0,0 +1,21 @@
|
||||
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;
|
||||
@@ -0,0 +1,480 @@
|
||||
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;
|
||||
544
src/components/ExamEditor/Exercises/Speaking/Speaking1.tsx
Normal file
544
src/components/ExamEditor/Exercises/Speaking/Speaking1.tsx
Normal file
@@ -0,0 +1,544 @@
|
||||
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;
|
||||
462
src/components/ExamEditor/Exercises/Speaking/Speaking2.tsx
Normal file
462
src/components/ExamEditor/Exercises/Speaking/Speaking2.tsx
Normal file
@@ -0,0 +1,462 @@
|
||||
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;
|
||||
34
src/components/ExamEditor/Exercises/Speaking/index.tsx
Normal file
34
src/components/ExamEditor/Exercises/Speaking/index.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
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;
|
||||
230
src/components/ExamEditor/Exercises/TrueFalse/index.tsx
Normal file
230
src/components/ExamEditor/Exercises/TrueFalse/index.tsx
Normal file
@@ -0,0 +1,230 @@
|
||||
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;
|
||||
46
src/components/ExamEditor/Exercises/TrueFalse/validation.ts
Normal file
46
src/components/ExamEditor/Exercises/TrueFalse/validation.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
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;
|
||||
344
src/components/ExamEditor/Exercises/WriteBlanks/index.tsx
Normal file
344
src/components/ExamEditor/Exercises/WriteBlanks/index.tsx
Normal file
@@ -0,0 +1,344 @@
|
||||
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;
|
||||
27
src/components/ExamEditor/Exercises/WriteBlanks/parsing.ts
Normal file
27
src/components/ExamEditor/Exercises/WriteBlanks/parsing.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
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;
|
||||
};
|
||||
@@ -0,0 +1,160 @@
|
||||
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">
|
||||
|
||||
</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;
|
||||
@@ -0,0 +1,41 @@
|
||||
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;
|
||||
302
src/components/ExamEditor/Exercises/WriteBlanksForm/index.tsx
Normal file
302
src/components/ExamEditor/Exercises/WriteBlanksForm/index.tsx
Normal file
@@ -0,0 +1,302 @@
|
||||
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;
|
||||
@@ -0,0 +1,79 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
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
|
||||
}
|
||||
178
src/components/ExamEditor/Exercises/Writing/index.tsx
Normal file
178
src/components/ExamEditor/Exercises/Writing/index.tsx
Normal file
@@ -0,0 +1,178 @@
|
||||
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;
|
||||
84
src/components/ExamEditor/Hooks/useSectionEdit.tsx
Normal file
84
src/components/ExamEditor/Hooks/useSectionEdit.tsx
Normal file
@@ -0,0 +1,84 @@
|
||||
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;
|
||||
84
src/components/ExamEditor/Hooks/useSettingsState.tsx
Normal file
84
src/components/ExamEditor/Hooks/useSettingsState.tsx
Normal file
@@ -0,0 +1,84 @@
|
||||
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;
|
||||
59
src/components/ExamEditor/ImportExam/ImportOrFromScratch.tsx
Normal file
59
src/components/ExamEditor/ImportExam/ImportOrFromScratch.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
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;
|
||||
213
src/components/ExamEditor/ImportExam/Templates.tsx
Normal file
213
src/components/ExamEditor/ImportExam/Templates.tsx
Normal file
@@ -0,0 +1,213 @@
|
||||
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'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'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;
|
||||
301
src/components/ExamEditor/ImportExam/WordUploader.tsx
Normal file
301
src/components/ExamEditor/ImportExam/WordUploader.tsx
Normal file
@@ -0,0 +1,301 @@
|
||||
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;
|
||||
@@ -0,0 +1,63 @@
|
||||
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;
|
||||
@@ -0,0 +1,40 @@
|
||||
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;
|
||||
@@ -0,0 +1,194 @@
|
||||
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;
|
||||
@@ -0,0 +1,145 @@
|
||||
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;
|
||||
@@ -0,0 +1,124 @@
|
||||
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;
|
||||
@@ -0,0 +1,78 @@
|
||||
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;
|
||||
@@ -0,0 +1,329 @@
|
||||
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;
|
||||
@@ -0,0 +1,50 @@
|
||||
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;
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,16 @@
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
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[];
|
||||
};
|
||||
@@ -0,0 +1,58 @@
|
||||
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;
|
||||
102
src/components/ExamEditor/SectionRenderer/index.tsx
Executable file
102
src/components/ExamEditor/SectionRenderer/index.tsx
Executable file
@@ -0,0 +1,102 @@
|
||||
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;
|
||||
19
src/components/ExamEditor/SectionRenderer/types.ts
Normal file
19
src/components/ExamEditor/SectionRenderer/types.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
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>>;
|
||||
}
|
||||
137
src/components/ExamEditor/SettingsEditor/Shared/Generate.ts
Normal file
137
src/components/ExamEditor/SettingsEditor/Shared/Generate.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
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.");
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
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;
|
||||
@@ -0,0 +1,129 @@
|
||||
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;
|
||||
@@ -0,0 +1,35 @@
|
||||
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;
|
||||
@@ -0,0 +1,125 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
182
src/components/ExamEditor/SettingsEditor/index.tsx
Normal file
182
src/components/ExamEditor/SettingsEditor/index.tsx
Normal file
@@ -0,0 +1,182 @@
|
||||
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: () => 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`}>
|
||||
<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-row justify-between 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}
|
||||
disabled={!canSubmit}
|
||||
>
|
||||
<FaFileUpload className="mr-2" size={18} />
|
||||
Submit Module as Exam
|
||||
</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;
|
||||
405
src/components/ExamEditor/SettingsEditor/level.tsx
Normal file
405
src/components/ExamEditor/SettingsEditor/level.tsx
Normal file
@@ -0,0 +1,405 @@
|
||||
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";
|
||||
|
||||
|
||||
const LevelSettings: React.FC = () => {
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const {
|
||||
setExam,
|
||||
setExerciseIndex,
|
||||
setPartIndex,
|
||||
setQuestionIndex,
|
||||
setBgColor,
|
||||
} = usePersistentExamStore();
|
||||
|
||||
const { currentModule, title } = useExamEditorStore();
|
||||
const {
|
||||
focusedSection,
|
||||
difficulty,
|
||||
sections,
|
||||
minTimer,
|
||||
isPrivate,
|
||||
} = 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 () => {
|
||||
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),
|
||||
isDiagnostic: false,
|
||||
minTimer,
|
||||
module: "level",
|
||||
id: title,
|
||||
difficulty,
|
||||
private: isPrivate,
|
||||
};
|
||||
|
||||
const result = await axios.post('/api/exam/level', exam);
|
||||
playSound("sent");
|
||||
toast.success(`Submitted Exam ID: ${result.data.id}`);
|
||||
|
||||
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,
|
||||
private: isPrivate,
|
||||
} 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;
|
||||
@@ -0,0 +1,148 @@
|
||||
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;
|
||||
@@ -0,0 +1,330 @@
|
||||
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 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;
|
||||
227
src/components/ExamEditor/SettingsEditor/listening/index.tsx
Normal file
227
src/components/ExamEditor/SettingsEditor/listening/index.tsx
Normal file
@@ -0,0 +1,227 @@
|
||||
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";
|
||||
|
||||
const ListeningSettings: React.FC = () => {
|
||||
const router = useRouter();
|
||||
const { currentModule, title } = useExamEditorStore();
|
||||
const {
|
||||
focusedSection,
|
||||
difficulty,
|
||||
sections,
|
||||
minTimer,
|
||||
isPrivate,
|
||||
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 () => {
|
||||
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
|
||||
};
|
||||
}),
|
||||
isDiagnostic: false,
|
||||
minTimer,
|
||||
module: "listening",
|
||||
id: title,
|
||||
variant: sections.length === 4 ? "full" : "partial",
|
||||
difficulty,
|
||||
private: isPrivate,
|
||||
instructions: instructionsURL
|
||||
};
|
||||
|
||||
const result = await axios.post('/api/exam/listening', exam);
|
||||
playSound("sent");
|
||||
toast.success(`Submitted Exam ID: ${result.data.id}`);
|
||||
|
||||
} 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,
|
||||
private: isPrivate,
|
||||
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;
|
||||
107
src/components/ExamEditor/SettingsEditor/reading/components.tsx
Normal file
107
src/components/ExamEditor/SettingsEditor/reading/components.tsx
Normal file
@@ -0,0 +1,107 @@
|
||||
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 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;
|
||||
143
src/components/ExamEditor/SettingsEditor/reading/index.tsx
Normal file
143
src/components/ExamEditor/SettingsEditor/reading/index.tsx
Normal file
@@ -0,0 +1,143 @@
|
||||
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";
|
||||
|
||||
const ReadingSettings: React.FC = () => {
|
||||
const router = useRouter();
|
||||
|
||||
const {
|
||||
setExam,
|
||||
setExerciseIndex,
|
||||
setPartIndex,
|
||||
setQuestionIndex,
|
||||
setBgColor,
|
||||
} = usePersistentExamStore();
|
||||
|
||||
const { currentModule, title } = useExamEditorStore();
|
||||
const {
|
||||
focusedSection,
|
||||
difficulty,
|
||||
sections,
|
||||
minTimer,
|
||||
isPrivate,
|
||||
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 = () => {
|
||||
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
|
||||
};
|
||||
}),
|
||||
isDiagnostic: false,
|
||||
minTimer,
|
||||
module: "reading",
|
||||
id: title,
|
||||
variant: sections.length === 3 ? "full" : "partial",
|
||||
difficulty,
|
||||
private: isPrivate,
|
||||
type: type!
|
||||
};
|
||||
|
||||
axios.post(`/api/exam/reading`, exam)
|
||||
.then((result) => {
|
||||
playSound("sent");
|
||||
toast.success(`Submitted Exam ID: ${result.data.id}`);
|
||||
})
|
||||
.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,
|
||||
private: isPrivate,
|
||||
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;
|
||||
371
src/components/ExamEditor/SettingsEditor/speaking/components.tsx
Normal file
371
src/components/ExamEditor/SettingsEditor/speaking/components.tsx
Normal file
@@ -0,0 +1,371 @@
|
||||
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;
|
||||
261
src/components/ExamEditor/SettingsEditor/speaking/index.tsx
Normal file
261
src/components/ExamEditor/SettingsEditor/speaking/index.tsx
Normal file
@@ -0,0 +1,261 @@
|
||||
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";
|
||||
|
||||
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, isPrivate } = 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 () => {
|
||||
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,
|
||||
isDiagnostic: false,
|
||||
variant: undefined,
|
||||
difficulty,
|
||||
instructorGender: "varied",
|
||||
private: isPrivate,
|
||||
};
|
||||
|
||||
const result = await axios.post('/api/exam/speaking', exam);
|
||||
playSound("sent");
|
||||
toast.success(`Submitted Exam ID: ${result.data.id}`);
|
||||
|
||||
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,
|
||||
private: isPrivate,
|
||||
} 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;
|
||||
@@ -0,0 +1,62 @@
|
||||
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;
|
||||
304
src/components/ExamEditor/SettingsEditor/writing/components.tsx
Normal file
304
src/components/ExamEditor/SettingsEditor/writing/components.tsx
Normal file
@@ -0,0 +1,304 @@
|
||||
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;
|
||||
169
src/components/ExamEditor/SettingsEditor/writing/index.tsx
Normal file
169
src/components/ExamEditor/SettingsEditor/writing/index.tsx
Normal file
@@ -0,0 +1,169 @@
|
||||
import React, { useCallback, useEffect, useState } from "react";
|
||||
import SettingsEditor from "..";
|
||||
import Option from "@/interfaces/option";
|
||||
import useSettingsState from "../../Hooks/useSettingsState";
|
||||
import { WritingSectionSettings } from "@/stores/examEditor/types";
|
||||
import useExamEditorStore from "@/stores/examEditor";
|
||||
import { useRouter } from "next/router";
|
||||
import { usePersistentExamStore } from "@/stores/exam";
|
||||
import openDetachedTab from "@/utils/popout";
|
||||
import { WritingExam, WritingExercise } from "@/interfaces/exam";
|
||||
import axios from "axios";
|
||||
import { playSound } from "@/utils/sound";
|
||||
import { toast } from "react-toastify";
|
||||
import WritingComponents from "./components";
|
||||
|
||||
const WritingSettings: React.FC = () => {
|
||||
const router = useRouter();
|
||||
const [canPreviewOrSubmit, setCanPreviewOrSubmit] = useState(false);
|
||||
|
||||
const { currentModule, title } = useExamEditorStore();
|
||||
const {
|
||||
minTimer,
|
||||
difficulty,
|
||||
isPrivate,
|
||||
sections,
|
||||
focusedSection,
|
||||
type,
|
||||
academic_url
|
||||
} = useExamEditorStore((store) => store.modules["writing"]);
|
||||
|
||||
const states = sections.flatMap((s) => s.state) as WritingExercise[];
|
||||
|
||||
const {
|
||||
setExam,
|
||||
setExerciseIndex,
|
||||
} = usePersistentExamStore();
|
||||
|
||||
const { localSettings, updateLocalAndScheduleGlobal } = useSettingsState<WritingSectionSettings>(
|
||||
currentModule,
|
||||
focusedSection,
|
||||
);
|
||||
|
||||
const defaultPresets: Option[] = [
|
||||
{
|
||||
label: "Preset: Writing Task 1",
|
||||
value: "Welcome to {part} of the {label}. You will write a letter of at least 150 words in response to a given situation. Your letter may be personal, semi-formal, or formal. You have 20 minutes for this task."
|
||||
},
|
||||
{
|
||||
label: "Preset: Writing Task 2",
|
||||
value: "Welcome to {part} of the {label}. You will write a semi-formal/neutral essay of at least 250 words on a topic of general interest. Discuss the given opinion, argument, or problem. Organize your ideas clearly and support them with relevant examples. You have 40 minutes for this task."
|
||||
}
|
||||
];
|
||||
|
||||
useEffect(() => {
|
||||
setCanPreviewOrSubmit(states.some((s) => s.prompt !== ""))
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [states.some((s) => s.prompt !== "")])
|
||||
|
||||
const openTab = () => {
|
||||
setExam({
|
||||
exercises: sections.map((s, index) => {
|
||||
const exercise = s.state as WritingExercise;
|
||||
if (type === "academic" && index == 0 && academic_url) {
|
||||
exercise["attachment"] = {
|
||||
url: academic_url,
|
||||
description: "Visual Information"
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...exercise,
|
||||
intro: s.settings.currentIntro,
|
||||
category: s.settings.category
|
||||
};
|
||||
}),
|
||||
minTimer,
|
||||
module: "writing",
|
||||
id: title,
|
||||
isDiagnostic: false,
|
||||
variant: undefined,
|
||||
difficulty,
|
||||
private: isPrivate,
|
||||
type: type!
|
||||
});
|
||||
setExerciseIndex(0);
|
||||
openDetachedTab("popout?type=Exam&module=writing", router)
|
||||
}
|
||||
|
||||
const submitWriting = async () => {
|
||||
if (title === "") {
|
||||
toast.error("Enter a title for the exam!");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
let firebase_url: string | undefined = undefined;
|
||||
if (type === "academic" && academic_url) {
|
||||
const formData = new FormData();
|
||||
const fetchedBlob = await fetch(academic_url);
|
||||
const blob = await fetchedBlob.blob();
|
||||
formData.append('file', blob);
|
||||
|
||||
const response = await axios.post('/api/storage', formData, {
|
||||
params: {
|
||||
directory: 'writing_attachments'
|
||||
},
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data'
|
||||
}
|
||||
});
|
||||
firebase_url = response.data.urls[0];
|
||||
}
|
||||
|
||||
const exam: WritingExam = {
|
||||
exercises: sections.map((s, index) => {
|
||||
const exercise = s.state as WritingExercise;
|
||||
if (index == 0 && firebase_url) {
|
||||
exercise["attachment"] = {
|
||||
url: firebase_url,
|
||||
description: "Visual Information"
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...exercise,
|
||||
intro: localSettings.currentIntro,
|
||||
category: localSettings.category
|
||||
};
|
||||
}),
|
||||
minTimer,
|
||||
module: "writing",
|
||||
id: title,
|
||||
isDiagnostic: false,
|
||||
variant: undefined,
|
||||
difficulty,
|
||||
private: isPrivate,
|
||||
type: type!
|
||||
};
|
||||
|
||||
const result = await axios.post(`/api/exam/writing`, exam)
|
||||
playSound("sent");
|
||||
toast.success(`Submitted Exam ID: ${result.data.id}`);
|
||||
|
||||
} catch (error: any) {
|
||||
console.error('Error submitting exam:', error);
|
||||
toast.error(
|
||||
"Something went wrong while submitting, please try again later."
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingsEditor
|
||||
sectionLabel={`Task ${focusedSection}`}
|
||||
sectionId={focusedSection}
|
||||
module="writing"
|
||||
introPresets={[defaultPresets[focusedSection - 1]]}
|
||||
preview={openTab}
|
||||
canPreview={canPreviewOrSubmit}
|
||||
canSubmit={canPreviewOrSubmit}
|
||||
submitModule={submitWriting}
|
||||
>
|
||||
<WritingComponents
|
||||
{...{ localSettings, updateLocalAndScheduleGlobal }}
|
||||
/>
|
||||
</SettingsEditor>
|
||||
);
|
||||
};
|
||||
|
||||
export default WritingSettings;
|
||||
44
src/components/ExamEditor/Shared/AudioEdit.tsx
Normal file
44
src/components/ExamEditor/Shared/AudioEdit.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
import dynamic from 'next/dynamic';
|
||||
import React, { useState } from 'react';
|
||||
|
||||
interface AudioFile {
|
||||
id: string;
|
||||
file: File;
|
||||
}
|
||||
|
||||
|
||||
const Waveform = dynamic(() => import("@/components/Waveform"), {ssr: false});
|
||||
|
||||
const MultipleAudioUploader = () => {
|
||||
const [audioFiles, setAudioFiles] = useState<AudioFile[]>([]);
|
||||
|
||||
const handleFileUpload = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = Array.from(event.target.files || []);
|
||||
const newAudioFiles = files.map((file) => ({
|
||||
id: URL.createObjectURL(file),
|
||||
file,
|
||||
}));
|
||||
setAudioFiles((prev) => [...prev, ...newAudioFiles]);
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<input type="file" multiple accept="audio/*" onChange={handleFileUpload} />
|
||||
<div className="audio-list">
|
||||
{audioFiles.map((audio) => (
|
||||
<div key={audio.id} className="audio-item">
|
||||
<h3>{audio.file.name}</h3>
|
||||
<Waveform
|
||||
variant='edit'
|
||||
audio={audio.id}
|
||||
waveColor="#ddd"
|
||||
progressColor="#4a90e2"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default MultipleAudioUploader;
|
||||
118
src/components/ExamEditor/Shared/ConfirmDeleteBtn.tsx
Normal file
118
src/components/ExamEditor/Shared/ConfirmDeleteBtn.tsx
Normal file
@@ -0,0 +1,118 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { MdClose, MdDelete } from 'react-icons/md';
|
||||
import clsx from 'clsx';
|
||||
|
||||
interface ConfirmDeleteBtnProps {
|
||||
onDelete: () => void;
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
position?: 'top-right' | 'top-left' | 'bottom-right' | 'bottom-left';
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const ConfirmDeleteBtn: React.FC<ConfirmDeleteBtnProps> = ({
|
||||
onDelete,
|
||||
size = 'sm',
|
||||
position = 'top-right',
|
||||
className
|
||||
}) => {
|
||||
const [showConfirm, setShowConfirm] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = () => setShowConfirm(false);
|
||||
if (showConfirm) {
|
||||
document.addEventListener('click', handleClickOutside);
|
||||
return () => document.removeEventListener('click', handleClickOutside);
|
||||
}
|
||||
}, [showConfirm]);
|
||||
|
||||
const handleClick = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
setShowConfirm(true);
|
||||
};
|
||||
|
||||
const handleConfirm = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
onDelete();
|
||||
setShowConfirm(false);
|
||||
};
|
||||
|
||||
const handleCancel = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
setShowConfirm(false);
|
||||
};
|
||||
|
||||
const sizeClasses = {
|
||||
sm: 'p-0.5',
|
||||
md: 'p-1',
|
||||
lg: 'p-1.5'
|
||||
};
|
||||
|
||||
const iconSizes = {
|
||||
sm: 14,
|
||||
md: 16,
|
||||
lg: 18
|
||||
};
|
||||
|
||||
const positionClasses = {
|
||||
'top-right': '-right-1 -top-1',
|
||||
'top-left': '-left-1 -top-1',
|
||||
'bottom-right': '-right-1 -bottom-1',
|
||||
'bottom-left': '-left-1 -bottom-1'
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
"absolute",
|
||||
positionClasses[position],
|
||||
"z-10",
|
||||
className
|
||||
)}
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
{!showConfirm && (
|
||||
<button
|
||||
onClick={handleClick}
|
||||
className={clsx(
|
||||
sizeClasses[size],
|
||||
"rounded-full",
|
||||
"bg-white/90 shadow-sm",
|
||||
"text-gray-400 hover:text-red-600",
|
||||
"transition-all duration-150",
|
||||
"opacity-0 group-hover:opacity-100",
|
||||
"hover:scale-110"
|
||||
)}
|
||||
title="Remove"
|
||||
>
|
||||
<MdClose size={iconSizes[size]} />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{showConfirm && (
|
||||
<div className={clsx(
|
||||
"flex items-center gap-1",
|
||||
"bg-white rounded-lg shadow-lg",
|
||||
sizeClasses[size]
|
||||
)}>
|
||||
<button
|
||||
onClick={handleConfirm}
|
||||
className="p-1 rounded-md bg-red-50 text-red-600 hover:bg-red-100 transition-colors"
|
||||
title="Confirm remove"
|
||||
>
|
||||
<MdDelete size={iconSizes[size]} />
|
||||
</button>
|
||||
<button
|
||||
onClick={handleCancel}
|
||||
className="p-1 rounded-md bg-gray-50 text-gray-600 hover:bg-gray-100 transition-colors"
|
||||
title="Cancel"
|
||||
>
|
||||
<MdClose size={iconSizes[size]} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ConfirmDeleteBtn;
|
||||
|
||||
28
src/components/ExamEditor/Shared/ExerciseLabel.tsx
Normal file
28
src/components/ExamEditor/Shared/ExerciseLabel.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
interface Props {
|
||||
type: string;
|
||||
firstId: string;
|
||||
lastId: string;
|
||||
prompt: string;
|
||||
}
|
||||
|
||||
const previewLabel = (text: string) => {
|
||||
return <>
|
||||
"{text !== undefined ? text.replaceAll('\\n', ' ').split(' ').slice(0, 15).join(' ') : ""}..."
|
||||
</>
|
||||
}
|
||||
|
||||
const label = (type: string, firstId: string, lastId: string) => {
|
||||
return `${type} #${firstId} ${firstId === lastId ? '' : `- #${lastId}`}`;
|
||||
}
|
||||
|
||||
|
||||
const ExerciseLabel: React.FC<Props> = ({type, firstId, lastId, prompt}) => {
|
||||
return (
|
||||
<div className="flex w-full justify-between items-center mr-4">
|
||||
<span className="font-semibold">{label(type, firstId, lastId)}</span>
|
||||
<div className="text-sm font-light italic">{previewLabel(prompt)}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ExerciseLabel;
|
||||
161
src/components/ExamEditor/Shared/Header.tsx
Normal file
161
src/components/ExamEditor/Shared/Header.tsx
Normal file
@@ -0,0 +1,161 @@
|
||||
import { Module } from "@/interfaces";
|
||||
import clsx from "clsx";
|
||||
import { ReactNode, useEffect, useState } from "react";
|
||||
import { MdDelete, MdEdit, MdEditOff, MdRefresh, MdSave, MdSignalCellularAlt } from "react-icons/md";
|
||||
import { HiOutlineClipboardCheck, HiOutlineClipboardList } from "react-icons/hi";
|
||||
import { Difficulty } from "@/interfaces/exam";
|
||||
import Option from "@/interfaces/option";
|
||||
import ReactSelect, { components } from "react-select";
|
||||
|
||||
interface Props {
|
||||
title: string;
|
||||
description: string;
|
||||
editing: boolean;
|
||||
difficulty?: Difficulty;
|
||||
saveDifficulty?: (diff: Difficulty) => void;
|
||||
module?: Module;
|
||||
handleSave: () => void;
|
||||
handleDiscard: () => void;
|
||||
handleDelete?: () => void;
|
||||
handlePractice?: () => void;
|
||||
handleEdit?: () => void;
|
||||
isEvaluationEnabled?: boolean;
|
||||
children?: ReactNode;
|
||||
}
|
||||
|
||||
const Header: React.FC<Props> = ({
|
||||
title, description, editing, difficulty, saveDifficulty, isEvaluationEnabled, handleSave, handleDiscard, handleDelete, handleEdit, handlePractice, children, module
|
||||
}) => {
|
||||
const DIFFICULTIES: Difficulty[] = ["A1", "A2", "B1", "B2", "C1", "C2"];
|
||||
const difficultyOptions: Option[] = DIFFICULTIES.map(level => ({
|
||||
label: level,
|
||||
value: level
|
||||
}));
|
||||
|
||||
return (
|
||||
<div className="flex flex-col md:flex-row items-start md:items-center mb-6 text-sm">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-800">{title}</h1>
|
||||
<p className="text-gray-600 mt-1">{description}</p>
|
||||
</div>
|
||||
<div className="flex w-[50%] ml-auto justify-end flex-wrap gap-2">
|
||||
<div className="flex flex-wrap gap-2 justify-end">
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={!editing}
|
||||
className={
|
||||
clsx("px-3 py-2 rounded-lg flex items-center gap-1 transition-all duration-200 min-w-[90px] justify-center",
|
||||
editing ? 'bg-green-500 text-white hover:bg-green-600' : 'bg-gray-100 text-gray-400 cursor-not-allowed'
|
||||
)}
|
||||
>
|
||||
<MdSave size={18} />
|
||||
Save
|
||||
</button>
|
||||
<button
|
||||
onClick={handleDiscard}
|
||||
disabled={!editing}
|
||||
className={clsx(
|
||||
"px-3 py-2 rounded-lg flex items-center gap-1 transition-all duration-200 min-w-[90px] justify-center",
|
||||
editing ? 'bg-gray-500 text-white hover:bg-gray-600' : 'bg-gray-100 text-gray-400 cursor-not-allowed'
|
||||
)}
|
||||
>
|
||||
<MdRefresh size={18} />
|
||||
Discard
|
||||
</button>
|
||||
{handleEdit && (
|
||||
<button
|
||||
onClick={handleEdit}
|
||||
className={`px-3 py-2 bg-ielts-${module}/80 text-white hover:bg-ielts-${module} rounded-lg transition-all duration-200 flex items-center gap-1 min-w-[90px] justify-center`}
|
||||
>
|
||||
{editing ? <MdEditOff size={18} /> : <MdEdit size={18} />}
|
||||
Edit
|
||||
</button>
|
||||
)}
|
||||
{handlePractice &&
|
||||
<button
|
||||
onClick={handlePractice}
|
||||
className={clsx(
|
||||
"px-3 py-2 rounded-lg flex items-center gap-1 transition-all duration-200 min-w-[90px] justify-center",
|
||||
isEvaluationEnabled
|
||||
? 'bg-amber-500 text-white hover:bg-amber-600'
|
||||
: 'bg-gray-200 text-gray-600 hover:bg-gray-300'
|
||||
)}
|
||||
>
|
||||
{isEvaluationEnabled ? <HiOutlineClipboardCheck size={18} /> : <HiOutlineClipboardList size={18} />}
|
||||
{isEvaluationEnabled ? 'Graded' : 'Practice'}
|
||||
</button>
|
||||
}
|
||||
{handleDelete && (
|
||||
<button
|
||||
onClick={handleDelete}
|
||||
className="px-3 py-2 bg-white border border-red-500 text-red-500 hover:bg-red-50 rounded-lg transition-all duration-200 flex items-center gap-1 min-w-[90px] justify-center"
|
||||
>
|
||||
<MdDelete size={18} />
|
||||
Delete
|
||||
</button>
|
||||
)}
|
||||
{difficulty !== undefined && (
|
||||
<div className="w-[92px]">
|
||||
<ReactSelect
|
||||
options={difficultyOptions}
|
||||
value={difficultyOptions.find(opt => opt.value === difficulty)}
|
||||
onChange={(value) => saveDifficulty!(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-0.5">
|
||||
<MdSignalCellularAlt size={14} className="text-gray-600" />
|
||||
{children}
|
||||
</div>
|
||||
</components.ValueContainer>
|
||||
)
|
||||
}}
|
||||
styles={{
|
||||
menuPortal: (base) => ({ ...base, zIndex: 9999 }),
|
||||
control: (styles) => ({
|
||||
...styles,
|
||||
minHeight: '40px',
|
||||
border: '1px solid #e5e7eb',
|
||||
borderRadius: '0.5rem',
|
||||
boxShadow: 'none',
|
||||
backgroundColor: '#f3f4f6',
|
||||
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>
|
||||
)}
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Header;
|
||||
49
src/components/ExamEditor/Shared/Passage.tsx
Normal file
49
src/components/ExamEditor/Shared/Passage.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
import { useState } from "react";
|
||||
import Dropdown from "@/components/Dropdown";
|
||||
import clsx from "clsx";
|
||||
|
||||
interface Props {
|
||||
title: string;
|
||||
content: string;
|
||||
open?: boolean;
|
||||
setIsOpen?: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
}
|
||||
|
||||
const Passage: React.FC<Props> = ({ title, content, open: externalOpen, setIsOpen: externalSetIsOpen }) => {
|
||||
const [internalOpen, setInternalOpen] = useState(false);
|
||||
|
||||
const isOpen = externalOpen ?? internalOpen;
|
||||
const setIsOpen = externalSetIsOpen ?? setInternalOpen;
|
||||
|
||||
const paragraphs = content.split('\n\n');
|
||||
|
||||
return (
|
||||
<Dropdown
|
||||
title={title}
|
||||
className={clsx(
|
||||
"bg-white p-6 w-full items-center",
|
||||
isOpen ? "rounded-t-lg border-b border-gray-200" : "rounded-lg shadow-lg"
|
||||
)}
|
||||
titleClassName="text-2xl font-semibold text-gray-800"
|
||||
contentWrapperClassName="p-6 bg-white rounded-b-lg shadow-md transition-all duration-300 ease-in-out"
|
||||
open={isOpen}
|
||||
setIsOpen={setIsOpen}
|
||||
>
|
||||
<div>
|
||||
{paragraphs.map((paragraph, index) => (
|
||||
<p
|
||||
key={index}
|
||||
className={clsx(
|
||||
"text-justify",
|
||||
index < paragraphs.length - 1 ? 'mb-4' : 'mb-6'
|
||||
)}
|
||||
>
|
||||
{paragraph.trim()}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
</Dropdown>
|
||||
);
|
||||
}
|
||||
|
||||
export default Passage;
|
||||
84
src/components/ExamEditor/Shared/SectionDropdown.tsx
Normal file
84
src/components/ExamEditor/Shared/SectionDropdown.tsx
Normal file
@@ -0,0 +1,84 @@
|
||||
import React, { useState, ReactNode, useRef, useEffect } from 'react';
|
||||
import { animated, useSpring } from '@react-spring/web';
|
||||
|
||||
interface DropdownProps {
|
||||
title: ReactNode;
|
||||
open: boolean;
|
||||
toggleOpen: () => void;
|
||||
className: string;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
// Would be way too messy to add the center the title in the other Dropdown
|
||||
const SectionDropdown: React.FC<DropdownProps> = ({
|
||||
title,
|
||||
open,
|
||||
className,
|
||||
children,
|
||||
toggleOpen
|
||||
}) => {
|
||||
const contentRef = useRef<HTMLDivElement>(null);
|
||||
const [contentHeight, setContentHeight] = useState<number>(0);
|
||||
|
||||
useEffect(() => {
|
||||
let resizeObserver: ResizeObserver | null = null;
|
||||
|
||||
if (contentRef.current) {
|
||||
resizeObserver = new ResizeObserver(entries => {
|
||||
for (let entry of entries) {
|
||||
if (entry.borderBoxSize && entry.borderBoxSize.length > 0) {
|
||||
const height = entry.borderBoxSize[0].blockSize;
|
||||
setContentHeight(height + 0);
|
||||
} else {
|
||||
// Fallback for browsers that don't support borderBoxSize
|
||||
const height = entry.contentRect.height;
|
||||
setContentHeight(height + 0);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
resizeObserver.observe(contentRef.current);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (resizeObserver) {
|
||||
resizeObserver.disconnect();
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const springProps = useSpring({
|
||||
height: open ? contentHeight : 0,
|
||||
opacity: open ? 1 : 0,
|
||||
config: { tension: 300, friction: 30 }
|
||||
});
|
||||
|
||||
return (
|
||||
<div>
|
||||
<button
|
||||
onClick={() => toggleOpen()}
|
||||
className={className}
|
||||
>
|
||||
<div className='flex flex-row w-full items-center'>
|
||||
<p className='flex-grow'>{title}</p>
|
||||
<svg
|
||||
className={`w-4 h-4 transform transition-transform ${open ? 'rotate-180' : ''}`}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</div>
|
||||
</button>
|
||||
<animated.div style={springProps} className="overflow-hidden">
|
||||
<div ref={contentRef}>
|
||||
{children}
|
||||
</div>
|
||||
</animated.div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SectionDropdown;
|
||||
36
src/components/ExamEditor/Shared/SortableSection.tsx
Normal file
36
src/components/ExamEditor/Shared/SortableSection.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import { useSortable } from "@dnd-kit/sortable";
|
||||
import { FaArrowsAlt } from "react-icons/fa";
|
||||
import { CSS } from '@dnd-kit/utilities';
|
||||
|
||||
const SortableSection: React.FC<{
|
||||
id: string;
|
||||
children: React.ReactNode;
|
||||
}> = ({ id, children }) => {
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
setNodeRef,
|
||||
transform,
|
||||
transition,
|
||||
isDragging,
|
||||
} = useSortable({ id });
|
||||
|
||||
const style = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
opacity: isDragging ? 0.5 : 1,
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={setNodeRef} style={style} className="flex items-center mb-2 bg-white p-4 rounded shadow">
|
||||
<div {...attributes} {...listeners} className="cursor-move mr-2">
|
||||
<div className="cursor-move mr-3 p-2 rounded bg-gray-200 text-gray-500 hover:bg-gray-300">
|
||||
<FaArrowsAlt size={16} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-grow">{children}</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SortableSection;
|
||||
@@ -0,0 +1,297 @@
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import Button from "@/components/Low/Button";
|
||||
import Modal from "@/components/Modal";
|
||||
import useExamEditorStore from "@/stores/examEditor";
|
||||
import { PRESETS, isValidPresetID } from "./presets";
|
||||
import AudioPlayer from "@/components/Low/AudioPlayer";
|
||||
import Select from "@/components/Low/Select";
|
||||
import AutoExpandingTextArea from "@/components/Low/AutoExpandingTextarea";
|
||||
import { ListeningInstructionsState } from "@/stores/examEditor/types";
|
||||
import { debounce } from "lodash";
|
||||
import axios from "axios";
|
||||
import { toast } from "react-toastify";
|
||||
import { playSound } from "@/utils/sound";
|
||||
import { BsArrowRepeat } from "react-icons/bs";
|
||||
import { GiBrain } from "react-icons/gi";
|
||||
|
||||
const ListeningInstructions: React.FC = () => {
|
||||
const { dispatch } = useExamEditorStore();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const { instructionsState: globalInstructions, sections } = useExamEditorStore(s => s.modules["listening"]);
|
||||
const [localInstructions, setLocalInstructions] = useState<ListeningInstructionsState>(globalInstructions);
|
||||
const pendingUpdatesRef = useRef<Partial<ListeningInstructionsState>>({});
|
||||
|
||||
useEffect(() => {
|
||||
if (globalInstructions) {
|
||||
setLocalInstructions(globalInstructions);
|
||||
}
|
||||
}, [globalInstructions]);
|
||||
|
||||
const debouncedUpdateGlobal = useMemo(() => {
|
||||
return debounce(() => {
|
||||
if (Object.keys(pendingUpdatesRef.current).length > 0) {
|
||||
dispatch({
|
||||
type: "UPDATE_MODULE",
|
||||
payload: {
|
||||
module: "listening",
|
||||
updates: {
|
||||
instructionsState: {
|
||||
...globalInstructions,
|
||||
...pendingUpdatesRef.current
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
pendingUpdatesRef.current = {};
|
||||
}
|
||||
}, 1000);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [dispatch, globalInstructions]);
|
||||
|
||||
const updateInstructionsAndSchedule = useCallback((
|
||||
updates: Partial<ListeningInstructionsState> | ((prev: ListeningInstructionsState) => Partial<ListeningInstructionsState>),
|
||||
schedule: boolean = true
|
||||
) => {
|
||||
const newUpdates = typeof updates === 'function' ? updates(localInstructions) : updates;
|
||||
|
||||
setLocalInstructions(prev => ({
|
||||
...prev,
|
||||
...newUpdates
|
||||
}));
|
||||
|
||||
if (schedule) {
|
||||
pendingUpdatesRef.current = {
|
||||
...pendingUpdatesRef.current,
|
||||
...newUpdates
|
||||
};
|
||||
debouncedUpdateGlobal();
|
||||
} else {
|
||||
dispatch({
|
||||
type: "UPDATE_MODULE",
|
||||
payload: {
|
||||
module: "listening",
|
||||
updates: {
|
||||
instructionsState: {
|
||||
...globalInstructions,
|
||||
...newUpdates
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [dispatch, debouncedUpdateGlobal]);
|
||||
|
||||
const setIsOpen = useCallback((isOpen: boolean) => {
|
||||
updateInstructionsAndSchedule({ isInstructionsOpen: isOpen }, false);
|
||||
}, [updateInstructionsAndSchedule]);
|
||||
|
||||
const onOptionChange = useCallback((option: { value: string, label: string }) => {
|
||||
const sectionIds = sections.map(s => s.sectionId);
|
||||
const presetID = [...sectionIds].sort((a, b) => a - b).join('_');
|
||||
const preset = isValidPresetID(presetID) ? PRESETS[presetID] : null;
|
||||
|
||||
updateInstructionsAndSchedule(prev => {
|
||||
const updates: Partial<ListeningInstructionsState> = {
|
||||
chosenOption: option
|
||||
};
|
||||
|
||||
if (option.value === "Automatic" && preset) {
|
||||
updates.currentInstructions = preset.text;
|
||||
updates.currentInstructionsURL = preset.url;
|
||||
} else if (option.value === "Custom") {
|
||||
updates.currentInstructions = prev.customInstructions || "";
|
||||
updates.currentInstructionsURL = "";
|
||||
}
|
||||
|
||||
return updates;
|
||||
}, false);
|
||||
}, [sections, updateInstructionsAndSchedule]);
|
||||
|
||||
const onCustomInstructionChange = useCallback((text: string) => {
|
||||
updateInstructionsAndSchedule({
|
||||
chosenOption: { value: 'Custom', label: 'Custom' },
|
||||
customInstructions: text,
|
||||
currentInstructions: text
|
||||
});
|
||||
}, [updateInstructionsAndSchedule]);
|
||||
|
||||
useEffect(() => {
|
||||
const sectionIds = sections.map(s => s.sectionId);
|
||||
const presetID = [...sectionIds].sort((a, b) => a - b).join('_');
|
||||
|
||||
if (isValidPresetID(presetID)) {
|
||||
const preset = PRESETS[presetID];
|
||||
updateInstructionsAndSchedule(prev => {
|
||||
const updates: Partial<ListeningInstructionsState> = {
|
||||
presetInstructions: preset.text,
|
||||
presetInstructionsURL: preset.url,
|
||||
};
|
||||
|
||||
if (prev.chosenOption?.value === "Automatic") {
|
||||
updates.currentInstructions = preset.text;
|
||||
updates.currentInstructionsURL = preset.url;
|
||||
}
|
||||
|
||||
return updates;
|
||||
}, false);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [sections.length]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (Object.keys(pendingUpdatesRef.current).length > 0) {
|
||||
dispatch({
|
||||
type: "UPDATE_MODULE",
|
||||
payload: {
|
||||
module: "listening",
|
||||
updates: {
|
||||
instructionsState: {
|
||||
...globalInstructions,
|
||||
...pendingUpdatesRef.current
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [dispatch]);
|
||||
|
||||
const options = [
|
||||
{ value: 'Automatic', label: 'Automatic' },
|
||||
{ value: 'Custom', label: 'Custom' }
|
||||
];
|
||||
|
||||
const generateInstructionsMP3 = useCallback(async () => {
|
||||
if (!localInstructions.currentInstructions) {
|
||||
toast.error('Please enter instructions text first');
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const response = await axios.post(
|
||||
'/api/exam/media/instructions',
|
||||
{
|
||||
text: localInstructions.currentInstructions
|
||||
},
|
||||
{
|
||||
responseType: 'arraybuffer',
|
||||
headers: {
|
||||
'Accept': 'audio/mpeg'
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
if (localInstructions.currentInstructionsURL?.startsWith('blob:')) {
|
||||
URL.revokeObjectURL(localInstructions.currentInstructionsURL);
|
||||
}
|
||||
|
||||
const blob = new Blob([response.data], { type: 'audio/mpeg' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
|
||||
updateInstructionsAndSchedule({
|
||||
customInstructionsURL: url,
|
||||
currentInstructionsURL: url
|
||||
}, false);
|
||||
|
||||
playSound("check");
|
||||
toast.success('Audio generated successfully!');
|
||||
} catch (error: any) {
|
||||
toast.error('Failed to generate audio');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [localInstructions.currentInstructions, localInstructions.currentInstructionsURL, updateInstructionsAndSchedule]);
|
||||
|
||||
|
||||
return (
|
||||
<>
|
||||
<Modal
|
||||
isOpen={localInstructions.isInstructionsOpen}
|
||||
onClose={() => setIsOpen(false)}
|
||||
>
|
||||
<div className="flex flex-col gap-6 p-6 w-full">
|
||||
<div className="flex flex-col gap-2">
|
||||
<h2 className="text-xl font-semibold text-gray-800">Listening Instructions</h2>
|
||||
<p className="text-sm text-gray-500">Choose instruction type or customize your own</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-sm font-medium text-gray-700">Instruction Type</label>
|
||||
<Select
|
||||
options={options}
|
||||
onChange={(o) => onOptionChange({ value: o!.value || "", label: o!.label })}
|
||||
value={localInstructions.chosenOption}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex flex-col flex-grow gap-1.5">
|
||||
<label className="text-sm font-medium text-gray-700">Instructions Text</label>
|
||||
<div className="flex flex-row gap-2">
|
||||
<div className="flex flex-grow bg-gray-50 rounded-lg p-4 border border-gray-200 items-center">
|
||||
<AutoExpandingTextArea
|
||||
value={localInstructions.currentInstructions || ''}
|
||||
onChange={onCustomInstructionChange}
|
||||
className="bg-transparent resize-none w-full focus:outline-none"
|
||||
placeholder="Enter custom instructions here..."
|
||||
/>
|
||||
</div>
|
||||
{localInstructions.chosenOption?.value === 'Custom' && (
|
||||
<div className="flex items-center">
|
||||
<Button
|
||||
onClick={generateInstructionsMP3}
|
||||
disabled={loading}
|
||||
customColor="bg-ielts-listening/70 hover:bg-ielts-listening border-ielts-listening"
|
||||
className="text-white rounded-md"
|
||||
>
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center">
|
||||
<BsArrowRepeat className="text-white animate-spin" size={25} />
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-row items-center">
|
||||
<GiBrain className="mr-2" size={24} />
|
||||
<span>Generate</span>
|
||||
</div>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{(localInstructions.chosenOption?.value === 'Automatic' ||
|
||||
(localInstructions.chosenOption?.value === 'Custom' && localInstructions.currentInstructionsURL.startsWith("blob:")) && localInstructions.currentInstructionsURL !== '') && (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-sm font-medium text-gray-700">Audio Preview</label>
|
||||
<div className="bg-gray-50 rounded-lg p-4 border border-gray-200">
|
||||
<AudioPlayer
|
||||
src={localInstructions.currentInstructionsURL ?? ''}
|
||||
color="listening"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
<Button
|
||||
onClick={() => setIsOpen(true)}
|
||||
customColor="bg-ielts-listening/70 hover:bg-ielts-listening border-ielts-listening"
|
||||
className="text-white self-end"
|
||||
>
|
||||
Audio Instructions
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default ListeningInstructions;
|
||||
@@ -0,0 +1,74 @@
|
||||
const PRESETS = {
|
||||
"1": {
|
||||
url: "https://firebasestorage.googleapis.com/v0/b/storied-phalanx-349916.appspot.com/o/listening_instructions%2FpresetInstructions_1.mp3?alt=media&token=abf0dc1a-6d24-4d33-be0e-7e15f4e4bec2",
|
||||
text: "You will hear one recording and you will have to answer questions on what you hear. There will be time for you to read the instructions and questions and you will have a chance to check your work. The recording can be played three times. The recording consists of a conversation between two people in an everyday social context. Pay close attention to the audio recording and answer the questions accordingly.",
|
||||
},
|
||||
"2": {
|
||||
url: "https://firebasestorage.googleapis.com/v0/b/storied-phalanx-349916.appspot.com/o/listening_instructions%2FpresetInstructions_2.mp3?alt=media&token=a635f234-e470-4980-9690-e81544bbbe42",
|
||||
text: "You will hear one recording and you will have to answer questions on what you hear. There will be time for you to read the instructions and questions and you will have a chance to check your work. The recording can be played three times. The recording consists of a monologue set in a social context. Pay close attention to the audio recording and answer the questions accordingly.",
|
||||
},
|
||||
"3": {
|
||||
url: "https://firebasestorage.googleapis.com/v0/b/storied-phalanx-349916.appspot.com/o/listening_instructions%2FpresetInstructions_3.mp3?alt=media&token=9659155d-0167-4288-9ba7-4135e135151d",
|
||||
text: "You will hear one recording and you will have to answer questions on what you hear. There will be time for you to read the instructions and questions and you will have a chance to check your work. The recording can be played three times. The recording consists of a conversation between up to four individuals in an educational context. Pay close attention to the audio recording and answer the questions accordingly."
|
||||
},
|
||||
"4": {
|
||||
url: "https://firebasestorage.googleapis.com/v0/b/storied-phalanx-349916.appspot.com/o/listening_instructions%2FpresetInstructions_4.mp3?alt=media&token=ed50aae9-2bd7-4d09-a5c9-81cb55ec29fb",
|
||||
text: "You will hear one recording and you will have to answer questions on what you hear. There will be time for you to read the instructions and questions and you will have a chance to check your work. The recording can be played three times. The recording consists of a monologue about an academic subject. Pay close attention to the audio recording and answer the questions accordingly."
|
||||
},
|
||||
"1_2": {
|
||||
url: "https://firebasestorage.googleapis.com/v0/b/storied-phalanx-349916.appspot.com/o/listening_instructions%2FpresetInstructions_1_2.mp3?alt=media&token=16b1b6a8-6664-40fa-bb10-f8c89798d43d",
|
||||
text: "You will hear two recordings and you will have to answer questions on what you hear. There will be time for you to read the instructions and questions and you will have a chance to check your work. The recordings can be played three times. The module is in 2 parts. In the first part you will hear a conversation between two people in an everyday social context. In the second part you will hear a monologue set in a social context. Pay close attention to the audio recordings and answer the questions accordingly."
|
||||
},
|
||||
"1_3": {
|
||||
url: "https://firebasestorage.googleapis.com/v0/b/storied-phalanx-349916.appspot.com/o/listening_instructions%2FpresetInstructions_1_3.mp3?alt=media&token=3c3264b9-d277-4e43-91f9-6fa77cfd701e",
|
||||
text: "You will hear two recordings and you will have to answer questions on what you hear. There will be time for you to read the instructions and questions and you will have a chance to check your work. The recordings can be played three times. The module is in 2 parts. In the first part you will hear a conversation between two people in an everyday social context. In the second part you will hear a conversation between up to four individuals in an educational context. Pay close attention to the audio recordings and answer the questions accordingly.",
|
||||
},
|
||||
"1_4": {
|
||||
url: "https://firebasestorage.googleapis.com/v0/b/storied-phalanx-349916.appspot.com/o/listening_instructions%2FpresetInstructions_1_4.mp3?alt=media&token=350511e6-7010-43f7-a258-662e91ff7399",
|
||||
text: "You will hear two recordings and you will have to answer questions on what you hear. There will be time for you to read the instructions and questions and you will have a chance to check your work. The recordings can be played three times. The module is in 2 parts. In the first part you will hear a conversation between two people in an everyday social context. In the second part you will hear a monologue about an academic subject. Pay close attention to the audio recordings and answer the questions accordingly."
|
||||
},
|
||||
"2_3": {
|
||||
url: "https://firebasestorage.googleapis.com/v0/b/storied-phalanx-349916.appspot.com/o/listening_instructions%2FpresetInstructions_2_3.mp3?alt=media&token=fd260687-35e9-4386-8843-b58c2146dd48",
|
||||
text: "You will hear two recordings and you will have to answer questions on what you hear. There will be time for you to read the instructions and questions and you will have a chance to check your work. The recordings can be played three times. The module is in 2 parts. In the first part you will hear a monologue set in a social context. In the second part you will hear a conversation between up to four individuals in an educational context. Pay close attention to the audio recordings and answer the questions accordingly."
|
||||
},
|
||||
"2_4": {
|
||||
url: "https://firebasestorage.googleapis.com/v0/b/storied-phalanx-349916.appspot.com/o/listening_instructions%2FpresetInstructions_2_4.mp3?alt=media&token=0d85d499-5461-4d0f-8952-20aba319f783",
|
||||
text: "You will hear two recordings and you will have to answer questions on what you hear. There will be time for you to read the instructions and questions and you will have a chance to check your work. The recordings can be played three times. The module is in 2 parts. In the first part you will hear a monologue set in a social context. In the second part you will hear a monologue about an academic subject. Pay close attention to the audio recordings and answer the questions accordingly."
|
||||
},
|
||||
"3_4": {
|
||||
url: "https://firebasestorage.googleapis.com/v0/b/storied-phalanx-349916.appspot.com/o/listening_instructions%2FpresetInstructions_3_4.mp3?alt=media&token=79bdabde-3d05-4234-bec7-5a8b385c2479",
|
||||
text: "You will hear two recordings and you will have to answer questions on what you hear. There will be time for you to read the instructions and questions and you will have a chance to check your work. The recordings can be played three times. The module is in 2 parts. In the first part you will hear a conversation between up to four individuals in an educational context. In the second part you will hear a monologue about an academic subject. Pay close attention to the audio recordings and answer the questions accordingly."
|
||||
},
|
||||
"1_2_3": {
|
||||
url: "https://firebasestorage.googleapis.com/v0/b/storied-phalanx-349916.appspot.com/o/listening_instructions%2FpresetInstructions_1_2_3.mp3?alt=media&token=8bdb42dd-e3ed-446b-8760-281768c005e6",
|
||||
text: "You will hear a number of different recordings and you will have to answer questions on what you hear. There will be time for you to read the instructions and questions and you will have a chance to check your work. All the recordings can be played three times. The module is in 3 parts. In the first part you will hear a conversation between two people in an everyday social context. In the second part you will hear a monologue set in a social context. In the third part you will hear a conversation between up to four individuals in an educational context. Pay close attention to the audio recordings and answer the questions accordingly."
|
||||
},
|
||||
"1_2_4": {
|
||||
url: "https://firebasestorage.googleapis.com/v0/b/storied-phalanx-349916.appspot.com/o/listening_instructions%2FpresetInstructions_1_2_4.mp3?alt=media&token=5458c3c1-d398-453f-be97-ef1785f9d7e3",
|
||||
text: "You will hear a number of different recordings and you will have to answer questions on what you hear. There will be time for you to read the instructions and questions and you will have a chance to check your work. All the recordings can be played three times. The module is in 3 parts. In the first part you will hear a conversation between two people in an everyday social context. In the second part you will hear a monologue set in a social context. In the third part you will hear a monologue about an academic subject. Pay close attention to the audio recordings and answer the questions accordingly."
|
||||
},
|
||||
"1_3_4": {
|
||||
url: "https://firebasestorage.googleapis.com/v0/b/storied-phalanx-349916.appspot.com/o/listening_instructions%2FpresetInstructions_1_3_4.mp3?alt=media&token=0380653e-be5b-4c89-9814-a996ae77a74a",
|
||||
text: "You will hear a number of different recordings and you will have to answer questions on what you hear. There will be time for you to read the instructions and questions and you will have a chance to check your work. All the recordings can be played three times. The module is in 3 parts. In the first part you will hear a conversation between two people in an everyday social context. In the second part you will hear a conversation between up to four individuals in an educational context. In the third part you will hear a monologue about an academic subject. Pay close attention to the audio recordings and answer the questions accordingly."
|
||||
},
|
||||
"2_3_4": {
|
||||
url: "https://firebasestorage.googleapis.com/v0/b/storied-phalanx-349916.appspot.com/o/listening_instructions%2FpresetInstructions_2_3_4.mp3?alt=media&token=74bf11d6-e3d4-4711-bdc6-b0adbcaf11d4",
|
||||
text: "You will hear a number of different recordings and you will have to answer questions on what you hear. There will be time for you to read the instructions and questions and you will have a chance to check your work. All the recordings can be played three times. The module is in 3 parts. In the first part you will hear a monologue set in a social context. In the second part you will hear a conversation between up to four individuals in an educational context. In the third part you will hear a monologue about an academic subject. Pay close attention to the audio recordings and answer the questions accordingly."
|
||||
},
|
||||
"1_2_3_4": {
|
||||
url: "https://firebasestorage.googleapis.com/v0/b/storied-phalanx-349916.appspot.com/o/listening_instructions%2FpresetInstructions_1_2_3_4.mp3?alt=media&token=7a7ac516-221d-4e79-bd28-5d6bee9d79d8",
|
||||
text: "You will hear a number of different recordings and you will have to answer questions on what you hear. There will be time for you to read the instructions and questions and you will have a chance to check your work. All the recordings can be played three times. The module is in 4 parts. In the first part you will hear a conversation between two people in an everyday social context. In the second part you will hear a monologue set in a social context. In the third part you will hear a conversation between up to four individuals in an educational context. In the fourth part you will hear a monologue about an academic subject. Pay close attention to the audio recordings and answer the questions accordingly."
|
||||
}
|
||||
}
|
||||
|
||||
type PresetID = keyof typeof PRESETS;
|
||||
|
||||
function isValidPresetID(id: string): id is PresetID {
|
||||
return id in PRESETS;
|
||||
}
|
||||
|
||||
|
||||
export {
|
||||
PRESETS,
|
||||
isValidPresetID
|
||||
};
|
||||
118
src/components/ExamEditor/Standalone/ResetModule.tsx
Normal file
118
src/components/ExamEditor/Standalone/ResetModule.tsx
Normal file
@@ -0,0 +1,118 @@
|
||||
import Button from "@/components/Low/Button";
|
||||
import { Module } from "@/interfaces";
|
||||
import useExamEditorStore from "@/stores/examEditor";
|
||||
import { Dialog, DialogPanel, DialogTitle, Transition, TransitionChild } from "@headlessui/react";
|
||||
import { capitalize } from "lodash";
|
||||
import React, { Fragment, useCallback, useEffect, useState } from "react";
|
||||
|
||||
interface Props {
|
||||
module: Module;
|
||||
isOpen: boolean;
|
||||
setIsOpen: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
setNumberOfLevelParts: React.Dispatch<React.SetStateAction<number>>;
|
||||
}
|
||||
|
||||
const ResetModule: React.FC<Props> = ({ module, isOpen, setIsOpen, setNumberOfLevelParts }) => {
|
||||
const [isClosing, setIsClosing] = useState(false);
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const { dispatch } = useExamEditorStore();
|
||||
|
||||
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(() => {
|
||||
if (isClosing) return;
|
||||
setIsClosing(true);
|
||||
setIsOpen(!isOpen);
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
setIsClosing(false);
|
||||
}, 300);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, [isClosing, setIsOpen, isOpen]);
|
||||
|
||||
if (!mounted && !isOpen) return null;
|
||||
|
||||
const handleResetModule = () => {
|
||||
dispatch({ type: 'RESET_MODULE', payload: { module } });
|
||||
setIsOpen(false);
|
||||
if (module === "level") {
|
||||
setNumberOfLevelParts(1);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Transition
|
||||
show={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-xl h-fit p-8 rounded-xl flex flex-col gap-4`}>
|
||||
<DialogTitle className="flex font-bold text-xl justify-center text-gray-700"><span>Reset {capitalize(module)} Module</span></DialogTitle>
|
||||
<div className="flex flex-col w-full mt-4 gap-6">
|
||||
<div className="bg-gray-50 rounded-lg p-4">
|
||||
<p className="text-gray-600">
|
||||
Do you want to reset the {module} module?
|
||||
</p>
|
||||
<br/>
|
||||
<p className="text-gray-600 font-bold">This will reset all the current data in the {module} module and cannot be undone.</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">
|
||||
Cancel
|
||||
</Button>
|
||||
|
||||
<Button color="purple" onClick={handleResetModule} variant="solid" className="self-end w-full">
|
||||
Reset
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogPanel>
|
||||
</div>
|
||||
</TransitionChild>
|
||||
</Dialog>
|
||||
</Transition>
|
||||
);
|
||||
}
|
||||
|
||||
export default ResetModule;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user