Navigation rework, added prompt edit to components that were missing
This commit is contained in:
22
package-lock.json
generated
22
package-lock.json
generated
@@ -105,6 +105,7 @@
|
|||||||
"@types/react-datepicker": "^4.15.1",
|
"@types/react-datepicker": "^4.15.1",
|
||||||
"@types/uuid": "^9.0.1",
|
"@types/uuid": "^9.0.1",
|
||||||
"@types/wavesurfer.js": "^6.0.6",
|
"@types/wavesurfer.js": "^6.0.6",
|
||||||
|
"@welldone-software/why-did-you-render": "^8.0.3",
|
||||||
"@wixc3/react-board": "^2.2.0",
|
"@wixc3/react-board": "^2.2.0",
|
||||||
"autoprefixer": "^10.4.13",
|
"autoprefixer": "^10.4.13",
|
||||||
"husky": "^8.0.3",
|
"husky": "^8.0.3",
|
||||||
@@ -3615,6 +3616,18 @@
|
|||||||
"react": ">= 16.8.0"
|
"react": ">= 16.8.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@welldone-software/why-did-you-render": {
|
||||||
|
"version": "8.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@welldone-software/why-did-you-render/-/why-did-you-render-8.0.3.tgz",
|
||||||
|
"integrity": "sha512-bb5bKPMStYnocyTBVBu4UTegZdBqzV1mPhxc0UIV/S43KFUSRflux9gvzJfu2aM4EWLJ3egTvdjOi+viK+LKGA==",
|
||||||
|
"dev": true,
|
||||||
|
"dependencies": {
|
||||||
|
"lodash": "^4"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^18"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@wixc3/board-core": {
|
"node_modules/@wixc3/board-core": {
|
||||||
"version": "2.2.0",
|
"version": "2.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/@wixc3/board-core/-/board-core-2.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/@wixc3/board-core/-/board-core-2.2.0.tgz",
|
||||||
@@ -14582,6 +14595,15 @@
|
|||||||
"@use-gesture/core": "10.3.1"
|
"@use-gesture/core": "10.3.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"@welldone-software/why-did-you-render": {
|
||||||
|
"version": "8.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@welldone-software/why-did-you-render/-/why-did-you-render-8.0.3.tgz",
|
||||||
|
"integrity": "sha512-bb5bKPMStYnocyTBVBu4UTegZdBqzV1mPhxc0UIV/S43KFUSRflux9gvzJfu2aM4EWLJ3egTvdjOi+viK+LKGA==",
|
||||||
|
"dev": true,
|
||||||
|
"requires": {
|
||||||
|
"lodash": "^4"
|
||||||
|
}
|
||||||
|
},
|
||||||
"@wixc3/board-core": {
|
"@wixc3/board-core": {
|
||||||
"version": "2.2.0",
|
"version": "2.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/@wixc3/board-core/-/board-core-2.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/@wixc3/board-core/-/board-core-2.2.0.tgz",
|
||||||
|
|||||||
@@ -107,6 +107,7 @@
|
|||||||
"@types/react-datepicker": "^4.15.1",
|
"@types/react-datepicker": "^4.15.1",
|
||||||
"@types/uuid": "^9.0.1",
|
"@types/uuid": "^9.0.1",
|
||||||
"@types/wavesurfer.js": "^6.0.6",
|
"@types/wavesurfer.js": "^6.0.6",
|
||||||
|
"@welldone-software/why-did-you-render": "^8.0.3",
|
||||||
"@wixc3/react-board": "^2.2.0",
|
"@wixc3/react-board": "^2.2.0",
|
||||||
"autoprefixer": "^10.4.13",
|
"autoprefixer": "^10.4.13",
|
||||||
"husky": "^8.0.3",
|
"husky": "^8.0.3",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import {infoButtonStyle} from "@/constants/buttonStyles";
|
import {infoButtonStyle} from "@/constants/buttonStyles";
|
||||||
import {Module} from "@/interfaces";
|
import {Module} from "@/interfaces";
|
||||||
import {User} from "@/interfaces/user";
|
import {User} from "@/interfaces/user";
|
||||||
import useExamStore from "@/stores/examStore";
|
import useExamStore from "@/stores/exam";
|
||||||
import {getExam, getExamById} from "@/utils/exams";
|
import {getExam, getExamById} from "@/utils/exams";
|
||||||
import {MODULE_ARRAY} from "@/utils/moduleUtils";
|
import {MODULE_ARRAY} from "@/utils/moduleUtils";
|
||||||
import {writingMarking} from "@/utils/score";
|
import {writingMarking} from "@/utils/score";
|
||||||
@@ -28,8 +28,7 @@ export default function Diagnostic({onFinish}: Props) {
|
|||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
const setExams = useExamStore((state) => state.setExams);
|
const dispatch = useExamStore((state) => state.dispatch);
|
||||||
const setSelectedModules = useExamStore((state) => state.setSelectedModules);
|
|
||||||
|
|
||||||
const isNextDisabled = () => {
|
const isNextDisabled = () => {
|
||||||
if (!focus) return true;
|
if (!focus) return true;
|
||||||
@@ -41,8 +40,7 @@ export default function Diagnostic({onFinish}: Props) {
|
|||||||
|
|
||||||
Promise.all(examPromises).then((exams) => {
|
Promise.all(examPromises).then((exams) => {
|
||||||
if (exams.every((x) => !!x)) {
|
if (exams.every((x) => !!x)) {
|
||||||
setExams(exams.map((x) => x!));
|
dispatch({type: 'INIT_EXAM', payload: {exams: exams.map((x) => x!), modules: exams.map((x) => x!.module)}})
|
||||||
setSelectedModules(exams.map((x) => x!.module));
|
|
||||||
router.push("/exam");
|
router.push("/exam");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { AlertItem } from "../../Shared/Alert";
|
|||||||
import validateBlanks from "../validateBlanks";
|
import validateBlanks from "../validateBlanks";
|
||||||
import { toast } from "react-toastify";
|
import { toast } from "react-toastify";
|
||||||
import setEditingAlert from "../../Shared/setEditingAlert";
|
import setEditingAlert from "../../Shared/setEditingAlert";
|
||||||
|
import PromptEdit from "../../Shared/PromptEdit";
|
||||||
|
|
||||||
interface Word {
|
interface Word {
|
||||||
letter: string;
|
letter: string;
|
||||||
@@ -38,6 +39,12 @@ const FillBlanksLetters: React.FC<{ exercise: FillBlanksExercise; sectionId: num
|
|||||||
|
|
||||||
const [editing, setEditing] = useState(false);
|
const [editing, setEditing] = useState(false);
|
||||||
|
|
||||||
|
const updateLocal = (exercise: FillBlanksExercise) => {
|
||||||
|
setLocal(exercise);
|
||||||
|
setEditingAlert(true, setAlerts);
|
||||||
|
setEditing(true);
|
||||||
|
};
|
||||||
|
|
||||||
const [blanksState, blanksDispatcher] = useReducer(blanksReducer, {
|
const [blanksState, blanksDispatcher] = useReducer(blanksReducer, {
|
||||||
text: exercise.text || "",
|
text: exercise.text || "",
|
||||||
blanks: [],
|
blanks: [],
|
||||||
@@ -266,6 +273,8 @@ const FillBlanksLetters: React.FC<{ exercise: FillBlanksExercise; sectionId: num
|
|||||||
setEditing={setEditing}
|
setEditing={setEditing}
|
||||||
onPractice={handlePractice}
|
onPractice={handlePractice}
|
||||||
isEvaluationEnabled={!local.isPractice}
|
isEvaluationEnabled={!local.isPractice}
|
||||||
|
prompt={local.prompt}
|
||||||
|
updatePrompt={(prompt: string) => updateLocal({...local, prompt})}
|
||||||
>
|
>
|
||||||
<>
|
<>
|
||||||
{!blanksState.textMode && <Card className="p-4">
|
{!blanksState.textMode && <Card className="p-4">
|
||||||
|
|||||||
@@ -36,6 +36,12 @@ const FillBlanksMC: React.FC<{ exercise: FillBlanksExercise; sectionId: number }
|
|||||||
const [isEditMode, setIsEditMode] = useState(false);
|
const [isEditMode, setIsEditMode] = useState(false);
|
||||||
const [editing, setEditing] = useState(false);
|
const [editing, setEditing] = useState(false);
|
||||||
|
|
||||||
|
const updateLocal = (exercise: FillBlanksExercise) => {
|
||||||
|
setLocal(exercise);
|
||||||
|
setEditingAlert(true, setAlerts);
|
||||||
|
setEditing(true);
|
||||||
|
};
|
||||||
|
|
||||||
const [blanksState, blanksDispatcher] = useReducer(blanksReducer, {
|
const [blanksState, blanksDispatcher] = useReducer(blanksReducer, {
|
||||||
text: exercise.text || "",
|
text: exercise.text || "",
|
||||||
blanks: [],
|
blanks: [],
|
||||||
@@ -268,6 +274,8 @@ const FillBlanksMC: React.FC<{ exercise: FillBlanksExercise; sectionId: number }
|
|||||||
setEditing={setEditing}
|
setEditing={setEditing}
|
||||||
onBlankRemove={handleBlankRemove}
|
onBlankRemove={handleBlankRemove}
|
||||||
isEvaluationEnabled={!local.isPractice}
|
isEvaluationEnabled={!local.isPractice}
|
||||||
|
prompt={local.prompt}
|
||||||
|
updatePrompt={(prompt: string) => updateLocal({...local, prompt})}
|
||||||
>
|
>
|
||||||
{!blanksState.textMode && selectedBlankId && (
|
{!blanksState.textMode && selectedBlankId && (
|
||||||
<Card className="p-4">
|
<Card className="p-4">
|
||||||
|
|||||||
@@ -24,6 +24,12 @@ const WriteBlanksFill: React.FC<{ exercise: WriteBlanksExercise; sectionId: numb
|
|||||||
const [selectedBlankId, setSelectedBlankId] = useState<string | null>(null);
|
const [selectedBlankId, setSelectedBlankId] = useState<string | null>(null);
|
||||||
const [editing, setEditing] = useState(false);
|
const [editing, setEditing] = useState(false);
|
||||||
|
|
||||||
|
const updateLocal = (exercise: WriteBlanksExercise) => {
|
||||||
|
setLocal(exercise);
|
||||||
|
setEditingAlert(true, setAlerts);
|
||||||
|
setEditing(true);
|
||||||
|
};
|
||||||
|
|
||||||
const [blanksState, blanksDispatcher] = useReducer(blanksReducer, {
|
const [blanksState, blanksDispatcher] = useReducer(blanksReducer, {
|
||||||
text: exercise.text || "",
|
text: exercise.text || "",
|
||||||
blanks: [],
|
blanks: [],
|
||||||
@@ -79,7 +85,7 @@ const WriteBlanksFill: React.FC<{ exercise: WriteBlanksExercise; sectionId: numb
|
|||||||
newState.exercises = newState.exercises.map((ex) =>
|
newState.exercises = newState.exercises.map((ex) =>
|
||||||
ex.id === exercise.id ? updatedExercise : ex
|
ex.id === exercise.id ? updatedExercise : ex
|
||||||
);
|
);
|
||||||
setLocal((prev) => ({...prev, isPractice: !local.isPractice}))
|
setLocal((prev) => ({ ...prev, isPractice: !local.isPractice }))
|
||||||
dispatch({ type: 'UPDATE_SECTION_STATE', payload: { sectionId, update: newState, module: currentModule } });
|
dispatch({ type: 'UPDATE_SECTION_STATE', payload: { sectionId, update: newState, module: currentModule } });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -175,6 +181,8 @@ const WriteBlanksFill: React.FC<{ exercise: WriteBlanksExercise; sectionId: numb
|
|||||||
onPractice={handlePractice}
|
onPractice={handlePractice}
|
||||||
setEditing={setEditing}
|
setEditing={setEditing}
|
||||||
isEvaluationEnabled={!local.isPractice}
|
isEvaluationEnabled={!local.isPractice}
|
||||||
|
prompt={local.prompt}
|
||||||
|
updatePrompt={(prompt: string) => updateLocal({ ...local, prompt })}
|
||||||
>
|
>
|
||||||
{!blanksState.textMode && (
|
{!blanksState.textMode && (
|
||||||
<Card>
|
<Card>
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import clsx from "clsx";
|
|||||||
import { Card, CardContent } from "@/components/ui/card";
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
import { Blank, DropZone } from "./DragNDrop";
|
import { Blank, DropZone } from "./DragNDrop";
|
||||||
import { getTextSegments, BlankState, BlanksState, BlanksAction, BlankToken } from "./BlanksReducer";
|
import { getTextSegments, BlankState, BlanksState, BlanksAction, BlankToken } from "./BlanksReducer";
|
||||||
|
import PromptEdit from "../Shared/PromptEdit";
|
||||||
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -30,6 +31,8 @@ interface Props {
|
|||||||
editing: boolean;
|
editing: boolean;
|
||||||
showBlankBank: boolean;
|
showBlankBank: boolean;
|
||||||
alerts: AlertItem[];
|
alerts: AlertItem[];
|
||||||
|
prompt: string;
|
||||||
|
updatePrompt: (prompt: string) => void;
|
||||||
setEditing: React.Dispatch<React.SetStateAction<boolean>>;
|
setEditing: React.Dispatch<React.SetStateAction<boolean>>;
|
||||||
blanksDispatcher: React.Dispatch<BlanksAction>
|
blanksDispatcher: React.Dispatch<BlanksAction>
|
||||||
onBlankSelect?: (blankId: number | null) => void;
|
onBlankSelect?: (blankId: number | null) => void;
|
||||||
@@ -60,7 +63,9 @@ const BlanksEditor: React.FC<Props> = ({
|
|||||||
onDelete,
|
onDelete,
|
||||||
onPractice,
|
onPractice,
|
||||||
isEvaluationEnabled,
|
isEvaluationEnabled,
|
||||||
setEditing
|
setEditing,
|
||||||
|
prompt,
|
||||||
|
updatePrompt
|
||||||
}) => {
|
}) => {
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -171,10 +176,11 @@ const BlanksEditor: React.FC<Props> = ({
|
|||||||
isEvaluationEnabled={isEvaluationEnabled}
|
isEvaluationEnabled={isEvaluationEnabled}
|
||||||
/>
|
/>
|
||||||
{alerts.length > 0 && <Alert alerts={alerts} />}
|
{alerts.length > 0 && <Alert alerts={alerts} />}
|
||||||
|
<PromptEdit value={prompt} onChange={(text: string) => updatePrompt(text)} />
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent className="p-4 text-white font-semibold flex gap-2">
|
<CardContent className="p-4 text-white font-semibold flex gap-2">
|
||||||
<button
|
<button
|
||||||
onClick={() => blanksDispatcher({ type: "ADD_BLANK" }) }
|
onClick={() => blanksDispatcher({ type: "ADD_BLANK" })}
|
||||||
className={`px-3 py-1.5 bg-ielts-${module} rounded-md hover:bg-ielts-${module}/50 transition-colors`}
|
className={`px-3 py-1.5 bg-ielts-${module} rounded-md hover:bg-ielts-${module}/50 transition-colors`}
|
||||||
>
|
>
|
||||||
Add Blank
|
Add Blank
|
||||||
@@ -203,7 +209,7 @@ const BlanksEditor: React.FC<Props> = ({
|
|||||||
{state.textMode ? (
|
{state.textMode ? (
|
||||||
<AutoExpandingTextArea
|
<AutoExpandingTextArea
|
||||||
value={state.text.replace(/{{(\d+)}}/g, "[$1]")}
|
value={state.text.replace(/{{(\d+)}}/g, "[$1]")}
|
||||||
onChange={(text) => { handleTextChange(text); if (!editing) setEditing(true) } }
|
onChange={(text) => { handleTextChange(text); if (!editing) setEditing(true) }}
|
||||||
className="w-full h-full min-h-[200px] p-2 bg-white border rounded-md"
|
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..."
|
placeholder="Enter text here. Use [1], [2], etc. for blanks..."
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import useExamEditorStore from "@/stores/examEditor";
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { MdAdd } from "react-icons/md";
|
import { MdAdd } from "react-icons/md";
|
||||||
import Alert, { AlertItem } from "../../Shared/Alert";
|
import Alert, { AlertItem } from "../../Shared/Alert";
|
||||||
|
import PromptEdit from "../../Shared/PromptEdit";
|
||||||
|
|
||||||
|
|
||||||
const UnderlineMultipleChoice: React.FC<{exercise: MultipleChoiceExercise, sectionId: number}> = ({
|
const UnderlineMultipleChoice: React.FC<{exercise: MultipleChoiceExercise, sectionId: number}> = ({
|
||||||
@@ -125,7 +126,7 @@ const UnderlineMultipleChoice: React.FC<{exercise: MultipleChoiceExercise, secti
|
|||||||
isEvaluationEnabled={!local.isPractice}
|
isEvaluationEnabled={!local.isPractice}
|
||||||
/>
|
/>
|
||||||
{alerts.length > 0 && <Alert className="mb-6" alerts={alerts} />}
|
{alerts.length > 0 && <Alert className="mb-6" alerts={alerts} />}
|
||||||
|
<PromptEdit value={local.prompt} onChange={(prompt: string) => updateLocal({...local, prompt})} />
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<QuestionsList
|
<QuestionsList
|
||||||
ids={local.questions.map(q => q.id)}
|
ids={local.questions.map(q => q.id)}
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import QuestionsList from '../../Shared/QuestionsList';
|
|||||||
import SortableQuestion from '../../Shared/SortableQuestion';
|
import SortableQuestion from '../../Shared/SortableQuestion';
|
||||||
import setEditingAlert from '../../Shared/setEditingAlert';
|
import setEditingAlert from '../../Shared/setEditingAlert';
|
||||||
import { handleMultipleChoiceReorder } from '@/stores/examEditor/reorder/local';
|
import { handleMultipleChoiceReorder } from '@/stores/examEditor/reorder/local';
|
||||||
|
import PromptEdit from '../../Shared/PromptEdit';
|
||||||
|
|
||||||
interface MultipleChoiceProps {
|
interface MultipleChoiceProps {
|
||||||
exercise: MultipleChoiceExercise;
|
exercise: MultipleChoiceExercise;
|
||||||
@@ -214,36 +215,7 @@ const MultipleChoice: React.FC<MultipleChoiceProps> = ({ exercise, sectionId, op
|
|||||||
isEvaluationEnabled={!local.isPractice}
|
isEvaluationEnabled={!local.isPractice}
|
||||||
/>
|
/>
|
||||||
{alerts.length > 0 && <Alert className="mb-6" alerts={alerts} />}
|
{alerts.length > 0 && <Alert className="mb-6" alerts={alerts} />}
|
||||||
<Card className="mb-6">
|
<PromptEdit value={local.prompt} onChange={(prompt: string) => updateLocal({...local, prompt})} />
|
||||||
<CardContent className="p-4">
|
|
||||||
<div className="flex justify-between items-start gap-4">
|
|
||||||
{editingPrompt ? (
|
|
||||||
<textarea
|
|
||||||
className="flex-1 p-3 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:outline-none min-h-[100px]"
|
|
||||||
value={local.prompt}
|
|
||||||
onChange={(e) => updateLocal({ ...local, prompt: e.target.value })}
|
|
||||||
onBlur={() => setEditingPrompt(false)}
|
|
||||||
autoFocus
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<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">{local.prompt}</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<button
|
|
||||||
onClick={() => setEditingPrompt(!editingPrompt)}
|
|
||||||
className="p-2 hover:bg-gray-100 rounded-lg transition-colors"
|
|
||||||
>
|
|
||||||
{editingPrompt ?
|
|
||||||
<MdEditOff size={20} className="text-gray-500" /> :
|
|
||||||
<MdEdit size={20} className="text-gray-500" />
|
|
||||||
}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<QuestionsList
|
<QuestionsList
|
||||||
ids={local.questions.map(q => q.id)}
|
ids={local.questions.map(q => q.id)}
|
||||||
|
|||||||
@@ -6,44 +6,70 @@ import { MdEdit, MdEditOff } from "react-icons/md";
|
|||||||
interface Props {
|
interface Props {
|
||||||
value: string;
|
value: string;
|
||||||
onChange: (text: string) => void;
|
onChange: (text: string) => void;
|
||||||
|
wrapperCard?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const PromptEdit: React.FC<Props> = ({ value, onChange, wrapperCard = true }) => {
|
||||||
const PromptEdit: React.FC<Props> = ({ value, onChange }) => {
|
|
||||||
const [editing, setEditing] = useState(false);
|
const [editing, setEditing] = useState(false);
|
||||||
|
|
||||||
return (
|
const renderTextWithLineBreaks = (text: string) => {
|
||||||
<>
|
const unescapedText = text.replace(/\\n/g, '\n');
|
||||||
<Card className="mb-6">
|
return unescapedText.split('\n').map((line, index, array) => (
|
||||||
<CardContent className="p-4">
|
<span key={index}>
|
||||||
<div className="flex justify-between items-start gap-4">
|
{line}
|
||||||
{editing ? (
|
{index < array.length - 1 && <br />}
|
||||||
<AutoExpandingTextArea
|
</span>
|
||||||
className="flex-1 p-3 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:outline-none min-h-[100px]"
|
));
|
||||||
value={value}
|
};
|
||||||
onChange={(text) => onChange(text)}
|
|
||||||
onBlur={()=> setEditing(false)}
|
const handleTextChange = (text: string) => {
|
||||||
/>
|
const escapedText = text.replace(/\n/g, '\\n');
|
||||||
) : (
|
onChange(escapedText);
|
||||||
<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">{value}</p>
|
const promptEditTsx = (
|
||||||
</div>
|
<div className="flex justify-between items-start gap-4">
|
||||||
)}
|
{editing ? (
|
||||||
<button
|
<AutoExpandingTextArea
|
||||||
onClick={() => setEditing(!editing)}
|
className="flex-1 p-3 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:outline-none min-h-[100px]"
|
||||||
className="p-2 hover:bg-gray-100 rounded-lg transition-colors"
|
value={value.replace(/\\n/g, '\n')}
|
||||||
>
|
onChange={handleTextChange}
|
||||||
{editing ?
|
onBlur={() => setEditing(false)}
|
||||||
<MdEditOff size={20} className="text-gray-500" /> :
|
/>
|
||||||
<MdEdit size={20} className="text-gray-500" />
|
) : (
|
||||||
}
|
<div className="flex-1">
|
||||||
</button>
|
<h3 className="font-medium text-gray-800 mb-2">
|
||||||
</div>
|
Question/Instructions displayed to the student:
|
||||||
</CardContent>
|
</h3>
|
||||||
</Card>
|
<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;
|
export default PromptEdit;
|
||||||
@@ -21,6 +21,7 @@ import { toast } from 'react-toastify';
|
|||||||
import { validateEmptySolutions, validateQuestionText, validateWordCount } from './validation';
|
import { validateEmptySolutions, validateQuestionText, validateWordCount } from './validation';
|
||||||
import { handleWriteBlanksReorder } from '@/stores/examEditor/reorder/local';
|
import { handleWriteBlanksReorder } from '@/stores/examEditor/reorder/local';
|
||||||
import { ParsedQuestion, parseText, reconstructText } from './parsing';
|
import { ParsedQuestion, parseText, reconstructText } from './parsing';
|
||||||
|
import PromptEdit from '../Shared/PromptEdit';
|
||||||
|
|
||||||
|
|
||||||
const WriteBlanks: React.FC<{ sectionId: number; exercise: WriteBlanksExercise; }> = ({ sectionId, exercise }) => {
|
const WriteBlanks: React.FC<{ sectionId: number; exercise: WriteBlanksExercise; }> = ({ sectionId, exercise }) => {
|
||||||
@@ -85,7 +86,7 @@ const WriteBlanks: React.FC<{ sectionId: number; exercise: WriteBlanksExercise;
|
|||||||
newState.exercises = newState.exercises.map((ex) =>
|
newState.exercises = newState.exercises.map((ex) =>
|
||||||
ex.id === exercise.id ? updatedExercise : ex
|
ex.id === exercise.id ? updatedExercise : ex
|
||||||
);
|
);
|
||||||
setLocal((prev) => ({...prev, isPractice: !local.isPractice}))
|
setLocal((prev) => ({ ...prev, isPractice: !local.isPractice }))
|
||||||
dispatch({ type: 'UPDATE_SECTION_STATE', payload: { sectionId, update: newState, module: currentModule } });
|
dispatch({ type: 'UPDATE_SECTION_STATE', payload: { sectionId, update: newState, module: currentModule } });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -246,30 +247,7 @@ const WriteBlanks: React.FC<{ sectionId: number; exercise: WriteBlanksExercise;
|
|||||||
{alerts.length > 0 && <Alert alerts={alerts} />}
|
{alerts.length > 0 && <Alert alerts={alerts} />}
|
||||||
<Card className="mb-6">
|
<Card className="mb-6">
|
||||||
<CardContent className="p-4 space-y-4">
|
<CardContent className="p-4 space-y-4">
|
||||||
<div className="flex justify-between items-start gap-4 mb-6">
|
<PromptEdit value={local.prompt} onChange={(prompt: string) => updateLocal({ ...local, prompt })} wrapperCard={false}/>
|
||||||
{editingPrompt ? (
|
|
||||||
<AutoExpandingTextArea
|
|
||||||
className="flex-1 p-3 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:outline-none min-h-[100px]"
|
|
||||||
value={local.prompt}
|
|
||||||
onChange={(text) => updateLocal({ ...local, prompt: text })}
|
|
||||||
onBlur={() => setEditingPrompt(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">{local.prompt}</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<button
|
|
||||||
onClick={() => setEditingPrompt(!editingPrompt)}
|
|
||||||
className="p-2 hover:bg-gray-100 rounded-lg transition-colors"
|
|
||||||
>
|
|
||||||
{editingPrompt ?
|
|
||||||
<MdEditOff size={20} className="text-gray-500" /> :
|
|
||||||
<MdEdit size={20} className="text-gray-500" />
|
|
||||||
}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-between items-start gap-4">
|
<div className="flex justify-between items-start gap-4">
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<label className="flex items-center gap-2">
|
<label className="flex items-center gap-2">
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import { ParsedQuestion, parseLine, reconstructLine } from "./parsing";
|
|||||||
import { validateQuestions, validateEmptySolutions, validateWordCount } from "./validation";
|
import { validateQuestions, validateEmptySolutions, validateWordCount } from "./validation";
|
||||||
import Header from "../../Shared/Header";
|
import Header from "../../Shared/Header";
|
||||||
import BlanksFormEditor from "./BlanksFormEditor";
|
import BlanksFormEditor from "./BlanksFormEditor";
|
||||||
|
import PromptEdit from "../Shared/PromptEdit";
|
||||||
|
|
||||||
|
|
||||||
const WriteBlanksForm: React.FC<{ sectionId: number; exercise: WriteBlanksExercise }> = ({ sectionId, exercise }) => {
|
const WriteBlanksForm: React.FC<{ sectionId: number; exercise: WriteBlanksExercise }> = ({ sectionId, exercise }) => {
|
||||||
@@ -220,49 +221,7 @@ const WriteBlanksForm: React.FC<{ sectionId: number; exercise: WriteBlanksExerci
|
|||||||
/>
|
/>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{alerts.length > 0 && <Alert alerts={alerts} />}
|
{alerts.length > 0 && <Alert alerts={alerts} />}
|
||||||
<Card className="mb-6">
|
<PromptEdit value={local.prompt} onChange={(prompt: string) => updateLocal({ ...local, prompt })}/>
|
||||||
<CardContent className="p-4 space-y-4">
|
|
||||||
<div className="flex justify-between items-start gap-4 mb-6">
|
|
||||||
{editingPrompt ? (
|
|
||||||
<AutoExpandingTextArea
|
|
||||||
className="flex-1 p-3 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:outline-none min-h-[100px]"
|
|
||||||
value={local.prompt}
|
|
||||||
onChange={(text) => updateLocal({ ...local, prompt: text })}
|
|
||||||
onBlur={() => setEditingPrompt(false)}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<div className="flex-1">
|
|
||||||
<h3 className="font-medium text-gray-800 mb-2">Question/Instructions:</h3>
|
|
||||||
<p className="text-gray-600">{local.prompt}</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<button
|
|
||||||
onClick={() => setEditingPrompt(!editingPrompt)}
|
|
||||||
className="p-2 hover:bg-gray-100 rounded-lg transition-colors"
|
|
||||||
>
|
|
||||||
{editingPrompt ?
|
|
||||||
<MdEditOff size={20} className="text-gray-500" /> :
|
|
||||||
<MdEdit size={20} className="text-gray-500" />
|
|
||||||
}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<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">
|
<div className="space-y-4">
|
||||||
<QuestionsList
|
<QuestionsList
|
||||||
ids={parsedQuestions.map(q => q.id)}
|
ids={parsedQuestions.map(q => q.id)}
|
||||||
|
|||||||
@@ -6,12 +6,12 @@ import clsx from "clsx";
|
|||||||
import ExercisePicker from "../ExercisePicker";
|
import ExercisePicker from "../ExercisePicker";
|
||||||
import useExamEditorStore from "@/stores/examEditor";
|
import useExamEditorStore from "@/stores/examEditor";
|
||||||
import useSettingsState from "../Hooks/useSettingsState";
|
import useSettingsState from "../Hooks/useSettingsState";
|
||||||
import { LevelSectionSettings, SectionSettings } from "@/stores/examEditor/types";
|
import { LevelSectionSettings } from "@/stores/examEditor/types";
|
||||||
import { toast } from "react-toastify";
|
import { toast } from "react-toastify";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import { playSound } from "@/utils/sound";
|
import { playSound } from "@/utils/sound";
|
||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
import { usePersistentExamStore } from "@/stores/examStore";
|
import { usePersistentExamStore } from "@/stores/exam";
|
||||||
import openDetachedTab from "@/utils/popout";
|
import openDetachedTab from "@/utils/popout";
|
||||||
import ListeningComponents from "./listening/components";
|
import ListeningComponents from "./listening/components";
|
||||||
import ReadingComponents from "./reading/components";
|
import ReadingComponents from "./reading/components";
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import Input from "@/components/Low/Input";
|
|||||||
import openDetachedTab from "@/utils/popout";
|
import openDetachedTab from "@/utils/popout";
|
||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import { usePersistentExamStore } from "@/stores/examStore";
|
import { usePersistentExamStore } from "@/stores/exam";
|
||||||
import { playSound } from "@/utils/sound";
|
import { playSound } from "@/utils/sound";
|
||||||
import { toast } from "react-toastify";
|
import { toast } from "react-toastify";
|
||||||
import ListeningComponents from "./components";
|
import ListeningComponents from "./components";
|
||||||
@@ -98,16 +98,16 @@ const ListeningSettings: React.FC = () => {
|
|||||||
const { urls } = response.data;
|
const { urls } = response.data;
|
||||||
|
|
||||||
const exam: ListeningExam = {
|
const exam: ListeningExam = {
|
||||||
parts: sections.map((s) => {
|
parts: sectionsWithAudio.map((s) => {
|
||||||
const exercise = s.state as ListeningPart;
|
const part = s.state as ListeningPart;
|
||||||
const index = Array.from(sectionMap.entries())
|
const index = Array.from(sectionMap.entries())
|
||||||
.findIndex(([id]) => id === s.sectionId);
|
.findIndex(([id]) => id === s.sectionId);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...exercise,
|
...part,
|
||||||
audio: exercise.audio ? {
|
audio: part.audio ? {
|
||||||
...exercise.audio,
|
...part.audio,
|
||||||
source: index !== -1 ? urls[index] : exercise.audio.source
|
source: index !== -1 ? urls[index] : part.audio.source
|
||||||
} : undefined,
|
} : undefined,
|
||||||
intro: s.settings.currentIntro,
|
intro: s.settings.currentIntro,
|
||||||
category: s.settings.category
|
category: s.settings.category
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { ReadingSectionSettings } from "@/stores/examEditor/types";
|
|||||||
import useExamEditorStore from "@/stores/examEditor";
|
import useExamEditorStore from "@/stores/examEditor";
|
||||||
import openDetachedTab from "@/utils/popout";
|
import openDetachedTab from "@/utils/popout";
|
||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
import { usePersistentExamStore } from "@/stores/examStore";
|
import { usePersistentExamStore } from "@/stores/exam";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import { playSound } from "@/utils/sound";
|
import { playSound } from "@/utils/sound";
|
||||||
import { toast } from "react-toastify";
|
import { toast } from "react-toastify";
|
||||||
@@ -98,9 +98,9 @@ const ReadingSettings: React.FC = () => {
|
|||||||
const preview = () => {
|
const preview = () => {
|
||||||
setExam({
|
setExam({
|
||||||
parts: sections.map((s) => {
|
parts: sections.map((s) => {
|
||||||
const exercise = s.state as ReadingPart;
|
const exercises = s.state as ReadingPart;
|
||||||
return {
|
return {
|
||||||
...exercise,
|
...exercises,
|
||||||
intro: s.settings.currentIntro,
|
intro: s.settings.currentIntro,
|
||||||
category: s.settings.category
|
category: s.settings.category
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import Option from "@/interfaces/option";
|
|||||||
import SettingsEditor from "..";
|
import SettingsEditor from "..";
|
||||||
import { InteractiveSpeakingExercise, SpeakingExam, SpeakingExercise } from "@/interfaces/exam";
|
import { InteractiveSpeakingExercise, SpeakingExam, SpeakingExercise } from "@/interfaces/exam";
|
||||||
import { toast } from "react-toastify";
|
import { toast } from "react-toastify";
|
||||||
import { usePersistentExamStore } from "@/stores/examStore";
|
import { usePersistentExamStore } from "@/stores/exam";
|
||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
import openDetachedTab from "@/utils/popout";
|
import openDetachedTab from "@/utils/popout";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
|
|||||||
@@ -1,15 +1,11 @@
|
|||||||
import React, { useCallback, useEffect, useState } from "react";
|
import React, { useCallback, useEffect, useState } from "react";
|
||||||
import SettingsEditor from "..";
|
import SettingsEditor from "..";
|
||||||
import Option from "@/interfaces/option";
|
import Option from "@/interfaces/option";
|
||||||
import Dropdown from "../Shared/SettingsDropdown";
|
|
||||||
import Input from "@/components/Low/Input";
|
|
||||||
import { generate } from "../Shared/Generate";
|
|
||||||
import useSettingsState from "../../Hooks/useSettingsState";
|
import useSettingsState from "../../Hooks/useSettingsState";
|
||||||
import GenerateBtn from "../Shared/GenerateBtn";
|
|
||||||
import { WritingSectionSettings } from "@/stores/examEditor/types";
|
import { WritingSectionSettings } from "@/stores/examEditor/types";
|
||||||
import useExamEditorStore from "@/stores/examEditor";
|
import useExamEditorStore from "@/stores/examEditor";
|
||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
import { usePersistentExamStore } from "@/stores/examStore";
|
import { usePersistentExamStore } from "@/stores/exam";
|
||||||
import openDetachedTab from "@/utils/popout";
|
import openDetachedTab from "@/utils/popout";
|
||||||
import { WritingExam, WritingExercise } from "@/interfaces/exam";
|
import { WritingExam, WritingExercise } from "@/interfaces/exam";
|
||||||
import { v4 } from "uuid";
|
import { v4 } from "uuid";
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ const MCDropdown: React.FC<MCDropdownProps> = ({
|
|||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`${className} inline-block`} style={{ width: `${width}px` }}>
|
<div key={`dropdown-${id}`} className={`${className} inline-block`} style={{ width: `${width}px` }}>
|
||||||
<button
|
<button
|
||||||
onClick={() => onToggle(id)}
|
onClick={() => onToggle(id)}
|
||||||
className={
|
className={
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
|
//import "@/utils/wdyr";
|
||||||
|
|
||||||
import { FillBlanksExercise, FillBlanksMCOption } from "@/interfaces/exam";
|
import { FillBlanksExercise, FillBlanksMCOption } from "@/interfaces/exam";
|
||||||
import useExamStore, { usePersistentExamStore } from "@/stores/examStore";
|
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import reactStringReplace from "react-string-replace";
|
import reactStringReplace from "react-string-replace";
|
||||||
import { CommonProps } from "..";
|
import { CommonProps } from "../types";
|
||||||
import Button from "../../Low/Button";
|
|
||||||
import { v4 } from "uuid";
|
import { v4 } from "uuid";
|
||||||
import MCDropdown from "./MCDropdown";
|
import MCDropdown from "./MCDropdown";
|
||||||
|
import useExamStore, { usePersistentExamStore } from "@/stores/exam";
|
||||||
|
|
||||||
const FillBlanks: React.FC<FillBlanksExercise & CommonProps> = ({
|
const FillBlanks: React.FC<FillBlanksExercise & CommonProps> = ({
|
||||||
id,
|
id,
|
||||||
@@ -18,43 +19,29 @@ const FillBlanks: React.FC<FillBlanksExercise & CommonProps> = ({
|
|||||||
words,
|
words,
|
||||||
userSolutions,
|
userSolutions,
|
||||||
variant,
|
variant,
|
||||||
|
registerSolution,
|
||||||
|
headerButtons,
|
||||||
|
footerButtons,
|
||||||
preview,
|
preview,
|
||||||
onNext,
|
|
||||||
onBack,
|
|
||||||
disableProgressButtons = false
|
|
||||||
}) => {
|
}) => {
|
||||||
|
|
||||||
const examState = useExamStore((state) => state);
|
const examState = useExamStore((state) => state);
|
||||||
const persistentExamState = usePersistentExamStore((state) => state);
|
const persistentExamState = usePersistentExamStore((state) => state);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
hasExamEnded,
|
|
||||||
exerciseIndex,
|
exerciseIndex,
|
||||||
partIndex,
|
partIndex,
|
||||||
questionIndex,
|
|
||||||
shuffles,
|
shuffles,
|
||||||
exam,
|
exam,
|
||||||
setCurrentSolution,
|
} = examState; !preview ? examState : persistentExamState;
|
||||||
} = !preview ? examState : persistentExamState;
|
|
||||||
|
|
||||||
const [answers, setAnswers] = useState<{ id: string; solution: string }[]>(userSolutions);
|
const [answers, setAnswers] = useState<{ id: string; solution: string }[]>(userSolutions);
|
||||||
const shuffleMaps = shuffles.find((x) => x.exerciseID == id)?.shuffles;
|
const shuffleMaps = shuffles.find((x) => x.exerciseID == id)?.shuffles;
|
||||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (disableProgressButtons) onNext({ exercise: id, solutions: answers, score: calculateScore(), type, isPractice });
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [answers, disableProgressButtons])
|
|
||||||
|
|
||||||
const excludeWordMCType = (x: any) => {
|
const excludeWordMCType = (x: any) => {
|
||||||
return typeof x === "string" ? x : (x as { letter: string; word: string });
|
return typeof x === "string" ? x : (x as { letter: string; word: string });
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (hasExamEnded) onNext({ exercise: id, solutions: answers, score: calculateScore(), type });
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [hasExamEnded]);
|
|
||||||
|
|
||||||
let correctWords: any;
|
let correctWords: any;
|
||||||
if (exam && (exam.module === "level" || exam.module === "reading") && exam.parts[partIndex].exercises[exerciseIndex].type === "fillBlanks") {
|
if (exam && (exam.module === "level" || exam.module === "reading") && exam.parts[partIndex].exercises[exerciseIndex].type === "fillBlanks") {
|
||||||
correctWords = (exam.parts[partIndex].exercises[exerciseIndex] as FillBlanksExercise).words;
|
correctWords = (exam.parts[partIndex].exercises[exerciseIndex] as FillBlanksExercise).words;
|
||||||
@@ -73,7 +60,7 @@ const FillBlanks: React.FC<FillBlanksExercise & CommonProps> = ({
|
|||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const calculateScore = () => {
|
const calculateScore = useCallback(() => {
|
||||||
const total = text.match(/({{\d+}})/g)?.length || 0;
|
const total = text.match(/({{\d+}})/g)?.length || 0;
|
||||||
const correct = answers!.filter((x) => {
|
const correct = answers!.filter((x) => {
|
||||||
const solution = solutions.find((y) => x.id.toString() === y.id.toString())?.solution;
|
const solution = solutions.find((y) => x.id.toString() === y.id.toString())?.solution;
|
||||||
@@ -100,7 +87,19 @@ const FillBlanks: React.FC<FillBlanksExercise & CommonProps> = ({
|
|||||||
}).length;
|
}).length;
|
||||||
const missing = total - answers!.filter((x) => solutions.find((y) => x.id.toString() === y.id.toString())).length;
|
const missing = total - answers!.filter((x) => solutions.find((y) => x.id.toString() === y.id.toString())).length;
|
||||||
return { total, correct, missing };
|
return { total, correct, missing };
|
||||||
};
|
}, [answers, correctWords, solutions, text]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
registerSolution(() => ({
|
||||||
|
exercise: id,
|
||||||
|
solutions: answers,
|
||||||
|
score: calculateScore(),
|
||||||
|
type,
|
||||||
|
shuffleMaps,
|
||||||
|
isPractice
|
||||||
|
}));
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [id, answers, type, isPractice, shuffleMaps, calculateScore]);
|
||||||
|
|
||||||
const [openDropdownId, setOpenDropdownId] = useState<string | null>(null);
|
const [openDropdownId, setOpenDropdownId] = useState<string | null>(null);
|
||||||
|
|
||||||
@@ -137,6 +136,7 @@ const FillBlanks: React.FC<FillBlanksExercise & CommonProps> = ({
|
|||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<input
|
<input
|
||||||
|
key={`input-${id}`}
|
||||||
className={styles}
|
className={styles}
|
||||||
onChange={(e) => setAnswers((prev) => [...prev.filter((x) => x.id !== id), { id, solution: e.target.value }])}
|
onChange={(e) => setAnswers((prev) => [...prev.filter((x) => x.id !== id), { id, solution: e.target.value }])}
|
||||||
value={userSolution?.solution}
|
value={userSolution?.solution}
|
||||||
@@ -151,10 +151,10 @@ const FillBlanks: React.FC<FillBlanksExercise & CommonProps> = ({
|
|||||||
|
|
||||||
const memoizedLines = useMemo(() => {
|
const memoizedLines = useMemo(() => {
|
||||||
return text.split("\\n").map((line, index) => (
|
return text.split("\\n").map((line, index) => (
|
||||||
<p key={index} className={clsx(variant === "mc" && "whitespace-pre-wrap")}>
|
<div key={index} className={clsx(variant === "mc" && "whitespace-pre-wrap")}>
|
||||||
{renderLines(line)}
|
{renderLines(line)}
|
||||||
<br />
|
<br />
|
||||||
</p>
|
</div>
|
||||||
));
|
));
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [text, variant, renderLines]);
|
}, [text, variant, renderLines]);
|
||||||
@@ -163,40 +163,10 @@ const FillBlanks: React.FC<FillBlanksExercise & CommonProps> = ({
|
|||||||
setAnswers((prev) => [...prev.filter((x) => x.id !== questionID), { id: questionID, solution: value }]);
|
setAnswers((prev) => [...prev.filter((x) => x.id !== questionID), { id: questionID, solution: value }]);
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (variant === "mc") {
|
|
||||||
setCurrentSolution({ exercise: id, solutions: answers, score: calculateScore(), type, shuffleMaps: shuffleMaps });
|
|
||||||
}
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [answers]);
|
|
||||||
|
|
||||||
const progressButtons = () => (
|
|
||||||
<div className="flex justify-between w-full gap-8">
|
|
||||||
<Button
|
|
||||||
color="purple"
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => onBack({ exercise: id, solutions: answers, score: calculateScore(), type, shuffleMaps: shuffleMaps, isPractice })}
|
|
||||||
className="max-w-[200px] w-full"
|
|
||||||
disabled={exam && exam.module === "level" && partIndex === 0 && questionIndex === 0}>
|
|
||||||
Previous Page
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
color="purple"
|
|
||||||
onClick={() => {
|
|
||||||
onNext({ exercise: id, solutions: answers, score: calculateScore(), type, shuffleMaps: shuffleMaps, isPractice });
|
|
||||||
}}
|
|
||||||
className="max-w-[200px] self-end w-full">
|
|
||||||
Next Page
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
{!disableProgressButtons && progressButtons()}
|
{headerButtons}
|
||||||
|
<div className={clsx("flex flex-col gap-4 mt-4 h-full w-full", (!headerButtons && !footerButtons) && "mb-20")}>
|
||||||
<div className={clsx("flex flex-col gap-4 mt-4 h-full w-full", !disableProgressButtons && "mb-20")}>
|
|
||||||
{variant !== "mc" && (
|
{variant !== "mc" && (
|
||||||
<span className="text-sm w-full leading-6">
|
<span className="text-sm w-full leading-6">
|
||||||
{prompt.split("\\n").map((line, index) => (
|
{prompt.split("\\n").map((line, index) => (
|
||||||
@@ -234,11 +204,12 @@ const FillBlanks: React.FC<FillBlanksExercise & CommonProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{footerButtons}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!disableProgressButtons && progressButtons()}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
//FillBlanks.whyDidYouRender = true
|
||||||
|
|
||||||
export default FillBlanks;
|
export default FillBlanks;
|
||||||
|
|||||||
203
src/components/Exercises/InteractiveSpeaking/index.tsx
Normal file
203
src/components/Exercises/InteractiveSpeaking/index.tsx
Normal file
@@ -0,0 +1,203 @@
|
|||||||
|
import { InteractiveSpeakingExercise } from "@/interfaces/exam";
|
||||||
|
import { CommonProps } from "../types";
|
||||||
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
import { BsCheckCircleFill, BsMicFill, BsPauseCircle, BsPlayCircle, BsTrashFill } from "react-icons/bs";
|
||||||
|
import dynamic from "next/dynamic";
|
||||||
|
import useExamStore, { usePersistentExamStore } from "@/stores/exam";
|
||||||
|
import useAnswers, { Answer } from "./useAnswers";
|
||||||
|
|
||||||
|
const Waveform = dynamic(() => import("../../Waveform"), { ssr: false });
|
||||||
|
const ReactMediaRecorder = dynamic(() => import("react-media-recorder").then((mod) => mod.ReactMediaRecorder), {
|
||||||
|
ssr: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const InteractiveSpeaking: React.FC<InteractiveSpeakingExercise & CommonProps> = ({
|
||||||
|
id,
|
||||||
|
title,
|
||||||
|
first_title,
|
||||||
|
second_title,
|
||||||
|
prompts,
|
||||||
|
userSolutions,
|
||||||
|
isPractice = false,
|
||||||
|
registerSolution,
|
||||||
|
headerButtons,
|
||||||
|
footerButtons,
|
||||||
|
type,
|
||||||
|
preview,
|
||||||
|
}) => {
|
||||||
|
const [isRecording, setIsRecording] = useState(false);
|
||||||
|
const [recordingDuration, setRecordingDuration] = useState(0);
|
||||||
|
const timerRef = useRef<NodeJS.Timeout>();
|
||||||
|
|
||||||
|
const { answers, setAnswers, updateAnswers } = useAnswers(
|
||||||
|
id,
|
||||||
|
type,
|
||||||
|
isPractice,
|
||||||
|
prompts,
|
||||||
|
registerSolution,
|
||||||
|
preview
|
||||||
|
);
|
||||||
|
|
||||||
|
const examState = useExamStore((state) => state);
|
||||||
|
const persistentExamState = usePersistentExamStore((state) => state);
|
||||||
|
|
||||||
|
const { questionIndex } = !preview ? examState : persistentExamState;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isRecording) {
|
||||||
|
timerRef.current = setInterval(() => {
|
||||||
|
setRecordingDuration(prev => prev + 1);
|
||||||
|
}, 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (timerRef.current) {
|
||||||
|
clearInterval(timerRef.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [isRecording]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (userSolutions.length > 0 && answers.length === 0) {
|
||||||
|
setAnswers(userSolutions.flatMap(solution =>
|
||||||
|
solution.solution.map(s => ({
|
||||||
|
questionIndex: s.questionIndex,
|
||||||
|
prompt: s.question,
|
||||||
|
blob: s.answer
|
||||||
|
}))
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}, [userSolutions, answers.length, setAnswers]);
|
||||||
|
|
||||||
|
const resetRecording = useCallback(() => {
|
||||||
|
setRecordingDuration(0);
|
||||||
|
setIsRecording(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleStartRecording = useCallback((startRecording: () => void) => {
|
||||||
|
resetRecording();
|
||||||
|
startRecording();
|
||||||
|
setIsRecording(true);
|
||||||
|
}, [resetRecording]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-4 mt-4 w-full">
|
||||||
|
{headerButtons}
|
||||||
|
<div className="flex flex-col h-full w-full gap-9">
|
||||||
|
<div className="flex flex-col w-full gap-8 bg-mti-gray-smoke rounded-xl py-8 pb-12 px-16">
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<span className="font-semibold">
|
||||||
|
{!!first_title && !!second_title ? `${first_title} & ${second_title}` : title}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{prompts && prompts.length > 0 && (
|
||||||
|
<div className="flex flex-col gap-4 w-full items-center">
|
||||||
|
<video key={questionIndex} autoPlay controls className="max-w-3xl rounded-xl">
|
||||||
|
<source src={prompts[questionIndex].video_url} />
|
||||||
|
</video>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ReactMediaRecorder
|
||||||
|
audio
|
||||||
|
key={questionIndex}
|
||||||
|
onStop={updateAnswers}
|
||||||
|
render={({ status, startRecording, stopRecording, pauseRecording, resumeRecording, mediaBlobUrl }) => (
|
||||||
|
<div className="w-full p-4 px-8 bg-transparent border-2 border-mti-gray-platinum rounded-2xl flex-col gap-8 items-center">
|
||||||
|
<p className="text-base font-normal">Record your answer:</p>
|
||||||
|
<div className="flex gap-8 items-center justify-center py-8">
|
||||||
|
{status === "idle" && (
|
||||||
|
<>
|
||||||
|
<div className="w-full h-2 max-w-4xl bg-mti-gray-smoke rounded-full" />
|
||||||
|
<BsMicFill
|
||||||
|
onClick={() => handleStartRecording(startRecording)}
|
||||||
|
className="h-5 w-5 text-mti-gray-cool cursor-pointer"
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{status === "recording" && (
|
||||||
|
<>
|
||||||
|
<div className="flex gap-4 items-center">
|
||||||
|
<span className="text-xs w-9">
|
||||||
|
{`${Math.floor(recordingDuration / 60).toString().padStart(2, "0")}:${Math.floor(recordingDuration % 60).toString().padStart(2, "0")}`}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="w-full h-2 max-w-4xl bg-mti-gray-smoke rounded-full" />
|
||||||
|
<div className="flex gap-4 items-center">
|
||||||
|
<BsPauseCircle
|
||||||
|
onClick={() => {
|
||||||
|
setIsRecording(false);
|
||||||
|
pauseRecording();
|
||||||
|
}}
|
||||||
|
className="text-red-500 w-8 h-8 cursor-pointer"
|
||||||
|
/>
|
||||||
|
<BsCheckCircleFill
|
||||||
|
onClick={() => {
|
||||||
|
setIsRecording(false);
|
||||||
|
stopRecording();
|
||||||
|
}}
|
||||||
|
className="text-mti-purple-light w-8 h-8 cursor-pointer"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{status === "paused" && (
|
||||||
|
<>
|
||||||
|
<div className="flex gap-4 items-center">
|
||||||
|
<span className="text-xs w-9">
|
||||||
|
{`${Math.floor(recordingDuration / 60).toString().padStart(2, "0")}:${Math.floor(recordingDuration % 60).toString().padStart(2, "0")}`}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="w-full h-2 max-w-4xl bg-mti-gray-smoke rounded-full" />
|
||||||
|
<div className="flex gap-4 items-center">
|
||||||
|
<BsPlayCircle
|
||||||
|
onClick={() => {
|
||||||
|
setIsRecording(true);
|
||||||
|
resumeRecording();
|
||||||
|
}}
|
||||||
|
className="text-mti-purple-light w-8 h-8 cursor-pointer"
|
||||||
|
/>
|
||||||
|
<BsCheckCircleFill
|
||||||
|
onClick={() => {
|
||||||
|
setIsRecording(false);
|
||||||
|
stopRecording();
|
||||||
|
}}
|
||||||
|
className="text-mti-purple-light w-8 h-8 cursor-pointer"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{status === "stopped" && mediaBlobUrl && (
|
||||||
|
<>
|
||||||
|
<Waveform audio={mediaBlobUrl} waveColor="#FCDDEC" progressColor="#EF5DA8" />
|
||||||
|
<div className="flex gap-4 items-center">
|
||||||
|
<BsTrashFill
|
||||||
|
className="text-mti-gray-cool cursor-pointer w-5 h-5"
|
||||||
|
onClick={() => {
|
||||||
|
resetRecording();
|
||||||
|
setAnswers(prev => prev.filter(x => x.questionIndex !== questionIndex));
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<BsMicFill
|
||||||
|
onClick={() => {
|
||||||
|
resetRecording();
|
||||||
|
startRecording();
|
||||||
|
setIsRecording(true);
|
||||||
|
}}
|
||||||
|
className="h-5 w-5 text-mti-gray-cool cursor-pointer"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{footerButtons}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default InteractiveSpeaking;
|
||||||
@@ -1,19 +1,16 @@
|
|||||||
import { InteractiveSpeakingExercise } from "@/interfaces/exam";
|
import { InteractiveSpeakingExercise } from "@/interfaces/exam";
|
||||||
import { CommonProps } from ".";
|
import { CommonProps } from "../types";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { BsCheckCircleFill, BsMicFill, BsPauseCircle, BsPlayCircle, BsTrashFill } from "react-icons/bs";
|
import { BsCheckCircleFill, BsMicFill, BsPauseCircle, BsPlayCircle, BsTrashFill } from "react-icons/bs";
|
||||||
import dynamic from "next/dynamic";
|
import dynamic from "next/dynamic";
|
||||||
import Button from "../Low/Button";
|
import useExamStore, { usePersistentExamStore } from "@/stores/exam";
|
||||||
import useExamStore from "@/stores/examStore";
|
|
||||||
import { downloadBlob } from "@/utils/evaluation";
|
|
||||||
import axios from "axios";
|
|
||||||
|
|
||||||
const Waveform = dynamic(() => import("../Waveform"), { ssr: false });
|
const Waveform = dynamic(() => import("../../Waveform"), { ssr: false });
|
||||||
const ReactMediaRecorder = dynamic(() => import("react-media-recorder").then((mod) => mod.ReactMediaRecorder), {
|
const ReactMediaRecorder = dynamic(() => import("react-media-recorder").then((mod) => mod.ReactMediaRecorder), {
|
||||||
ssr: false,
|
ssr: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
export default function InteractiveSpeaking({
|
const InteractiveSpeaking: React.FC<InteractiveSpeakingExercise & CommonProps> = ({
|
||||||
id,
|
id,
|
||||||
title,
|
title,
|
||||||
first_title,
|
first_title,
|
||||||
@@ -22,65 +19,40 @@ export default function InteractiveSpeaking({
|
|||||||
type,
|
type,
|
||||||
prompts,
|
prompts,
|
||||||
userSolutions,
|
userSolutions,
|
||||||
onNext,
|
|
||||||
onBack,
|
|
||||||
isPractice = false,
|
isPractice = false,
|
||||||
preview = false
|
registerSolution,
|
||||||
}: InteractiveSpeakingExercise & CommonProps) {
|
headerButtons,
|
||||||
|
footerButtons,
|
||||||
|
preview,
|
||||||
|
}) => {
|
||||||
const [recordingDuration, setRecordingDuration] = useState(0);
|
const [recordingDuration, setRecordingDuration] = useState(0);
|
||||||
const [isRecording, setIsRecording] = useState(false);
|
const [isRecording, setIsRecording] = useState(false);
|
||||||
const [mediaBlob, setMediaBlob] = useState<string>();
|
const [mediaBlob, setMediaBlob] = useState<string>();
|
||||||
const [answers, setAnswers] = useState<{ prompt: string; blob: string; questionIndex: number }[]>([]);
|
const [answers, setAnswers] = useState<{ prompt: string; blob: string; questionIndex: number }[]>([]);
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
|
||||||
|
|
||||||
const { questionIndex, setQuestionIndex } = useExamStore((state) => state);
|
const examState = useExamStore((state) => state);
|
||||||
const { userSolutions: storeUserSolutions, setUserSolutions } = useExamStore((state) => state);
|
const persistentExamState = usePersistentExamStore((state) => state);
|
||||||
|
|
||||||
const hasExamEnded = useExamStore((state) => state.hasExamEnded);
|
const { questionIndex } = !preview ? examState : persistentExamState;
|
||||||
|
|
||||||
const back = async () => {
|
useEffect(() => {
|
||||||
setIsLoading(true);
|
setAnswers((prev) => [...prev.filter(x => x.questionIndex !== questionIndex), {
|
||||||
|
questionIndex: questionIndex,
|
||||||
|
prompt: prompts[questionIndex].text,
|
||||||
|
blob: mediaBlob!,
|
||||||
|
}]);
|
||||||
|
setMediaBlob(undefined);
|
||||||
|
}, [answers, mediaBlob, prompts, questionIndex]);
|
||||||
|
|
||||||
const answer = await saveAnswer(questionIndex);
|
useEffect(() => {
|
||||||
if (questionIndex - 1 >= 0) {
|
registerSolution(() => ({
|
||||||
setQuestionIndex(questionIndex - 1);
|
|
||||||
setIsLoading(false);
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setIsLoading(false);
|
|
||||||
|
|
||||||
onBack({
|
|
||||||
exercise: id,
|
exercise: id,
|
||||||
solutions: [...answers.filter((x) => x.questionIndex !== questionIndex), answer],
|
solutions: answers,
|
||||||
score: { correct: 100, total: 100, missing: 0 },
|
score: { correct: 100, total: 100, missing: 0 },
|
||||||
type,
|
type,
|
||||||
isPractice
|
isPractice
|
||||||
});
|
}));
|
||||||
};
|
}, [id, answers, mediaBlob, type, isPractice, prompts, registerSolution]);
|
||||||
|
|
||||||
const next = async () => {
|
|
||||||
setIsLoading(true);
|
|
||||||
|
|
||||||
const answer = await saveAnswer(questionIndex);
|
|
||||||
if (questionIndex + 1 < prompts.length) {
|
|
||||||
setQuestionIndex(questionIndex + 1);
|
|
||||||
setIsLoading(false);
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setIsLoading(false);
|
|
||||||
setQuestionIndex(0);
|
|
||||||
|
|
||||||
onNext({
|
|
||||||
exercise: id,
|
|
||||||
solutions: [...answers.filter((x) => x.questionIndex !== questionIndex), answer],
|
|
||||||
score: { correct: 100, total: 100, missing: 0 },
|
|
||||||
type,
|
|
||||||
isPractice
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (userSolutions.length > 0 && answers.length === 0) {
|
if (userSolutions.length > 0 && answers.length === 0) {
|
||||||
@@ -92,24 +64,6 @@ export default function InteractiveSpeaking({
|
|||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [userSolutions, mediaBlob, answers]);
|
}, [userSolutions, mediaBlob, answers]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (hasExamEnded) {
|
|
||||||
const answer = {
|
|
||||||
questionIndex,
|
|
||||||
prompt: prompts[questionIndex].text,
|
|
||||||
blob: mediaBlob!,
|
|
||||||
};
|
|
||||||
|
|
||||||
onNext({
|
|
||||||
exercise: id,
|
|
||||||
solutions: [...answers.filter((x) => x.questionIndex !== questionIndex), answer],
|
|
||||||
score: { correct: 100, total: 100, missing: 0 },
|
|
||||||
type,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [hasExamEnded]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let recordingInterval: NodeJS.Timer | undefined = undefined;
|
let recordingInterval: NodeJS.Timer | undefined = undefined;
|
||||||
if (isRecording) {
|
if (isRecording) {
|
||||||
@@ -123,50 +77,9 @@ export default function InteractiveSpeaking({
|
|||||||
};
|
};
|
||||||
}, [isRecording]);
|
}, [isRecording]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (questionIndex <= answers.length - 1) {
|
|
||||||
const blob = answers.find((x) => x.questionIndex === questionIndex)?.blob;
|
|
||||||
setMediaBlob(blob);
|
|
||||||
}
|
|
||||||
}, [answers, questionIndex]);
|
|
||||||
|
|
||||||
const saveAnswer = async (index: number) => {
|
|
||||||
const answer = {
|
|
||||||
questionIndex,
|
|
||||||
prompt: prompts[questionIndex].text,
|
|
||||||
blob: mediaBlob!,
|
|
||||||
};
|
|
||||||
|
|
||||||
setAnswers((prev) => [...prev.filter((x) => x.questionIndex !== index), answer]);
|
|
||||||
setMediaBlob(undefined);
|
|
||||||
|
|
||||||
setUserSolutions([
|
|
||||||
...storeUserSolutions.filter((x) => x.exercise !== id),
|
|
||||||
{
|
|
||||||
exercise: id,
|
|
||||||
solutions: [...answers.filter((x) => x.questionIndex !== questionIndex), answer],
|
|
||||||
score: { correct: 100, total: 100, missing: 0 },
|
|
||||||
module: "speaking",
|
|
||||||
exam: examID,
|
|
||||||
type,
|
|
||||||
isPractice
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
|
|
||||||
return answer;
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-4 mt-4 w-full">
|
<div className="flex flex-col gap-4 mt-4 w-full">
|
||||||
<div className="flex justify-between w-full gap-8">
|
{headerButtons}
|
||||||
<Button color="purple" variant="outline" isLoading={isLoading} onClick={back} className="max-w-[200px] self-end w-full">
|
|
||||||
Back
|
|
||||||
</Button>
|
|
||||||
<Button color="purple" disabled={!mediaBlob} isLoading={isLoading} onClick={next} className="max-w-[200px] self-end w-full">
|
|
||||||
{questionIndex + 1 < prompts.length ? "Next Prompt" : "Submit"}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-col h-full w-full gap-9">
|
<div className="flex flex-col h-full w-full gap-9">
|
||||||
<div className="flex flex-col w-full gap-8 bg-mti-gray-smoke rounded-xl py-8 pb-12 px-16">
|
<div className="flex flex-col w-full gap-8 bg-mti-gray-smoke rounded-xl py-8 pb-12 px-16">
|
||||||
<div className="flex flex-col gap-3">
|
<div className="flex flex-col gap-3">
|
||||||
@@ -298,22 +211,10 @@ export default function InteractiveSpeaking({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="self-end flex justify-between w-full gap-8">
|
|
||||||
<Button color="purple" variant="outline" isLoading={isLoading} onClick={back} className="max-w-[200px] self-end w-full">
|
|
||||||
Back
|
|
||||||
</Button>
|
|
||||||
{preview ? (
|
|
||||||
<Button color="purple" isLoading={isLoading} onClick={next} className="max-w-[200px] self-end w-full">
|
|
||||||
{questionIndex + 1 < prompts.length ? "Next Prompt" : "Submit"}
|
|
||||||
</Button>
|
|
||||||
) : (
|
|
||||||
<Button color="purple" disabled={!mediaBlob} isLoading={isLoading} onClick={next} className="max-w-[200px] self-end w-full">
|
|
||||||
{questionIndex + 1 < prompts.length ? "Next Prompt" : "Submit"}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
{footerButtons}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default InteractiveSpeaking;
|
||||||
68
src/components/Exercises/InteractiveSpeaking/useAnswers.ts
Normal file
68
src/components/Exercises/InteractiveSpeaking/useAnswers.ts
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
import useExamStore, { usePersistentExamStore } from "@/stores/exam";
|
||||||
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
|
||||||
|
export interface Answer {
|
||||||
|
prompt: string;
|
||||||
|
blob: string;
|
||||||
|
questionIndex: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const useAnswers = (id: string, type: string, isPractice: boolean, prompts: any[], registerSolution: Function, preview: boolean) => {
|
||||||
|
const [answers, setAnswers] = useState<Answer[]>([]);
|
||||||
|
|
||||||
|
const examState = useExamStore((state) => state);
|
||||||
|
const persistentExamState = usePersistentExamStore((state) => state);
|
||||||
|
|
||||||
|
const { questionIndex } = !preview ? examState : persistentExamState;
|
||||||
|
|
||||||
|
const currentBlobUrlRef = useRef<string | null>(null);
|
||||||
|
|
||||||
|
const cleanupBlobUrl = useCallback((url: string) => {
|
||||||
|
if (url.startsWith('blob:')) {
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Update answers with new recording
|
||||||
|
const updateAnswers = useCallback((blobUrl: string) => {
|
||||||
|
if (!blobUrl) return;
|
||||||
|
|
||||||
|
setAnswers(prev => {
|
||||||
|
// Cleanup old blob URL for this question if it exists
|
||||||
|
const existingAnswer = prev.find(x => x.questionIndex === questionIndex);
|
||||||
|
if (existingAnswer?.blob) {
|
||||||
|
cleanupBlobUrl(existingAnswer.blob);
|
||||||
|
}
|
||||||
|
|
||||||
|
const filteredAnswers = prev.filter(x => x.questionIndex !== questionIndex);
|
||||||
|
return [...filteredAnswers, {
|
||||||
|
questionIndex,
|
||||||
|
prompt: prompts[questionIndex].text,
|
||||||
|
blob: blobUrl,
|
||||||
|
}];
|
||||||
|
});
|
||||||
|
|
||||||
|
// Store current blob URL for cleanup
|
||||||
|
currentBlobUrlRef.current = blobUrl;
|
||||||
|
}, [questionIndex, prompts, cleanupBlobUrl]);
|
||||||
|
|
||||||
|
// Register solutions
|
||||||
|
useEffect(() => {
|
||||||
|
registerSolution(() => ({
|
||||||
|
exercise: id,
|
||||||
|
solutions: answers,
|
||||||
|
score: { correct: 100, total: 100, missing: 0 },
|
||||||
|
type,
|
||||||
|
isPractice
|
||||||
|
}));
|
||||||
|
}, [answers, id, type, isPractice, registerSolution]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
answers,
|
||||||
|
setAnswers,
|
||||||
|
updateAnswers,
|
||||||
|
getCurrentBlob: () => currentBlobUrlRef.current
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export default useAnswers;
|
||||||
@@ -1,177 +0,0 @@
|
|||||||
import { errorButtonStyle, infoButtonStyle } from "@/constants/buttonStyles";
|
|
||||||
import { MatchSentenceExerciseOption, MatchSentenceExerciseSentence, MatchSentencesExercise } from "@/interfaces/exam";
|
|
||||||
import { mdiArrowLeft, mdiArrowRight } from "@mdi/js";
|
|
||||||
import Icon from "@mdi/react";
|
|
||||||
import clsx from "clsx";
|
|
||||||
import { Fragment, useEffect, useState } from "react";
|
|
||||||
import LineTo from "react-lineto";
|
|
||||||
import { CommonProps } from ".";
|
|
||||||
import Button from "../Low/Button";
|
|
||||||
import Xarrow from "react-xarrows";
|
|
||||||
import useExamStore from "@/stores/examStore";
|
|
||||||
import { DndContext, DragEndEvent, useDraggable, useDroppable } from "@dnd-kit/core";
|
|
||||||
|
|
||||||
function DroppableQuestionArea({ question, answer }: { question: MatchSentenceExerciseSentence; answer?: string }) {
|
|
||||||
const { isOver, setNodeRef } = useDroppable({ id: `droppable_sentence_${question.id}` });
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="grid grid-cols-3 gap-4" ref={setNodeRef}>
|
|
||||||
<div className="flex items-center gap-3 cursor-pointer col-span-2">
|
|
||||||
<button
|
|
||||||
className={clsx(
|
|
||||||
"bg-mti-purple-ultralight text-mti-purple hover:text-white hover:bg-mti-purple w-8 h-8 rounded-full z-10",
|
|
||||||
"transition duration-300 ease-in-out",
|
|
||||||
)}>
|
|
||||||
{question.id}
|
|
||||||
</button>
|
|
||||||
<span>{question.sentence}</span>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
key={`answer_${question.id}_${answer}`}
|
|
||||||
className={clsx("w-48 h-10 border rounded-xl flex items-center justify-center", isOver && "border-mti-purple-light")}>
|
|
||||||
{answer && `Paragraph ${answer}`}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function DraggableOptionArea({ option }: { option: MatchSentenceExerciseOption }) {
|
|
||||||
const { attributes, listeners, setNodeRef, transform } = useDraggable({
|
|
||||||
id: `draggable_option_${option.id}`,
|
|
||||||
});
|
|
||||||
|
|
||||||
const style = transform
|
|
||||||
? {
|
|
||||||
transform: `translate3d(${transform.x}px, ${transform.y}px, 0)`,
|
|
||||||
zIndex: 99,
|
|
||||||
}
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={clsx("flex items-center justify-start gap-6 cursor-pointer")} ref={setNodeRef} style={style} {...listeners} {...attributes}>
|
|
||||||
<button
|
|
||||||
id={`option_${option.id}`}
|
|
||||||
// onClick={() => selectOption(id)}
|
|
||||||
className={clsx(
|
|
||||||
"bg-mti-purple-ultralight text-mti-purple hover:text-white hover:bg-mti-purple px-3 py-2 rounded-full z-10",
|
|
||||||
"transition duration-300 ease-in-out",
|
|
||||||
option.id,
|
|
||||||
)}>
|
|
||||||
Paragraph {option.id}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function MatchSentences({
|
|
||||||
id,
|
|
||||||
options,
|
|
||||||
type,
|
|
||||||
prompt,
|
|
||||||
sentences,
|
|
||||||
userSolutions,
|
|
||||||
onNext,
|
|
||||||
onBack,
|
|
||||||
isPractice = false,
|
|
||||||
disableProgressButtons = false
|
|
||||||
}: MatchSentencesExercise & CommonProps) {
|
|
||||||
const [answers, setAnswers] = useState<{ question: string; option: string }[]>(userSolutions);
|
|
||||||
|
|
||||||
const hasExamEnded = useExamStore((state) => state.hasExamEnded);
|
|
||||||
|
|
||||||
const setCurrentSolution = useExamStore((state) => state.setCurrentSolution);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setCurrentSolution({ exercise: id, solutions: answers, score: calculateScore(), type, isPractice });
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [answers, setAnswers]);
|
|
||||||
|
|
||||||
const handleDragEnd = (event: DragEndEvent) => {
|
|
||||||
if (event.over && event.over.id.toString().startsWith("droppable")) {
|
|
||||||
const optionID = event.active.id.toString().replace("draggable_option_", "");
|
|
||||||
const sentenceID = event.over.id.toString().replace("droppable_sentence_", "");
|
|
||||||
|
|
||||||
setAnswers((prev) => [...prev.filter((x) => x.question.toString() !== sentenceID), { question: sentenceID, option: optionID }]);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const calculateScore = () => {
|
|
||||||
const total = sentences.length;
|
|
||||||
const correct = answers.filter(
|
|
||||||
(x) => sentences.find((y) => y.id.toString() === x.question.toString())?.solution === x.option || false,
|
|
||||||
).length;
|
|
||||||
const missing = total - answers.filter((x) => sentences.find((y) => y.id.toString() === x.question.toString())).length;
|
|
||||||
|
|
||||||
return { total, correct, missing };
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (disableProgressButtons) onNext({ exercise: id, solutions: answers, score: calculateScore(), type, isPractice });
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [answers, disableProgressButtons])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (hasExamEnded) onNext({ exercise: id, solutions: answers, score: calculateScore(), type, isPractice });
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [hasExamEnded]);
|
|
||||||
|
|
||||||
const progressButtons = () => (
|
|
||||||
<div className="flex justify-between w-full gap-8">
|
|
||||||
<Button
|
|
||||||
color="purple"
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => onBack({ exercise: id, solutions: answers, score: calculateScore(), type, isPractice })}
|
|
||||||
className="max-w-[200px] w-full">
|
|
||||||
Back
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
color="purple"
|
|
||||||
onClick={() => onNext({ exercise: id, solutions: answers, score: calculateScore(), type, isPractice })}
|
|
||||||
className="max-w-[200px] self-end w-full">
|
|
||||||
Next
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col gap-4 mt-4">
|
|
||||||
{!disableProgressButtons && progressButtons()}
|
|
||||||
|
|
||||||
<div className={clsx("flex flex-col gap-4 mt-4", !disableProgressButtons && "mb-20")}>
|
|
||||||
<span className="text-sm w-full leading-6">
|
|
||||||
{prompt.split("\\n").map((line, index) => (
|
|
||||||
<Fragment key={index}>
|
|
||||||
{line}
|
|
||||||
<br />
|
|
||||||
</Fragment>
|
|
||||||
))}
|
|
||||||
</span>
|
|
||||||
|
|
||||||
<DndContext onDragEnd={handleDragEnd}>
|
|
||||||
<div className="flex flex-col gap-8 w-full items-center justify-between bg-mti-gray-smoke rounded-xl px-24 py-6">
|
|
||||||
<div className="flex flex-col gap-4">
|
|
||||||
{sentences.map((question) => (
|
|
||||||
<DroppableQuestionArea
|
|
||||||
key={`question_${question.id}`}
|
|
||||||
question={question}
|
|
||||||
answer={answers.find((x) => x.question.toString() === question.id.toString())?.option}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-4">
|
|
||||||
<span>Drag one of these paragraphs into the slots above:</span>
|
|
||||||
<div className="flex gap-4 flex-wrap justify-center items-center max-w-lg">
|
|
||||||
{options.map((option) => (
|
|
||||||
<DraggableOptionArea key={`answer_${option.id}`} option={option} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</DndContext>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{!disableProgressButtons && progressButtons()}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
65
src/components/Exercises/MatchSentences/DragNDrop.tsx
Normal file
65
src/components/Exercises/MatchSentences/DragNDrop.tsx
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
import { MatchSentenceExerciseOption, MatchSentenceExerciseSentence } from "@/interfaces/exam";
|
||||||
|
import { useDraggable, useDroppable } from "@dnd-kit/core";
|
||||||
|
import clsx from "clsx";
|
||||||
|
|
||||||
|
interface DroppableQuestionAreaProps {
|
||||||
|
question: MatchSentenceExerciseSentence;
|
||||||
|
answer?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const DroppableQuestionArea: React.FC<DroppableQuestionAreaProps> = ({ question, answer }) => {
|
||||||
|
const { isOver, setNodeRef } = useDroppable({ id: `droppable_sentence_${question.id}` });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-3 gap-4" ref={setNodeRef}>
|
||||||
|
<div className="flex items-center gap-3 cursor-pointer col-span-2">
|
||||||
|
<button
|
||||||
|
className={clsx(
|
||||||
|
"bg-mti-purple-ultralight text-mti-purple hover:text-white hover:bg-mti-purple p-2 rounded-full z-10",
|
||||||
|
"transition duration-300 ease-in-out",
|
||||||
|
)}>
|
||||||
|
{question.id}
|
||||||
|
</button>
|
||||||
|
<span>{question.sentence}</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
key={`answer_${question.id}_${answer}`}
|
||||||
|
className={clsx("w-48 h-10 border rounded-xl flex items-center justify-center", isOver && "border-mti-purple-light")}>
|
||||||
|
{answer && `Paragraph ${answer}`}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const DraggableOptionArea: React.FC<{ option: MatchSentenceExerciseOption }> = ({ option }) => {
|
||||||
|
const { attributes, listeners, setNodeRef, transform } = useDraggable({
|
||||||
|
id: `draggable_option_${option.id}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
const style = transform
|
||||||
|
? {
|
||||||
|
transform: `translate3d(${transform.x}px, ${transform.y}px, 0)`,
|
||||||
|
zIndex: 99,
|
||||||
|
}
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={clsx("flex items-center justify-start gap-6 cursor-pointer")} ref={setNodeRef} style={style} {...listeners} {...attributes}>
|
||||||
|
<button
|
||||||
|
id={`option_${option.id}`}
|
||||||
|
// onClick={() => selectOption(id)}
|
||||||
|
className={clsx(
|
||||||
|
"bg-mti-purple-ultralight text-mti-purple hover:text-white hover:bg-mti-purple px-3 py-2 rounded-full z-10",
|
||||||
|
"transition duration-300 ease-in-out",
|
||||||
|
option.id,
|
||||||
|
)}>
|
||||||
|
Paragraph {option.id}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
DroppableQuestionArea,
|
||||||
|
DraggableOptionArea,
|
||||||
|
}
|
||||||
92
src/components/Exercises/MatchSentences/index.tsx
Normal file
92
src/components/Exercises/MatchSentences/index.tsx
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
import { MatchSentencesExercise } from "@/interfaces/exam";
|
||||||
|
import clsx from "clsx";
|
||||||
|
import { Fragment, useCallback, useEffect, useState } from "react";
|
||||||
|
import { CommonProps } from "../types";
|
||||||
|
import { DndContext, DragEndEvent } from "@dnd-kit/core";
|
||||||
|
import { DraggableOptionArea, DroppableQuestionArea } from "./DragNDrop";
|
||||||
|
|
||||||
|
const MatchSentences: React.FC<MatchSentencesExercise & CommonProps> = ({
|
||||||
|
id,
|
||||||
|
options,
|
||||||
|
type,
|
||||||
|
prompt,
|
||||||
|
sentences,
|
||||||
|
userSolutions,
|
||||||
|
isPractice = false,
|
||||||
|
registerSolution,
|
||||||
|
headerButtons,
|
||||||
|
footerButtons,
|
||||||
|
}) => {
|
||||||
|
const [answers, setAnswers] = useState<{ question: string; option: string }[]>(userSolutions);
|
||||||
|
|
||||||
|
const handleDragEnd = (event: DragEndEvent) => {
|
||||||
|
if (event.over && event.over.id.toString().startsWith("droppable")) {
|
||||||
|
const optionID = event.active.id.toString().replace("draggable_option_", "");
|
||||||
|
const sentenceID = event.over.id.toString().replace("droppable_sentence_", "");
|
||||||
|
|
||||||
|
setAnswers((prev) => [...prev.filter((x) => x.question.toString() !== sentenceID), { question: sentenceID, option: optionID }]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const calculateScore = useCallback(() => {
|
||||||
|
const total = sentences.length;
|
||||||
|
const correct = answers.filter(
|
||||||
|
(x) => sentences.find((y) => y.id.toString() === x.question.toString())?.solution === x.option || false,
|
||||||
|
).length;
|
||||||
|
const missing = total - answers.filter((x) => sentences.find((y) => y.id.toString() === x.question.toString())).length;
|
||||||
|
|
||||||
|
return { total, correct, missing };
|
||||||
|
}, [answers, sentences]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
registerSolution(() => ({
|
||||||
|
exercise: id,
|
||||||
|
solutions: answers,
|
||||||
|
score: calculateScore(),
|
||||||
|
type,
|
||||||
|
isPractice
|
||||||
|
}));
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [id, answers, type, isPractice, calculateScore]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-4 mt-4">
|
||||||
|
{headerButtons}
|
||||||
|
<div className={clsx("flex flex-col gap-4 mt-4", (!headerButtons && !footerButtons) && "mb-20")}>
|
||||||
|
<span className="text-sm w-full leading-6">
|
||||||
|
{prompt.split("\\n").map((line, index) => (
|
||||||
|
<Fragment key={index}>
|
||||||
|
{line}
|
||||||
|
<br />
|
||||||
|
</Fragment>
|
||||||
|
))}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<DndContext onDragEnd={handleDragEnd}>
|
||||||
|
<div className="flex flex-col gap-8 w-full items-center justify-between bg-mti-gray-smoke rounded-xl px-24 py-6">
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
{sentences.map((question) => (
|
||||||
|
<DroppableQuestionArea
|
||||||
|
key={`question_${question.id}`}
|
||||||
|
question={question}
|
||||||
|
answer={answers.find((x) => x.question.toString() === question.id.toString())?.option}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<span>Drag one of these paragraphs into the slots above:</span>
|
||||||
|
<div className="flex gap-4 flex-wrap justify-center items-center max-w-lg">
|
||||||
|
{options.map((option) => (
|
||||||
|
<DraggableOptionArea key={`answer_${option.id}`} option={option} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DndContext>
|
||||||
|
</div>
|
||||||
|
{footerButtons}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default MatchSentences;
|
||||||
@@ -1,238 +0,0 @@
|
|||||||
/* eslint-disable @next/next/no-img-element */
|
|
||||||
import { MultipleChoiceExercise, MultipleChoiceQuestion, ShuffleMap } from "@/interfaces/exam";
|
|
||||||
import useExamStore from "@/stores/examStore";
|
|
||||||
import clsx from "clsx";
|
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
import reactStringReplace from "react-string-replace";
|
|
||||||
import { CommonProps } from ".";
|
|
||||||
import Button from "../Low/Button";
|
|
||||||
import { v4 } from "uuid";
|
|
||||||
|
|
||||||
function Question({
|
|
||||||
id,
|
|
||||||
variant,
|
|
||||||
prompt,
|
|
||||||
options,
|
|
||||||
userSolution,
|
|
||||||
onSelectOption,
|
|
||||||
}: MultipleChoiceQuestion & {
|
|
||||||
userSolution: string | undefined;
|
|
||||||
onSelectOption?: (option: string) => void;
|
|
||||||
showSolution?: boolean;
|
|
||||||
}) {
|
|
||||||
const renderPrompt = (prompt: string) => {
|
|
||||||
return reactStringReplace(prompt, /(<u>.*?<\/u>)/g, (match) => {
|
|
||||||
const word = match.replaceAll("<u>", "").replaceAll("</u>", "");
|
|
||||||
return word.length > 0 ? <u key={v4()}>{word}</u> : null;
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col gap-8">
|
|
||||||
{isNaN(Number(id)) ? (
|
|
||||||
<span className="text-lg">{renderPrompt(prompt).filter((x) => x?.toString() !== "<u>")}</span>
|
|
||||||
) : (
|
|
||||||
<span className="text-lg">
|
|
||||||
<>
|
|
||||||
{id} - <span>{renderPrompt(prompt).filter((x) => x?.toString() !== "<u>")}</span>
|
|
||||||
</>
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
<div className="flex flex-wrap gap-4 justify-between">
|
|
||||||
{variant === "image" &&
|
|
||||||
options.map((option) => (
|
|
||||||
<div
|
|
||||||
key={v4()}
|
|
||||||
onClick={() => (onSelectOption ? onSelectOption(option.id.toString()) : null)}
|
|
||||||
className={clsx(
|
|
||||||
"flex flex-col items-center border border-mti-gray-platinum p-4 px-8 rounded-xl gap-4 cursor-pointer bg-white relative select-none",
|
|
||||||
userSolution === option.id.toString() && "border-mti-purple-light",
|
|
||||||
)}>
|
|
||||||
<span key={v4()} className={clsx("text-sm", userSolution !== option.id.toString() && "opacity-50")}>
|
|
||||||
{option.id.toString()}
|
|
||||||
</span>
|
|
||||||
<img src={option.src!} alt={`Option ${option.id.toString()}`} />
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
{variant === "text" &&
|
|
||||||
options.map((option) => (
|
|
||||||
<div
|
|
||||||
key={v4()}
|
|
||||||
onClick={() => (onSelectOption ? onSelectOption(option.id.toString()) : null)}
|
|
||||||
className={clsx(
|
|
||||||
"flex border p-4 rounded-xl gap-2 cursor-pointer bg-white text-base select-none",
|
|
||||||
userSolution === option.id.toString() && "!bg-mti-purple-light !text-white",
|
|
||||||
)}>
|
|
||||||
<span className="font-semibold">{option.id.toString()}.</span>
|
|
||||||
<span>{option.text}</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function MultipleChoice({
|
|
||||||
id,
|
|
||||||
prompt,
|
|
||||||
type,
|
|
||||||
questions,
|
|
||||||
userSolutions,
|
|
||||||
isPractice = false,
|
|
||||||
onNext,
|
|
||||||
onBack,
|
|
||||||
disableProgressButtons = false
|
|
||||||
}: MultipleChoiceExercise & CommonProps) {
|
|
||||||
const [answers, setAnswers] = useState<{ question: string; option: string }[]>(userSolutions || []);
|
|
||||||
|
|
||||||
const { questionIndex, exerciseIndex, exam, shuffles, hasExamEnded, partIndex, setQuestionIndex, setCurrentSolution } = useExamStore(
|
|
||||||
(state) => state,
|
|
||||||
);
|
|
||||||
|
|
||||||
const shuffleMaps = shuffles.find((x) => x.exerciseID == id)?.shuffles;
|
|
||||||
|
|
||||||
const scrollToTop = () => Array.from(document.getElementsByTagName("body")).forEach((body) => body.scrollTo(0, 0));
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (hasExamEnded) onNext({ exercise: id, solutions: answers, score: calculateScore(), type, isPractice });
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [hasExamEnded]);
|
|
||||||
|
|
||||||
const onSelectOption = (option: string, question: MultipleChoiceQuestion) => {
|
|
||||||
setAnswers((prev) => [...prev.filter((x) => x.question !== question.id), { option, question: question.id }]);
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setCurrentSolution({ exercise: id, solutions: answers, score: calculateScore(), type, shuffleMaps: shuffleMaps, isPractice });
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [answers, setAnswers]);
|
|
||||||
|
|
||||||
const getShuffledSolution = (originalSolution: string, questionShuffleMap: ShuffleMap) => {
|
|
||||||
for (const [newPosition, originalPosition] of Object.entries(questionShuffleMap.map)) {
|
|
||||||
if (originalPosition === originalSolution) {
|
|
||||||
return newPosition;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return originalSolution;
|
|
||||||
};
|
|
||||||
|
|
||||||
const calculateScore = () => {
|
|
||||||
const total = questions.length;
|
|
||||||
const correct = answers.filter((x) => {
|
|
||||||
const matchingQuestion = questions.find((y) => {
|
|
||||||
return y.id.toString() === x.question.toString();
|
|
||||||
});
|
|
||||||
|
|
||||||
let isSolutionCorrect;
|
|
||||||
if (!shuffleMaps) {
|
|
||||||
isSolutionCorrect = matchingQuestion?.solution === x.option;
|
|
||||||
} else {
|
|
||||||
const shuffleMap = shuffleMaps.find((map) => map.questionID == x.question);
|
|
||||||
if (shuffleMap) {
|
|
||||||
isSolutionCorrect = getShuffledSolution(x.option, shuffleMap) == matchingQuestion?.solution;
|
|
||||||
} else {
|
|
||||||
isSolutionCorrect = matchingQuestion?.solution === x.option;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return isSolutionCorrect || false;
|
|
||||||
}).length;
|
|
||||||
const missing = total - answers!.filter((x) => questions.find((y) => x.question.toString() === y.id.toString())).length;
|
|
||||||
return { total, correct, missing };
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (disableProgressButtons) onNext({ exercise: id, solutions: answers, score: calculateScore(), type, shuffleMaps: shuffleMaps, isPractice });
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [answers, disableProgressButtons])
|
|
||||||
|
|
||||||
const next = () => {
|
|
||||||
if (questionIndex + 1 >= questions.length - 1) {
|
|
||||||
onNext({ exercise: id, solutions: answers, score: calculateScore(), type, shuffleMaps: shuffleMaps, isPractice });
|
|
||||||
} else {
|
|
||||||
setQuestionIndex(questionIndex + 2);
|
|
||||||
}
|
|
||||||
scrollToTop();
|
|
||||||
};
|
|
||||||
|
|
||||||
const back = () => {
|
|
||||||
if (questionIndex === 0) {
|
|
||||||
onBack({ exercise: id, solutions: answers, score: calculateScore(), type, shuffleMaps: shuffleMaps, isPractice });
|
|
||||||
} else {
|
|
||||||
if (exam?.module === "level" && typeof exam.parts[0].intro !== "undefined" && questionIndex === 0) return;
|
|
||||||
setQuestionIndex(questionIndex - 2);
|
|
||||||
}
|
|
||||||
|
|
||||||
scrollToTop();
|
|
||||||
};
|
|
||||||
|
|
||||||
const progressButtons = () => (
|
|
||||||
<div className="flex justify-between w-full gap-8">
|
|
||||||
<Button
|
|
||||||
color="purple"
|
|
||||||
variant="outline"
|
|
||||||
onClick={back}
|
|
||||||
className="max-w-[200px] w-full"
|
|
||||||
disabled={exam && exam.module === "level" && partIndex === 0 && questionIndex === 0}>
|
|
||||||
Back
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button color="purple" onClick={next} className="max-w-[200px] self-end w-full">
|
|
||||||
{exam &&
|
|
||||||
exam.module === "level" &&
|
|
||||||
partIndex === exam.parts.length - 1 &&
|
|
||||||
exerciseIndex === exam.parts[partIndex].exercises.length - 1 &&
|
|
||||||
questionIndex + 1 >= questions.length - 1
|
|
||||||
? "Submit"
|
|
||||||
: "Next"}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
|
|
||||||
const renderAllQuestions = () =>
|
|
||||||
questions.map(question => (
|
|
||||||
<div
|
|
||||||
key={question.id} className="flex flex-col gap-8 h-fit w-full bg-mti-gray-smoke rounded-xl px-16 py-8">
|
|
||||||
<Question
|
|
||||||
{...question}
|
|
||||||
userSolution={answers.find((x) => question.id === x.question)?.option}
|
|
||||||
onSelectOption={(option) => onSelectOption(option, question)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
))
|
|
||||||
|
|
||||||
const renderTwoQuestions = () => (
|
|
||||||
<>
|
|
||||||
<div className="flex flex-col gap-8 h-fit w-full bg-mti-gray-smoke rounded-xl px-16 py-8">
|
|
||||||
{questionIndex < questions.length && (
|
|
||||||
<Question
|
|
||||||
{...questions[questionIndex]}
|
|
||||||
userSolution={answers.find((x) => questions[questionIndex].id === x.question)?.option}
|
|
||||||
onSelectOption={(option) => onSelectOption(option, questions[questionIndex])}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{questionIndex + 1 < questions.length && (
|
|
||||||
<div className="flex flex-col gap-8 h-fit w-full bg-mti-gray-smoke rounded-xl px-16 py-8">
|
|
||||||
<Question
|
|
||||||
{...questions[questionIndex + 1]}
|
|
||||||
userSolution={answers.find((x) => questions[questionIndex + 1].id === x.question)?.option}
|
|
||||||
onSelectOption={(option) => onSelectOption(option, questions[questionIndex + 1])}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col gap-4">
|
|
||||||
{!disableProgressButtons && progressButtons()}
|
|
||||||
|
|
||||||
<div className={clsx("flex flex-col gap-4 mt-4", !disableProgressButtons && "mb-20")}>
|
|
||||||
{disableProgressButtons ? renderAllQuestions() : renderTwoQuestions()}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{!disableProgressButtons && progressButtons()}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
73
src/components/Exercises/MultipleChoice/Question.tsx
Normal file
73
src/components/Exercises/MultipleChoice/Question.tsx
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
/* eslint-disable @next/next/no-img-element */
|
||||||
|
import { MultipleChoiceQuestion } from "@/interfaces/exam";
|
||||||
|
import clsx from "clsx";
|
||||||
|
import reactStringReplace from "react-string-replace";
|
||||||
|
import { v4 } from "uuid";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
userSolution: string | undefined;
|
||||||
|
onSelectOption?: (option: string) => void;
|
||||||
|
showSolution?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Question: React.FC<MultipleChoiceQuestion & Props> = ({
|
||||||
|
id,
|
||||||
|
variant,
|
||||||
|
prompt,
|
||||||
|
options,
|
||||||
|
userSolution,
|
||||||
|
onSelectOption,
|
||||||
|
}) => {
|
||||||
|
const renderPrompt = (prompt: string) => {
|
||||||
|
return reactStringReplace(prompt, /(<u>.*?<\/u>)/g, (match) => {
|
||||||
|
const word = match.replaceAll("<u>", "").replaceAll("</u>", "");
|
||||||
|
return word.length > 0 ? <u key={v4()}>{word}</u> : null;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-8">
|
||||||
|
{isNaN(Number(id)) ? (
|
||||||
|
<span className="text-lg">{renderPrompt(prompt).filter((x) => x?.toString() !== "<u>")}</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-lg">
|
||||||
|
<>
|
||||||
|
{id} - <span>{renderPrompt(prompt).filter((x) => x?.toString() !== "<u>")}</span>
|
||||||
|
</>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<div className="flex flex-wrap gap-4 justify-between">
|
||||||
|
{variant === "image" &&
|
||||||
|
options.map((option) => (
|
||||||
|
<div
|
||||||
|
key={v4()}
|
||||||
|
onClick={() => (onSelectOption ? onSelectOption(option.id.toString()) : null)}
|
||||||
|
className={clsx(
|
||||||
|
"flex flex-col items-center border border-mti-gray-platinum p-4 px-8 rounded-xl gap-4 cursor-pointer bg-white relative select-none",
|
||||||
|
userSolution === option.id.toString() && "border-mti-purple-light",
|
||||||
|
)}>
|
||||||
|
<span key={v4()} className={clsx("text-sm", userSolution !== option.id.toString() && "opacity-50")}>
|
||||||
|
{option.id.toString()}
|
||||||
|
</span>
|
||||||
|
<img src={option.src!} alt={`Option ${option.id.toString()}`} />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{variant === "text" &&
|
||||||
|
options.map((option) => (
|
||||||
|
<div
|
||||||
|
key={v4()}
|
||||||
|
onClick={() => (onSelectOption ? onSelectOption(option.id.toString()) : null)}
|
||||||
|
className={clsx(
|
||||||
|
"flex border p-4 rounded-xl gap-2 cursor-pointer bg-white text-base select-none",
|
||||||
|
userSolution === option.id.toString() && "!bg-mti-purple-light !text-white",
|
||||||
|
)}>
|
||||||
|
<span className="font-semibold">{option.id.toString()}.</span>
|
||||||
|
<span>{option.text}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Question;
|
||||||
125
src/components/Exercises/MultipleChoice/index.tsx
Normal file
125
src/components/Exercises/MultipleChoice/index.tsx
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
import { MultipleChoiceExercise, MultipleChoiceQuestion, ShuffleMap } from "@/interfaces/exam";
|
||||||
|
import useExamStore, { usePersistentExamStore } from "@/stores/exam";
|
||||||
|
import clsx from "clsx";
|
||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import { CommonProps } from "../types";
|
||||||
|
import Question from "./Question";
|
||||||
|
|
||||||
|
|
||||||
|
const MultipleChoice: React.FC<MultipleChoiceExercise & CommonProps> = ({
|
||||||
|
id,
|
||||||
|
type,
|
||||||
|
questions,
|
||||||
|
userSolutions,
|
||||||
|
isPractice = false,
|
||||||
|
registerSolution,
|
||||||
|
headerButtons,
|
||||||
|
footerButtons,
|
||||||
|
preview,
|
||||||
|
}) => {
|
||||||
|
const [answers, setAnswers] = useState<{ question: string; option: string }[]>(userSolutions || []);
|
||||||
|
|
||||||
|
const examState = useExamStore((state) => state);
|
||||||
|
const persistentExamState = usePersistentExamStore((state) => state);
|
||||||
|
|
||||||
|
const { questionIndex, shuffles } = !preview ? examState : persistentExamState;
|
||||||
|
|
||||||
|
const shuffleMaps = shuffles.find((x) => x.exerciseID == id)?.shuffles;
|
||||||
|
|
||||||
|
const onSelectOption = (option: string, question: MultipleChoiceQuestion) => {
|
||||||
|
setAnswers((prev) => [...prev.filter((x) => x.question !== question.id), { option, question: question.id }]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getShuffledSolution = (originalSolution: string, questionShuffleMap: ShuffleMap) => {
|
||||||
|
for (const [newPosition, originalPosition] of Object.entries(questionShuffleMap.map)) {
|
||||||
|
if (originalPosition === originalSolution) {
|
||||||
|
return newPosition;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return originalSolution;
|
||||||
|
};
|
||||||
|
|
||||||
|
const calculateScore = useCallback(() => {
|
||||||
|
const total = questions.length;
|
||||||
|
const correct = answers.filter((x) => {
|
||||||
|
const matchingQuestion = questions.find((y) => {
|
||||||
|
return y.id.toString() === x.question.toString();
|
||||||
|
});
|
||||||
|
|
||||||
|
let isSolutionCorrect;
|
||||||
|
if (!shuffleMaps) {
|
||||||
|
isSolutionCorrect = matchingQuestion?.solution === x.option;
|
||||||
|
} else {
|
||||||
|
const shuffleMap = shuffleMaps.find((map) => map.questionID == x.question);
|
||||||
|
if (shuffleMap) {
|
||||||
|
isSolutionCorrect = getShuffledSolution(x.option, shuffleMap) == matchingQuestion?.solution;
|
||||||
|
} else {
|
||||||
|
isSolutionCorrect = matchingQuestion?.solution === x.option;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return isSolutionCorrect || false;
|
||||||
|
}).length;
|
||||||
|
const missing = total - answers!.filter((x) => questions.find((y) => x.question.toString() === y.id.toString())).length;
|
||||||
|
return { total, correct, missing };
|
||||||
|
}, [answers, questions, shuffleMaps]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
registerSolution(() => ({
|
||||||
|
exercise: id,
|
||||||
|
solutions: answers,
|
||||||
|
score: calculateScore(),
|
||||||
|
shuffleMaps,
|
||||||
|
type,
|
||||||
|
isPractice
|
||||||
|
}));
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [id, answers, type, isPractice, shuffleMaps, calculateScore]);
|
||||||
|
|
||||||
|
const renderAllQuestions = () =>
|
||||||
|
questions.map(question => (
|
||||||
|
<div
|
||||||
|
key={question.id} className="flex flex-col gap-8 h-fit w-full bg-mti-gray-smoke rounded-xl px-16 py-8">
|
||||||
|
<Question
|
||||||
|
{...question}
|
||||||
|
userSolution={answers.find((x) => question.id === x.question)?.option}
|
||||||
|
onSelectOption={(option) => onSelectOption(option, question)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
|
||||||
|
const renderTwoQuestions = () => (
|
||||||
|
<>
|
||||||
|
<div className="flex flex-col gap-8 h-fit w-full bg-mti-gray-smoke rounded-xl px-16 py-8">
|
||||||
|
{questionIndex < questions.length && (
|
||||||
|
<Question
|
||||||
|
{...questions[questionIndex]}
|
||||||
|
userSolution={answers.find((x) => questions[questionIndex].id === x.question)?.option}
|
||||||
|
onSelectOption={(option) => onSelectOption(option, questions[questionIndex])}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{questionIndex + 1 < questions.length && (
|
||||||
|
<div className="flex flex-col gap-8 h-fit w-full bg-mti-gray-smoke rounded-xl px-16 py-8">
|
||||||
|
<Question
|
||||||
|
{...questions[questionIndex + 1]}
|
||||||
|
userSolution={answers.find((x) => questions[questionIndex + 1].id === x.question)?.option}
|
||||||
|
onSelectOption={(option) => onSelectOption(option, questions[questionIndex + 1])}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
{headerButtons}
|
||||||
|
<div className={clsx("flex flex-col gap-4 mt-4", (headerButtons && footerButtons) && "mb-20")}>
|
||||||
|
{(!headerButtons && !footerButtons) ? renderAllQuestions() : renderTwoQuestions()}
|
||||||
|
</div>
|
||||||
|
{footerButtons}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default MultipleChoice;
|
||||||
@@ -1,67 +1,41 @@
|
|||||||
import { SpeakingExercise } from "@/interfaces/exam";
|
import { SpeakingExercise } from "@/interfaces/exam";
|
||||||
import { CommonProps } from ".";
|
import { CommonProps } from "./types";
|
||||||
import { Fragment, useEffect, useState } from "react";
|
import { Fragment, useEffect, useState } from "react";
|
||||||
import { BsCheckCircleFill, BsMicFill, BsPauseCircle, BsPlayCircle, BsTrashFill } from "react-icons/bs";
|
import { BsCheckCircleFill, BsMicFill, BsPauseCircle, BsPlayCircle, BsTrashFill } from "react-icons/bs";
|
||||||
import dynamic from "next/dynamic";
|
import dynamic from "next/dynamic";
|
||||||
import Button from "../Low/Button";
|
import Button from "../Low/Button";
|
||||||
import useExamStore from "@/stores/examStore";
|
|
||||||
import { downloadBlob } from "@/utils/evaluation";
|
|
||||||
import axios from "axios";
|
|
||||||
import Modal from "../Modal";
|
import Modal from "../Modal";
|
||||||
|
import useExamStore, { usePersistentExamStore } from "@/stores/exam";
|
||||||
|
|
||||||
const Waveform = dynamic(() => import("../Waveform"), { ssr: false });
|
const Waveform = dynamic(() => import("../Waveform"), { ssr: false });
|
||||||
const ReactMediaRecorder = dynamic(() => import("react-media-recorder").then((mod) => mod.ReactMediaRecorder), {
|
const ReactMediaRecorder = dynamic(() => import("react-media-recorder").then((mod) => mod.ReactMediaRecorder), {
|
||||||
ssr: false,
|
ssr: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
export default function Speaking({ id, title, text, video_url, type, prompts, suffix, userSolutions, isPractice = false, onNext, onBack, preview = false }: SpeakingExercise & CommonProps) {
|
const Speaking: React.FC<SpeakingExercise & CommonProps> = ({
|
||||||
|
id, title, text, video_url, type, prompts,
|
||||||
|
suffix, userSolutions, isPractice = false,
|
||||||
|
registerSolution, headerButtons, footerButtons, preview
|
||||||
|
}) => {
|
||||||
const [recordingDuration, setRecordingDuration] = useState(0);
|
const [recordingDuration, setRecordingDuration] = useState(0);
|
||||||
const [isRecording, setIsRecording] = useState(false);
|
const [isRecording, setIsRecording] = useState(false);
|
||||||
const [mediaBlob, setMediaBlob] = useState<string>();
|
const [mediaBlob, setMediaBlob] = useState<string>();
|
||||||
const [audioURL, setAudioURL] = useState<string>();
|
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
|
||||||
const [isPromptsModalOpen, setIsPromptsModalOpen] = useState(false);
|
const [isPromptsModalOpen, setIsPromptsModalOpen] = useState(false);
|
||||||
const [inputText, setInputText] = useState("");
|
const [inputText, setInputText] = useState("");
|
||||||
|
|
||||||
const hasExamEnded = useExamStore((state) => state.hasExamEnded);
|
const examState = useExamStore((state) => state);
|
||||||
|
const persistentExamState = usePersistentExamStore((state) => state);
|
||||||
|
|
||||||
const saveToStorage = async () => {
|
const {setNavigation} = !preview ? examState : persistentExamState;
|
||||||
if (mediaBlob && mediaBlob.startsWith("blob")) {
|
|
||||||
const blobBuffer = await downloadBlob(mediaBlob);
|
|
||||||
const audioFile = new File([blobBuffer], "audio.wav", { type: "audio/wav" });
|
|
||||||
|
|
||||||
const seed = Math.random().toString().replace("0.", "");
|
useEffect(()=> { if (!preview) setNavigation({nextDisabled: true}) }, [setNavigation, preview])
|
||||||
|
|
||||||
const formData = new FormData();
|
/*useEffect(() => {
|
||||||
formData.append("audio", audioFile, `${seed}.wav`);
|
|
||||||
formData.append("root", "speaking_recordings");
|
|
||||||
|
|
||||||
const config = {
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "audio/wav",
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const response = await axios.post<{ path: string }>("/api/storage/insert", formData, config);
|
|
||||||
if (audioURL) await axios.post("/api/storage/delete", { path: audioURL });
|
|
||||||
return response.data.path;
|
|
||||||
}
|
|
||||||
|
|
||||||
return undefined;
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (userSolutions.length > 0) {
|
if (userSolutions.length > 0) {
|
||||||
const { solution } = userSolutions[0] as { solution?: string };
|
const { solution } = userSolutions[0] as { solution?: string };
|
||||||
if (solution && !mediaBlob) setMediaBlob(solution);
|
if (solution && !mediaBlob) setMediaBlob(solution);
|
||||||
if (solution && !solution.startsWith("blob")) setAudioURL(solution);
|
|
||||||
}
|
}
|
||||||
}, [userSolutions, mediaBlob]);
|
}, [userSolutions, mediaBlob]);*/
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (hasExamEnded) next();
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [hasExamEnded]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let recordingInterval: NodeJS.Timer | undefined = undefined;
|
let recordingInterval: NodeJS.Timer | undefined = undefined;
|
||||||
@@ -76,23 +50,14 @@ export default function Speaking({ id, title, text, video_url, type, prompts, su
|
|||||||
};
|
};
|
||||||
}, [isRecording]);
|
}, [isRecording]);
|
||||||
|
|
||||||
const next = async () => {
|
useEffect(() => {
|
||||||
onNext({
|
registerSolution(() => ({
|
||||||
exercise: id,
|
exercise: id,
|
||||||
solutions: mediaBlob ? [{ id, solution: mediaBlob }] : [],
|
solutions: mediaBlob ? [{ id, solution: mediaBlob }] : [],
|
||||||
score: { correct: 0, total: 100, missing: 0 },
|
score: { correct: 0, total: 100, missing: 0 },
|
||||||
type, isPractice
|
type, isPractice
|
||||||
});
|
}));
|
||||||
};
|
}, [id, isPractice, mediaBlob, registerSolution, type]);
|
||||||
|
|
||||||
const back = async () => {
|
|
||||||
onBack({
|
|
||||||
exercise: id,
|
|
||||||
solutions: mediaBlob ? [{ id, solution: mediaBlob }] : [],
|
|
||||||
score: { correct: 0, total: 100, missing: 0 },
|
|
||||||
type, isPractice
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleNoteWriting = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
const handleNoteWriting = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||||
const newText = e.target.value;
|
const newText = e.target.value;
|
||||||
@@ -115,17 +80,13 @@ export default function Speaking({ id, title, text, video_url, type, prompts, su
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
useEffect(()=> {
|
||||||
|
if(mediaBlob) setNavigation({nextDisabled: false});
|
||||||
|
}, [mediaBlob, setNavigation])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-4 mt-4 w-full">
|
<div className="flex flex-col gap-4 mt-4 w-full">
|
||||||
<div className="flex justify-between w-full gap-8">
|
{headerButtons}
|
||||||
<Button color="purple" variant="outline" isLoading={isLoading} onClick={back} className="max-w-[200px] self-end w-full">
|
|
||||||
Back
|
|
||||||
</Button>
|
|
||||||
<Button color="purple" isLoading={isLoading} disabled={!mediaBlob} onClick={next} className="max-w-[200px] self-end w-full">
|
|
||||||
Next
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-col h-full w-full gap-9">
|
<div className="flex flex-col h-full w-full gap-9">
|
||||||
<Modal title="Prompts" className="!w-96 aspect-square" isOpen={isPromptsModalOpen} onClose={() => setIsPromptsModalOpen(false)}>
|
<Modal title="Prompts" className="!w-96 aspect-square" isOpen={isPromptsModalOpen} onClose={() => setIsPromptsModalOpen(false)}>
|
||||||
<div className="flex flex-col items-center justify-center gap-4 w-full h-full">
|
<div className="flex flex-col items-center justify-center gap-4 w-full h-full">
|
||||||
@@ -302,22 +263,10 @@ export default function Speaking({ id, title, text, video_url, type, prompts, su
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
{footerButtons}
|
||||||
<div className="self-end flex justify-between w-full gap-8">
|
|
||||||
<Button color="purple" variant="outline" isLoading={isLoading} onClick={back} className="max-w-[200px] self-end w-full">
|
|
||||||
Back
|
|
||||||
</Button>
|
|
||||||
{preview ? (
|
|
||||||
<Button color="purple" isLoading={isLoading} onClick={next} className="max-w-[200px] self-end w-full">
|
|
||||||
Next
|
|
||||||
</Button>
|
|
||||||
) : (
|
|
||||||
<Button color="purple" isLoading={isLoading} disabled={!mediaBlob} onClick={next} className="max-w-[200px] self-end w-full">
|
|
||||||
Next
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default Speaking;
|
||||||
|
|||||||
@@ -1,32 +1,23 @@
|
|||||||
import { TrueFalseExercise } from "@/interfaces/exam";
|
import { TrueFalseExercise } from "@/interfaces/exam";
|
||||||
import useExamStore from "@/stores/examStore";
|
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import { Fragment, useEffect, useState } from "react";
|
import { Fragment, useCallback, useEffect, useState } from "react";
|
||||||
import { CommonProps } from ".";
|
import { CommonProps } from "./types";
|
||||||
import Button from "../Low/Button";
|
import Button from "../Low/Button";
|
||||||
|
|
||||||
export default function TrueFalse({
|
const TrueFalse: React.FC<TrueFalseExercise & CommonProps> = ({
|
||||||
id,
|
id,
|
||||||
type,
|
type,
|
||||||
prompt,
|
prompt,
|
||||||
questions,
|
questions,
|
||||||
userSolutions,
|
userSolutions,
|
||||||
isPractice = false,
|
isPractice = false,
|
||||||
onNext,
|
registerSolution,
|
||||||
onBack,
|
headerButtons,
|
||||||
disableProgressButtons = false
|
footerButtons,
|
||||||
}: TrueFalseExercise & CommonProps) {
|
}) => {
|
||||||
const [answers, setAnswers] = useState<{ id: string; solution: "true" | "false" | "not_given" }[]>(userSolutions);
|
const [answers, setAnswers] = useState<{ id: string; solution: "true" | "false" | "not_given" }[]>(userSolutions);
|
||||||
|
|
||||||
const hasExamEnded = useExamStore((state) => state.hasExamEnded);
|
const calculateScore = useCallback(() => {
|
||||||
const setCurrentSolution = useExamStore((state) => state.setCurrentSolution);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (hasExamEnded) onNext({ exercise: id, solutions: answers, score: calculateScore(), type, isPractice });
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [hasExamEnded]);
|
|
||||||
|
|
||||||
const calculateScore = () => {
|
|
||||||
const total = questions.length || 0;
|
const total = questions.length || 0;
|
||||||
const correct = answers.filter(
|
const correct = answers.filter(
|
||||||
(x) =>
|
(x) =>
|
||||||
@@ -38,12 +29,7 @@ export default function TrueFalse({
|
|||||||
const missing = total - answers.filter((x) => questions.find((y) => x.id.toString() === y.id.toString())).length;
|
const missing = total - answers.filter((x) => questions.find((y) => x.id.toString() === y.id.toString())).length;
|
||||||
|
|
||||||
return { total, correct, missing };
|
return { total, correct, missing };
|
||||||
};
|
}, [answers, questions]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setCurrentSolution({ exercise: id, solutions: answers, score: calculateScore(), type, isPractice });
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [answers, setAnswers]);
|
|
||||||
|
|
||||||
const toggleAnswer = (solution: "true" | "false" | "not_given", questionId: string) => {
|
const toggleAnswer = (solution: "true" | "false" | "not_given", questionId: string) => {
|
||||||
const answer = answers.find((x) => x.id === questionId);
|
const answer = answers.find((x) => x.id === questionId);
|
||||||
@@ -56,34 +42,20 @@ export default function TrueFalse({
|
|||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (disableProgressButtons) onNext({ exercise: id, solutions: answers, score: calculateScore(), type, isPractice });
|
registerSolution(() => ({
|
||||||
|
exercise: id,
|
||||||
|
solutions: answers,
|
||||||
|
score: calculateScore(),
|
||||||
|
type,
|
||||||
|
isPractice
|
||||||
|
}));
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [answers, disableProgressButtons])
|
}, [id, answers, type, isPractice, calculateScore]);
|
||||||
|
|
||||||
const progressButtons = () => (
|
|
||||||
<div className="flex justify-between w-full gap-8">
|
|
||||||
<Button
|
|
||||||
color="purple"
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => onBack({ exercise: id, solutions: answers, score: calculateScore(), type, isPractice })}
|
|
||||||
className="max-w-[200px] w-full">
|
|
||||||
Back
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
color="purple"
|
|
||||||
onClick={() => onNext({ exercise: id, solutions: answers, score: calculateScore(), type, isPractice })}
|
|
||||||
className="max-w-[200px] self-end w-full">
|
|
||||||
Next
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-4 mt-4">
|
<div className="flex flex-col gap-4 mt-4">
|
||||||
{!disableProgressButtons && progressButtons()}
|
{headerButtons}
|
||||||
|
<div className={clsx("flex flex-col gap-4 mt-4 h-full w-full", (headerButtons && footerButtons) && "mb-20")}>
|
||||||
<div className={clsx("flex flex-col gap-4 mt-4 h-full w-full", !disableProgressButtons && "mb-20")}>
|
|
||||||
<span className="text-sm w-full leading-6">
|
<span className="text-sm w-full leading-6">
|
||||||
{prompt.split("\\n").map((line, index) => (
|
{prompt.split("\\n").map((line, index) => (
|
||||||
<Fragment key={index}>
|
<Fragment key={index}>
|
||||||
@@ -141,9 +113,11 @@ export default function TrueFalse({
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
{footerButtons}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!disableProgressButtons && progressButtons()}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default TrueFalse;
|
||||||
|
|||||||
@@ -1,156 +0,0 @@
|
|||||||
import { errorButtonStyle, infoButtonStyle } from "@/constants/buttonStyles";
|
|
||||||
import { WriteBlanksExercise } from "@/interfaces/exam";
|
|
||||||
import { mdiArrowLeft, mdiArrowRight } from "@mdi/js";
|
|
||||||
import Icon from "@mdi/react";
|
|
||||||
import clsx from "clsx";
|
|
||||||
import { Fragment, useEffect, useState } from "react";
|
|
||||||
import reactStringReplace from "react-string-replace";
|
|
||||||
import { CommonProps } from ".";
|
|
||||||
import { toast } from "react-toastify";
|
|
||||||
import Button from "../Low/Button";
|
|
||||||
import useExamStore from "@/stores/examStore";
|
|
||||||
|
|
||||||
function Blank({
|
|
||||||
id,
|
|
||||||
maxWords,
|
|
||||||
userSolution,
|
|
||||||
showSolutions = false,
|
|
||||||
setUserSolution,
|
|
||||||
}: {
|
|
||||||
id: string;
|
|
||||||
solutions?: string[];
|
|
||||||
userSolution?: string;
|
|
||||||
maxWords: number;
|
|
||||||
showSolutions?: boolean;
|
|
||||||
setUserSolution: (solution: string) => void;
|
|
||||||
}) {
|
|
||||||
const [userInput, setUserInput] = useState(userSolution || "");
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const words = userInput.split(" ");
|
|
||||||
if (words.length > maxWords) {
|
|
||||||
toast.warning(`You have reached your word limit of ${maxWords} words!`, { toastId: "word-limit" });
|
|
||||||
setUserInput(words.join(" ").trim());
|
|
||||||
}
|
|
||||||
}, [maxWords, userInput]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<input
|
|
||||||
className="py-2 px-3 mx-2 rounded-2xl w-48 bg-white focus:outline-none my-2"
|
|
||||||
placeholder={id}
|
|
||||||
onChange={(e) => setUserInput(e.target.value)}
|
|
||||||
onBlur={() => setUserSolution(userInput)}
|
|
||||||
value={userInput}
|
|
||||||
contentEditable={showSolutions}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function WriteBlanks({
|
|
||||||
id,
|
|
||||||
prompt,
|
|
||||||
type,
|
|
||||||
maxWords,
|
|
||||||
solutions,
|
|
||||||
userSolutions,
|
|
||||||
isPractice = false,
|
|
||||||
text,
|
|
||||||
onNext,
|
|
||||||
onBack,
|
|
||||||
disableProgressButtons = false
|
|
||||||
}: WriteBlanksExercise & CommonProps) {
|
|
||||||
const [answers, setAnswers] = useState<{ id: string; solution: string }[]>(userSolutions);
|
|
||||||
|
|
||||||
const { hasExamEnded, setCurrentSolution } = useExamStore((state) => state);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (hasExamEnded) onNext({ exercise: id, solutions: answers, score: calculateScore(), type, isPractice });
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [hasExamEnded]);
|
|
||||||
|
|
||||||
const calculateScore = () => {
|
|
||||||
const total = text.match(/({{\d+}})/g)?.length || 0;
|
|
||||||
const correct = answers.filter(
|
|
||||||
(x) =>
|
|
||||||
solutions
|
|
||||||
.find((y) => x.id.toString() === y.id.toString())
|
|
||||||
?.solution.map((y) => y.toLowerCase().trim())
|
|
||||||
.includes(x.solution.toLowerCase().trim()) || false,
|
|
||||||
).length;
|
|
||||||
const missing = total - answers.filter((x) => solutions.find((y) => x.id === y.id)).length;
|
|
||||||
|
|
||||||
return { total, correct, missing };
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setCurrentSolution({ exercise: id, solutions: answers, score: calculateScore(), type, isPractice });
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [answers, setAnswers]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (disableProgressButtons) onNext({ exercise: id, solutions: answers, score: calculateScore(), type, isPractice });
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [answers, disableProgressButtons])
|
|
||||||
|
|
||||||
const renderLines = (line: string) => {
|
|
||||||
return (
|
|
||||||
<span className="text-base leading-5">
|
|
||||||
{reactStringReplace(line, /({{\d+}})/g, (match) => {
|
|
||||||
const id = match.replaceAll(/[\{\}]/g, "");
|
|
||||||
const userSolution = answers.find((x) => x.id === id);
|
|
||||||
const setUserSolution = (solution: string) => {
|
|
||||||
setAnswers((prev) => [...prev.filter((x) => x.id !== id), { id, solution }]);
|
|
||||||
};
|
|
||||||
|
|
||||||
return <Blank userSolution={userSolution?.solution} maxWords={maxWords} id={id} setUserSolution={setUserSolution} />;
|
|
||||||
})}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const progressButtons = () => (
|
|
||||||
<div className="flex justify-between w-full gap-8">
|
|
||||||
<Button
|
|
||||||
color="purple"
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => onBack({ exercise: id, solutions: answers, score: calculateScore(), type, isPractice })}
|
|
||||||
className="max-w-[200px] w-full">
|
|
||||||
Back
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
color="purple"
|
|
||||||
onClick={() => onNext({ exercise: id, solutions: answers, score: calculateScore(), type, isPractice })}
|
|
||||||
className="max-w-[200px] self-end w-full">
|
|
||||||
Next
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col gap-4">
|
|
||||||
{!disableProgressButtons && progressButtons()}
|
|
||||||
|
|
||||||
<div className={clsx("flex flex-col gap-4 mt-4 h-full w-full", !disableProgressButtons && "mb-20")}>
|
|
||||||
<span className="text-sm w-full leading-6">
|
|
||||||
{prompt.split("\\n").map((line, index) => (
|
|
||||||
<span key={index}>
|
|
||||||
{line}
|
|
||||||
<br />
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</span>
|
|
||||||
<span className="bg-mti-gray-smoke rounded-xl px-5 py-6">
|
|
||||||
{text.split("\\n").map((line, index) => (
|
|
||||||
<p key={index}>
|
|
||||||
{renderLines(line)}
|
|
||||||
<br />
|
|
||||||
</p>
|
|
||||||
))}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{!disableProgressButtons && progressButtons()}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
41
src/components/Exercises/WriteBlanks/Blank.tsx
Normal file
41
src/components/Exercises/WriteBlanks/Blank.tsx
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { toast } from "react-toastify";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
id: string;
|
||||||
|
solutions?: string[];
|
||||||
|
userSolution?: string;
|
||||||
|
maxWords: number;
|
||||||
|
showSolutions?: boolean;
|
||||||
|
setUserSolution: (solution: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Blank: React.FC<Props> = ({ id,
|
||||||
|
maxWords,
|
||||||
|
userSolution,
|
||||||
|
showSolutions = false,
|
||||||
|
setUserSolution, }) => {
|
||||||
|
|
||||||
|
const [userInput, setUserInput] = useState(userSolution || "");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const words = userInput.split(" ");
|
||||||
|
if (words.length > maxWords) {
|
||||||
|
toast.warning(`You have reached your word limit of ${maxWords} words!`, { toastId: "word-limit" });
|
||||||
|
setUserInput(words.join(" ").trim());
|
||||||
|
}
|
||||||
|
}, [maxWords, userInput]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
className="py-2 px-3 mx-2 rounded-2xl w-48 bg-white focus:outline-none my-2"
|
||||||
|
placeholder={id}
|
||||||
|
onChange={(e) => setUserInput(e.target.value)}
|
||||||
|
onBlur={() => setUserSolution(userInput)}
|
||||||
|
value={userInput}
|
||||||
|
contentEditable={showSolutions}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Blank;
|
||||||
92
src/components/Exercises/WriteBlanks/index.tsx
Normal file
92
src/components/Exercises/WriteBlanks/index.tsx
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
import { WriteBlanksExercise } from "@/interfaces/exam";
|
||||||
|
import clsx from "clsx";
|
||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import reactStringReplace from "react-string-replace";
|
||||||
|
import { CommonProps } from "../types";
|
||||||
|
import Blank from "./Blank";
|
||||||
|
|
||||||
|
const WriteBlanks: React.FC<WriteBlanksExercise & CommonProps> = ({
|
||||||
|
id,
|
||||||
|
prompt,
|
||||||
|
type,
|
||||||
|
maxWords,
|
||||||
|
solutions,
|
||||||
|
userSolutions,
|
||||||
|
isPractice = false,
|
||||||
|
text,
|
||||||
|
registerSolution,
|
||||||
|
headerButtons,
|
||||||
|
footerButtons,
|
||||||
|
}) => {
|
||||||
|
const [answers, setAnswers] = useState<{ id: string; solution: string }[]>(userSolutions);
|
||||||
|
|
||||||
|
const calculateScore = useCallback(() => {
|
||||||
|
const total = text.match(/({{\d+}})/g)?.length || 0;
|
||||||
|
const correct = answers.filter(
|
||||||
|
(x) =>
|
||||||
|
solutions
|
||||||
|
.find((y) => x.id.toString() === y.id.toString())
|
||||||
|
?.solution.map((y) => y.toLowerCase().trim())
|
||||||
|
.includes(x.solution.toLowerCase().trim()) || false,
|
||||||
|
).length;
|
||||||
|
const missing = total - answers.filter((x) => solutions.find((y) => x.id === y.id)).length;
|
||||||
|
|
||||||
|
return { total, correct, missing };
|
||||||
|
}, [answers, solutions, text]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
registerSolution(() => ({
|
||||||
|
exercise: id,
|
||||||
|
solutions: answers,
|
||||||
|
score: calculateScore(),
|
||||||
|
type,
|
||||||
|
isPractice
|
||||||
|
}));
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [id, answers, type, isPractice, calculateScore]);
|
||||||
|
|
||||||
|
|
||||||
|
const renderLines = (line: string) => {
|
||||||
|
return (
|
||||||
|
<span className="text-base leading-5">
|
||||||
|
{reactStringReplace(line, /({{\d+}})/g, (match) => {
|
||||||
|
const id = match.replaceAll(/[\{\}]/g, "");
|
||||||
|
const userSolution = answers.find((x) => x.id === id);
|
||||||
|
const setUserSolution = (solution: string) => {
|
||||||
|
setAnswers((prev) => [...prev.filter((x) => x.id !== id), { id, solution }]);
|
||||||
|
};
|
||||||
|
|
||||||
|
return <Blank key={`blank-${id}`} userSolution={userSolution?.solution} maxWords={maxWords} id={id} setUserSolution={setUserSolution} />;
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
{headerButtons}
|
||||||
|
<div className={clsx("flex flex-col gap-4 mt-4 h-full w-full", (!headerButtons && !footerButtons) && "mb-20")}>
|
||||||
|
<span className="text-sm w-full leading-6">
|
||||||
|
{prompt.split("\\n").map((line, index) => (
|
||||||
|
<span key={index}>
|
||||||
|
{line}
|
||||||
|
<br />
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</span>
|
||||||
|
<span className="bg-mti-gray-smoke rounded-xl px-5 py-6">
|
||||||
|
{text.split("\\n").map((line, index) => (
|
||||||
|
<p key={index}>
|
||||||
|
{renderLines(line)}
|
||||||
|
<br />
|
||||||
|
</p>
|
||||||
|
))}
|
||||||
|
</span>
|
||||||
|
{footerButtons}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default WriteBlanks;
|
||||||
@@ -1,13 +1,12 @@
|
|||||||
/* eslint-disable @next/next/no-img-element */
|
/* eslint-disable @next/next/no-img-element */
|
||||||
import { WritingExercise } from "@/interfaces/exam";
|
import { WritingExercise } from "@/interfaces/exam";
|
||||||
import { CommonProps } from ".";
|
import React, { Fragment, useEffect, useState } from "react";
|
||||||
import React, { Fragment, useEffect, useRef, useState } from "react";
|
import { Dialog, DialogPanel, Transition, TransitionChild } from "@headlessui/react";
|
||||||
|
import useExamStore, { usePersistentExamStore } from "@/stores/exam";
|
||||||
|
import { CommonProps } from "./types";
|
||||||
import { toast } from "react-toastify";
|
import { toast } from "react-toastify";
|
||||||
import Button from "../Low/Button";
|
|
||||||
import { Dialog, Transition } from "@headlessui/react";
|
|
||||||
import useExamStore from "@/stores/examStore";
|
|
||||||
|
|
||||||
export default function Writing({
|
const Writing: React.FC<WritingExercise & CommonProps> = ({
|
||||||
id,
|
id,
|
||||||
prompt,
|
prompt,
|
||||||
prefix,
|
prefix,
|
||||||
@@ -17,17 +16,24 @@ export default function Writing({
|
|||||||
attachment,
|
attachment,
|
||||||
userSolutions,
|
userSolutions,
|
||||||
isPractice = false,
|
isPractice = false,
|
||||||
onNext,
|
registerSolution,
|
||||||
onBack,
|
headerButtons,
|
||||||
enableNavigation = false
|
footerButtons,
|
||||||
}: WritingExercise & CommonProps) {
|
preview,
|
||||||
|
}) => {
|
||||||
|
|
||||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||||
const [inputText, setInputText] = useState(userSolutions.length === 1 ? userSolutions[0].solution : "");
|
const [inputText, setInputText] = useState(userSolutions.length === 1 ? userSolutions[0].solution : "");
|
||||||
const [isSubmitEnabled, setIsSubmitEnabled] = useState(false);
|
|
||||||
const [saveTimer, setSaveTimer] = useState(0);
|
const [saveTimer, setSaveTimer] = useState(0);
|
||||||
|
|
||||||
const { userSolutions: storeUserSolutions, setUserSolutions } = useExamStore((state) => state);
|
const examState = useExamStore((state) => state);
|
||||||
const hasExamEnded = useExamStore((state) => state.hasExamEnded);
|
const persistentExamState = usePersistentExamStore((state) => state);
|
||||||
|
|
||||||
|
const { userSolutions: storeUserSolutions, setUserSolutions, setNavigation } = !preview ? examState : persistentExamState;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!preview) setNavigation({ nextDisabled: true });
|
||||||
|
}, [setNavigation, preview]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const saveTimerInterval = setInterval(() => {
|
const saveTimerInterval = setInterval(() => {
|
||||||
@@ -43,7 +49,7 @@ export default function Writing({
|
|||||||
if (inputText.length > 0 && saveTimer % 10 === 0) {
|
if (inputText.length > 0 && saveTimer % 10 === 0) {
|
||||||
setUserSolutions([
|
setUserSolutions([
|
||||||
...storeUserSolutions.filter((x) => x.exercise !== id),
|
...storeUserSolutions.filter((x) => x.exercise !== id),
|
||||||
{ exercise: id, solutions: [{ id, solution: inputText }], score: { correct: 100, total: 100, missing: 0 }, type, module: "writing" },
|
{ exercise: id, solutions: [{ id, solution: inputText.replaceAll(/\s{2,}/g, " ") }], score: { correct: 100, total: 100, missing: 0 }, type, module: "writing" },
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
@@ -65,59 +71,39 @@ export default function Writing({
|
|||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (hasExamEnded)
|
|
||||||
onNext({ exercise: id, solutions: [{ id, solution: inputText }], score: { correct: 100, total: 100, missing: 0 }, type, module: "writing", isPractice });
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [hasExamEnded]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const words = inputText.split(" ").filter((x) => x !== "");
|
const words = inputText.split(" ").filter((x) => x !== "");
|
||||||
|
|
||||||
if (wordCounter.type === "min") {
|
if (wordCounter.type === "min") {
|
||||||
setIsSubmitEnabled(wordCounter.limit <= words.length || enableNavigation);
|
setNavigation({ nextDisabled: !(wordCounter.limit <= words.length) });
|
||||||
} else {
|
} else {
|
||||||
setIsSubmitEnabled(true);
|
setNavigation({ nextDisabled: false });
|
||||||
|
|
||||||
if (wordCounter.limit < words.length) {
|
if (wordCounter.limit < words.length) {
|
||||||
toast.warning(`You have reached your word limit of ${wordCounter.limit} words!`, { toastId: "word-limit" });
|
toast.warning(`You have reached your word limit of ${wordCounter.limit} words!`, { toastId: "word-limit" });
|
||||||
setInputText(words.slice(0, words.length - 1).join(" "));
|
setInputText(words.slice(0, words.length - 1).join(" "));
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
}, [enableNavigation, inputText, wordCounter]);
|
}, [inputText, setNavigation, wordCounter]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
registerSolution(() => ({
|
||||||
|
exercise: id,
|
||||||
|
solutions: [{ id, solution: inputText.replaceAll(/\s{2,}/g, " ") }],
|
||||||
|
score: { correct: 100, total: 100, missing: 0 },
|
||||||
|
type,
|
||||||
|
isPractice
|
||||||
|
}));
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [id, type, isPractice, inputText]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-4 mt-4">
|
<div className="flex flex-col gap-4 mt-4">
|
||||||
<div className="flex justify-between w-full gap-8">
|
{headerButtons}
|
||||||
<Button
|
|
||||||
color="purple"
|
|
||||||
variant="outline"
|
|
||||||
onClick={() =>
|
|
||||||
onBack({ exercise: id, solutions: [{ id, solution: inputText }], score: { correct: 100, total: 100, missing: 0 }, type, isPractice })
|
|
||||||
}
|
|
||||||
className="max-w-[200px] self-end w-full">
|
|
||||||
Back
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
color="purple"
|
|
||||||
disabled={!isSubmitEnabled}
|
|
||||||
onClick={() =>
|
|
||||||
onNext({
|
|
||||||
exercise: id,
|
|
||||||
solutions: [{ id, solution: inputText.replaceAll(/\s{2,}/g, " ") }],
|
|
||||||
score: { correct: 100, total: 100, missing: 0 },
|
|
||||||
type,
|
|
||||||
module: "writing", isPractice
|
|
||||||
})
|
|
||||||
}
|
|
||||||
className="max-w-[200px] self-end w-full">
|
|
||||||
Next
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{attachment && (
|
{attachment && (
|
||||||
<Transition show={isModalOpen} as={Fragment}>
|
<Transition show={isModalOpen} as={Fragment}>
|
||||||
<Dialog onClose={() => setIsModalOpen(false)} className="relative z-50">
|
<Dialog onClose={() => setIsModalOpen(false)} className="relative z-50">
|
||||||
<Transition.Child
|
<TransitionChild
|
||||||
as={Fragment}
|
as={Fragment}
|
||||||
enter="ease-out duration-300"
|
enter="ease-out duration-300"
|
||||||
enterFrom="opacity-0"
|
enterFrom="opacity-0"
|
||||||
@@ -126,9 +112,9 @@ export default function Writing({
|
|||||||
leaveFrom="opacity-100"
|
leaveFrom="opacity-100"
|
||||||
leaveTo="opacity-0">
|
leaveTo="opacity-0">
|
||||||
<div className="fixed inset-0 bg-black/30" />
|
<div className="fixed inset-0 bg-black/30" />
|
||||||
</Transition.Child>
|
</TransitionChild>
|
||||||
|
|
||||||
<Transition.Child
|
<TransitionChild
|
||||||
as={Fragment}
|
as={Fragment}
|
||||||
enter="ease-out duration-300"
|
enter="ease-out duration-300"
|
||||||
enterFrom="opacity-0 scale-95"
|
enterFrom="opacity-0 scale-95"
|
||||||
@@ -137,11 +123,11 @@ export default function Writing({
|
|||||||
leaveFrom="opacity-100 scale-100"
|
leaveFrom="opacity-100 scale-100"
|
||||||
leaveTo="opacity-0 scale-95">
|
leaveTo="opacity-0 scale-95">
|
||||||
<div className="fixed inset-0 flex items-center justify-center p-4">
|
<div className="fixed inset-0 flex items-center justify-center p-4">
|
||||||
<Dialog.Panel className="w-fit h-fit rounded-xl bg-white">
|
<DialogPanel className="w-fit h-fit rounded-xl bg-white">
|
||||||
<img src={attachment.url} alt={attachment.description} className="max-w-4xl w-full self-center rounded-xl p-4" />
|
<img src={attachment.url} alt={attachment.description} className="max-w-4xl w-full self-center rounded-xl p-4" />
|
||||||
</Dialog.Panel>
|
</DialogPanel>
|
||||||
</div>
|
</div>
|
||||||
</Transition.Child>
|
</TransitionChild>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
</Transition>
|
</Transition>
|
||||||
)}
|
)}
|
||||||
@@ -172,33 +158,9 @@ export default function Writing({
|
|||||||
<span className="text-base self-end text-mti-gray-cool">Word Count: {inputText.split(" ").filter((x) => x !== "").length}</span>
|
<span className="text-base self-end text-mti-gray-cool">Word Count: {inputText.split(" ").filter((x) => x !== "").length}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{footerButtons}
|
||||||
<div className="self-end flex justify-between w-full gap-8 absolute bottom-8 left-0 px-8">
|
|
||||||
<Button
|
|
||||||
color="purple"
|
|
||||||
variant="outline"
|
|
||||||
onClick={() =>
|
|
||||||
onBack({ exercise: id, solutions: [{ id, solution: inputText }], score: { correct: 100, total: 100, missing: 0 }, type, isPractice })
|
|
||||||
}
|
|
||||||
className="max-w-[200px] self-end w-full">
|
|
||||||
Back
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
color="purple"
|
|
||||||
disabled={!isSubmitEnabled}
|
|
||||||
onClick={() =>
|
|
||||||
onNext({
|
|
||||||
exercise: id,
|
|
||||||
solutions: [{ id, solution: inputText.replaceAll(/\s{2,}/g, " ") }],
|
|
||||||
score: { correct: 100, total: 100, missing: 0 },
|
|
||||||
type,
|
|
||||||
module: "writing", isPractice
|
|
||||||
})
|
|
||||||
}
|
|
||||||
className="max-w-[200px] self-end w-full">
|
|
||||||
Next
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default Writing;
|
||||||
|
|||||||
@@ -21,49 +21,38 @@ import InteractiveSpeaking from "./InteractiveSpeaking";
|
|||||||
|
|
||||||
const MatchSentences = dynamic(() => import("@/components/Exercises/MatchSentences"), { ssr: false });
|
const MatchSentences = dynamic(() => import("@/components/Exercises/MatchSentences"), { ssr: false });
|
||||||
|
|
||||||
export interface CommonProps {
|
|
||||||
examID?: string;
|
|
||||||
onNext: (userSolutions: UserSolution) => void;
|
|
||||||
onBack: (userSolutions: UserSolution) => void;
|
|
||||||
enableNavigation?: boolean;
|
|
||||||
disableProgressButtons?: boolean
|
|
||||||
preview?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const renderExercise = (
|
export const renderExercise = (
|
||||||
exercise: Exercise,
|
exercise: Exercise,
|
||||||
examID: string,
|
examID: string,
|
||||||
onNext: (userSolutions: UserSolution) => void,
|
registerSolution: (updateSolution: () => UserSolution) => void,
|
||||||
onBack: (userSolutions: UserSolution) => void,
|
preview: boolean,
|
||||||
enableNavigation?: boolean,
|
headerButtons?: React.ReactNode,
|
||||||
disableProgressButtons?: boolean,
|
footerButtons?: React.ReactNode,
|
||||||
preview?: boolean,
|
|
||||||
) => {
|
) => {
|
||||||
|
const sharedProps = {
|
||||||
|
key: exercise.id,
|
||||||
|
registerSolution,
|
||||||
|
headerButtons,
|
||||||
|
footerButtons,
|
||||||
|
examID,
|
||||||
|
preview
|
||||||
|
}
|
||||||
switch (exercise.type) {
|
switch (exercise.type) {
|
||||||
case "fillBlanks":
|
case "fillBlanks":
|
||||||
return <FillBlanks disableProgressButtons={disableProgressButtons} key={exercise.id} {...(exercise as FillBlanksExercise)} onNext={onNext} onBack={onBack} examID={examID} preview={preview} />;
|
return <FillBlanks {...(exercise as FillBlanksExercise)} {...sharedProps}/>;
|
||||||
case "trueFalse":
|
case "trueFalse":
|
||||||
return <TrueFalse disableProgressButtons={disableProgressButtons} key={exercise.id} {...(exercise as TrueFalseExercise)} onNext={onNext} onBack={onBack} examID={examID} preview={preview} />;
|
return <TrueFalse {...(exercise as TrueFalseExercise)} {...sharedProps}/>;
|
||||||
case "matchSentences":
|
case "matchSentences":
|
||||||
return <MatchSentences disableProgressButtons={disableProgressButtons} key={exercise.id} {...(exercise as MatchSentencesExercise)} onNext={onNext} onBack={onBack} examID={examID} preview={preview} />;
|
return <MatchSentences {...(exercise as MatchSentencesExercise)} {...sharedProps}/>;
|
||||||
case "multipleChoice":
|
case "multipleChoice":
|
||||||
return <MultipleChoice disableProgressButtons={disableProgressButtons} key={exercise.id} {...(exercise as MultipleChoiceExercise)} onNext={onNext} onBack={onBack} examID={examID} preview={preview} />;
|
return <MultipleChoice {...(exercise as MultipleChoiceExercise)} {...sharedProps}/>;
|
||||||
case "writeBlanks":
|
case "writeBlanks":
|
||||||
return <WriteBlanks disableProgressButtons={disableProgressButtons} key={exercise.id} {...(exercise as WriteBlanksExercise)} onNext={onNext} onBack={onBack} examID={examID} preview={preview} />;
|
return <WriteBlanks {...(exercise as WriteBlanksExercise)} {...sharedProps}/>;
|
||||||
case "writing":
|
case "writing":
|
||||||
return <Writing key={exercise.id} {...(exercise as WritingExercise)} onNext={onNext} onBack={onBack} examID={examID} enableNavigation={enableNavigation} preview={preview} />;
|
return <Writing {...(exercise as WritingExercise)} {...sharedProps}/>;
|
||||||
case "speaking":
|
case "speaking":
|
||||||
return <Speaking key={exercise.id} {...(exercise as SpeakingExercise)} onNext={onNext} onBack={onBack} examID={examID} preview={preview} />;
|
return <Speaking {...(exercise as SpeakingExercise)} {...sharedProps}/>;
|
||||||
case "interactiveSpeaking":
|
case "interactiveSpeaking":
|
||||||
return (
|
return <InteractiveSpeaking {...(exercise as InteractiveSpeakingExercise)} {...sharedProps}/>;
|
||||||
<InteractiveSpeaking
|
|
||||||
key={exercise.id}
|
|
||||||
{...(exercise as InteractiveSpeakingExercise)}
|
|
||||||
examID={examID}
|
|
||||||
onNext={onNext}
|
|
||||||
onBack={onBack}
|
|
||||||
preview={preview}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
9
src/components/Exercises/types.ts
Normal file
9
src/components/Exercises/types.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import { UserSolution } from "@/interfaces/exam";
|
||||||
|
|
||||||
|
export interface CommonProps {
|
||||||
|
examID?: string;
|
||||||
|
registerSolution: (updateSolution: () => UserSolution) => void;
|
||||||
|
headerButtons?: React.ReactNode,
|
||||||
|
footerButtons?: React.ReactNode,
|
||||||
|
preview: boolean,
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import {Ticket, TicketType, TicketTypeLabel} from "@/interfaces/ticket";
|
import {Ticket, TicketType, TicketTypeLabel} from "@/interfaces/ticket";
|
||||||
import {User} from "@/interfaces/user";
|
import {User} from "@/interfaces/user";
|
||||||
import useExamStore from "@/stores/examStore";
|
import useExamStore from "@/stores/exam";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import {useState} from "react";
|
import {useState} from "react";
|
||||||
import {toast} from "react-toastify";
|
import {toast} from "react-toastify";
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import Button from "@/components/Low/Button";
|
import Button from "@/components/Low/Button";
|
||||||
import Modal from "@/components/Modal";
|
import Modal from "@/components/Modal";
|
||||||
import { Exam, LevelExam, MultipleChoiceExercise, ShuffleMap } from "@/interfaces/exam";
|
import { Exam, LevelExam, MultipleChoiceExercise, ShuffleMap } from "@/interfaces/exam";
|
||||||
import useExamStore from "@/stores/examStore";
|
import useExamStore, { usePersistentExamStore } from "@/stores/exam";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { BsFillGrid3X3GapFill } from "react-icons/bs";
|
import { BsFillGrid3X3GapFill } from "react-icons/bs";
|
||||||
@@ -10,16 +10,20 @@ interface Props {
|
|||||||
exam: LevelExam
|
exam: LevelExam
|
||||||
showSolutions: boolean;
|
showSolutions: boolean;
|
||||||
runOnClick: ((index: number) => void) | undefined;
|
runOnClick: ((index: number) => void) | undefined;
|
||||||
|
preview: boolean,
|
||||||
}
|
}
|
||||||
|
|
||||||
const MCQuestionGrid: React.FC<Props> = ({ exam, showSolutions, runOnClick }) => {
|
const MCQuestionGrid: React.FC<Props> = ({ exam, showSolutions, runOnClick, preview }) => {
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
|
||||||
|
const examState = useExamStore((state) => state);
|
||||||
|
const persistentExamState = usePersistentExamStore((state) => state);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
userSolutions,
|
userSolutions,
|
||||||
partIndex: sectionIndex,
|
partIndex: sectionIndex,
|
||||||
exerciseIndex,
|
exerciseIndex,
|
||||||
} = useExamStore((state) => state);
|
} = !preview ? examState : persistentExamState;
|
||||||
|
|
||||||
const currentExercise = useMemo(() => (exam as LevelExam).parts[sectionIndex!].exercises[exerciseIndex] as MultipleChoiceExercise, [exam, exerciseIndex, sectionIndex])
|
const currentExercise = useMemo(() => (exam as LevelExam).parts[sectionIndex!].exercises[exerciseIndex] as MultipleChoiceExercise, [exam, exerciseIndex, sectionIndex])
|
||||||
const userSolution = useMemo(() => userSolutions!.find((x) => x.exercise.toString() == currentExercise.id.toString())!, [currentExercise.id, userSolutions])
|
const userSolution = useMemo(() => userSolutions!.find((x) => x.exercise.toString() == currentExercise.id.toString())!, [currentExercise.id, userSolutions])
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { BsBook, BsClipboard, BsHeadphones, BsMegaphone, BsPen } from "react-ico
|
|||||||
import ProgressBar from "../../Low/ProgressBar";
|
import ProgressBar from "../../Low/ProgressBar";
|
||||||
import Timer from "../Timer";
|
import Timer from "../Timer";
|
||||||
import { Exercise, LevelExam } from "@/interfaces/exam";
|
import { Exercise, LevelExam } from "@/interfaces/exam";
|
||||||
import useExamStore from "@/stores/examStore";
|
import useExamStore, { usePersistentExamStore } from "@/stores/exam";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import MCQuestionGrid from "./MCQuestionGrid";
|
import MCQuestionGrid from "./MCQuestionGrid";
|
||||||
|
|
||||||
@@ -23,7 +23,8 @@ interface Props {
|
|||||||
showSolutions?: boolean;
|
showSolutions?: boolean;
|
||||||
currentExercise?: Exercise;
|
currentExercise?: Exercise;
|
||||||
runOnClick?: ((questionIndex: number) => void) | undefined;
|
runOnClick?: ((questionIndex: number) => void) | undefined;
|
||||||
indexLabel?: string
|
indexLabel?: string,
|
||||||
|
preview: boolean,
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ModuleTitle({
|
export default function ModuleTitle({
|
||||||
@@ -38,9 +39,13 @@ export default function ModuleTitle({
|
|||||||
showTimer = true,
|
showTimer = true,
|
||||||
showSolutions = false,
|
showSolutions = false,
|
||||||
runOnClick = undefined,
|
runOnClick = undefined,
|
||||||
indexLabel = "Question"
|
indexLabel = "Question",
|
||||||
|
preview,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const { exam, partIndex, exerciseIndex: examExerciseIndex, userSolutions } = useExamStore((state) => state);
|
const examState = useExamStore((state) => state);
|
||||||
|
const persistentExamState = usePersistentExamStore((state) => state);
|
||||||
|
|
||||||
|
const { exam, partIndex, exerciseIndex: examExerciseIndex, userSolutions } = !preview ? examState : persistentExamState;
|
||||||
|
|
||||||
const moduleIcon: { [key in Module]: ReactNode } = {
|
const moduleIcon: { [key in Module]: ReactNode } = {
|
||||||
reading: <BsBook className="text-ielts-reading w-6 h-6" />,
|
reading: <BsBook className="text-ielts-reading w-6 h-6" />,
|
||||||
@@ -52,7 +57,6 @@ export default function ModuleTitle({
|
|||||||
|
|
||||||
const showGrid = useMemo(() =>
|
const showGrid = useMemo(() =>
|
||||||
exam?.module === "level"
|
exam?.module === "level"
|
||||||
&& partIndex > -1
|
|
||||||
&& exam.parts[partIndex].exercises[examExerciseIndex].type === "multipleChoice"
|
&& exam.parts[partIndex].exercises[examExerciseIndex].type === "multipleChoice"
|
||||||
&& !!userSolutions,
|
&& !!userSolutions,
|
||||||
[exam, examExerciseIndex, partIndex, userSolutions]
|
[exam, examExerciseIndex, partIndex, userSolutions]
|
||||||
@@ -95,7 +99,7 @@ export default function ModuleTitle({
|
|||||||
</div>
|
</div>
|
||||||
<ProgressBar color={module} label="" percentage={(exerciseIndex * 100) / totalExercises} className="h-2 w-full" />
|
<ProgressBar color={module} label="" percentage={(exerciseIndex * 100) / totalExercises} className="h-2 w-full" />
|
||||||
</div>
|
</div>
|
||||||
{showGrid && <MCQuestionGrid exam={exam as LevelExam} showSolutions={showSolutions} runOnClick={runOnClick} />}
|
{showGrid && <MCQuestionGrid exam={exam as LevelExam} showSolutions={showSolutions} runOnClick={runOnClick} preview={preview}/>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import {Session} from "@/hooks/useSessions";
|
import {Session} from "@/hooks/useSessions";
|
||||||
import useExamStore from "@/stores/examStore";
|
|
||||||
import {sortByModuleName} from "@/utils/moduleUtils";
|
import {sortByModuleName} from "@/utils/moduleUtils";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
|
|||||||
@@ -11,10 +11,10 @@ import { uuidv4 } from "@firebase/util";
|
|||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
import { uniqBy } from "lodash";
|
import { uniqBy } from "lodash";
|
||||||
import { sortByModule } from "@/utils/moduleUtils";
|
import { sortByModule } from "@/utils/moduleUtils";
|
||||||
import { convertToUserSolutions } from "@/utils/stats";
|
|
||||||
import { getExamById } from "@/utils/exams";
|
import { getExamById } from "@/utils/exams";
|
||||||
import { Exam, UserSolution } from "@/interfaces/exam";
|
import { Exam, UserSolution } from "@/interfaces/exam";
|
||||||
import ModuleBadge from "../ModuleBadge";
|
import ModuleBadge from "../ModuleBadge";
|
||||||
|
import useExamStore from "@/stores/exam";
|
||||||
|
|
||||||
const formatTimestamp = (timestamp: string | number) => {
|
const formatTimestamp = (timestamp: string | number) => {
|
||||||
const time = typeof timestamp === "string" ? parseInt(timestamp) : timestamp;
|
const time = typeof timestamp === "string" ? parseInt(timestamp) : timestamp;
|
||||||
@@ -81,12 +81,6 @@ interface StatsGridItemProps {
|
|||||||
selectedTrainingExams?: string[];
|
selectedTrainingExams?: string[];
|
||||||
maxTrainingExams?: number;
|
maxTrainingExams?: number;
|
||||||
setSelectedTrainingExams?: React.Dispatch<React.SetStateAction<string[]>>;
|
setSelectedTrainingExams?: React.Dispatch<React.SetStateAction<string[]>>;
|
||||||
setExams: (exams: Exam[]) => void;
|
|
||||||
setShowSolutions: (show: boolean) => void;
|
|
||||||
setUserSolutions: (solutions: UserSolution[]) => void;
|
|
||||||
setSelectedModules: (modules: Module[]) => void;
|
|
||||||
setInactivity: (inactivity: number) => void;
|
|
||||||
setTimeSpent: (time: number) => void;
|
|
||||||
renderPdfIcon: (session: string, color: string, textColor: string) => React.ReactNode;
|
renderPdfIcon: (session: string, color: string, textColor: string) => React.ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -100,12 +94,6 @@ const StatsGridItem: React.FC<StatsGridItemProps> = ({
|
|||||||
selectedTrainingExams,
|
selectedTrainingExams,
|
||||||
gradingSystem,
|
gradingSystem,
|
||||||
setSelectedTrainingExams,
|
setSelectedTrainingExams,
|
||||||
setExams,
|
|
||||||
setShowSolutions,
|
|
||||||
setUserSolutions,
|
|
||||||
setSelectedModules,
|
|
||||||
setInactivity,
|
|
||||||
setTimeSpent,
|
|
||||||
renderPdfIcon,
|
renderPdfIcon,
|
||||||
width = undefined,
|
width = undefined,
|
||||||
height = undefined,
|
height = undefined,
|
||||||
@@ -113,6 +101,8 @@ const StatsGridItem: React.FC<StatsGridItemProps> = ({
|
|||||||
maxTrainingExams = undefined,
|
maxTrainingExams = undefined,
|
||||||
}) => {
|
}) => {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const dispatch = useExamStore((s) => s.dispatch);
|
||||||
|
|
||||||
const correct = stats.reduce((accumulator, current) => accumulator + current.score.correct, 0);
|
const correct = stats.reduce((accumulator, current) => accumulator + current.score.correct, 0);
|
||||||
const total = stats.reduce((accumulator, current) => accumulator + current.score.total, 0);
|
const total = stats.reduce((accumulator, current) => accumulator + current.score.total, 0);
|
||||||
const aggregatedScores = aggregateScoresByModule(stats).filter((x) => x.total > 0);
|
const aggregatedScores = aggregateScoresByModule(stats).filter((x) => x.total > 0);
|
||||||
@@ -171,17 +161,18 @@ const StatsGridItem: React.FC<StatsGridItemProps> = ({
|
|||||||
|
|
||||||
Promise.all(examPromises).then((exams) => {
|
Promise.all(examPromises).then((exams) => {
|
||||||
if (exams.every((x) => !!x)) {
|
if (exams.every((x) => !!x)) {
|
||||||
if (!!timeSpent) setTimeSpent(timeSpent);
|
dispatch({
|
||||||
if (!!inactivity) setInactivity(inactivity);
|
type: 'INIT_SOLUTIONS', payload: {
|
||||||
setUserSolutions(convertToUserSolutions(stats));
|
exams: exams.map((x) => x!).sort(sortByModule),
|
||||||
setShowSolutions(true);
|
modules: exams
|
||||||
setExams(exams.map((x) => x!).sort(sortByModule));
|
.map((x) => x!)
|
||||||
setSelectedModules(
|
.sort(sortByModule)
|
||||||
exams
|
.map((x) => x!.module),
|
||||||
.map((x) => x!)
|
stats,
|
||||||
.sort(sortByModule)
|
timeSpent,
|
||||||
.map((x) => x!.module),
|
inactivity
|
||||||
);
|
}
|
||||||
|
});
|
||||||
router.push("/exam");
|
router.push("/exam");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import useExamStore from "@/stores/examStore";
|
import useExamStore from "@/stores/exam";
|
||||||
import {useEffect, useState} from "react";
|
import {useEffect, useState} from "react";
|
||||||
import {motion} from "framer-motion";
|
import {motion} from "framer-motion";
|
||||||
import TimerEndedModal from "../TimerEndedModal";
|
import TimerEndedModal from "../TimerEndedModal";
|
||||||
@@ -16,10 +16,7 @@ const Timer: React.FC<Props> = ({minTimer, disableTimer, standalone = false}) =>
|
|||||||
const [showModal, setShowModal] = useState(false);
|
const [showModal, setShowModal] = useState(false);
|
||||||
const [warningMode, setWarningMode] = useState(false);
|
const [warningMode, setWarningMode] = useState(false);
|
||||||
|
|
||||||
const setHasExamEnded = useExamStore((state) => state.setHasExamEnded);
|
const setTimeIsUp = useExamStore((state) => state.setTimeIsUp);
|
||||||
const {timeSpent} = useExamStore((state) => state);
|
|
||||||
|
|
||||||
useEffect(() => setTimer((prev) => prev - timeSpent), [timeSpent]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!disableTimer) {
|
if (!disableTimer) {
|
||||||
@@ -44,7 +41,7 @@ const Timer: React.FC<Props> = ({minTimer, disableTimer, standalone = false}) =>
|
|||||||
<TimerEndedModal
|
<TimerEndedModal
|
||||||
isOpen={showModal}
|
isOpen={showModal}
|
||||||
onClose={() => {
|
onClose={() => {
|
||||||
setHasExamEnded(true);
|
setTimeIsUp(true);
|
||||||
setShowModal(false);
|
setShowModal(false);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -6,10 +6,10 @@ import Writing from "@/exams/Writing";
|
|||||||
import { usePersistentStorage } from "@/hooks/usePersistentStorage";
|
import { usePersistentStorage } from "@/hooks/usePersistentStorage";
|
||||||
import { LevelExam, ListeningExam, ReadingExam, SpeakingExam, WritingExam } from "@/interfaces/exam";
|
import { LevelExam, ListeningExam, ReadingExam, SpeakingExam, WritingExam } from "@/interfaces/exam";
|
||||||
import { User } from "@/interfaces/user";
|
import { User } from "@/interfaces/user";
|
||||||
import { usePersistentExamStore } from "@/stores/examStore";
|
import { usePersistentExamStore } from "@/stores/exam";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
|
|
||||||
// todo: perms
|
// TODO: perms
|
||||||
|
|
||||||
const Popout: React.FC<{ user: User }> = ({ user }) => {
|
const Popout: React.FC<{ user: User }> = ({ user }) => {
|
||||||
const state = usePersistentExamStore((state) => state);
|
const state = usePersistentExamStore((state) => state);
|
||||||
@@ -18,35 +18,19 @@ const Popout: React.FC<{ user: User }> = ({ user }) => {
|
|||||||
<div className={`relative flex w-full min-h-screen p-4 shadow-md items-center rounded-2xl ${state.bgColor}`}>
|
<div className={`relative flex w-full min-h-screen p-4 shadow-md items-center rounded-2xl ${state.bgColor}`}>
|
||||||
<div className={clsx("relative flex p-20 justify-center flex-1")}>
|
<div className={clsx("relative flex p-20 justify-center flex-1")}>
|
||||||
{state.exam?.module == "level" && state.exam.parts && state.partIndex >= 0 &&
|
{state.exam?.module == "level" && state.exam.parts && state.partIndex >= 0 &&
|
||||||
<Level exam={state.exam as LevelExam} onFinish={() => {
|
<Level exam={state.exam as LevelExam} preview={true} />
|
||||||
state.setPartIndex(0);
|
|
||||||
state.setExerciseIndex(0);
|
|
||||||
state.setQuestionIndex(0);
|
|
||||||
}} preview={true} />
|
|
||||||
}
|
}
|
||||||
{state.exam?.module == "writing" && state.exam.exercises && state.partIndex >= 0 &&
|
{state.exam?.module == "writing" && state.exam.exercises && state.partIndex >= 0 &&
|
||||||
<Writing exam={state.exam as WritingExam} onFinish={() => {
|
<Writing exam={state.exam as WritingExam} preview={true} />
|
||||||
state.setExerciseIndex(0);
|
|
||||||
}} preview={true} />
|
|
||||||
}
|
}
|
||||||
{state.exam?.module == "reading" && state.exam.parts.length > 0 &&
|
{state.exam?.module == "reading" && state.exam.parts.length > 0 &&
|
||||||
<Reading exam={state.exam as ReadingExam} onFinish={() => {
|
<Reading exam={state.exam as ReadingExam} preview={true} />
|
||||||
state.setPartIndex(0);
|
|
||||||
state.setExerciseIndex(-1);
|
|
||||||
state.setQuestionIndex(0);
|
|
||||||
}} preview={true} />
|
|
||||||
}
|
}
|
||||||
{state.exam?.module == "listening" && state.exam.parts.length > 0 &&
|
{state.exam?.module == "listening" && state.exam.parts.length > 0 &&
|
||||||
<Listening exam={state.exam as ListeningExam} onFinish={() => {
|
<Listening exam={state.exam as ListeningExam} preview={true} />
|
||||||
state.setPartIndex(0);
|
|
||||||
state.setExerciseIndex(-1);
|
|
||||||
state.setQuestionIndex(0);
|
|
||||||
}} preview={true} />
|
|
||||||
}
|
}
|
||||||
{state.exam?.module == "speaking" && state.exam.exercises.length > 0 &&
|
{state.exam?.module == "speaking" && state.exam.exercises.length > 0 &&
|
||||||
<Speaking exam={state.exam as SpeakingExam} onFinish={() => {
|
<Speaking exam={state.exam as SpeakingExam} preview={true} />
|
||||||
state.setExerciseIndex(-1);
|
|
||||||
}} preview={true} />
|
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,48 +2,12 @@ import { FillBlanksExercise, FillBlanksMCOption, ShuffleMap } from "@/interfaces
|
|||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import reactStringReplace from "react-string-replace";
|
import reactStringReplace from "react-string-replace";
|
||||||
import { CommonProps } from ".";
|
import { CommonProps } from ".";
|
||||||
import { Fragment } from "react";
|
import useExamStore from "@/stores/exam";
|
||||||
import Button from "../Low/Button";
|
|
||||||
import useExamStore from "@/stores/examStore";
|
|
||||||
import { typeCheckWordsMC } from "@/utils/type.check";
|
import { typeCheckWordsMC } from "@/utils/type.check";
|
||||||
|
|
||||||
export default function FillBlanksSolutions({ id, type, prompt, solutions, words, text, onNext, onBack, disableProgressButtons = false }: FillBlanksExercise & CommonProps) {
|
const FillBlanksSolutions: React.FC<FillBlanksExercise & CommonProps> = ({ id, solutions, words, text, headerButtons, footerButtons}) => {
|
||||||
const storeUserSolutions = useExamStore((state) => state.userSolutions);
|
const {userSolutions, shuffles} = useExamStore();
|
||||||
const { questionIndex, setQuestionIndex, partIndex, exam } = useExamStore((state) => state);
|
const correctUserSolutions = userSolutions.find((solution) => solution.exercise === id)?.solutions;
|
||||||
|
|
||||||
const correctUserSolutions = storeUserSolutions.find((solution) => solution.exercise === id)?.solutions;
|
|
||||||
|
|
||||||
const shuffles = useExamStore((state) => state.shuffles);
|
|
||||||
|
|
||||||
const calculateScore = () => {
|
|
||||||
const total = text.match(/({{\d+}})/g)?.length || 0;
|
|
||||||
const correct = correctUserSolutions!.filter((x) => {
|
|
||||||
const solution = solutions.find((y) => x.id.toString() === y.id.toString())?.solution;
|
|
||||||
if (!solution) return false;
|
|
||||||
|
|
||||||
const option = words.find((w) => {
|
|
||||||
if (typeof w === "string") {
|
|
||||||
return w.toLowerCase() === x.solution.toLowerCase();
|
|
||||||
} else if ("letter" in w) {
|
|
||||||
return w.letter.toLowerCase() === x.solution.toLowerCase();
|
|
||||||
} else {
|
|
||||||
return w.id.toString() === x.id.toString();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
if (!option) return false;
|
|
||||||
|
|
||||||
if (typeof option === "string") {
|
|
||||||
return solution.toLowerCase() === option.toLowerCase();
|
|
||||||
} else if ("letter" in option) {
|
|
||||||
return solution.toLowerCase() === option.word.toLowerCase();
|
|
||||||
} else if ("options" in option) {
|
|
||||||
return option.options[solution as keyof typeof option.options] == x.solution;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}).length;
|
|
||||||
const missing = total - correctUserSolutions!.filter((x) => solutions.find((y) => x.id.toString() === y.id.toString())).length;
|
|
||||||
return { total, correct, missing };
|
|
||||||
};
|
|
||||||
|
|
||||||
const renderLines = (line: string) => {
|
const renderLines = (line: string) => {
|
||||||
return (
|
return (
|
||||||
@@ -149,31 +113,10 @@ export default function FillBlanksSolutions({ id, type, prompt, solutions, words
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const progressButtons = () => (
|
|
||||||
<div className="flex justify-between w-full gap-8">
|
|
||||||
<Button
|
|
||||||
color="purple"
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => onBack({ exercise: id, solutions: correctUserSolutions!, score: calculateScore(), type })}
|
|
||||||
className="max-w-[200px] w-full"
|
|
||||||
disabled={exam && typeof partIndex !== "undefined" && exam.module === "level" && questionIndex === 0 && partIndex === 0}>
|
|
||||||
Back
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
color="purple"
|
|
||||||
onClick={() => onNext({ exercise: id, solutions: correctUserSolutions!, score: calculateScore(), type })}
|
|
||||||
className="max-w-[200px] self-end w-full">
|
|
||||||
Next
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
{!disableProgressButtons && progressButtons()}
|
{headerButtons}
|
||||||
|
<div className={clsx("flex flex-col gap-4 mt-4 h-full w-full", (!headerButtons || !footerButtons) && "mb-20")}>
|
||||||
<div className={clsx("flex flex-col gap-4 mt-4 h-full w-full", !disableProgressButtons && "mb-20")}>
|
|
||||||
<span className="bg-mti-gray-smoke rounded-xl px-5 py-6">
|
<span className="bg-mti-gray-smoke rounded-xl px-5 py-6">
|
||||||
{correctUserSolutions &&
|
{correctUserSolutions &&
|
||||||
text.split("\\n").map((line, index) => (
|
text.split("\\n").map((line, index) => (
|
||||||
@@ -197,9 +140,10 @@ export default function FillBlanksSolutions({ id, type, prompt, solutions, words
|
|||||||
Wrong
|
Wrong
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{footerButtons}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!disableProgressButtons && progressButtons()}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default FillBlanksSolutions;
|
||||||
|
|||||||
@@ -14,14 +14,11 @@ import ReactDiffViewer, {DiffMethod} from "react-diff-viewer";
|
|||||||
const Waveform = dynamic(() => import("../Waveform"), {ssr: false});
|
const Waveform = dynamic(() => import("../Waveform"), {ssr: false});
|
||||||
|
|
||||||
export default function InteractiveSpeaking({
|
export default function InteractiveSpeaking({
|
||||||
id,
|
|
||||||
type,
|
|
||||||
title,
|
title,
|
||||||
text,
|
|
||||||
prompts,
|
prompts,
|
||||||
userSolutions,
|
userSolutions,
|
||||||
onNext,
|
headerButtons,
|
||||||
onBack,
|
footerButtons,
|
||||||
}: InteractiveSpeakingExercise & CommonProps) {
|
}: InteractiveSpeakingExercise & CommonProps) {
|
||||||
const [solutionsURL, setSolutionsURL] = useState<string[]>([]);
|
const [solutionsURL, setSolutionsURL] = useState<string[]>([]);
|
||||||
const [diffNumber, setDiffNumber] = useState(0);
|
const [diffNumber, setDiffNumber] = useState(0);
|
||||||
@@ -56,40 +53,7 @@ export default function InteractiveSpeaking({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-4 mt-4 w-full">
|
<div className="flex flex-col gap-4 mt-4 w-full">
|
||||||
<div className="flex justify-between w-full gap-8">
|
{headerButtons}
|
||||||
<Button
|
|
||||||
color="purple"
|
|
||||||
variant="outline"
|
|
||||||
onClick={() =>
|
|
||||||
onBack({
|
|
||||||
exercise: id,
|
|
||||||
solutions: userSolutions,
|
|
||||||
score: {total: 100, missing: 0, correct: speakingReverseMarking[userSolutions[0]!.evaluation!.overall] || 0},
|
|
||||||
type,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
className="max-w-[200px] self-end w-full">
|
|
||||||
Back
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
color="purple"
|
|
||||||
onClick={() =>
|
|
||||||
onNext({
|
|
||||||
exercise: id,
|
|
||||||
solutions: userSolutions,
|
|
||||||
score: {
|
|
||||||
total: 100,
|
|
||||||
missing: 0,
|
|
||||||
correct: userSolutions[0]?.evaluation ? speakingReverseMarking[userSolutions[0]!.evaluation!.overall] || 0 : 0,
|
|
||||||
},
|
|
||||||
type,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
className="max-w-[200px] self-end w-full">
|
|
||||||
Next
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Modal title={`Correction (Prompt ${diffNumber})`} isOpen={diffNumber !== 0} onClose={() => setDiffNumber(0)}>
|
<Modal title={`Correction (Prompt ${diffNumber})`} isOpen={diffNumber !== 0} onClose={() => setDiffNumber(0)}>
|
||||||
<>
|
<>
|
||||||
{userSolutions &&
|
{userSolutions &&
|
||||||
@@ -291,40 +255,7 @@ export default function InteractiveSpeaking({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{footerButtons}
|
||||||
<div className="self-end flex justify-between w-full gap-8 absolute bottom-8 left-0 px-8">
|
|
||||||
<Button
|
|
||||||
color="purple"
|
|
||||||
variant="outline"
|
|
||||||
onClick={() =>
|
|
||||||
onBack({
|
|
||||||
exercise: id,
|
|
||||||
solutions: userSolutions,
|
|
||||||
score: {total: 100, missing: 0, correct: speakingReverseMarking[userSolutions[0]!.evaluation!.overall] || 0},
|
|
||||||
type,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
className="max-w-[200px] self-end w-full">
|
|
||||||
Back
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
color="purple"
|
|
||||||
onClick={() =>
|
|
||||||
onNext({
|
|
||||||
exercise: id,
|
|
||||||
solutions: userSolutions,
|
|
||||||
score: {
|
|
||||||
total: 100,
|
|
||||||
missing: 0,
|
|
||||||
correct: userSolutions[0]?.evaluation ? speakingReverseMarking[userSolutions[0]!.evaluation!.overall] || 0 : 0,
|
|
||||||
},
|
|
||||||
type,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
className="max-w-[200px] self-end w-full">
|
|
||||||
Next
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,7 @@
|
|||||||
import { MatchSentenceExerciseSentence, MatchSentencesExercise } from "@/interfaces/exam";
|
import { MatchSentenceExerciseSentence, MatchSentencesExercise } from "@/interfaces/exam";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import LineTo from "react-lineto";
|
|
||||||
import { CommonProps } from ".";
|
import { CommonProps } from ".";
|
||||||
import { errorButtonStyle, infoButtonStyle } from "@/constants/buttonStyles";
|
|
||||||
import { mdiArrowLeft, mdiArrowRight } from "@mdi/js";
|
|
||||||
import Icon from "@mdi/react";
|
|
||||||
import { Fragment } from "react";
|
import { Fragment } from "react";
|
||||||
import Button from "../Low/Button";
|
|
||||||
import Xarrow from "react-xarrows";
|
|
||||||
import useExamStore from "@/stores/examStore";
|
|
||||||
|
|
||||||
function QuestionSolutionArea({
|
function QuestionSolutionArea({
|
||||||
question,
|
question,
|
||||||
@@ -22,7 +15,7 @@ function QuestionSolutionArea({
|
|||||||
<div className="flex items-center gap-3 cursor-pointer col-span-2">
|
<div className="flex items-center gap-3 cursor-pointer col-span-2">
|
||||||
<button
|
<button
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"text-white w-8 h-8 rounded-full z-10",
|
"text-white p-2 rounded-full z-10",
|
||||||
!userSolution
|
!userSolution
|
||||||
? "bg-mti-gray-davy"
|
? "bg-mti-gray-davy"
|
||||||
: userSolution.option.toString() === question.solution.toString()
|
: userSolution.option.toString() === question.solution.toString()
|
||||||
@@ -55,51 +48,16 @@ function QuestionSolutionArea({
|
|||||||
export default function MatchSentencesSolutions({
|
export default function MatchSentencesSolutions({
|
||||||
id,
|
id,
|
||||||
type,
|
type,
|
||||||
options,
|
|
||||||
prompt,
|
prompt,
|
||||||
sentences,
|
sentences,
|
||||||
userSolutions,
|
userSolutions,
|
||||||
onNext,
|
headerButtons,
|
||||||
onBack,
|
footerButtons,
|
||||||
disableProgressButtons = false
|
|
||||||
}: MatchSentencesExercise & CommonProps) {
|
}: MatchSentencesExercise & CommonProps) {
|
||||||
const { questionIndex, setQuestionIndex, partIndex, exam } = useExamStore((state) => state);
|
|
||||||
|
|
||||||
const calculateScore = () => {
|
|
||||||
const total = sentences.length;
|
|
||||||
const correct = userSolutions.filter(
|
|
||||||
(x) => sentences.find((y) => y.id.toString() === x.question.toString())?.solution === x.option || false,
|
|
||||||
).length;
|
|
||||||
const missing = total - userSolutions.filter((x) => sentences.find((y) => y.id.toString() === x.question.toString())).length;
|
|
||||||
|
|
||||||
return { total, correct, missing };
|
|
||||||
};
|
|
||||||
|
|
||||||
const progressButtons = () => (
|
|
||||||
<div className="flex justify-between w-full gap-8">
|
|
||||||
<Button
|
|
||||||
color="purple"
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => onBack({ exercise: id, solutions: userSolutions, score: calculateScore(), type })}
|
|
||||||
className="max-w-[200px] w-full"
|
|
||||||
disabled={exam && typeof partIndex !== "undefined" && exam.module === "level" && questionIndex === 0 && partIndex === 0}>
|
|
||||||
Back
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
color="purple"
|
|
||||||
onClick={() => onNext({ exercise: id, solutions: userSolutions, score: calculateScore(), type })}
|
|
||||||
className="max-w-[200px] self-end w-full">
|
|
||||||
Next
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-4 mt-4">
|
<div className="flex flex-col gap-4 mt-4">
|
||||||
{!disableProgressButtons && progressButtons()}
|
{headerButtons}
|
||||||
|
<div className={clsx("flex flex-col gap-4 mt-4 h-full w-full", (!headerButtons || !footerButtons) && "mb-20")}>
|
||||||
<div className={clsx("flex flex-col gap-4 mt-4 h-full w-full", !disableProgressButtons && "mb-20")}>
|
|
||||||
<span className="text-sm w-full leading-6">
|
<span className="text-sm w-full leading-6">
|
||||||
{prompt.split("\\n").map((line, index) => (
|
{prompt.split("\\n").map((line, index) => (
|
||||||
<Fragment key={index}>
|
<Fragment key={index}>
|
||||||
@@ -131,9 +89,8 @@ export default function MatchSentencesSolutions({
|
|||||||
<div className="w-4 h-4 rounded-full bg-mti-rose" /> Wrong
|
<div className="w-4 h-4 rounded-full bg-mti-rose" /> Wrong
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{footerButtons}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!disableProgressButtons && progressButtons()}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
/* eslint-disable @next/next/no-img-element */
|
/* eslint-disable @next/next/no-img-element */
|
||||||
import { MultipleChoiceExercise, MultipleChoiceQuestion, ShuffleMap } from "@/interfaces/exam";
|
import { MultipleChoiceExercise, MultipleChoiceQuestion, ShuffleMap } from "@/interfaces/exam";
|
||||||
import useExamStore from "@/stores/examStore";
|
import useExamStore from "@/stores/exam";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import reactStringReplace from "react-string-replace";
|
import reactStringReplace from "react-string-replace";
|
||||||
import { CommonProps } from ".";
|
import { CommonProps } from ".";
|
||||||
@@ -89,7 +89,7 @@ function Question({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function MultipleChoice({ id, type, prompt, questions, userSolutions, onNext, onBack, disableProgressButtons = false }: MultipleChoiceExercise & CommonProps) {
|
export default function MultipleChoice({ id, type, prompt, questions, userSolutions, headerButtons, footerButtons}: MultipleChoiceExercise & CommonProps) {
|
||||||
const { questionIndex, setQuestionIndex, partIndex, exam } = useExamStore((state) => state);
|
const { questionIndex, setQuestionIndex, partIndex, exam } = useExamStore((state) => state);
|
||||||
|
|
||||||
const stats = useExamStore((state) => state.userSolutions);
|
const stats = useExamStore((state) => state.userSolutions);
|
||||||
@@ -110,39 +110,6 @@ export default function MultipleChoice({ id, type, prompt, questions, userSoluti
|
|||||||
return { total, correct, missing };
|
return { total, correct, missing };
|
||||||
};
|
};
|
||||||
|
|
||||||
const next = () => {
|
|
||||||
if (questionIndex + 1 >= questions.length - 1) {
|
|
||||||
onNext({ exercise: id, solutions: userSolutions, score: calculateScore(), type });
|
|
||||||
} else {
|
|
||||||
setQuestionIndex(questionIndex + 2);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const back = () => {
|
|
||||||
if (questionIndex === 0) {
|
|
||||||
onBack({ exercise: id, solutions: userSolutions, score: calculateScore(), type });
|
|
||||||
} else {
|
|
||||||
setQuestionIndex(questionIndex - 2);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const progressButtons = () => (
|
|
||||||
<div className="flex justify-between w-full gap-8">
|
|
||||||
<Button
|
|
||||||
color="purple"
|
|
||||||
variant="outline"
|
|
||||||
onClick={back}
|
|
||||||
className="max-w-[200px] w-full"
|
|
||||||
disabled={exam && typeof partIndex !== "undefined" && exam.module === "level" && questionIndex === 0 && partIndex === 0}>
|
|
||||||
Back
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button color="purple" onClick={next} className="max-w-[200px] self-end w-full">
|
|
||||||
Next
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
|
|
||||||
const renderAllQuestions = () =>
|
const renderAllQuestions = () =>
|
||||||
questions.map(question => (
|
questions.map(question => (
|
||||||
<div
|
<div
|
||||||
@@ -178,10 +145,10 @@ export default function MultipleChoice({ id, type, prompt, questions, userSoluti
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
{!disableProgressButtons && progressButtons()}
|
{headerButtons}
|
||||||
|
|
||||||
<div className={clsx("flex flex-col gap-4 mt-4", !disableProgressButtons && "mb-20")}>
|
<div className={clsx("flex flex-col gap-4 mt-4", (!headerButtons || !footerButtons) && "mb-20")}>
|
||||||
{disableProgressButtons ? renderAllQuestions() : renderTwoQuestions()}
|
{(!headerButtons || !footerButtons) ? renderAllQuestions() : renderTwoQuestions()}
|
||||||
|
|
||||||
<div className="flex gap-4 items-center">
|
<div className="flex gap-4 items-center">
|
||||||
<div className="flex gap-2 items-center">
|
<div className="flex gap-2 items-center">
|
||||||
@@ -197,9 +164,8 @@ export default function MultipleChoice({ id, type, prompt, questions, userSoluti
|
|||||||
Wrong
|
Wrong
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{footerButtons}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!disableProgressButtons && progressButtons()}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import ReactDiffViewer, {DiffMethod} from "react-diff-viewer";
|
|||||||
|
|
||||||
const Waveform = dynamic(() => import("../Waveform"), {ssr: false});
|
const Waveform = dynamic(() => import("../Waveform"), {ssr: false});
|
||||||
|
|
||||||
export default function Speaking({id, type, title, video_url, text, prompts, userSolutions, onNext, onBack}: SpeakingExercise & CommonProps) {
|
export default function Speaking({id, type, title, video_url, text, prompts, userSolutions, headerButtons, footerButtons}: SpeakingExercise & CommonProps) {
|
||||||
const [solutionURL, setSolutionURL] = useState<string>();
|
const [solutionURL, setSolutionURL] = useState<string>();
|
||||||
const [showDiff, setShowDiff] = useState(false);
|
const [showDiff, setShowDiff] = useState(false);
|
||||||
|
|
||||||
@@ -45,40 +45,7 @@ export default function Speaking({id, type, title, video_url, text, prompts, use
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-4 mt-4 w-full">
|
<div className="flex flex-col gap-4 mt-4 w-full">
|
||||||
<div className="flex justify-between w-full gap-8">
|
{headerButtons}
|
||||||
<Button
|
|
||||||
color="purple"
|
|
||||||
variant="outline"
|
|
||||||
onClick={() =>
|
|
||||||
onBack({
|
|
||||||
exercise: id,
|
|
||||||
solutions: userSolutions,
|
|
||||||
score: {total: 100, missing: 0, correct: speakingReverseMarking[userSolutions[0]!.evaluation!.overall] || 0},
|
|
||||||
type,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
className="max-w-[200px] self-end w-full">
|
|
||||||
Back
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
color="purple"
|
|
||||||
onClick={() =>
|
|
||||||
onNext({
|
|
||||||
exercise: id,
|
|
||||||
solutions: userSolutions,
|
|
||||||
score: {
|
|
||||||
total: 100,
|
|
||||||
missing: 0,
|
|
||||||
correct: userSolutions[0]?.evaluation ? speakingReverseMarking[userSolutions[0]!.evaluation!.overall] || 0 : 0,
|
|
||||||
},
|
|
||||||
type,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
className="max-w-[200px] self-end w-full">
|
|
||||||
Next
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Modal title="Correction" isOpen={showDiff} onClose={() => setShowDiff(false)}>
|
<Modal title="Correction" isOpen={showDiff} onClose={() => setShowDiff(false)}>
|
||||||
<>
|
<>
|
||||||
{userSolutions &&
|
{userSolutions &&
|
||||||
@@ -275,40 +242,7 @@ export default function Speaking({id, type, title, video_url, text, prompts, use
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{footerButtons}
|
||||||
<div className="self-end flex justify-between w-full gap-8 absolute bottom-8 left-0 px-8">
|
|
||||||
<Button
|
|
||||||
color="purple"
|
|
||||||
variant="outline"
|
|
||||||
onClick={() =>
|
|
||||||
onBack({
|
|
||||||
exercise: id,
|
|
||||||
solutions: userSolutions,
|
|
||||||
score: {total: 100, missing: 0, correct: speakingReverseMarking[userSolutions[0]!.evaluation!.overall] || 0},
|
|
||||||
type,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
className="max-w-[200px] self-end w-full">
|
|
||||||
Back
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
color="purple"
|
|
||||||
onClick={() =>
|
|
||||||
onNext({
|
|
||||||
exercise: id,
|
|
||||||
solutions: userSolutions,
|
|
||||||
score: {
|
|
||||||
total: 100,
|
|
||||||
missing: 0,
|
|
||||||
correct: userSolutions[0]?.evaluation ? speakingReverseMarking[userSolutions[0]!.evaluation!.overall] || 0 : 0,
|
|
||||||
},
|
|
||||||
type,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
className="max-w-[200px] self-end w-full">
|
|
||||||
Next
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,23 +4,10 @@ import reactStringReplace from "react-string-replace";
|
|||||||
import { CommonProps } from ".";
|
import { CommonProps } from ".";
|
||||||
import { Fragment } from "react";
|
import { Fragment } from "react";
|
||||||
import Button from "../Low/Button";
|
import Button from "../Low/Button";
|
||||||
import useExamStore from "@/stores/examStore";
|
|
||||||
|
|
||||||
type Solution = "true" | "false" | "not_given";
|
type Solution = "true" | "false" | "not_given";
|
||||||
|
|
||||||
export default function TrueFalseSolution({ prompt, type, id, questions, userSolutions, onNext, onBack, disableProgressButtons = false }: TrueFalseExercise & CommonProps) {
|
export default function TrueFalseSolution({ prompt, type, id, questions, userSolutions, headerButtons, footerButtons }: TrueFalseExercise & CommonProps) {
|
||||||
const { questionIndex, setQuestionIndex, partIndex, exam } = useExamStore((state) => state);
|
|
||||||
|
|
||||||
const calculateScore = () => {
|
|
||||||
const total = questions.length || 0;
|
|
||||||
const correct = userSolutions.filter(
|
|
||||||
(x) => questions.find((y) => x.id.toString() === y.id.toString())?.solution === x.solution.toLowerCase() || false,
|
|
||||||
).length;
|
|
||||||
const missing = total - userSolutions.filter((x) => questions.find((y) => x.id.toString() === y.id.toString())).length;
|
|
||||||
|
|
||||||
return { total, correct, missing };
|
|
||||||
};
|
|
||||||
|
|
||||||
const getButtonColor = (buttonSolution: Solution, solution: Solution, userSolution: Solution | undefined) => {
|
const getButtonColor = (buttonSolution: Solution, solution: Solution, userSolution: Solution | undefined) => {
|
||||||
if (buttonSolution !== userSolution && buttonSolution !== solution) return "purple";
|
if (buttonSolution !== userSolution && buttonSolution !== solution) return "purple";
|
||||||
|
|
||||||
@@ -39,31 +26,11 @@ export default function TrueFalseSolution({ prompt, type, id, questions, userSol
|
|||||||
return "gray";
|
return "gray";
|
||||||
};
|
};
|
||||||
|
|
||||||
const progressButtons = () => (
|
|
||||||
<div className="flex justify-between w-full gap-8">
|
|
||||||
<Button
|
|
||||||
color="purple"
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => onBack({ exercise: id, solutions: userSolutions, score: calculateScore(), type })}
|
|
||||||
className="max-w-[200px] w-full"
|
|
||||||
disabled={exam && typeof partIndex !== "undefined" && exam.module === "level" && questionIndex === 0 && partIndex === 0}>
|
|
||||||
Back
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
color="purple"
|
|
||||||
onClick={() => onNext({ exercise: id, solutions: userSolutions, score: calculateScore(), type })}
|
|
||||||
className="max-w-[200px] self-end w-full">
|
|
||||||
Next
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-4 mt-4">
|
<div className="flex flex-col gap-4 mt-4">
|
||||||
{!disableProgressButtons && progressButtons()}
|
{headerButtons}
|
||||||
|
|
||||||
<div className={clsx("flex flex-col gap-4 mt-4 h-full w-full", !disableProgressButtons && "mb-20")}>
|
<div className={clsx("flex flex-col gap-4 mt-4 h-full w-full", (!headerButtons || !footerButtons) && "mb-20")}>
|
||||||
<span className="text-sm w-full leading-6">
|
<span className="text-sm w-full leading-6">
|
||||||
{prompt.split("\\n").map((line, index) => (
|
{prompt.split("\\n").map((line, index) => (
|
||||||
<Fragment key={index}>
|
<Fragment key={index}>
|
||||||
@@ -139,9 +106,8 @@ export default function TrueFalseSolution({ prompt, type, id, questions, userSol
|
|||||||
Wrong
|
Wrong
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{footerButtons}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!disableProgressButtons && progressButtons()}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,9 +6,6 @@ import clsx from "clsx";
|
|||||||
import { Fragment, useEffect, useState } from "react";
|
import { Fragment, useEffect, useState } from "react";
|
||||||
import reactStringReplace from "react-string-replace";
|
import reactStringReplace from "react-string-replace";
|
||||||
import { CommonProps } from ".";
|
import { CommonProps } from ".";
|
||||||
import { toast } from "react-toastify";
|
|
||||||
import Button from "../Low/Button";
|
|
||||||
import useExamStore from "@/stores/examStore";
|
|
||||||
|
|
||||||
function Blank({
|
function Blank({
|
||||||
id,
|
id,
|
||||||
@@ -50,11 +47,11 @@ function Blank({
|
|||||||
{userSolution && !isUserSolutionCorrect() && (
|
{userSolution && !isUserSolutionCorrect() && (
|
||||||
<div
|
<div
|
||||||
className="py-2 px-3 rounded-2xl w-fit focus:outline-none my-2 bg-mti-rose-ultralight text-mti-rose-light"
|
className="py-2 px-3 rounded-2xl w-fit focus:outline-none my-2 bg-mti-rose-ultralight text-mti-rose-light"
|
||||||
contentEditable={disabled}>
|
>
|
||||||
{userSolution}
|
{userSolution}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className={clsx("py-2 px-3 rounded-2xl w-fit focus:outline-none my-2", getSolutionStyling())} contentEditable={disabled}>
|
<div className={clsx("py-2 px-3 rounded-2xl w-fit focus:outline-none my-2", getSolutionStyling())} >
|
||||||
{!solutions ? userInput : solutions.join(" / ")}
|
{!solutions ? userInput : solutions.join(" / ")}
|
||||||
</div>
|
</div>
|
||||||
</span>
|
</span>
|
||||||
@@ -62,33 +59,14 @@ function Blank({
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function WriteBlanksSolutions({
|
export default function WriteBlanksSolutions({
|
||||||
id,
|
|
||||||
type,
|
|
||||||
prompt,
|
prompt,
|
||||||
maxWords,
|
maxWords,
|
||||||
solutions,
|
solutions,
|
||||||
userSolutions,
|
userSolutions,
|
||||||
text,
|
text,
|
||||||
onNext,
|
headerButtons,
|
||||||
onBack,
|
footerButtons,
|
||||||
disableProgressButtons = false
|
|
||||||
}: WriteBlanksExercise & CommonProps) {
|
}: WriteBlanksExercise & CommonProps) {
|
||||||
const { questionIndex, setQuestionIndex, partIndex, exam } = useExamStore((state) => state);
|
|
||||||
|
|
||||||
const calculateScore = () => {
|
|
||||||
const total = text.match(/({{\d+}})/g)?.length || 0;
|
|
||||||
const correct = userSolutions.filter(
|
|
||||||
(x) =>
|
|
||||||
solutions
|
|
||||||
.find((y) => x.id.toString() === y.id.toString())
|
|
||||||
?.solution.map((y) => y.toLowerCase().trim())
|
|
||||||
.includes(x.solution.toLowerCase().trim()) || false,
|
|
||||||
).length;
|
|
||||||
const missing = total - userSolutions.filter((x) => solutions.find((y) => x.id.toString() === y.id.toString())).length;
|
|
||||||
|
|
||||||
return { total, correct, missing };
|
|
||||||
};
|
|
||||||
|
|
||||||
const renderLines = (line: string) => {
|
const renderLines = (line: string) => {
|
||||||
return (
|
return (
|
||||||
<span className="text-base leading-5">
|
<span className="text-base leading-5">
|
||||||
@@ -105,31 +83,11 @@ export default function WriteBlanksSolutions({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const progressButtons = () => (
|
|
||||||
<div className="flex justify-between w-full gap-8">
|
|
||||||
<Button
|
|
||||||
color="purple"
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => onBack({ exercise: id, solutions: userSolutions, score: calculateScore(), type })}
|
|
||||||
className="max-w-[200px] w-full"
|
|
||||||
disabled={exam && typeof partIndex !== "undefined" && exam.module === "level" && questionIndex === 0 && partIndex === 0}>
|
|
||||||
Back
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
color="purple"
|
|
||||||
onClick={() => onNext({ exercise: id, solutions: userSolutions, score: calculateScore(), type })}
|
|
||||||
className="max-w-[200px] self-end w-full">
|
|
||||||
Next
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
{!disableProgressButtons && progressButtons()}
|
{headerButtons}
|
||||||
|
|
||||||
<div className={clsx("flex flex-col gap-4 mt-4 h-full w-full", !disableProgressButtons && "mb-20")}>
|
<div className={clsx("flex flex-col gap-4 mt-4 h-full w-full", (!headerButtons || !footerButtons) && "mb-20")}>
|
||||||
<span className="text-sm w-full leading-6">
|
<span className="text-sm w-full leading-6">
|
||||||
{prompt.split("\\n").map((line, index) => (
|
{prompt.split("\\n").map((line, index) => (
|
||||||
<Fragment key={index}>
|
<Fragment key={index}>
|
||||||
@@ -161,9 +119,8 @@ export default function WriteBlanksSolutions({
|
|||||||
Wrong
|
Wrong
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{footerButtons}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!disableProgressButtons && progressButtons()}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import ReactDiffViewer, {DiffMethod} from "react-diff-viewer";
|
|||||||
import useUser from "@/hooks/useUser";
|
import useUser from "@/hooks/useUser";
|
||||||
import AIDetection from "../AIDetection";
|
import AIDetection from "../AIDetection";
|
||||||
|
|
||||||
export default function Writing({id, type, prompt, attachment, userSolutions, onNext, onBack}: WritingExercise & CommonProps) {
|
export default function Writing({id, type, prompt, attachment, userSolutions, headerButtons, footerButtons}: WritingExercise & CommonProps) {
|
||||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||||
const [showDiff, setShowDiff] = useState(false);
|
const [showDiff, setShowDiff] = useState(false);
|
||||||
|
|
||||||
@@ -31,40 +31,7 @@ export default function Writing({id, type, prompt, attachment, userSolutions, on
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-4 mt-4">
|
<div className="flex flex-col gap-4 mt-4">
|
||||||
<div className="self-end flex justify-between w-full gap-8 absolute bottom-8 left-0 px-8">
|
{headerButtons}
|
||||||
<Button
|
|
||||||
color="purple"
|
|
||||||
variant="outline"
|
|
||||||
onClick={() =>
|
|
||||||
onBack({
|
|
||||||
exercise: id,
|
|
||||||
solutions: userSolutions,
|
|
||||||
score: {total: 100, missing: 0, correct: writingReverseMarking[userSolutions[0]!.evaluation!.overall] || 0},
|
|
||||||
type,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
className="max-w-[200px] self-end w-full">
|
|
||||||
Back
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
color="purple"
|
|
||||||
onClick={() =>
|
|
||||||
onNext({
|
|
||||||
exercise: id,
|
|
||||||
solutions: userSolutions,
|
|
||||||
score: {
|
|
||||||
total: 100,
|
|
||||||
missing: 0,
|
|
||||||
correct: userSolutions[0]?.evaluation ? writingReverseMarking[userSolutions[0]!.evaluation!.overall] || 0 : 0,
|
|
||||||
},
|
|
||||||
type,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
className="max-w-[200px] self-end w-full">
|
|
||||||
Next
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{attachment && (
|
{attachment && (
|
||||||
<Transition show={isModalOpen} as={Fragment}>
|
<Transition show={isModalOpen} as={Fragment}>
|
||||||
<Dialog onClose={() => setIsModalOpen(false)} className="relative z-50">
|
<Dialog onClose={() => setIsModalOpen(false)} className="relative z-50">
|
||||||
@@ -286,40 +253,7 @@ export default function Writing({id, type, prompt, attachment, userSolutions, on
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{footerButtons}
|
||||||
<div className="self-end flex justify-between w-full gap-8 absolute bottom-8 left-0 px-8">
|
|
||||||
<Button
|
|
||||||
color="purple"
|
|
||||||
variant="outline"
|
|
||||||
onClick={() =>
|
|
||||||
onBack({
|
|
||||||
exercise: id,
|
|
||||||
solutions: userSolutions,
|
|
||||||
score: {total: 100, missing: 0, correct: writingReverseMarking[userSolutions[0]!.evaluation!.overall] || 0},
|
|
||||||
type,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
className="max-w-[200px] self-end w-full">
|
|
||||||
Back
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
color="purple"
|
|
||||||
onClick={() =>
|
|
||||||
onNext({
|
|
||||||
exercise: id,
|
|
||||||
solutions: userSolutions,
|
|
||||||
score: {
|
|
||||||
total: 100,
|
|
||||||
missing: 0,
|
|
||||||
correct: userSolutions[0]?.evaluation ? writingReverseMarking[userSolutions[0]!.evaluation!.overall] || 0 : 0,
|
|
||||||
},
|
|
||||||
type,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
className="max-w-[200px] self-end w-full">
|
|
||||||
Next
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,29 +22,36 @@ import Writing from "./Writing";
|
|||||||
const MatchSentences = dynamic(() => import("@/components/Solutions/MatchSentences"), { ssr: false });
|
const MatchSentences = dynamic(() => import("@/components/Solutions/MatchSentences"), { ssr: false });
|
||||||
|
|
||||||
export interface CommonProps {
|
export interface CommonProps {
|
||||||
onNext: (userSolutions: UserSolution) => void;
|
headerButtons?: React.ReactNode,
|
||||||
onBack: (userSolutions: UserSolution) => void;
|
footerButtons?: React.ReactNode,
|
||||||
disableProgressButtons?: boolean,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const renderSolution = (exercise: Exercise, onNext: () => void, onBack: () => void, updateIndex?: (internalIndex: number) => void,
|
export const renderSolution = (
|
||||||
disableProgressButtons?: boolean) => {
|
exercise: Exercise,
|
||||||
|
headerButtons?: React.ReactNode,
|
||||||
|
footerButtons?: React.ReactNode,
|
||||||
|
) => {
|
||||||
|
const sharedProps = {
|
||||||
|
key: exercise.id,
|
||||||
|
headerButtons,
|
||||||
|
footerButtons,
|
||||||
|
}
|
||||||
switch (exercise.type) {
|
switch (exercise.type) {
|
||||||
case "fillBlanks":
|
case "fillBlanks":
|
||||||
return <FillBlanks disableProgressButtons={disableProgressButtons} key={exercise.id} {...(exercise as FillBlanksExercise)} onNext={onNext} onBack={onBack} />;
|
return <FillBlanks {...(exercise as FillBlanksExercise)} {...sharedProps}/>;
|
||||||
case "trueFalse":
|
case "trueFalse":
|
||||||
return <TrueFalseSolution disableProgressButtons={disableProgressButtons} key={exercise.id} {...(exercise as TrueFalseExercise)} onNext={onNext} onBack={onBack} />;
|
return <TrueFalseSolution {...(exercise as TrueFalseExercise)} {...sharedProps}/>;
|
||||||
case "matchSentences":
|
case "matchSentences":
|
||||||
return <MatchSentences disableProgressButtons={disableProgressButtons} key={exercise.id} {...(exercise as MatchSentencesExercise)} onNext={onNext} onBack={onBack} />;
|
return <MatchSentences {...(exercise as MatchSentencesExercise)} {...sharedProps}/>;
|
||||||
case "multipleChoice":
|
case "multipleChoice":
|
||||||
return <MultipleChoice disableProgressButtons={disableProgressButtons} key={exercise.id} {...(exercise as MultipleChoiceExercise)} onNext={onNext} onBack={onBack} />;
|
return <MultipleChoice {...(exercise as MultipleChoiceExercise)} {...sharedProps}/>;
|
||||||
case "writeBlanks":
|
case "writeBlanks":
|
||||||
return <WriteBlanks disableProgressButtons={disableProgressButtons} key={exercise.id} {...(exercise as WriteBlanksExercise)} onNext={onNext} onBack={onBack} />;
|
return <WriteBlanks {...(exercise as WriteBlanksExercise)} {...sharedProps}/>;
|
||||||
case "writing":
|
case "writing":
|
||||||
return <Writing key={exercise.id} {...(exercise as WritingExercise)} onNext={onNext} onBack={onBack} />;
|
return <Writing {...(exercise as WritingExercise)} {...sharedProps}/>;
|
||||||
case "speaking":
|
case "speaking":
|
||||||
return <Speaking key={exercise.id} {...(exercise as SpeakingExercise)} onNext={onNext} onBack={onBack} />;
|
return <Speaking {...(exercise as SpeakingExercise)} {...sharedProps}/>;
|
||||||
case "interactiveSpeaking":
|
case "interactiveSpeaking":
|
||||||
return <InteractiveSpeaking key={exercise.id} {...(exercise as InteractiveSpeakingExercise)} onNext={onNext} onBack={onBack} />;
|
return <InteractiveSpeaking {...(exercise as InteractiveSpeakingExercise)} {...sharedProps}/>;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,23 +2,22 @@ import Button from "@/components/Low/Button";
|
|||||||
import ProgressBar from "@/components/Low/ProgressBar";
|
import ProgressBar from "@/components/Low/ProgressBar";
|
||||||
import Modal from "@/components/Modal";
|
import Modal from "@/components/Modal";
|
||||||
import useUsers from "@/hooks/useUsers";
|
import useUsers from "@/hooks/useUsers";
|
||||||
import {Module} from "@/interfaces";
|
import { Module } from "@/interfaces";
|
||||||
import {Assignment} from "@/interfaces/results";
|
import { Assignment } from "@/interfaces/results";
|
||||||
import {Stat, User} from "@/interfaces/user";
|
import { Stat, User } from "@/interfaces/user";
|
||||||
import useExamStore from "@/stores/examStore";
|
import useExamStore from "@/stores/exam";
|
||||||
import {getExamById} from "@/utils/exams";
|
import { getExamById } from "@/utils/exams";
|
||||||
import {sortByModule} from "@/utils/moduleUtils";
|
import { sortByModule } from "@/utils/moduleUtils";
|
||||||
import {calculateBandScore} from "@/utils/score";
|
import { calculateBandScore } from "@/utils/score";
|
||||||
import {convertToUserSolutions} from "@/utils/stats";
|
import { getUserName } from "@/utils/users";
|
||||||
import {getUserName} from "@/utils/users";
|
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import {capitalize, uniqBy} from "lodash";
|
import { capitalize, uniqBy } from "lodash";
|
||||||
import moment from "moment";
|
import moment from "moment";
|
||||||
import {useRouter} from "next/router";
|
import { useRouter } from "next/router";
|
||||||
import {BsBook, BsClipboard, BsHeadphones, BsMegaphone, BsPen} from "react-icons/bs";
|
import { BsBook, BsClipboard, BsHeadphones, BsMegaphone, BsPen } from "react-icons/bs";
|
||||||
import {toast} from "react-toastify";
|
import { toast } from "react-toastify";
|
||||||
import {futureAssignmentFilter} from "@/utils/assignments";
|
import { futureAssignmentFilter } from "@/utils/assignments";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
@@ -27,13 +26,10 @@ interface Props {
|
|||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function AssignmentView({isOpen, users, assignment, onClose}: Props) {
|
export default function AssignmentView({ isOpen, users, assignment, onClose }: Props) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
const setExams = useExamStore((state) => state.setExams);
|
const dispatch = useExamStore((s) => s.dispatch);
|
||||||
const setShowSolutions = useExamStore((state) => state.setShowSolutions);
|
|
||||||
const setUserSolutions = useExamStore((state) => state.setUserSolutions);
|
|
||||||
const setSelectedModules = useExamStore((state) => state.setSelectedModules);
|
|
||||||
|
|
||||||
const deleteAssignment = async () => {
|
const deleteAssignment = async () => {
|
||||||
if (!confirm("Are you sure you want to delete this assignment?")) return;
|
if (!confirm("Are you sure you want to delete this assignment?")) return;
|
||||||
@@ -80,9 +76,9 @@ export default function AssignmentView({isOpen, users, assignment, onClose}: Pro
|
|||||||
return resultModuleBandScores.length === 0 ? -1 : resultModuleBandScores.reduce((acc, curr) => acc + curr, 0) / assignment.results.length;
|
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 aggregateScoresByModule = (stats: Stat[]): { module: Module; total: number; missing: number; correct: number }[] => {
|
||||||
const scores: {
|
const scores: {
|
||||||
[key in Module]: {total: number; missing: number; correct: number};
|
[key in Module]: { total: number; missing: number; correct: number };
|
||||||
} = {
|
} = {
|
||||||
reading: {
|
reading: {
|
||||||
total: 0,
|
total: 0,
|
||||||
@@ -121,7 +117,7 @@ export default function AssignmentView({isOpen, users, assignment, onClose}: Pro
|
|||||||
|
|
||||||
return Object.keys(scores)
|
return Object.keys(scores)
|
||||||
.filter((x) => scores[x as Module].total > 0)
|
.filter((x) => scores[x as Module].total > 0)
|
||||||
.map((x) => ({module: x as Module, ...scores[x as Module]}));
|
.map((x) => ({ module: x as Module, ...scores[x as Module] }));
|
||||||
};
|
};
|
||||||
|
|
||||||
const customContent = (stats: Stat[], user: string, focus: "academic" | "general") => {
|
const customContent = (stats: Stat[], user: string, focus: "academic" | "general") => {
|
||||||
@@ -141,15 +137,16 @@ export default function AssignmentView({isOpen, users, assignment, onClose}: Pro
|
|||||||
|
|
||||||
Promise.all(examPromises).then((exams) => {
|
Promise.all(examPromises).then((exams) => {
|
||||||
if (exams.every((x) => !!x)) {
|
if (exams.every((x) => !!x)) {
|
||||||
setUserSolutions(convertToUserSolutions(stats));
|
dispatch({
|
||||||
setShowSolutions(true);
|
type: 'INIT_SOLUTIONS', payload: {
|
||||||
setExams(exams.map((x) => x!).sort(sortByModule));
|
exams: exams.map((x) => x!).sort(sortByModule),
|
||||||
setSelectedModules(
|
modules: exams
|
||||||
exams
|
.map((x) => x!)
|
||||||
.map((x) => x!)
|
.sort(sortByModule)
|
||||||
.sort(sortByModule)
|
.map((x) => x!.module),
|
||||||
.map((x) => x!.module),
|
stats
|
||||||
);
|
}
|
||||||
|
});
|
||||||
router.push("/exam");
|
router.push("/exam");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -180,7 +177,7 @@ export default function AssignmentView({isOpen, users, assignment, onClose}: Pro
|
|||||||
|
|
||||||
<div className="flex w-full flex-col gap-1">
|
<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">
|
<div className="-md:mt-2 grid w-full grid-cols-4 place-items-start gap-2">
|
||||||
{aggregatedLevels.map(({module, level}) => (
|
{aggregatedLevels.map(({ module, level }) => (
|
||||||
<div
|
<div
|
||||||
key={module}
|
key={module}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
@@ -282,7 +279,7 @@ export default function AssignmentView({isOpen, users, assignment, onClose}: Pro
|
|||||||
<span className="text-xl font-bold">Average Scores</span>
|
<span className="text-xl font-bold">Average Scores</span>
|
||||||
<div className="-md:mt-2 flex w-full items-center gap-4">
|
<div className="-md:mt-2 flex w-full items-center gap-4">
|
||||||
{assignment &&
|
{assignment &&
|
||||||
uniqBy(assignment.exams, (x) => x.module).map(({module}) => (
|
uniqBy(assignment.exams, (x) => x.module).map(({ module }) => (
|
||||||
<div
|
<div
|
||||||
data-tip={capitalize(module)}
|
data-tip={capitalize(module)}
|
||||||
key={module}
|
key={module}
|
||||||
|
|||||||
@@ -6,28 +6,28 @@ import useAssignments from "@/hooks/useAssignments";
|
|||||||
import useGradingSystem from "@/hooks/useGrading";
|
import useGradingSystem from "@/hooks/useGrading";
|
||||||
import useInvites from "@/hooks/useInvites";
|
import useInvites from "@/hooks/useInvites";
|
||||||
import useFilterRecordsByUser from "@/hooks/useFilterRecordsByUser";
|
import useFilterRecordsByUser from "@/hooks/useFilterRecordsByUser";
|
||||||
import useUsers, { userHashStudent, userHashTeacher, userHashCorporate} from "@/hooks/useUsers";
|
import useUsers, { userHashStudent, userHashTeacher, userHashCorporate } from "@/hooks/useUsers";
|
||||||
import {Invite} from "@/interfaces/invite";
|
import { Invite } from "@/interfaces/invite";
|
||||||
import {Assignment} from "@/interfaces/results";
|
import { Assignment } from "@/interfaces/results";
|
||||||
import {CorporateUser, MasterCorporateUser, Stat, User} from "@/interfaces/user";
|
import { CorporateUser, MasterCorporateUser, Stat, User } from "@/interfaces/user";
|
||||||
import useExamStore from "@/stores/examStore";
|
import useExamStore from "@/stores/exam";
|
||||||
import {getExamById} from "@/utils/exams";
|
import { getExamById } from "@/utils/exams";
|
||||||
import {getUserCorporate} from "@/utils/groups";
|
import { getUserCorporate } from "@/utils/groups";
|
||||||
import {countExamModules, countFullExams, MODULE_ARRAY, sortByModule, sortByModuleName} from "@/utils/moduleUtils";
|
import { countExamModules, countFullExams, MODULE_ARRAY, sortByModule, sortByModuleName } from "@/utils/moduleUtils";
|
||||||
import {getGradingLabel, getLevelLabel, getLevelScore} from "@/utils/score";
|
import { getGradingLabel, getLevelLabel, getLevelScore } from "@/utils/score";
|
||||||
import {averageScore, groupBySession} from "@/utils/stats";
|
import { averageScore, groupBySession } from "@/utils/stats";
|
||||||
import {CreateOrderActions, CreateOrderData, OnApproveActions, OnApproveData, OrderResponseBody} from "@paypal/paypal-js";
|
import { CreateOrderActions, CreateOrderData, OnApproveActions, OnApproveData, OrderResponseBody } from "@paypal/paypal-js";
|
||||||
import {PayPalButtons} from "@paypal/react-paypal-js";
|
import { PayPalButtons } from "@paypal/react-paypal-js";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import {capitalize} from "lodash";
|
import { capitalize } from "lodash";
|
||||||
import moment from "moment";
|
import moment from "moment";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import {useRouter} from "next/router";
|
import { useRouter } from "next/router";
|
||||||
import {useEffect, useMemo, useState} from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import {BsArrowRepeat, BsBook, BsClipboard, BsFileEarmarkText, BsHeadphones, BsMegaphone, BsPen, BsPencil, BsStar} from "react-icons/bs";
|
import { BsArrowRepeat, BsBook, BsClipboard, BsFileEarmarkText, BsHeadphones, BsMegaphone, BsPen, BsPencil, BsStar } from "react-icons/bs";
|
||||||
import {toast} from "react-toastify";
|
import { toast } from "react-toastify";
|
||||||
import {activeAssignmentFilter} from "@/utils/assignments";
|
import { activeAssignmentFilter } from "@/utils/assignments";
|
||||||
import ModuleBadge from "@/components/ModuleBadge";
|
import ModuleBadge from "@/components/ModuleBadge";
|
||||||
import useSessions from "@/hooks/useSessions";
|
import useSessions from "@/hooks/useSessions";
|
||||||
|
|
||||||
@@ -36,41 +36,36 @@ interface Props {
|
|||||||
linkedCorporate?: CorporateUser | MasterCorporateUser;
|
linkedCorporate?: CorporateUser | MasterCorporateUser;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function StudentDashboard({user, linkedCorporate}: Props) {
|
export default function StudentDashboard({ user, linkedCorporate }: Props) {
|
||||||
const {gradingSystem} = useGradingSystem();
|
const { gradingSystem } = useGradingSystem();
|
||||||
const {sessions} = useSessions(user.id);
|
const { sessions } = useSessions(user.id);
|
||||||
const {data: stats} = useFilterRecordsByUser<Stat[]>(user.id, !user?.id);
|
const { data: stats } = useFilterRecordsByUser<Stat[]>(user.id, !user?.id);
|
||||||
const {assignments, isLoading: isAssignmentsLoading, reload: reloadAssignments} = useAssignments({assignees: user?.id});
|
const { assignments, isLoading: isAssignmentsLoading, reload: reloadAssignments } = useAssignments({ assignees: user?.id });
|
||||||
const {invites, isLoading: isInvitesLoading, reload: reloadInvites} = useInvites({to: user.id});
|
const { invites, isLoading: isInvitesLoading, reload: reloadInvites } = useInvites({ to: user.id });
|
||||||
|
|
||||||
const {users: teachers} = useUsers(userHashTeacher);
|
const { users: teachers } = useUsers(userHashTeacher);
|
||||||
const {users: corporates} = useUsers(userHashCorporate);
|
const { users: corporates } = useUsers(userHashCorporate);
|
||||||
|
|
||||||
const users = useMemo(() => [...teachers, ...corporates], [teachers, corporates]);
|
const users = useMemo(() => [...teachers, ...corporates], [teachers, corporates]);
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
const setExams = useExamStore((state) => state.setExams);
|
const dispatch = useExamStore((state) => state.dispatch);
|
||||||
const setShowSolutions = useExamStore((state) => state.setShowSolutions);
|
|
||||||
const setUserSolutions = useExamStore((state) => state.setUserSolutions);
|
|
||||||
const setSelectedModules = useExamStore((state) => state.setSelectedModules);
|
|
||||||
const setAssignment = useExamStore((state) => state.setAssignment);
|
|
||||||
|
|
||||||
const startAssignment = (assignment: Assignment) => {
|
const startAssignment = (assignment: Assignment) => {
|
||||||
const examPromises = assignment.exams.filter((e) => e.assignee === user.id).map((e) => getExamById(e.module, e.id));
|
const examPromises = assignment.exams.filter((e) => e.assignee === user.id).map((e) => getExamById(e.module, e.id));
|
||||||
|
|
||||||
Promise.all(examPromises).then((exams) => {
|
Promise.all(examPromises).then((exams) => {
|
||||||
if (exams.every((x) => !!x)) {
|
if (exams.every((x) => !!x)) {
|
||||||
setUserSolutions([]);
|
dispatch({
|
||||||
setShowSolutions(false);
|
type: "INIT_EXAM", payload: {
|
||||||
setExams(exams.map((x) => x!).sort(sortByModule));
|
exams: exams.map((x) => x!).sort(sortByModule),
|
||||||
setSelectedModules(
|
modules: exams
|
||||||
exams
|
.map((x) => x!)
|
||||||
.map((x) => x!)
|
.sort(sortByModule)
|
||||||
.sort(sortByModule)
|
.map((x) => x!.module),
|
||||||
.map((x) => x!.module),
|
assignment
|
||||||
);
|
}
|
||||||
setAssignment(assignment);
|
})
|
||||||
|
|
||||||
router.push("/exam");
|
router.push("/exam");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,12 +3,11 @@ import ModuleTitle from "@/components/Medium/ModuleTitle";
|
|||||||
import { moduleResultText } from "@/constants/ielts";
|
import { moduleResultText } from "@/constants/ielts";
|
||||||
import { Module } from "@/interfaces";
|
import { Module } from "@/interfaces";
|
||||||
import { User } from "@/interfaces/user";
|
import { User } from "@/interfaces/user";
|
||||||
import useExamStore from "@/stores/examStore";
|
import useExamStore from "@/stores/exam";
|
||||||
import { calculateBandScore, getGradingLabel } from "@/utils/score";
|
import { calculateBandScore, getGradingLabel } from "@/utils/score";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import Link from "next/link";
|
|
||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
import { Fragment, useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import {
|
import {
|
||||||
BsArrowCounterclockwise,
|
BsArrowCounterclockwise,
|
||||||
BsBan,
|
BsBan,
|
||||||
@@ -56,9 +55,9 @@ export default function Finish({ user, scores, modules, information, solutions,
|
|||||||
const [selectedModule, setSelectedModule] = useState(modules[0]);
|
const [selectedModule, setSelectedModule] = useState(modules[0]);
|
||||||
const [selectedScore, setSelectedScore] = useState<Score>(scores.find((x) => x.module === modules[0])!);
|
const [selectedScore, setSelectedScore] = useState<Score>(scores.find((x) => x.module === modules[0])!);
|
||||||
const [isExtraInformationOpen, setIsExtraInformationOpen] = useState(false);
|
const [isExtraInformationOpen, setIsExtraInformationOpen] = useState(false);
|
||||||
|
const {selectedModules, exams, dispatch} = useExamStore((s) => s);
|
||||||
|
|
||||||
const aiUsage = Math.round(ai_usage(solutions) * 100);
|
const aiUsage = Math.round(ai_usage(solutions) * 100);
|
||||||
const exams = useExamStore((state) => state.exams);
|
|
||||||
const { gradingSystem } = useGradingSystem();
|
const { gradingSystem } = useGradingSystem();
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
@@ -112,6 +111,11 @@ export default function Finish({ user, scores, modules, information, solutions,
|
|||||||
return <span className="text-3xl font-bold">{level}</span>;
|
return <span className="text-3xl font-bold">{level}</span>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handlePlayAgain = () => {
|
||||||
|
dispatch({type: "INIT_EXAM", payload: {exams, modules: selectedModules}})
|
||||||
|
router.push(destination || "/exam")
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Modal title="Extra Information" isOpen={isExtraInformationOpen} onClose={() => setIsExtraInformationOpen(false)}>
|
<Modal title="Extra Information" isOpen={isExtraInformationOpen} onClose={() => setIsExtraInformationOpen(false)}>
|
||||||
@@ -135,6 +139,7 @@ export default function Finish({ user, scores, modules, information, solutions,
|
|||||||
totalExercises={getTotalExercises()}
|
totalExercises={getTotalExercises()}
|
||||||
exerciseIndex={getTotalExercises()}
|
exerciseIndex={getTotalExercises()}
|
||||||
minTimer={exams.find((x) => x.module === selectedModule)!.minTimer}
|
minTimer={exams.find((x) => x.module === selectedModule)!.minTimer}
|
||||||
|
preview={false}
|
||||||
disableTimer
|
disableTimer
|
||||||
/>
|
/>
|
||||||
<div className="flex gap-4 self-start w-full">
|
<div className="flex gap-4 self-start w-full">
|
||||||
@@ -289,7 +294,7 @@ export default function Finish({ user, scores, modules, information, solutions,
|
|||||||
<div className="flex gap-8">
|
<div className="flex gap-8">
|
||||||
<div className="flex w-fit cursor-pointer flex-col items-center gap-1">
|
<div className="flex w-fit cursor-pointer flex-col items-center gap-1">
|
||||||
<button
|
<button
|
||||||
onClick={() => router.push(destination || "/exam")}
|
onClick={handlePlayAgain}
|
||||||
disabled={!!assignment}
|
disabled={!!assignment}
|
||||||
className="bg-mti-purple-light hover:bg-mti-purple flex h-11 w-11 items-center justify-center rounded-full transition duration-300 ease-in-out">
|
className="bg-mti-purple-light hover:bg-mti-purple flex h-11 w-11 items-center justify-center rounded-full transition duration-300 ease-in-out">
|
||||||
<BsArrowCounterclockwise className="h-7 w-7 text-white" />
|
<BsArrowCounterclockwise className="h-7 w-7 text-white" />
|
||||||
|
|||||||
@@ -5,11 +5,10 @@ import ModuleTitle from "@/components/Medium/ModuleTitle";
|
|||||||
import { renderSolution } from "@/components/Solutions";
|
import { renderSolution } from "@/components/Solutions";
|
||||||
import { Module } from "@/interfaces";
|
import { Module } from "@/interfaces";
|
||||||
import { Exercise, FillBlanksMCOption, LevelExam, MultipleChoiceExercise, UserSolution } from "@/interfaces/exam";
|
import { Exercise, FillBlanksMCOption, LevelExam, MultipleChoiceExercise, UserSolution } from "@/interfaces/exam";
|
||||||
import useExamStore from "@/stores/examStore";
|
import useExamStore, { usePersistentExamStore } from "@/stores/exam";
|
||||||
import { usePersistentExamStore } from "@/stores/examStore";
|
|
||||||
import { countExercises } from "@/utils/moduleUtils";
|
import { countExercises } from "@/utils/moduleUtils";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import { use, useEffect, useMemo, useState } from "react";
|
import { use, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import TextComponent from "./TextComponent";
|
import TextComponent from "./TextComponent";
|
||||||
import PartDivider from "../Navigation/SectionDivider";
|
import PartDivider from "../Navigation/SectionDivider";
|
||||||
import Timer from "@/components/Medium/Timer";
|
import Timer from "@/components/Medium/Timer";
|
||||||
@@ -19,39 +18,44 @@ import Modal from "@/components/Modal";
|
|||||||
import { typeCheckWordsMC } from "@/utils/type.check";
|
import { typeCheckWordsMC } from "@/utils/type.check";
|
||||||
import SectionNavbar from "../Navigation/SectionNavbar";
|
import SectionNavbar from "../Navigation/SectionNavbar";
|
||||||
import AudioPlayer from "@/components/Low/AudioPlayer";
|
import AudioPlayer from "@/components/Low/AudioPlayer";
|
||||||
|
import { ExamProps } from "../types";
|
||||||
|
import {answeredEveryQuestionInPart} from "../utils/answeredEveryQuestion";
|
||||||
|
import useExamTimer from "@/hooks/useExamTimer";
|
||||||
|
import ProgressButtons from "../components/ProgressButtons";
|
||||||
|
|
||||||
interface Props {
|
|
||||||
exam: LevelExam;
|
|
||||||
showSolutions?: boolean;
|
|
||||||
onFinish: (userSolutions: UserSolution[]) => void;
|
|
||||||
preview?: boolean;
|
|
||||||
partDividers?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function Level({ exam, showSolutions = false, onFinish, preview = false }: Props) {
|
const Level: React.FC<ExamProps<LevelExam>> = ({ exam, showSolutions = false, preview = false }) => {
|
||||||
const levelBgColor = "bg-ielts-level-light";
|
const levelBgColor = "bg-ielts-level-light";
|
||||||
|
|
||||||
|
const updateTimers = useExamTimer(exam.module, preview || showSolutions);
|
||||||
|
const userSolutionRef = useRef<(() => UserSolution) | null>(null);
|
||||||
|
const [solutionWasUpdated, setSolutionWasUpdated] = useState(false);
|
||||||
|
|
||||||
const examState = useExamStore((state) => state);
|
const examState = useExamStore((state) => state);
|
||||||
const persistentExamState = usePersistentExamStore((state) => state);
|
const persistentExamState = usePersistentExamStore((state) => state);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
userSolutions,
|
userSolutions,
|
||||||
hasExamEnded,
|
|
||||||
partIndex,
|
partIndex,
|
||||||
exerciseIndex,
|
exerciseIndex,
|
||||||
questionIndex,
|
questionIndex,
|
||||||
shuffles,
|
shuffles,
|
||||||
currentSolution,
|
setTimeIsUp,
|
||||||
setBgColor,
|
setBgColor,
|
||||||
setUserSolutions,
|
setUserSolutions,
|
||||||
setHasExamEnded,
|
|
||||||
setPartIndex,
|
setPartIndex,
|
||||||
setExerciseIndex,
|
setExerciseIndex,
|
||||||
setQuestionIndex,
|
setQuestionIndex,
|
||||||
setShuffles,
|
setShuffles,
|
||||||
setCurrentSolution
|
flags,
|
||||||
|
timeSpentCurrentModule,
|
||||||
|
dispatch,
|
||||||
} = !preview ? examState : persistentExamState;
|
} = !preview ? examState : persistentExamState;
|
||||||
|
|
||||||
|
const { finalizeModule, timeIsUp } = flags;
|
||||||
|
|
||||||
|
const timer = useRef(exam.minTimer - timeSpentCurrentModule / 60);
|
||||||
|
|
||||||
// In case client want to switch back
|
// In case client want to switch back
|
||||||
const textRenderDisabled = true;
|
const textRenderDisabled = true;
|
||||||
|
|
||||||
@@ -61,8 +65,6 @@ export default function Level({ exam, showSolutions = false, onFinish, preview =
|
|||||||
const [continueAnyways, setContinueAnyways] = useState(false);
|
const [continueAnyways, setContinueAnyways] = useState(false);
|
||||||
const [textRender, setTextRender] = useState(false);
|
const [textRender, setTextRender] = useState(false);
|
||||||
const [changedPrompt, setChangedPrompt] = useState(false);
|
const [changedPrompt, setChangedPrompt] = useState(false);
|
||||||
const [nextExerciseCalled, setNextExerciseCalled] = useState(false);
|
|
||||||
const [currentSolutionSet, setCurrentSolutionSet] = useState(false);
|
|
||||||
|
|
||||||
const [seenParts, setSeenParts] = useState<Set<number>>(new Set(showSolutions ? exam.parts.map((_, index) => index) : [0]));
|
const [seenParts, setSeenParts] = useState<Set<number>>(new Set(showSolutions ? exam.parts.map((_, index) => index) : [0]));
|
||||||
|
|
||||||
@@ -72,8 +74,6 @@ export default function Level({ exam, showSolutions = false, onFinish, preview =
|
|||||||
type: "blankQuestions",
|
type: "blankQuestions",
|
||||||
onClose: function (x: boolean | undefined) { if (x) { setShowQuestionsModal(false); nextExercise(); } else { setShowQuestionsModal(false) } }
|
onClose: function (x: boolean | undefined) { if (x) { setShowQuestionsModal(false); nextExercise(); } else { setShowQuestionsModal(false) } }
|
||||||
});
|
});
|
||||||
|
|
||||||
const [currentExercise, setCurrentExercise] = useState<Exercise | undefined>(undefined);
|
|
||||||
const [showPartDivider, setShowPartDivider] = useState<boolean>(typeof exam.parts[0].intro === "string" && !showSolutions);
|
const [showPartDivider, setShowPartDivider] = useState<boolean>(typeof exam.parts[0].intro === "string" && !showSolutions);
|
||||||
const [startNow, setStartNow] = useState<boolean>(!showSolutions);
|
const [startNow, setStartNow] = useState<boolean>(!showSolutions);
|
||||||
|
|
||||||
@@ -86,12 +86,10 @@ export default function Level({ exam, showSolutions = false, onFinish, preview =
|
|||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [exerciseIndex]);
|
}, [exerciseIndex]);
|
||||||
|
|
||||||
useEffect(() => {
|
const registerSolution = useCallback((updateSolution: () => UserSolution) => {
|
||||||
if (currentExercise === undefined && partIndex === 0 && exerciseIndex === 0) {
|
userSolutionRef.current = updateSolution;
|
||||||
setCurrentExercise(exam.parts[0].exercises[0]);
|
setSolutionWasUpdated(true);
|
||||||
}
|
}, []);
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [currentExercise, partIndex, exerciseIndex]);
|
|
||||||
|
|
||||||
const scrollToTop = () => Array.from(document.getElementsByTagName("body")).forEach((body) => body.scrollTo(0, 0));
|
const scrollToTop = () => Array.from(document.getElementsByTagName("body")).forEach((body) => body.scrollTo(0, 0));
|
||||||
|
|
||||||
@@ -99,23 +97,6 @@ export default function Level({ exam, showSolutions = false, onFinish, preview =
|
|||||||
const [contextWordLines, setContextWordLines] = useState<number[] | undefined>(undefined);
|
const [contextWordLines, setContextWordLines] = useState<number[] | undefined>(undefined);
|
||||||
const [totalLines, setTotalLines] = useState<number>(0);
|
const [totalLines, setTotalLines] = useState<number>(0);
|
||||||
|
|
||||||
const [showSolutionsSave, setShowSolutionsSave] = useState(showSolutions ? userSolutions.filter((x) => x.module === "level") : undefined)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (typeof currentSolution !== "undefined") {
|
|
||||||
setUserSolutions([...userSolutions.filter((x) => x.exercise !== currentSolution.exercise), { ...currentSolution, module: "level" as Module, exam: exam.id, shuffleMaps: exam.shuffle ? [...shuffles.find((x) => x.exerciseID == currentExercise?.id)?.shuffles!] : [] }]);
|
|
||||||
setCurrentSolutionSet(true);
|
|
||||||
}
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [currentSolution, exam.id, exam.shuffle, shuffles, currentExercise])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (typeof currentSolution !== "undefined") {
|
|
||||||
setCurrentSolution(undefined);
|
|
||||||
}
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [currentSolution]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (showSolutions) {
|
if (showSolutions) {
|
||||||
const solutionShuffles = userSolutions.map(solution => ({
|
const solutionShuffles = userSolutions.map(solution => ({
|
||||||
@@ -127,42 +108,53 @@ export default function Level({ exam, showSolutions = false, onFinish, preview =
|
|||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const getExercise = () => {
|
|
||||||
let exercise = exam.parts[partIndex]?.exercises[exerciseIndex];
|
const currentExercise = useMemo<Exercise>(() => {
|
||||||
|
let exercise = exam.parts[partIndex].exercises[exerciseIndex];
|
||||||
exercise = {
|
exercise = {
|
||||||
...exercise,
|
...exercise,
|
||||||
userSolutions: userSolutions.find((x) => x.exercise == exercise.id)?.solutions || [],
|
userSolutions: userSolutions.find((x) => x.exercise === exercise.id)?.solutions || [],
|
||||||
};
|
};
|
||||||
exercise = shuffleExamExercise(exam.shuffle, exercise, showSolutions, userSolutions, shuffles, setShuffles);
|
exercise = shuffleExamExercise(exam.shuffle, exercise, showSolutions, userSolutions, shuffles, setShuffles);
|
||||||
return exercise;
|
return exercise;
|
||||||
};
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [partIndex, exerciseIndex]);
|
||||||
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setCurrentExercise(getExercise());
|
if (solutionWasUpdated && userSolutionRef.current) {
|
||||||
|
const solution = userSolutionRef.current();
|
||||||
|
setUserSolutions([...userSolutions.filter((x) => x.exercise !== solution.exercise), { ...solution, module: "level" as Module, exam: exam.id, shuffleMaps: exam.shuffle ? [...shuffles.find((x) => x.exerciseID == currentExercise?.id)?.shuffles!] : [] }]);
|
||||||
|
setSolutionWasUpdated(false);
|
||||||
|
}
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [partIndex, exerciseIndex, questionIndex]);
|
}, [solutionWasUpdated]);
|
||||||
|
|
||||||
const next = () => {
|
useEffect(() => {
|
||||||
setNextExerciseCalled(true);
|
if (finalizeModule || timeIsUp) {
|
||||||
}
|
updateTimers();
|
||||||
|
if (timeIsUp) setTimeIsUp(false);
|
||||||
|
dispatch({ type: "FINALIZE_MODULE", payload: { updateTimers: false } })
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [finalizeModule, timeIsUp])
|
||||||
|
|
||||||
const nextExercise = () => {
|
const nextExercise = () => {
|
||||||
scrollToTop();
|
scrollToTop();
|
||||||
|
|
||||||
if (exerciseIndex + 1 < exam.parts[partIndex].exercises.length && !hasExamEnded) {
|
if (exerciseIndex + 1 < exam.parts[partIndex].exercises.length) {
|
||||||
setExerciseIndex(exerciseIndex + 1);
|
setExerciseIndex(exerciseIndex + 1);
|
||||||
setCurrentSolutionSet(false);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (partIndex + 1 === exam.parts.length && !hasExamEnded && !showQuestionsModal && !showSolutions && !continueAnyways) {
|
if (partIndex + 1 === exam.parts.length && !showQuestionsModal && !showSolutions && !continueAnyways) {
|
||||||
modalKwargs();
|
modalKwargs();
|
||||||
setShowQuestionsModal(true);
|
setShowQuestionsModal(true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (partIndex + 1 < exam.parts.length && !hasExamEnded) {
|
if (partIndex + 1 < exam.parts.length) {
|
||||||
if (!answeredEveryQuestion(partIndex) && !continueAnyways && !showSolutions && !seenParts.has(partIndex + 1)) {
|
if (!answeredEveryQuestionInPart(exam, partIndex, userSolutions) && !continueAnyways && !showSolutions && !seenParts.has(partIndex + 1)) {
|
||||||
modalKwargs();
|
modalKwargs();
|
||||||
setShowQuestionsModal(true);
|
setShowQuestionsModal(true);
|
||||||
return;
|
return;
|
||||||
@@ -181,7 +173,6 @@ export default function Level({ exam, showSolutions = false, onFinish, preview =
|
|||||||
setPartIndex(partIndex + 1);
|
setPartIndex(partIndex + 1);
|
||||||
setExerciseIndex(0);
|
setExerciseIndex(0);
|
||||||
setQuestionIndex(0);
|
setQuestionIndex(0);
|
||||||
setCurrentSolutionSet(false);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -190,24 +181,14 @@ export default function Level({ exam, showSolutions = false, onFinish, preview =
|
|||||||
setShowQuestionsModal(true);
|
setShowQuestionsModal(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
setHasExamEnded(false);
|
if (!showSolutions) {
|
||||||
setCurrentSolutionSet(false);
|
dispatch({ type: "FINALIZE_MODULE", payload: { updateTimers: true } })
|
||||||
if (typeof showSolutionsSave !== "undefined") {
|
} else {
|
||||||
onFinish(showSolutionsSave);
|
dispatch({ type: "FINALIZE_MODULE_SOLUTIONS"})
|
||||||
} else {
|
}
|
||||||
onFinish(userSolutions);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
const previousExercise = () => {
|
||||||
if (nextExerciseCalled && currentSolutionSet) {
|
|
||||||
nextExercise();
|
|
||||||
setNextExerciseCalled(false);
|
|
||||||
}
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [nextExerciseCalled, currentSolutionSet])
|
|
||||||
|
|
||||||
const previousExercise = (solution?: UserSolution) => {
|
|
||||||
scrollToTop();
|
scrollToTop();
|
||||||
|
|
||||||
if (exam.parts[partIndex].context && questionIndex === 0 && !textRender && !textRenderDisabled) {
|
if (exam.parts[partIndex].context && questionIndex === 0 && !textRender && !textRenderDisabled) {
|
||||||
@@ -353,25 +334,6 @@ export default function Level({ exam, showSolutions = false, onFinish, preview =
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const answeredEveryQuestion = (partIndex: number) => {
|
|
||||||
return exam.parts[partIndex].exercises.every((exercise) => {
|
|
||||||
const userSolution = userSolutions.find(x => x.exercise === exercise.id);
|
|
||||||
switch (exercise.type) {
|
|
||||||
case 'multipleChoice':
|
|
||||||
return userSolution?.solutions.length === exercise.questions.length;
|
|
||||||
case 'fillBlanks':
|
|
||||||
return userSolution?.solutions.length === exercise.words.length;
|
|
||||||
case 'writeBlanks':
|
|
||||||
return userSolution?.solutions.length === exercise.solutions.length;
|
|
||||||
case 'matchSentences':
|
|
||||||
return userSolution?.solutions.length === exercise.sentences.length;
|
|
||||||
case 'trueFalse':
|
|
||||||
return userSolution?.solutions.length === exercise.questions.length;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const regex = /.*?['"](.*?)['"] in line (\d+)\?$/;
|
const regex = /.*?['"](.*?)['"] in line (\d+)\?$/;
|
||||||
|
|
||||||
@@ -408,8 +370,7 @@ export default function Level({ exam, showSolutions = false, onFinish, preview =
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (
|
if (
|
||||||
exerciseIndex !== -1 && currentExercise &&
|
currentExercise && currentExercise.type === "multipleChoice" &&
|
||||||
currentExercise.type === "multipleChoice" &&
|
|
||||||
exam.parts[partIndex].context && contextWordLines
|
exam.parts[partIndex].context && contextWordLines
|
||||||
) {
|
) {
|
||||||
if (contextWordLines.length > 0) {
|
if (contextWordLines.length > 0) {
|
||||||
@@ -446,7 +407,7 @@ export default function Level({ exam, showSolutions = false, onFinish, preview =
|
|||||||
|
|
||||||
if (partIndex === exam.parts.length - 1) {
|
if (partIndex === exam.parts.length - 1) {
|
||||||
kwargs.type = "submit"
|
kwargs.type = "submit"
|
||||||
kwargs.unanswered = !exam.parts.every((_, partIndex) => answeredEveryQuestion(partIndex));
|
kwargs.unanswered = !exam.parts.every((_, partIndex) => answeredEveryQuestionInPart(exam, partIndex, userSolutions));
|
||||||
kwargs.onClose = function (x: boolean | undefined) { if (x) { setShowSubmissionModal(true); setShowQuestionsModal(false); } else { setShowQuestionsModal(false) } };
|
kwargs.onClose = function (x: boolean | undefined) { if (x) { setShowSubmissionModal(true); setShowQuestionsModal(false); } else { setShowQuestionsModal(false) } };
|
||||||
}
|
}
|
||||||
setQuestionModalKwargs(kwargs);
|
setQuestionModalKwargs(kwargs);
|
||||||
@@ -462,6 +423,7 @@ export default function Level({ exam, showSolutions = false, onFinish, preview =
|
|||||||
runOnClick: setQuestionIndex
|
runOnClick: setQuestionIndex
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const progressButtons = <ProgressButtons handlePrevious={previousExercise} handleNext={nextExercise} />;
|
||||||
|
|
||||||
const memoizedRender = useMemo(() => {
|
const memoizedRender = useMemo(() => {
|
||||||
setChangedPrompt(false);
|
setChangedPrompt(false);
|
||||||
@@ -473,8 +435,8 @@ export default function Level({ exam, showSolutions = false, onFinish, preview =
|
|||||||
{exam.parts[partIndex]?.context && renderText()}
|
{exam.parts[partIndex]?.context && renderText()}
|
||||||
{exam.parts[partIndex]?.audio && renderAudioPlayer()}
|
{exam.parts[partIndex]?.audio && renderAudioPlayer()}
|
||||||
{(showSolutions) ?
|
{(showSolutions) ?
|
||||||
currentExercise && renderSolution(currentExercise, nextExercise, previousExercise) :
|
currentExercise && renderSolution(currentExercise, progressButtons, progressButtons) :
|
||||||
currentExercise && renderExercise(currentExercise, exam.id, next, previousExercise)
|
currentExercise && renderExercise(currentExercise, exam.id, registerSolution, preview, progressButtons, progressButtons)
|
||||||
}
|
}
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
@@ -520,34 +482,24 @@ export default function Level({ exam, showSolutions = false, onFinish, preview =
|
|||||||
onNext={() => { setShowPartDivider(false); setStartNow(false); setBgColor("bg-white"); setSeenParts(prev => new Set(prev).add(partIndex)); }}
|
onNext={() => { setShowPartDivider(false); setStartNow(false); setBgColor("bg-white"); setSeenParts(prev => new Set(prev).add(partIndex)); }}
|
||||||
/> : (
|
/> : (
|
||||||
<>
|
<>
|
||||||
{exam.parts[0].intro && (
|
<SectionNavbar
|
||||||
<SectionNavbar
|
module="level"
|
||||||
module="level"
|
sectionLabel="Part"
|
||||||
sections={exam.parts}
|
seenParts={seenParts}
|
||||||
sectionLabel="Part"
|
setShowPartDivider={setShowPartDivider}
|
||||||
sectionIndex={partIndex}
|
setSeenParts={setSeenParts}
|
||||||
setSectionIndex={setPartIndex}
|
preview={preview}
|
||||||
onClick={
|
/>
|
||||||
(index: number) => {
|
|
||||||
setExerciseIndex(0);
|
|
||||||
setQuestionIndex(0);
|
|
||||||
if (!seenParts.has(index)) {
|
|
||||||
setShowPartDivider(true);
|
|
||||||
setBgColor(levelBgColor);
|
|
||||||
setSeenParts(prev => new Set(prev).add(index));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} />
|
|
||||||
)}
|
|
||||||
<ModuleTitle
|
<ModuleTitle
|
||||||
examLabel={exam.label}
|
examLabel={exam.label}
|
||||||
partLabel={partLabel()}
|
partLabel={partLabel()}
|
||||||
minTimer={exam.minTimer}
|
minTimer={timer.current}
|
||||||
exerciseIndex={calculateExerciseIndex()}
|
exerciseIndex={calculateExerciseIndex()}
|
||||||
module="level"
|
module="level"
|
||||||
totalExercises={countExercises(exam.parts.flatMap((x) => x.exercises))}
|
totalExercises={countExercises(exam.parts.flatMap((x) => x.exercises))}
|
||||||
disableTimer={showSolutions}
|
disableTimer={showSolutions}
|
||||||
showTimer={false}
|
showTimer={false}
|
||||||
|
preview={preview}
|
||||||
{...mcNavKwargs}
|
{...mcNavKwargs}
|
||||||
/>
|
/>
|
||||||
<div
|
<div
|
||||||
@@ -563,3 +515,5 @@ export default function Level({ exam, showSolutions = false, onFinish, preview =
|
|||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default Level;
|
||||||
|
|||||||
@@ -1,287 +1,127 @@
|
|||||||
import { ListeningExam, MultipleChoiceExercise, Script, UserSolution } from "@/interfaces/exam";
|
import { Exercise, ListeningExam, MultipleChoiceExercise, Script, UserSolution } from "@/interfaces/exam";
|
||||||
import { Fragment, useEffect, useState } from "react";
|
import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { renderExercise } from "@/components/Exercises";
|
import { renderExercise } from "@/components/Exercises";
|
||||||
import { renderSolution } from "@/components/Solutions";
|
import { renderSolution } from "@/components/Solutions";
|
||||||
import ModuleTitle from "@/components/Medium/ModuleTitle";
|
import ModuleTitle from "@/components/Medium/ModuleTitle";
|
||||||
import AudioPlayer from "@/components/Low/AudioPlayer";
|
import AudioPlayer from "@/components/Low/AudioPlayer";
|
||||||
import Button from "@/components/Low/Button";
|
import Button from "@/components/Low/Button";
|
||||||
import BlankQuestionsModal from "@/components/QuestionsModal";
|
import BlankQuestionsModal from "@/components/QuestionsModal";
|
||||||
import useExamStore, { usePersistentExamStore } from "@/stores/examStore";
|
import useExamStore, { usePersistentExamStore } from "@/stores/exam";
|
||||||
import PartDivider from "./Navigation/SectionDivider";
|
import PartDivider from "./Navigation/SectionDivider";
|
||||||
import { Dialog, Transition } from "@headlessui/react";
|
import { Dialog, Transition } from "@headlessui/react";
|
||||||
import { capitalize } from "lodash";
|
import { capitalize } from "lodash";
|
||||||
import { mapBy } from "@/utils";
|
import { mapBy } from "@/utils";
|
||||||
|
import ScriptModal from "./components/ScriptModal";
|
||||||
|
import { ExamProps } from "./types";
|
||||||
|
import useExamTimer from "@/hooks/useExamTimer";
|
||||||
|
import useExamNavigation from "./Navigation/useExamNavigation";
|
||||||
|
import RenderAudioInstructionsPlayer from "./components/RenderAudioInstructionsPlayer";
|
||||||
|
import RenderAudioPlayer from "./components/RenderAudioPlayer";
|
||||||
|
import SectionNavbar from "./Navigation/SectionNavbar";
|
||||||
|
import ProgressButtons from "./components/ProgressButtons";
|
||||||
|
import { countExercises } from "@/utils/moduleUtils";
|
||||||
|
import { calculateExerciseIndex } from "./utils/calculateExerciseIndex";
|
||||||
|
|
||||||
interface Props {
|
|
||||||
exam: ListeningExam;
|
|
||||||
showSolutions?: boolean;
|
|
||||||
preview?: boolean;
|
|
||||||
onFinish: (userSolutions: UserSolution[]) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
function ScriptModal({ isOpen, script, onClose }: { isOpen: boolean; script: Script; onClose: () => void }) {
|
const Listening: React.FC<ExamProps<ListeningExam>> = ({ exam, showSolutions = false, preview = false }) => {
|
||||||
return (
|
const updateTimers = useExamTimer(exam.module, preview || showSolutions);
|
||||||
<Transition appear show={isOpen} as={Fragment}>
|
const userSolutionRef = useRef<(() => UserSolution) | null>(null);
|
||||||
<Dialog as="div" className="relative z-10" onClose={onClose}>
|
const [solutionWasUpdated, setSolutionWasUpdated] = useState(false);
|
||||||
<Transition.Child
|
|
||||||
as={Fragment}
|
|
||||||
enter="ease-out duration-300"
|
|
||||||
enterFrom="opacity-0"
|
|
||||||
enterTo="opacity-100"
|
|
||||||
leave="ease-in duration-200"
|
|
||||||
leaveFrom="opacity-100"
|
|
||||||
leaveTo="opacity-0">
|
|
||||||
<div className="fixed inset-0 bg-black bg-opacity-25" />
|
|
||||||
</Transition.Child>
|
|
||||||
|
|
||||||
<div className="fixed inset-0 overflow-y-auto">
|
|
||||||
<div className="flex min-h-full items-center justify-center p-4 text-center">
|
|
||||||
<Transition.Child
|
|
||||||
as={Fragment}
|
|
||||||
enter="ease-out duration-300"
|
|
||||||
enterFrom="opacity-0 scale-95"
|
|
||||||
enterTo="opacity-100 scale-100"
|
|
||||||
leave="ease-in duration-200"
|
|
||||||
leaveFrom="opacity-100 scale-100"
|
|
||||||
leaveTo="opacity-0 scale-95">
|
|
||||||
<Dialog.Panel className="w-full relative max-w-4xl transform rounded-2xl bg-white p-6 text-left align-middle shadow-xl transition-all">
|
|
||||||
<div className="mt-2 overflow-auto mb-28">
|
|
||||||
<p className="text-sm">
|
|
||||||
{typeof script === "string" && script.split("\\n").map((line, index) => (
|
|
||||||
<Fragment key={index}>
|
|
||||||
{line}
|
|
||||||
<br />
|
|
||||||
</Fragment>
|
|
||||||
))}
|
|
||||||
|
|
||||||
{typeof script === "object" && script.map((line, index) => (
|
|
||||||
<span key={index}>
|
|
||||||
<b>{line.name} ({capitalize(line.gender)})</b>: {line.text}
|
|
||||||
<br />
|
|
||||||
<br />
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="absolute bottom-8 right-8 max-w-[200px] self-end w-full">
|
|
||||||
<Button color="purple" variant="outline" className="max-w-[200px] self-end w-full" onClick={onClose}>
|
|
||||||
Close
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</Dialog.Panel>
|
|
||||||
</Transition.Child>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Dialog>
|
|
||||||
</Transition>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const INSTRUCTIONS_AUDIO_SRC =
|
|
||||||
"https://firebasestorage.googleapis.com/v0/b/storied-phalanx-349916.appspot.com/o/generic_listening_intro_v2.mp3?alt=media&token=16769f5f-1e9b-4a72-86a9-45a6f0fa9f82";
|
|
||||||
|
|
||||||
export default function Listening({ exam, showSolutions = false, preview = false, onFinish }: Props) {
|
|
||||||
const listeningBgColor = "bg-ielts-listening-light";
|
|
||||||
|
|
||||||
const [showTextModal, setShowTextModal] = useState(false);
|
const [showTextModal, setShowTextModal] = useState(false);
|
||||||
const [timesListened, setTimesListened] = useState(0);
|
const [timesListened, setTimesListened] = useState(0);
|
||||||
const [showBlankModal, setShowBlankModal] = useState(false);
|
const [showBlankModal, setShowBlankModal] = useState(false);
|
||||||
const [multipleChoicesDone, setMultipleChoicesDone] = useState<{ id: string; amount: number }[]>([]);
|
|
||||||
|
|
||||||
const [seenParts, setSeenParts] = useState<Set<number>>(new Set(showSolutions ? exam.parts.map((_, index) => index) : []));
|
|
||||||
const [showPartDivider, setShowPartDivider] = useState<boolean>(typeof exam.parts[0].intro === "string" && exam.parts[0].intro !== "");
|
|
||||||
|
|
||||||
const examState = useExamStore((state) => state);
|
const examState = useExamStore((state) => state);
|
||||||
const persistentExamState = usePersistentExamStore((state) => state);
|
const persistentExamState = usePersistentExamStore((state) => state);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
hasExamEnded,
|
exerciseIndex, partIndex, assignment,
|
||||||
userSolutions,
|
userSolutions, flags, timeSpentCurrentModule,
|
||||||
exerciseIndex,
|
questionIndex,
|
||||||
partIndex,
|
setBgColor, setUserSolutions, setTimeIsUp,
|
||||||
questionIndex: storeQuestionIndex,
|
dispatch
|
||||||
setBgColor,
|
|
||||||
setUserSolutions,
|
|
||||||
setHasExamEnded,
|
|
||||||
setExerciseIndex,
|
|
||||||
setPartIndex,
|
|
||||||
setQuestionIndex: setStoreQuestionIndex
|
|
||||||
} = !preview ? examState : persistentExamState;
|
} = !preview ? examState : persistentExamState;
|
||||||
|
|
||||||
const scrollToTop = () => Array.from(document.getElementsByTagName("body")).forEach((body) => body.scrollTo(0, 0));
|
const { finalizeModule, timeIsUp } = flags;
|
||||||
|
|
||||||
|
const timer = useRef(exam.minTimer - timeSpentCurrentModule / 60);
|
||||||
|
|
||||||
|
const [isFirstTimeRender, setIsFirstTimeRender] = useState(partIndex === 0 && exerciseIndex == 0 && !showSolutions);
|
||||||
|
|
||||||
|
const {
|
||||||
|
nextExercise, previousExercise,
|
||||||
|
showPartDivider, setShowPartDivider,
|
||||||
|
seenParts, setSeenParts
|
||||||
|
} = useExamNavigation({ exam, module: "listening", showBlankModal, setShowBlankModal, showSolutions, preview, disableBetweenParts: true });
|
||||||
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!showSolutions && exam.parts[partIndex]?.intro !== undefined && exam.parts[partIndex]?.intro !== "" && !seenParts.has(exerciseIndex)) {
|
if (finalizeModule || timeIsUp) {
|
||||||
setShowPartDivider(true);
|
updateTimers();
|
||||||
setBgColor(listeningBgColor);
|
if (timeIsUp) setTimeIsUp(false);
|
||||||
|
dispatch({ type: "FINALIZE_MODULE", payload: { updateTimers: false } })
|
||||||
}
|
}
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [partIndex]);
|
}, [finalizeModule, timeIsUp])
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (showSolutions) return setExerciseIndex(-1);
|
|
||||||
}, [setExerciseIndex, showSolutions]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
const registerSolution = useCallback((updateSolution: () => UserSolution) => {
|
||||||
if (partIndex === -1 && exam.variant === "partial") {
|
userSolutionRef.current = updateSolution;
|
||||||
setPartIndex(0);
|
setSolutionWasUpdated(true);
|
||||||
}
|
|
||||||
}, [partIndex, exam, setPartIndex]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const previousParts = exam.parts.filter((_, index) => index < partIndex);
|
|
||||||
let previousMultipleChoice = previousParts.flatMap((x) => x.exercises).filter((x) => x.type === "multipleChoice") as MultipleChoiceExercise[];
|
|
||||||
|
|
||||||
if (partIndex > -1 && exerciseIndex > -1) {
|
|
||||||
const previousPartExercises = exam.parts[partIndex].exercises.filter((_, index) => index < exerciseIndex);
|
|
||||||
const partMultipleChoice = previousPartExercises.filter((x) => x.type === "multipleChoice") as MultipleChoiceExercise[];
|
|
||||||
|
|
||||||
previousMultipleChoice = [...previousMultipleChoice, ...partMultipleChoice];
|
|
||||||
}
|
|
||||||
|
|
||||||
setMultipleChoicesDone(previousMultipleChoice.map((x) => ({ id: x.id, amount: x.questions.length - 1 })));
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (hasExamEnded && exerciseIndex === -1) {
|
if (solutionWasUpdated && userSolutionRef.current) {
|
||||||
setExerciseIndex(exerciseIndex + 1);
|
const solution = userSolutionRef.current();
|
||||||
|
setUserSolutions([...userSolutions.filter((x) => x.exercise !== solution.exercise), { ...solution, module: "listening", exam: exam.id }]);
|
||||||
|
setSolutionWasUpdated(false);
|
||||||
}
|
}
|
||||||
}, [hasExamEnded, exerciseIndex, setExerciseIndex]);
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [solutionWasUpdated])
|
||||||
|
|
||||||
|
const currentExercise = useMemo<Exercise>(() => {
|
||||||
|
const exercise = exam.parts[partIndex].exercises[exerciseIndex];
|
||||||
|
return {
|
||||||
|
...exercise,
|
||||||
|
userSolutions: userSolutions.find((x) => x.exercise === exercise.id)?.solutions || [],
|
||||||
|
};
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [partIndex, exerciseIndex]);
|
||||||
|
|
||||||
|
|
||||||
const confirmFinishModule = (keepGoing?: boolean) => {
|
const confirmFinishModule = (keepGoing?: boolean) => {
|
||||||
if (!keepGoing) {
|
if (!keepGoing) {
|
||||||
setShowBlankModal(false);
|
setShowBlankModal(false);
|
||||||
return;
|
return;
|
||||||
|
} else {
|
||||||
|
nextExercise(true);
|
||||||
|
setShowBlankModal(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
onFinish(userSolutions);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const nextExercise = (solution?: UserSolution) => {
|
const memoizedExerciseIndex = useMemo(() =>
|
||||||
if (solution)
|
calculateExerciseIndex(exam, partIndex, exerciseIndex, questionIndex)
|
||||||
setUserSolutions([
|
|
||||||
...userSolutions.filter((x) => x.exercise !== solution.exercise),
|
|
||||||
{ ...solution, module: "listening", exam: exam.id }
|
|
||||||
]);
|
|
||||||
};
|
|
||||||
|
|
||||||
const previousExercise = (solution?: UserSolution) => { };
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
, [partIndex, exerciseIndex, questionIndex]
|
||||||
const nextPart = () => {
|
|
||||||
scrollToTop()
|
|
||||||
if (partIndex + 1 < exam.parts.length && !hasExamEnded) {
|
|
||||||
setPartIndex(partIndex + 1);
|
|
||||||
setExerciseIndex(0);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!showSolutions && !hasExamEnded) {
|
|
||||||
const exercises = partIndex < exam.parts.length ? exam.parts[partIndex].exercises : []
|
|
||||||
const exerciseIDs = mapBy(exercises, 'id')
|
|
||||||
|
|
||||||
const hasMissing = userSolutions.filter(x => exerciseIDs.includes(x.exercise)).map(x => x.score.missing).some(x => x > 0)
|
|
||||||
|
|
||||||
if (hasMissing) return setShowBlankModal(true);
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
setHasExamEnded(false);
|
|
||||||
onFinish(userSolutions);
|
|
||||||
}
|
|
||||||
|
|
||||||
const renderPartExercises = () => {
|
|
||||||
const exercises = partIndex > -1 ? exam.parts[partIndex].exercises : []
|
|
||||||
const formattedExercises = exercises.map(exercise => ({
|
|
||||||
...exercise,
|
|
||||||
userSolutions: userSolutions.find((x) => x.exercise === exercise.id)?.solutions || [],
|
|
||||||
}))
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col gap-4">
|
|
||||||
{formattedExercises.map(e => showSolutions
|
|
||||||
? renderSolution(e, nextExercise, previousExercise, undefined, true)
|
|
||||||
: renderExercise(e, exam.id, nextExercise, previousExercise, undefined, true))}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const renderAudioInstructionsPlayer = () => (
|
|
||||||
<div className="flex flex-col gap-8 w-full bg-mti-gray-seasalt rounded-xl py-8 px-16">
|
|
||||||
<div className="flex flex-col w-full gap-2">
|
|
||||||
<h4 className="text-xl font-semibold">Please listen to the instructions audio attentively.</h4>
|
|
||||||
</div>
|
|
||||||
<div className="rounded-xl flex flex-col gap-4 items-center w-full h-fit">
|
|
||||||
<AudioPlayer key={partIndex} src={INSTRUCTIONS_AUDIO_SRC} color="listening" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const renderAudioPlayer = () => (
|
const handlePartDividerClick = () => {
|
||||||
<div className="flex flex-col gap-8 w-full bg-mti-gray-seasalt rounded-xl py-8 px-16">
|
setShowPartDivider(false);
|
||||||
{exam?.parts[partIndex]?.audio?.source ? (
|
setBgColor("bg-white");
|
||||||
<>
|
setSeenParts((prev) => new Set(prev).add(partIndex));
|
||||||
<div className="w-full items-start flex justify-between">
|
if (isFirstTimeRender) setIsFirstTimeRender(false);
|
||||||
<div className="flex flex-col w-full gap-2">
|
}
|
||||||
<h4 className="text-xl font-semibold">Please listen to the following audio attentively.</h4>
|
|
||||||
<span className="text-base">
|
|
||||||
{(() => {
|
|
||||||
const audioRepeatTimes = exam?.parts[partIndex]?.audio?.repeatableTimes;
|
|
||||||
return audioRepeatTimes && audioRepeatTimes > 0
|
|
||||||
? `You will only be allowed to listen to the audio ${audioRepeatTimes - timesListened} time(s).`
|
|
||||||
: "You may listen to the audio as many times as you would like.";
|
|
||||||
})()}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
{partIndex > -1 && !examState.assignment && !!exam.parts[partIndex].script && (
|
|
||||||
<Button
|
|
||||||
onClick={() => setShowTextModal(true)}
|
|
||||||
variant="outline"
|
|
||||||
color="gray"
|
|
||||||
className="w-full max-w-[200px]"
|
|
||||||
>
|
|
||||||
View Transcript
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="rounded-xl flex flex-col gap-4 items-center w-full h-fit">
|
|
||||||
<AudioPlayer
|
|
||||||
key={partIndex}
|
|
||||||
src={exam?.parts[partIndex]?.audio?.source ?? ''}
|
|
||||||
color="listening"
|
|
||||||
onEnd={() => setTimesListened((prev) => prev + 1)}
|
|
||||||
disabled={exam?.parts[partIndex]?.audio?.repeatableTimes != null &&
|
|
||||||
timesListened === exam.parts[partIndex]?.audio?.repeatableTimes}
|
|
||||||
disablePause
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<span>This section will be displayed the audio once it has been generated.</span>
|
|
||||||
)}
|
|
||||||
|
|
||||||
</div>
|
useEffect(() => {
|
||||||
);
|
setTimesListened(0);
|
||||||
|
}, [partIndex])
|
||||||
|
|
||||||
const progressButtons = () => (
|
const progressButtons = useMemo(() =>
|
||||||
<div className="flex justify-between w-full gap-8">
|
// Do not remove the ()=> in handle next
|
||||||
<Button
|
<ProgressButtons handlePrevious={previousExercise} handleNext={() => nextExercise()} />
|
||||||
color="purple"
|
, [nextExercise, previousExercise]);
|
||||||
variant="outline"
|
|
||||||
onClick={previousExercise}
|
|
||||||
className="max-w-[200px] w-full">
|
|
||||||
Back
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
color="purple"
|
|
||||||
onClick={nextPart}
|
|
||||||
className="max-w-[200px] self-end w-full">
|
|
||||||
Next
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -292,73 +132,64 @@ export default function Listening({ exam, showSolutions = false, preview = false
|
|||||||
defaultTitle="Listening exam"
|
defaultTitle="Listening exam"
|
||||||
section={exam.parts[partIndex]}
|
section={exam.parts[partIndex]}
|
||||||
sectionIndex={partIndex}
|
sectionIndex={partIndex}
|
||||||
onNext={() => { setShowPartDivider(false); setBgColor("bg-white"); setSeenParts((prev) => new Set(prev).add(exerciseIndex)) }}
|
onNext={handlePartDividerClick}
|
||||||
/> : (
|
/> : (
|
||||||
<>
|
<>
|
||||||
<BlankQuestionsModal isOpen={showBlankModal} onClose={confirmFinishModule} />
|
<BlankQuestionsModal isOpen={showBlankModal} onClose={confirmFinishModule} />
|
||||||
{partIndex > -1 && exam.parts[partIndex].script &&
|
{!isFirstTimeRender && exam.parts[partIndex].script &&
|
||||||
<ScriptModal script={exam.parts[partIndex].script!} isOpen={showTextModal} onClose={() => setShowTextModal(false)} />
|
<ScriptModal script={exam.parts[partIndex].script!} isOpen={showTextModal} onClose={() => setShowTextModal(false)} />
|
||||||
}
|
}
|
||||||
<div className="flex flex-col h-full w-full gap-8 justify-between">
|
<div className="flex flex-col h-full w-full gap-8 justify-between">
|
||||||
<ModuleTitle
|
{exam.parts.length > 1 && <SectionNavbar
|
||||||
exerciseIndex={partIndex + 1}
|
|
||||||
minTimer={exam.minTimer}
|
|
||||||
module="listening"
|
module="listening"
|
||||||
totalExercises={exam.parts.length}
|
sectionLabel="Section"
|
||||||
|
seenParts={seenParts}
|
||||||
|
setShowPartDivider={setShowPartDivider}
|
||||||
|
setSeenParts={setSeenParts}
|
||||||
|
preview={preview}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
<ModuleTitle
|
||||||
|
minTimer={timer.current}
|
||||||
|
module="listening"
|
||||||
|
exerciseIndex={memoizedExerciseIndex}
|
||||||
|
totalExercises={countExercises(exam.parts.flatMap((x) => x.exercises))}
|
||||||
disableTimer={showSolutions || preview}
|
disableTimer={showSolutions || preview}
|
||||||
indexLabel="Part"
|
indexLabel="Exercise"
|
||||||
|
preview={preview}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Audio Player for the Instructions */}
|
{/* Audio Player for the Instructions */}
|
||||||
{partIndex === -1 && renderAudioInstructionsPlayer()}
|
{isFirstTimeRender && <RenderAudioInstructionsPlayer />}
|
||||||
|
|
||||||
{/* Part's audio player */}
|
{/* Part's audio player */}
|
||||||
{partIndex > -1 && renderAudioPlayer()}
|
{!isFirstTimeRender &&
|
||||||
|
<RenderAudioPlayer
|
||||||
|
audioSource={exam?.parts[partIndex]?.audio?.source}
|
||||||
|
repeatableTimes={exam?.parts[partIndex]?.audio?.repeatableTimes}
|
||||||
|
script={exam?.parts[partIndex]?.script}
|
||||||
|
assignment={assignment}
|
||||||
|
timesListened={timesListened}
|
||||||
|
setShowTextModal={setShowTextModal}
|
||||||
|
setTimesListened={setTimesListened}
|
||||||
|
/>}
|
||||||
|
|
||||||
{/* Exercise renderer */}
|
{/* Exercise renderer */}
|
||||||
|
{!isFirstTimeRender && !showPartDivider && !showSolutions && renderExercise(currentExercise, exam.id, registerSolution, preview, progressButtons, progressButtons)}
|
||||||
{exerciseIndex > -1 && partIndex > -1 && (
|
{showSolutions && renderSolution(currentExercise, progressButtons, progressButtons)}
|
||||||
<>
|
|
||||||
{progressButtons()}
|
|
||||||
{renderPartExercises()}
|
|
||||||
{progressButtons()}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{exerciseIndex === -1 && partIndex > -1 && exam.variant !== "partial" && (
|
{((isFirstTimeRender) && !showPartDivider && !showSolutions) &&
|
||||||
<div className="self-end flex justify-between w-full gap-8 absolute bottom-8 left-0 px-8">
|
<ProgressButtons
|
||||||
<Button
|
hidePrevious={partIndex == 0 && isFirstTimeRender}
|
||||||
color="purple"
|
nextLabel={isFirstTimeRender ? "Start now" : "Next Page"}
|
||||||
variant="outline"
|
handlePrevious={previousExercise}
|
||||||
onClick={() => {
|
handleNext={() => isFirstTimeRender ? setIsFirstTimeRender(false) : nextExercise()} />
|
||||||
if (partIndex === 0) return setPartIndex(-1);
|
}
|
||||||
|
|
||||||
setExerciseIndex(exam.parts[partIndex - 1].exercises.length - 1);
|
|
||||||
setPartIndex(partIndex - 1);
|
|
||||||
}}
|
|
||||||
className="max-w-[200px] w-full">
|
|
||||||
Back
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button color="purple" onClick={() => setExerciseIndex(0)} className="max-w-[200px] self-end w-full">
|
|
||||||
Next
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{partIndex === -1 && exam.variant !== "partial" && (
|
|
||||||
<Button color="purple" onClick={() => setPartIndex(0)} className="max-w-[200px] self-end w-full justify-self-end">
|
|
||||||
Start now
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
{exerciseIndex === -1 && partIndex === 0 && exam.variant === "partial" && (
|
|
||||||
<Button color="purple" onClick={() => setExerciseIndex(0)} className="max-w-[200px] self-end w-full justify-self-end">
|
|
||||||
Start now
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</>)
|
</>)
|
||||||
}
|
}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default Listening;
|
||||||
|
|||||||
@@ -1,26 +1,64 @@
|
|||||||
import { Module } from "@/interfaces";
|
import { Module } from "@/interfaces";
|
||||||
import { LevelPart, ListeningPart, ReadingPart, SpeakingExercise, WritingExercise } from "@/interfaces/exam";
|
import { ExerciseOnlyExam, LevelPart, ListeningPart, PartExam, ReadingPart, SpeakingExercise, WritingExercise } from "@/interfaces/exam";
|
||||||
|
import useExamStore, { usePersistentExamStore } from "@/stores/exam";
|
||||||
import { Tab, TabGroup, TabList } from "@headlessui/react";
|
import { Tab, TabGroup, TabList } from "@headlessui/react";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
|
import hasDivider from "../utils/hasDivider";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
module: Module;
|
module: Module;
|
||||||
sections: LevelPart[] | ReadingPart[] | ListeningPart[] | WritingExercise[] | SpeakingExercise[];
|
|
||||||
sectionIndex: number;
|
|
||||||
sectionLabel: string;
|
sectionLabel: string;
|
||||||
setSectionIndex: (index: number) => void;
|
seenParts: Set<number>;
|
||||||
onClick: (index: number) => void;
|
setShowPartDivider: React.Dispatch<React.SetStateAction<boolean>>;
|
||||||
|
setSeenParts: React.Dispatch<React.SetStateAction<Set<number>>>;
|
||||||
|
preview: boolean;
|
||||||
|
setIsBetweenParts?: React.Dispatch<React.SetStateAction<boolean>>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const SectionNavbar: React.FC<Props> = ({module, sections, sectionIndex, sectionLabel, setSectionIndex, onClick}) => {
|
const SectionNavbar: React.FC<Props> = ({ module, sectionLabel, seenParts, setSeenParts, setShowPartDivider, setIsBetweenParts, preview }) => {
|
||||||
|
|
||||||
|
const isPartExam = ["reading", "listening", "level"].includes(module);
|
||||||
|
const examState = useExamStore((state) => state);
|
||||||
|
const persistentExamState = usePersistentExamStore((state) => state);
|
||||||
|
|
||||||
|
const {
|
||||||
|
exam,
|
||||||
|
partIndex, setPartIndex,
|
||||||
|
exerciseIndex, setExerciseIndex,
|
||||||
|
setQuestionIndex,
|
||||||
|
setBgColor
|
||||||
|
} = !preview ? examState : persistentExamState;
|
||||||
|
|
||||||
|
const sections = isPartExam ? (exam as PartExam).parts : (exam as ExerciseOnlyExam).exercises;
|
||||||
|
const sectionIndex = isPartExam ? partIndex : exerciseIndex;
|
||||||
|
|
||||||
|
const handleSectionChange = (index: number) => {
|
||||||
|
const changeSection = isPartExam ? setPartIndex : setExerciseIndex;
|
||||||
|
changeSection(index);
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleClick = (index: number) => {
|
||||||
|
setExerciseIndex(0);
|
||||||
|
setQuestionIndex(0);
|
||||||
|
if (!seenParts.has(index)) {
|
||||||
|
if (hasDivider(exam!, index)) {
|
||||||
|
setShowPartDivider(true);
|
||||||
|
setBgColor(`bg-ielts-${module}-light`);
|
||||||
|
} else if(setIsBetweenParts) {
|
||||||
|
setIsBetweenParts(true);
|
||||||
|
}
|
||||||
|
setSeenParts(prev => new Set(prev).add(index));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
||||||
<div className="w-full">
|
<div className="w-full">
|
||||||
<TabGroup className="w-[90%]" selectedIndex={sectionIndex} onChange={setSectionIndex}>
|
<TabGroup className="w-[90%]" selectedIndex={sectionIndex} onChange={handleSectionChange}>
|
||||||
<TabList className={`flex space-x-1 rounded-xl bg-ielts-${module}/20 p-1`}>
|
<TabList className={`flex space-x-1 rounded-xl bg-ielts-${module}/20 p-1`}>
|
||||||
{sections.map((_, index) =>
|
{sections.map((_, index) =>
|
||||||
<Tab key={index} onClick={() => onClick(index)}
|
<Tab key={index} onClick={() => handleClick(index)}
|
||||||
className={({ selected }) =>
|
className={({ selected }) =>
|
||||||
clsx(
|
clsx(
|
||||||
`w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-ielts-${module}/80`,
|
`w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-ielts-${module}/80`,
|
||||||
|
|||||||
217
src/exams/Navigation/useExamNavigation.tsx
Normal file
217
src/exams/Navigation/useExamNavigation.tsx
Normal file
@@ -0,0 +1,217 @@
|
|||||||
|
import { ExerciseOnlyExam, ModuleExam, PartExam } from "@/interfaces/exam";
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import scrollToTop from "../utils/scrollToTop";
|
||||||
|
import { Module } from "@/interfaces";
|
||||||
|
import { answeredEveryQuestion } from "../utils/answeredEveryQuestion";
|
||||||
|
import useExamStore, { usePersistentExamStore } from "@/stores/exam";
|
||||||
|
import hasDivider from "../utils/hasDivider";
|
||||||
|
|
||||||
|
const MC_PER_PAGE = 2;
|
||||||
|
|
||||||
|
type UseExamNavigation = (props: {
|
||||||
|
exam: ModuleExam;
|
||||||
|
module: Module;
|
||||||
|
showBlankModal?: boolean;
|
||||||
|
setShowBlankModal?: React.Dispatch<React.SetStateAction<boolean>>;
|
||||||
|
showSolutions: boolean;
|
||||||
|
preview: boolean;
|
||||||
|
disableBetweenParts?: boolean;
|
||||||
|
}) => {
|
||||||
|
showPartDivider: boolean;
|
||||||
|
seenParts: Set<number>;
|
||||||
|
isBetweenParts: boolean;
|
||||||
|
nextExercise: (isBetweenParts?: boolean) => void;
|
||||||
|
previousExercise: (isBetweenParts?: boolean) => void;
|
||||||
|
setShowPartDivider: React.Dispatch<React.SetStateAction<boolean>>;
|
||||||
|
setSeenParts: React.Dispatch<React.SetStateAction<Set<number>>>;
|
||||||
|
setIsBetweenParts: React.Dispatch<React.SetStateAction<boolean>>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const useExamNavigation: UseExamNavigation = ({
|
||||||
|
exam,
|
||||||
|
module,
|
||||||
|
setShowBlankModal,
|
||||||
|
showSolutions,
|
||||||
|
preview,
|
||||||
|
disableBetweenParts = false,
|
||||||
|
}) => {
|
||||||
|
|
||||||
|
const examState = useExamStore((state) => state);
|
||||||
|
const persistentExamState = usePersistentExamStore((state) => state);
|
||||||
|
|
||||||
|
const {
|
||||||
|
exerciseIndex, setExerciseIndex,
|
||||||
|
partIndex, setPartIndex,
|
||||||
|
questionIndex, setQuestionIndex,
|
||||||
|
userSolutions, setModuleIndex,
|
||||||
|
setBgColor,
|
||||||
|
dispatch,
|
||||||
|
} = !preview ? examState : persistentExamState;
|
||||||
|
|
||||||
|
const [isBetweenParts, setIsBetweenParts] = useState(partIndex !== 0 && exerciseIndex == 0 && !disableBetweenParts);
|
||||||
|
const isPartExam = ["reading", "listening", "level"].includes(exam.module);
|
||||||
|
|
||||||
|
const [seenParts, setSeenParts] = useState<Set<number>>(
|
||||||
|
new Set(showSolutions ?
|
||||||
|
(isPartExam ?
|
||||||
|
(exam as PartExam).parts.map((_, index) => index) :
|
||||||
|
(exam as ExerciseOnlyExam).exercises.map((_, index) => index)
|
||||||
|
) :
|
||||||
|
[]
|
||||||
|
)
|
||||||
|
);
|
||||||
|
const [showPartDivider, setShowPartDivider] = useState<boolean>(hasDivider(exam, 0));
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!showSolutions && hasDivider(exam, isPartExam ? partIndex : exerciseIndex) && !seenParts.has(partIndex)) {
|
||||||
|
setShowPartDivider(true);
|
||||||
|
setBgColor(`bg-ielts-${module}-light`);
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [partIndex]);
|
||||||
|
|
||||||
|
const nextExercise = (keepGoing: boolean = false) => {
|
||||||
|
scrollToTop();
|
||||||
|
|
||||||
|
if (isPartExam) {
|
||||||
|
nextPartExam(keepGoing);
|
||||||
|
} else {
|
||||||
|
nextExerciseOnlyExam();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const previousExercise = () => {
|
||||||
|
scrollToTop();
|
||||||
|
|
||||||
|
if (isPartExam) {
|
||||||
|
previousPartExam();
|
||||||
|
} else {
|
||||||
|
previousExerciseOnlyExam();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const nextPartExam = (keepGoing: boolean) => {
|
||||||
|
const partExam = (exam as PartExam);
|
||||||
|
|
||||||
|
const reachedFinalExercise = exerciseIndex + 1 === partExam.parts[partIndex].exercises.length;
|
||||||
|
const currentExercise = partExam.parts[partIndex].exercises[exerciseIndex];
|
||||||
|
|
||||||
|
if (isBetweenParts) {
|
||||||
|
setIsBetweenParts(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentExercise.type === "multipleChoice" && questionIndex < currentExercise.questions.length - 1) {
|
||||||
|
setQuestionIndex(questionIndex + MC_PER_PAGE);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!reachedFinalExercise) {
|
||||||
|
setExerciseIndex(exerciseIndex + 1);
|
||||||
|
setQuestionIndex(0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (partIndex < partExam.parts.length - 1) {
|
||||||
|
if (!disableBetweenParts) setIsBetweenParts(true);
|
||||||
|
setPartIndex(partIndex + 1);
|
||||||
|
setExerciseIndex(0);
|
||||||
|
setQuestionIndex(0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!answeredEveryQuestion(exam as PartExam, userSolutions) && !keepGoing && setShowBlankModal && !showSolutions && !preview) {
|
||||||
|
setShowBlankModal(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (preview) {
|
||||||
|
setPartIndex(0);
|
||||||
|
setExerciseIndex(0);
|
||||||
|
setQuestionIndex(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!showSolutions) {
|
||||||
|
dispatch({ type: "FINALIZE_MODULE", payload: { updateTimers: true } });
|
||||||
|
} else {
|
||||||
|
dispatch({ type: "FINALIZE_MODULE_SOLUTIONS"});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const previousPartExam = () => {
|
||||||
|
if (partIndex !== 0) {
|
||||||
|
setPartIndex(partIndex - 1);
|
||||||
|
setExerciseIndex((exam as PartExam).parts[partIndex].exercises.length - 1);
|
||||||
|
if (isBetweenParts) setIsBetweenParts(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setQuestionIndex(0);
|
||||||
|
|
||||||
|
if (exerciseIndex === 0 && !disableBetweenParts) {
|
||||||
|
setIsBetweenParts(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (exerciseIndex !== 0) {
|
||||||
|
setExerciseIndex(exerciseIndex - 1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const nextExerciseOnlyExam = () => {
|
||||||
|
const exerciseOnlyExam = (exam as ExerciseOnlyExam);
|
||||||
|
const reachedFinalExercise = exerciseIndex + 1 === exerciseOnlyExam.exercises.length;
|
||||||
|
const currentExercise = exerciseOnlyExam.exercises[exerciseIndex];
|
||||||
|
|
||||||
|
if (currentExercise.type === "interactiveSpeaking" && questionIndex < currentExercise.prompts.length - 1) {
|
||||||
|
setQuestionIndex(questionIndex + 1)
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!reachedFinalExercise) {
|
||||||
|
setQuestionIndex(0);
|
||||||
|
setExerciseIndex(exerciseIndex + 1);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (preview) {
|
||||||
|
setPartIndex(0);
|
||||||
|
setExerciseIndex(0);
|
||||||
|
setQuestionIndex(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!showSolutions) {
|
||||||
|
dispatch({ type: "FINALIZE_MODULE", payload: { updateTimers: true } });
|
||||||
|
} else {
|
||||||
|
dispatch({ type: "FINALIZE_MODULE_SOLUTIONS"});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
const previousExerciseOnlyExam = () => {
|
||||||
|
const currentExercise = (exam as ExerciseOnlyExam).exercises[exerciseIndex];
|
||||||
|
|
||||||
|
if (currentExercise.type === "interactiveSpeaking" && questionIndex !== 0) {
|
||||||
|
setQuestionIndex(questionIndex - 1);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (exerciseIndex !== 0) {
|
||||||
|
setExerciseIndex(exerciseIndex - 1);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
seenParts,
|
||||||
|
showPartDivider,
|
||||||
|
nextExercise,
|
||||||
|
previousExercise,
|
||||||
|
setShowPartDivider,
|
||||||
|
setSeenParts,
|
||||||
|
isBetweenParts,
|
||||||
|
setIsBetweenParts,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default useExamNavigation;
|
||||||
@@ -1,162 +1,66 @@
|
|||||||
import { MultipleChoiceExercise, ReadingExam, ReadingPart, UserSolution } from "@/interfaces/exam";
|
import { Exercise, ReadingExam, UserSolution } from "@/interfaces/exam";
|
||||||
import { Fragment, useEffect, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import { convertCamelCaseToReadable } from "@/utils/string";
|
import { convertCamelCaseToReadable } from "@/utils/string";
|
||||||
import { Dialog, Transition } from "@headlessui/react";
|
|
||||||
import { renderExercise } from "@/components/Exercises";
|
import { renderExercise } from "@/components/Exercises";
|
||||||
import { renderSolution } from "@/components/Solutions";
|
import { renderSolution } from "@/components/Solutions";
|
||||||
import { BsChevronDown, BsChevronUp } from "react-icons/bs";
|
|
||||||
import ModuleTitle from "@/components/Medium/ModuleTitle";
|
import ModuleTitle from "@/components/Medium/ModuleTitle";
|
||||||
import Button from "@/components/Low/Button";
|
|
||||||
import BlankQuestionsModal from "@/components/QuestionsModal";
|
import BlankQuestionsModal from "@/components/QuestionsModal";
|
||||||
import useExamStore, { usePersistentExamStore } from "@/stores/examStore";
|
|
||||||
import { countExercises } from "@/utils/moduleUtils";
|
import { countExercises } from "@/utils/moduleUtils";
|
||||||
import PartDivider from "./Navigation/SectionDivider";
|
import PartDivider from "./Navigation/SectionDivider";
|
||||||
|
import ReadingPassage from "./components/ReadingPassage";
|
||||||
|
//import ReadingPassageModal from "./components/ReadingPassageModal";
|
||||||
|
import {calculateExerciseIndex} from "./utils/calculateExerciseIndex";
|
||||||
|
import useExamNavigation from "./Navigation/useExamNavigation";
|
||||||
|
import { ExamProps } from "./types";
|
||||||
|
import useExamStore, { usePersistentExamStore } from "@/stores/exam";
|
||||||
|
import useExamTimer from "@/hooks/useExamTimer";
|
||||||
|
import SectionNavbar from "./Navigation/SectionNavbar";
|
||||||
|
import ProgressButtons from "./components/ProgressButtons";
|
||||||
|
|
||||||
interface Props {
|
const Reading: React.FC<ExamProps<ReadingExam>> = ({ exam, showSolutions = false, preview = false }) => {
|
||||||
exam: ReadingExam;
|
const updateTimers = useExamTimer(exam.module, preview || showSolutions);
|
||||||
showSolutions?: boolean;
|
const userSolutionRef = useRef<(() => UserSolution) | null>(null);
|
||||||
preview?: boolean;
|
const [solutionWasUpdated, setSolutionWasUpdated] = useState(false);
|
||||||
onFinish: (userSolutions: UserSolution[]) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const numberToLetter = (number: number) => (number + 9).toString(36).toUpperCase();
|
|
||||||
|
|
||||||
function TextModal({ isOpen, title, content, onClose }: { isOpen: boolean; title: string; content: string; onClose: () => void }) {
|
|
||||||
return (
|
|
||||||
<Transition appear show={isOpen} as={Fragment}>
|
|
||||||
<Dialog as="div" className="relative z-10" onClose={onClose}>
|
|
||||||
<Transition.Child
|
|
||||||
as={Fragment}
|
|
||||||
enter="ease-out duration-300"
|
|
||||||
enterFrom="opacity-0"
|
|
||||||
enterTo="opacity-100"
|
|
||||||
leave="ease-in duration-200"
|
|
||||||
leaveFrom="opacity-100"
|
|
||||||
leaveTo="opacity-0">
|
|
||||||
<div className="fixed inset-0 bg-black bg-opacity-25" />
|
|
||||||
</Transition.Child>
|
|
||||||
|
|
||||||
<div className="fixed inset-0 overflow-y-auto">
|
|
||||||
<div className="flex min-h-full items-center justify-center p-4 text-center">
|
|
||||||
<Transition.Child
|
|
||||||
as={Fragment}
|
|
||||||
enter="ease-out duration-300"
|
|
||||||
enterFrom="opacity-0 scale-95"
|
|
||||||
enterTo="opacity-100 scale-100"
|
|
||||||
leave="ease-in duration-200"
|
|
||||||
leaveFrom="opacity-100 scale-100"
|
|
||||||
leaveTo="opacity-0 scale-95">
|
|
||||||
<Dialog.Panel className="w-full relative max-w-4xl transform rounded-2xl bg-white p-6 text-left align-middle shadow-xl transition-all">
|
|
||||||
<Dialog.Title as="h3" className="text-lg font-medium leading-6 text-gray-900">
|
|
||||||
{title}
|
|
||||||
</Dialog.Title>
|
|
||||||
<div className="mt-2 overflow-auto mb-28">
|
|
||||||
<p className="text-sm">
|
|
||||||
{content.split("\\n").map((line, index) => (
|
|
||||||
<Fragment key={index}>
|
|
||||||
{line}
|
|
||||||
<br />
|
|
||||||
</Fragment>
|
|
||||||
))}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="absolute bottom-8 right-8 max-w-[200px] self-end w-full">
|
|
||||||
<Button color="purple" variant="outline" className="max-w-[200px] self-end w-full" onClick={onClose}>
|
|
||||||
Close
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</Dialog.Panel>
|
|
||||||
</Transition.Child>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Dialog>
|
|
||||||
</Transition>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function TextComponent({ part, exerciseType }: { part: ReadingPart; exerciseType: string }) {
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col gap-2 w-full">
|
|
||||||
<h3 className="text-xl font-semibold">{part.text.title}</h3>
|
|
||||||
<div className="border border-mti-gray-dim w-full rounded-full opacity-10" />
|
|
||||||
{part.text.content
|
|
||||||
.split(/\n|(\\n)/g)
|
|
||||||
.filter((x) => x && x.length > 0 && x !== "\\n")
|
|
||||||
.map((line, index) => (
|
|
||||||
<Fragment key={index}>
|
|
||||||
{exerciseType === "matchSentences" && (
|
|
||||||
<div className="flex gap-3 border border-transparent hover:border-mti-purple-light rounded-lg transition ease-in-out duration-300 p-2 px-3 cursor-pointer">
|
|
||||||
<span className="font-bold text-mti-purple-dark">{numberToLetter(index + 1)}</span>
|
|
||||||
<p>{line}</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{exerciseType !== "matchSentences" && <p key={index}>{line}</p>}
|
|
||||||
</Fragment>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function Reading({ exam, showSolutions = false, preview = false, onFinish }: Props) {
|
|
||||||
const readingBgColor = "bg-ielts-reading-light";
|
|
||||||
|
|
||||||
const [showTextModal, setShowTextModal] = useState(false);
|
|
||||||
const [showBlankModal, setShowBlankModal] = useState(false);
|
const [showBlankModal, setShowBlankModal] = useState(false);
|
||||||
const [multipleChoicesDone, setMultipleChoicesDone] = useState<{ id: string; amount: number }[]>([]);
|
//const [showTextModal, setShowTextModal] = useState(false);
|
||||||
const [isTextMinimized, setIsTextMinimzed] = useState(false);
|
const [isTextMinimized, setIsTextMinimzed] = useState(false);
|
||||||
const [exerciseType, setExerciseType] = useState("");
|
|
||||||
|
|
||||||
const [seenParts, setSeenParts] = useState<Set<number>>(new Set(showSolutions ? exam.parts.map((_, index) => index) : []));
|
|
||||||
const [showPartDivider, setShowPartDivider] = useState<boolean>(typeof exam.parts[0].intro === "string" && exam.parts[0].intro !== "");
|
|
||||||
|
|
||||||
const examState = useExamStore((state) => state);
|
const examState = useExamStore((state) => state);
|
||||||
const persistentExamState = usePersistentExamStore((state) => state);
|
const persistentExamState = usePersistentExamStore((state) => state);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
hasExamEnded,
|
exerciseIndex, partIndex, questionIndex,
|
||||||
userSolutions,
|
userSolutions, flags, timeSpentCurrentModule,
|
||||||
exerciseIndex,
|
setBgColor, setUserSolutions, setTimeIsUp,
|
||||||
partIndex,
|
dispatch
|
||||||
questionIndex: storeQuestionIndex,
|
|
||||||
setBgColor,
|
|
||||||
setUserSolutions,
|
|
||||||
setHasExamEnded,
|
|
||||||
setExerciseIndex,
|
|
||||||
setPartIndex,
|
|
||||||
setQuestionIndex: setStoreQuestionIndex
|
|
||||||
} = !preview ? examState : persistentExamState;
|
} = !preview ? examState : persistentExamState;
|
||||||
|
|
||||||
|
|
||||||
const scrollToTop = () => Array.from(document.getElementsByTagName("body")).forEach((body) => body.scrollTo(0, 0));
|
const timer = useRef(exam.minTimer - timeSpentCurrentModule / 60);
|
||||||
|
|
||||||
|
const { finalizeModule, timeIsUp } = flags;
|
||||||
|
const [isFirstTimeRender, setIsFirstTimeRender] = useState(partIndex === 0 && exerciseIndex == 0 && !showSolutions);
|
||||||
|
|
||||||
|
const {
|
||||||
|
nextExercise, previousExercise,
|
||||||
|
showPartDivider, setShowPartDivider,
|
||||||
|
seenParts, setSeenParts,
|
||||||
|
isBetweenParts, setIsBetweenParts
|
||||||
|
} = useExamNavigation({ exam, module: "reading", showBlankModal, setShowBlankModal, showSolutions, preview, disableBetweenParts: showSolutions });
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!showSolutions && exam.parts[partIndex]?.intro !== undefined && exam.parts[partIndex]?.intro !== "" && !seenParts.has(exerciseIndex)) {
|
if (finalizeModule || timeIsUp) {
|
||||||
setShowPartDivider(true);
|
updateTimers();
|
||||||
setBgColor(readingBgColor);
|
|
||||||
|
if (timeIsUp) {
|
||||||
|
setTimeIsUp(false);
|
||||||
|
}
|
||||||
|
dispatch({ type: "FINALIZE_MODULE", payload: { updateTimers: false } })
|
||||||
}
|
}
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [partIndex]);
|
}, [finalizeModule, timeIsUp])
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (showSolutions) setExerciseIndex(-1);
|
|
||||||
}, [setExerciseIndex, showSolutions]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const previousParts = exam.parts.filter((_, index) => index < partIndex);
|
|
||||||
let previousMultipleChoice = previousParts.flatMap((x) => x.exercises).filter((x) => x.type === "multipleChoice") as MultipleChoiceExercise[];
|
|
||||||
|
|
||||||
if (partIndex > -1 && exerciseIndex > -1) {
|
|
||||||
const previousPartExercises = exam.parts[partIndex].exercises.filter((_, index) => index < exerciseIndex);
|
|
||||||
const partMultipleChoice = previousPartExercises.filter((x) => x.type === "multipleChoice") as MultipleChoiceExercise[];
|
|
||||||
|
|
||||||
previousMultipleChoice = [...previousMultipleChoice, ...partMultipleChoice];
|
|
||||||
}
|
|
||||||
|
|
||||||
setMultipleChoicesDone(previousMultipleChoice.map((x) => ({ id: x.id, amount: x.questions.length - 1 })));
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const listener = (e: KeyboardEvent) => {
|
const listener = (e: KeyboardEvent) => {
|
||||||
@@ -164,144 +68,70 @@ export default function Reading({ exam, showSolutions = false, preview = false,
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
document.addEventListener("keydown", listener);
|
document.addEventListener("keydown", listener);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
document.removeEventListener("keydown", listener);
|
document.removeEventListener("keydown", listener);
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const registerSolution = useCallback((updateSolution: () => UserSolution) => {
|
||||||
|
userSolutionRef.current = updateSolution;
|
||||||
|
setSolutionWasUpdated(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (hasExamEnded && exerciseIndex === -1) {
|
if (solutionWasUpdated && userSolutionRef.current) {
|
||||||
setExerciseIndex(exerciseIndex + 1);
|
const solution = userSolutionRef.current();
|
||||||
}
|
|
||||||
}, [hasExamEnded, exerciseIndex, setExerciseIndex]);
|
|
||||||
|
|
||||||
const confirmFinishModule = (keepGoing?: boolean) => {
|
|
||||||
if (!keepGoing) {
|
|
||||||
setShowBlankModal(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
onFinish(userSolutions);
|
|
||||||
};
|
|
||||||
|
|
||||||
const nextExercise = (solution?: UserSolution) => {
|
|
||||||
scrollToTop();
|
|
||||||
if (solution) {
|
|
||||||
setUserSolutions([...userSolutions.filter((x) => x.exercise !== solution.exercise), { ...solution, module: "reading", exam: exam.id }]);
|
setUserSolutions([...userSolutions.filter((x) => x.exercise !== solution.exercise), { ...solution, module: "reading", exam: exam.id }]);
|
||||||
|
setSolutionWasUpdated(false);
|
||||||
}
|
}
|
||||||
if (storeQuestionIndex > 0) {
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
const exercise = getExercise();
|
}, [solutionWasUpdated])
|
||||||
setExerciseType(exercise.type);
|
|
||||||
setMultipleChoicesDone((prev) => [...prev.filter((x) => x.id !== exercise.id), { id: exercise.id, amount: storeQuestionIndex }]);
|
|
||||||
}
|
|
||||||
setStoreQuestionIndex(0);
|
|
||||||
|
|
||||||
if (exerciseIndex + 1 < exam.parts[partIndex].exercises.length && !hasExamEnded) {
|
const currentExercise = useMemo<Exercise>(() => {
|
||||||
setExerciseIndex(exerciseIndex + 1);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (partIndex + 1 < exam.parts.length && !hasExamEnded) {
|
|
||||||
setPartIndex(partIndex + 1);
|
|
||||||
setExerciseIndex(showSolutions ? 0 : -1);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
solution &&
|
|
||||||
![...userSolutions.filter((x) => x.exercise !== solution?.exercise).map((x) => x.score.missing), solution?.score.missing].every(
|
|
||||||
(x) => x === 0,
|
|
||||||
) &&
|
|
||||||
!showSolutions &&
|
|
||||||
!preview &&
|
|
||||||
!hasExamEnded
|
|
||||||
) {
|
|
||||||
setShowBlankModal(true);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setHasExamEnded(false);
|
|
||||||
|
|
||||||
if (solution) {
|
|
||||||
onFinish([...userSolutions.filter((x) => x.exercise !== solution.exercise), { ...solution, module: "reading", exam: exam.id }]);
|
|
||||||
} else {
|
|
||||||
onFinish(userSolutions);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const previousExercise = (solution?: UserSolution) => {
|
|
||||||
scrollToTop();
|
|
||||||
if (solution) {
|
|
||||||
setUserSolutions([...userSolutions.filter((x) => x.exercise !== solution.exercise), { ...solution, module: "reading", exam: exam.id }]);
|
|
||||||
}
|
|
||||||
setStoreQuestionIndex(0);
|
|
||||||
|
|
||||||
setExerciseIndex(exerciseIndex - 1);
|
|
||||||
};
|
|
||||||
|
|
||||||
const getExercise = () => {
|
|
||||||
const exercise = exam.parts[partIndex].exercises[exerciseIndex];
|
const exercise = exam.parts[partIndex].exercises[exerciseIndex];
|
||||||
return {
|
return {
|
||||||
...exercise,
|
...exercise,
|
||||||
userSolutions: userSolutions.find((x) => x.exercise === exercise.id)?.solutions || [],
|
userSolutions: userSolutions.find((x) => x.exercise === exercise.id)?.solutions || [],
|
||||||
};
|
};
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [partIndex, exerciseIndex]);
|
||||||
|
|
||||||
|
|
||||||
|
const confirmFinishModule = (keepGoing?: boolean) => {
|
||||||
|
if (!keepGoing) {
|
||||||
|
setShowBlankModal(false);
|
||||||
|
return;
|
||||||
|
} else {
|
||||||
|
nextExercise(true);
|
||||||
|
setShowBlankModal(false);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const memoizedExerciseIndex = useMemo(() =>
|
||||||
|
calculateExerciseIndex(exam, partIndex, exerciseIndex, questionIndex)
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
, [partIndex, exerciseIndex, questionIndex]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handlePartDividerClick = () => {
|
||||||
|
setShowPartDivider(false);
|
||||||
|
setBgColor("bg-white");
|
||||||
|
setSeenParts((prev) => new Set(prev).add(partIndex));
|
||||||
|
if (isFirstTimeRender) setIsFirstTimeRender(false);
|
||||||
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (partIndex > -1 && exerciseIndex > -1) {
|
if (partIndex !== 0 && !showSolutions) {
|
||||||
const exercise = getExercise();
|
setIsBetweenParts(true);
|
||||||
setExerciseType(exercise.type);
|
|
||||||
setMultipleChoicesDone((prev) => prev.filter((x) => x.id !== exercise.id));
|
|
||||||
}
|
}
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
}, [partIndex, setIsBetweenParts, showSolutions])
|
||||||
}, [exerciseIndex, partIndex]);
|
|
||||||
|
|
||||||
const calculateExerciseIndex = () => {
|
|
||||||
if (partIndex === -1) return 0;
|
|
||||||
if (partIndex === 0)
|
|
||||||
return (
|
|
||||||
(exerciseIndex === -1 ? 0 : exerciseIndex + 1) + storeQuestionIndex + multipleChoicesDone.reduce((acc, curr) => acc + curr.amount, 0)
|
|
||||||
);
|
|
||||||
|
|
||||||
const exercisesPerPart = exam.parts.map((x) => x.exercises.length);
|
const progressButtons = useMemo(() =>
|
||||||
const exercisesDone = exercisesPerPart.filter((_, index) => index < partIndex).reduce((acc, curr) => curr + acc, 0);
|
// Do not remove the ()=> in handle next
|
||||||
return (
|
<ProgressButtons handlePrevious={previousExercise} handleNext={() => nextExercise()} />
|
||||||
exercisesDone +
|
, [nextExercise, previousExercise]);
|
||||||
(exerciseIndex === -1 ? 0 : exerciseIndex + 1) +
|
|
||||||
storeQuestionIndex +
|
|
||||||
multipleChoicesDone.reduce((acc, curr) => acc + curr.amount, 0)
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const renderText = () => (
|
|
||||||
<div className={clsx("flex flex-col gap-6 w-full bg-mti-gray-seasalt rounded-xl mt-4 relative", isTextMinimized ? "p-2 px-8" : "py-8 px-16")}>
|
|
||||||
<button
|
|
||||||
data-tip={isTextMinimized ? "Maximise" : "Minimize"}
|
|
||||||
className={clsx("absolute right-8 tooltip", isTextMinimized ? "top-1/2 -translate-y-1/2" : "top-8")}
|
|
||||||
onClick={() => setIsTextMinimzed((prev) => !prev)}>
|
|
||||||
{isTextMinimized ? (
|
|
||||||
<BsChevronDown className="text-mti-purple-dark text-lg" />
|
|
||||||
) : (
|
|
||||||
<BsChevronUp className="text-mti-purple-dark text-lg" />
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
{!isTextMinimized && (
|
|
||||||
<>
|
|
||||||
<div className="flex flex-col w-full gap-2">
|
|
||||||
<h4 className="text-xl font-semibold">
|
|
||||||
Please read the following excerpt attentively, you will then be asked questions about the text you've read.
|
|
||||||
</h4>
|
|
||||||
<span className="text-base">You will be allowed to read the text while doing the exercises</span>
|
|
||||||
</div>
|
|
||||||
<TextComponent part={exam.parts[partIndex]} exerciseType={exerciseType} />
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{isTextMinimized && <span className="font-semibold">Reading Passage</span>}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -313,42 +143,47 @@ export default function Reading({ exam, showSolutions = false, preview = false,
|
|||||||
defaultTitle="Reading exam"
|
defaultTitle="Reading exam"
|
||||||
section={exam.parts[partIndex]}
|
section={exam.parts[partIndex]}
|
||||||
sectionIndex={partIndex}
|
sectionIndex={partIndex}
|
||||||
onNext={() => { setShowPartDivider(false); setBgColor("bg-white"); setSeenParts((prev) => new Set(prev).add(exerciseIndex)) }}
|
onNext={() => handlePartDividerClick()}
|
||||||
/>
|
/>
|
||||||
</div> : (
|
</div> : (
|
||||||
<>
|
<>
|
||||||
<div className="flex flex-col h-full w-full gap-8">
|
<div className="flex flex-col h-full w-full gap-8">
|
||||||
<BlankQuestionsModal isOpen={showBlankModal} onClose={confirmFinishModule} />
|
<BlankQuestionsModal isOpen={showBlankModal} onClose={confirmFinishModule} />
|
||||||
{partIndex > -1 && <TextModal {...exam.parts[partIndex].text} isOpen={showTextModal} onClose={() => setShowTextModal(false)} />}
|
{/*<ReadingPassageModal text={exam.parts[partIndex].text} isOpen={showTextModal} onClose={() => setShowTextModal(false)} />*/}
|
||||||
|
{exam.parts.length > 1 && <SectionNavbar
|
||||||
|
module="reading"
|
||||||
|
sectionLabel="Part"
|
||||||
|
seenParts={seenParts}
|
||||||
|
setShowPartDivider={setShowPartDivider}
|
||||||
|
setSeenParts={setSeenParts}
|
||||||
|
setIsBetweenParts={setIsBetweenParts}
|
||||||
|
preview={preview}
|
||||||
|
/>}
|
||||||
<ModuleTitle
|
<ModuleTitle
|
||||||
minTimer={exam.minTimer}
|
minTimer={timer.current}
|
||||||
exerciseIndex={calculateExerciseIndex()}
|
exerciseIndex={memoizedExerciseIndex}
|
||||||
module="reading"
|
module="reading"
|
||||||
totalExercises={countExercises(exam.parts.flatMap((x) => x.exercises))}
|
totalExercises={countExercises(exam.parts.flatMap((x) => x.exercises))}
|
||||||
disableTimer={showSolutions || preview}
|
disableTimer={showSolutions || preview}
|
||||||
label={exerciseIndex === -1 ? undefined : convertCamelCaseToReadable(exam.parts[partIndex].exercises[exerciseIndex].type)}
|
label={convertCamelCaseToReadable(exam.parts[partIndex].exercises[exerciseIndex].type)}
|
||||||
|
preview={preview}
|
||||||
/>
|
/>
|
||||||
<div
|
<div
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"mb-20 w-full",
|
"mb-20 w-full",
|
||||||
exerciseIndex > -1 && !isTextMinimized && "grid grid-cols-2 gap-4",
|
((isFirstTimeRender || isBetweenParts) && !showSolutions) ? "flex flex-col gap-2" : "grid grid-cols-2 gap-4",
|
||||||
exerciseIndex > -1 && isTextMinimized && "flex flex-col gap-2",
|
|
||||||
)}>
|
)}>
|
||||||
{partIndex > -1 && renderText()}
|
<ReadingPassage
|
||||||
|
exam={exam}
|
||||||
{exerciseIndex > -1 &&
|
partIndex={partIndex}
|
||||||
partIndex > -1 &&
|
exerciseType={currentExercise.type}
|
||||||
exerciseIndex < exam.parts[partIndex].exercises.length &&
|
isTextMinimized={isTextMinimized}
|
||||||
!showSolutions &&
|
setIsTextMinimized={setIsTextMinimzed}
|
||||||
renderExercise(getExercise(), exam.id, nextExercise, previousExercise, undefined, preview)}
|
/>
|
||||||
|
{!isFirstTimeRender && !showPartDivider && !showSolutions && !isBetweenParts && renderExercise(currentExercise, exam.id, registerSolution, preview, progressButtons, progressButtons)}
|
||||||
{exerciseIndex > -1 &&
|
{showSolutions && renderSolution(currentExercise, progressButtons, progressButtons)}
|
||||||
partIndex > -1 &&
|
|
||||||
exerciseIndex < exam.parts[partIndex].exercises.length &&
|
|
||||||
showSolutions &&
|
|
||||||
renderSolution(exam.parts[partIndex].exercises[exerciseIndex], nextExercise, previousExercise)}
|
|
||||||
</div>
|
</div>
|
||||||
{exerciseIndex > -1 && partIndex > -1 && exerciseIndex < exam.parts[partIndex].exercises.length && (
|
{/*exerciseIndex > -1 && partIndex > -1 && exerciseIndex < exam.parts[partIndex].exercises.length && (
|
||||||
<Button
|
<Button
|
||||||
color="purple"
|
color="purple"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
@@ -356,33 +191,19 @@ export default function Reading({ exam, showSolutions = false, preview = false,
|
|||||||
className="max-w-[200px] self-end w-full absolute bottom-[31px] right-64">
|
className="max-w-[200px] self-end w-full absolute bottom-[31px] right-64">
|
||||||
Read text
|
Read text
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)*/}
|
||||||
</div>
|
</div>
|
||||||
{exerciseIndex === -1 && partIndex > 0 && (
|
{((isFirstTimeRender || isBetweenParts) && !showPartDivider && !showSolutions) &&
|
||||||
<div className="self-end flex justify-between w-full gap-8 absolute bottom-8 left-0 px-8">
|
<ProgressButtons
|
||||||
<Button
|
hidePrevious={partIndex == 0 && isBetweenParts || isFirstTimeRender}
|
||||||
color="purple"
|
nextLabel={isFirstTimeRender ? "Start now" : "Next Page"}
|
||||||
variant="outline"
|
handlePrevious={previousExercise}
|
||||||
onClick={() => {
|
handleNext={() => isFirstTimeRender ? setIsFirstTimeRender(false) : nextExercise()} />
|
||||||
setExerciseIndex(exam.parts[partIndex - 1].exercises.length - 1);
|
}
|
||||||
setPartIndex(partIndex - 1);
|
|
||||||
}}
|
|
||||||
className="max-w-[200px] w-full">
|
|
||||||
Back
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button color="purple" onClick={() => nextExercise()} className="max-w-[200px] self-end w-full">
|
|
||||||
Next
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{exerciseIndex === -1 && partIndex === 0 && (
|
|
||||||
<Button color="purple" onClick={() => nextExercise()} className="max-w-[200px] self-end w-full">
|
|
||||||
Start now
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default Reading;
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import ProfileSummary from "@/components/ProfileSummary";
|
|||||||
import {ShuffleMap, Shuffles, Variant} from "@/interfaces/exam";
|
import {ShuffleMap, Shuffles, Variant} from "@/interfaces/exam";
|
||||||
import useSessions, {Session} from "@/hooks/useSessions";
|
import useSessions, {Session} from "@/hooks/useSessions";
|
||||||
import SessionCard from "@/components/Medium/SessionCard";
|
import SessionCard from "@/components/Medium/SessionCard";
|
||||||
import useExamStore from "@/stores/examStore";
|
import useExamStore from "@/stores/exam";
|
||||||
import moment from "moment";
|
import moment from "moment";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -32,7 +32,7 @@ export default function Selection({user, page, onStart}: Props) {
|
|||||||
const {data: stats} = useFilterRecordsByUser<Stat[]>(user?.id);
|
const {data: stats} = useFilterRecordsByUser<Stat[]>(user?.id);
|
||||||
const {sessions, isLoading, reload} = useSessions(user.id);
|
const {sessions, isLoading, reload} = useSessions(user.id);
|
||||||
|
|
||||||
const state = useExamStore((state) => state);
|
const dispatch = useExamStore((state) => state.dispatch);
|
||||||
|
|
||||||
const toggleModule = (module: Module) => {
|
const toggleModule = (module: Module) => {
|
||||||
const modules = selectedModules.filter((x) => x !== module);
|
const modules = selectedModules.filter((x) => x !== module);
|
||||||
@@ -44,19 +44,7 @@ export default function Selection({user, page, onStart}: Props) {
|
|||||||
)
|
)
|
||||||
|
|
||||||
const loadSession = async (session: Session) => {
|
const loadSession = async (session: Session) => {
|
||||||
state.setShuffles(session.userSolutions.map((x) => ({exerciseID: x.exercise, shuffles: x.shuffleMaps ? x.shuffleMaps : []})));
|
dispatch({type: "SET_SESSION", payload: { session }})
|
||||||
state.setSelectedModules(session.selectedModules);
|
|
||||||
state.setExam(session.exam);
|
|
||||||
state.setExams(session.exams);
|
|
||||||
state.setSessionId(session.sessionId);
|
|
||||||
state.setAssignment(session.assignment);
|
|
||||||
state.setExerciseIndex(session.exerciseIndex);
|
|
||||||
state.setPartIndex(session.partIndex);
|
|
||||||
state.setModuleIndex(session.moduleIndex);
|
|
||||||
state.setTimeSpent(session.timeSpent);
|
|
||||||
state.setUserSolutions(session.userSolutions);
|
|
||||||
state.setShowSolutions(false);
|
|
||||||
state.setQuestionIndex(session.questionIndex);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,112 +1,104 @@
|
|||||||
import { renderExercise } from "@/components/Exercises";
|
import { renderExercise } from "@/components/Exercises";
|
||||||
import ModuleTitle from "@/components/Medium/ModuleTitle";
|
import ModuleTitle from "@/components/Medium/ModuleTitle";
|
||||||
import { renderSolution } from "@/components/Solutions";
|
import { renderSolution } from "@/components/Solutions";
|
||||||
import { infoButtonStyle } from "@/constants/buttonStyles";
|
import { UserSolution, SpeakingExam, SpeakingExercise, InteractiveSpeakingExercise, Exercise } from "@/interfaces/exam";
|
||||||
import { UserSolution, SpeakingExam, SpeakingExercise, InteractiveSpeakingExercise } from "@/interfaces/exam";
|
import useExamStore, { usePersistentExamStore } from "@/stores/exam";
|
||||||
import useExamStore, { usePersistentExamStore } from "@/stores/examStore";
|
|
||||||
import { defaultUserSolutions } from "@/utils/exams";
|
|
||||||
import { countExercises } from "@/utils/moduleUtils";
|
import { countExercises } from "@/utils/moduleUtils";
|
||||||
import { convertCamelCaseToReadable } from "@/utils/string";
|
import { convertCamelCaseToReadable } from "@/utils/string";
|
||||||
import { mdiArrowRight } from "@mdi/js";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import Icon from "@mdi/react";
|
|
||||||
import clsx from "clsx";
|
|
||||||
import { Fragment, useEffect, useState } from "react";
|
|
||||||
import { toast } from "react-toastify";
|
|
||||||
import PartDivider from "./Navigation/SectionDivider";
|
import PartDivider from "./Navigation/SectionDivider";
|
||||||
|
import { ExamProps } from "./types";
|
||||||
|
import useExamTimer from "@/hooks/useExamTimer";
|
||||||
|
import useExamNavigation from "./Navigation/useExamNavigation";
|
||||||
|
import ProgressButtons from "./components/ProgressButtons";
|
||||||
|
import { calculateExerciseIndexSpeaking } from "./utils/calculateExerciseIndex";
|
||||||
|
|
||||||
interface Props {
|
|
||||||
exam: SpeakingExam;
|
|
||||||
showSolutions?: boolean;
|
|
||||||
onFinish: (userSolutions: UserSolution[]) => void;
|
|
||||||
preview?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function Speaking({ exam, showSolutions = false, onFinish, preview = false }: Props) {
|
const Speaking: React.FC<ExamProps<SpeakingExam>> = ({ exam, showSolutions = false, preview = false }) => {
|
||||||
const [speakingPromptsDone, setSpeakingPromptsDone] = useState<{ id: string; amount: number }[]>([]);
|
const updateTimers = useExamTimer(exam.module, preview);
|
||||||
|
const userSolutionRef = useRef<(() => UserSolution) | null>(null);
|
||||||
const speakingBgColor = "bg-ielts-speaking-light";
|
const [solutionWasUpdated, setSolutionWasUpdated] = useState(false);
|
||||||
|
|
||||||
const examState = useExamStore((state) => state);
|
const examState = useExamStore((state) => state);
|
||||||
const persistentExamState = usePersistentExamStore((state) => state);
|
const persistentExamState = usePersistentExamStore((state) => state);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
userSolutions,
|
exerciseIndex, userSolutions, flags,
|
||||||
questionIndex,
|
timeSpentCurrentModule, questionIndex,
|
||||||
exerciseIndex,
|
setBgColor, setUserSolutions, setTimeIsUp,
|
||||||
hasExamEnded,
|
dispatch,
|
||||||
setBgColor,
|
|
||||||
setUserSolutions,
|
|
||||||
setHasExamEnded,
|
|
||||||
setQuestionIndex,
|
|
||||||
setExerciseIndex,
|
|
||||||
} = !preview ? examState : persistentExamState;
|
} = !preview ? examState : persistentExamState;
|
||||||
|
|
||||||
const [seenParts, setSeenParts] = useState<Set<number>>(new Set(showSolutions ? exam.exercises.map((_, index) => index) : []));
|
const { finalizeModule, timeIsUp } = flags;
|
||||||
const [showPartDivider, setShowPartDivider] = useState<boolean>(typeof exam.exercises[0].intro === "string" && exam.exercises[0].intro !== "");
|
|
||||||
|
const timer = useRef(exam.minTimer - timeSpentCurrentModule / 60);
|
||||||
|
|
||||||
|
const {
|
||||||
|
nextExercise, previousExercise,
|
||||||
|
showPartDivider, setShowPartDivider,
|
||||||
|
setSeenParts,
|
||||||
|
} = useExamNavigation({ exam, module: "speaking", showSolutions, preview, disableBetweenParts: true });
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!showSolutions && exam.exercises[exerciseIndex]?.intro !== undefined && exam.exercises[exerciseIndex]?.intro !== "" && !seenParts.has(exerciseIndex)) {
|
if (finalizeModule || timeIsUp) {
|
||||||
setShowPartDivider(true);
|
updateTimers();
|
||||||
setBgColor(speakingBgColor);
|
|
||||||
|
if (timeIsUp) {
|
||||||
|
setTimeIsUp(false);
|
||||||
|
}
|
||||||
|
dispatch({ type: "FINALIZE_MODULE", payload: { updateTimers: false } })
|
||||||
}
|
}
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [exerciseIndex]);
|
}, [finalizeModule, timeIsUp])
|
||||||
|
|
||||||
|
|
||||||
|
const registerSolution = useCallback((updateSolution: () => UserSolution) => {
|
||||||
|
userSolutionRef.current = updateSolution;
|
||||||
|
setSolutionWasUpdated(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (hasExamEnded && exerciseIndex === -1) {
|
if (solutionWasUpdated && userSolutionRef.current) {
|
||||||
setExerciseIndex(exerciseIndex + 1);
|
const solution = userSolutionRef.current();
|
||||||
}
|
|
||||||
}, [hasExamEnded, exerciseIndex, setExerciseIndex]);
|
|
||||||
|
|
||||||
const scrollToTop = () => Array.from(document.getElementsByTagName("body")).forEach((body) => body.scrollTo(0, 0));
|
|
||||||
|
|
||||||
const nextExercise = (solution?: UserSolution) => {
|
|
||||||
scrollToTop();
|
|
||||||
if (solution) {
|
|
||||||
setUserSolutions([...userSolutions.filter((x) => x.exercise !== solution.exercise), { ...solution, module: "speaking", exam: exam.id }]);
|
setUserSolutions([...userSolutions.filter((x) => x.exercise !== solution.exercise), { ...solution, module: "speaking", exam: exam.id }]);
|
||||||
|
setSolutionWasUpdated(false);
|
||||||
}
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [solutionWasUpdated])
|
||||||
|
|
||||||
if (questionIndex > 0) {
|
|
||||||
const exercise = getExercise();
|
|
||||||
setSpeakingPromptsDone((prev) => [...prev.filter((x) => x.id !== exercise.id), { id: exercise.id, amount: questionIndex }]);
|
|
||||||
}
|
|
||||||
setQuestionIndex(0);
|
|
||||||
|
|
||||||
if (exerciseIndex + 1 < exam.exercises.length) {
|
const currentExercise = useMemo<Exercise>(() => {
|
||||||
setExerciseIndex(exerciseIndex + 1);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (exerciseIndex >= exam.exercises.length) return;
|
|
||||||
|
|
||||||
setHasExamEnded(false);
|
|
||||||
|
|
||||||
if (solution) {
|
|
||||||
onFinish([...userSolutions.filter((x) => x.exercise !== solution.exercise), { ...solution, module: "speaking", exam: exam.id }]);
|
|
||||||
} else {
|
|
||||||
onFinish(userSolutions);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const previousExercise = (solution?: UserSolution) => {
|
|
||||||
scrollToTop();
|
|
||||||
if (solution) {
|
|
||||||
setUserSolutions([...userSolutions.filter((x) => x.exercise !== solution.exercise), { ...solution, module: "speaking", exam: exam.id }]);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (exerciseIndex > 0) {
|
|
||||||
setExerciseIndex(exerciseIndex - 1);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const getExercise = () => {
|
|
||||||
const exercise = exam.exercises[exerciseIndex];
|
const exercise = exam.exercises[exerciseIndex];
|
||||||
return {
|
return {
|
||||||
...exercise,
|
...exercise,
|
||||||
variant: exerciseIndex < 2 && exercise.type === "interactiveSpeaking" ? "initial" : undefined,
|
variant: exerciseIndex < 2 && exercise.type === "interactiveSpeaking" ? "initial" : undefined,
|
||||||
userSolutions: userSolutions.find((x) => x.exercise === exercise.id)?.solutions || [],
|
userSolutions: userSolutions.find((x) => x.exercise === exercise.id)?.solutions || [],
|
||||||
} as SpeakingExercise | InteractiveSpeakingExercise;
|
} as SpeakingExercise | InteractiveSpeakingExercise;
|
||||||
};
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [exerciseIndex]);
|
||||||
|
|
||||||
|
|
||||||
|
const progressButtons = useMemo(() =>
|
||||||
|
// Do not remove the ()=> in handle next
|
||||||
|
<ProgressButtons handlePrevious={previousExercise} handleNext={() => nextExercise()} />
|
||||||
|
, [nextExercise, previousExercise]);
|
||||||
|
|
||||||
|
|
||||||
|
const handlePartDividerClick = () => {
|
||||||
|
setShowPartDivider(false);
|
||||||
|
setBgColor("bg-white");
|
||||||
|
setSeenParts((prev) => new Set(prev).add(exerciseIndex));
|
||||||
|
}
|
||||||
|
|
||||||
|
const memoizedExerciseIndex = useMemo(() => {
|
||||||
|
const bruh = calculateExerciseIndexSpeaking(exam, exerciseIndex, questionIndex)
|
||||||
|
console.log(bruh);
|
||||||
|
return bruh;
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
, [exerciseIndex, questionIndex]
|
||||||
|
);
|
||||||
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -117,27 +109,24 @@ export default function Speaking({ exam, showSolutions = false, onFinish, previe
|
|||||||
defaultTitle="Speaking exam"
|
defaultTitle="Speaking exam"
|
||||||
section={exam.exercises[exerciseIndex]}
|
section={exam.exercises[exerciseIndex]}
|
||||||
sectionIndex={exerciseIndex}
|
sectionIndex={exerciseIndex}
|
||||||
onNext={() => { setShowPartDivider(false); setBgColor("bg-white"); setSeenParts((prev) => new Set(prev).add(exerciseIndex)) }}
|
onNext={handlePartDividerClick}
|
||||||
/> : (
|
/> : (
|
||||||
<div className="flex flex-col h-full w-full gap-8 items-center">
|
<div className="flex flex-col h-full w-full gap-8 items-center">
|
||||||
<ModuleTitle
|
<ModuleTitle
|
||||||
label={convertCamelCaseToReadable(exam.exercises[exerciseIndex].type)}
|
label={convertCamelCaseToReadable(exam.exercises[exerciseIndex].type)}
|
||||||
minTimer={exam.minTimer}
|
minTimer={timer.current}
|
||||||
exerciseIndex={exerciseIndex + 1 + questionIndex + speakingPromptsDone.reduce((acc, curr) => acc + curr.amount, 0)}
|
exerciseIndex={memoizedExerciseIndex}
|
||||||
module="speaking"
|
module="speaking"
|
||||||
totalExercises={countExercises(exam.exercises)}
|
totalExercises={countExercises(exam.exercises)}
|
||||||
disableTimer={showSolutions || preview}
|
disableTimer={showSolutions || preview}
|
||||||
|
preview={preview}
|
||||||
/>
|
/>
|
||||||
{exerciseIndex > -1 &&
|
{!showPartDivider && !showSolutions && renderExercise(currentExercise, exam.id, registerSolution, preview, progressButtons, progressButtons)}
|
||||||
exerciseIndex < exam.exercises.length &&
|
{showSolutions && renderSolution(currentExercise, progressButtons, progressButtons)}
|
||||||
!showSolutions &&
|
|
||||||
renderExercise(getExercise(), exam.id, nextExercise, previousExercise, undefined, preview)}
|
|
||||||
{exerciseIndex > -1 &&
|
|
||||||
exerciseIndex < exam.exercises.length &&
|
|
||||||
showSolutions &&
|
|
||||||
renderSolution(exam.exercises[exerciseIndex], nextExercise, previousExercise)}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default Speaking;
|
||||||
|
|||||||
@@ -1,95 +1,88 @@
|
|||||||
import { renderExercise } from "@/components/Exercises";
|
import { renderExercise } from "@/components/Exercises";
|
||||||
import ModuleTitle from "@/components/Medium/ModuleTitle";
|
import ModuleTitle from "@/components/Medium/ModuleTitle";
|
||||||
import { renderSolution } from "@/components/Solutions";
|
import { renderSolution } from "@/components/Solutions";
|
||||||
import { UserSolution, WritingExam } from "@/interfaces/exam";
|
import { Exercise, UserSolution, WritingExam } from "@/interfaces/exam";
|
||||||
import useExamStore, { usePersistentExamStore } from "@/stores/examStore";
|
import useExamStore, { usePersistentExamStore } from "@/stores/exam";
|
||||||
import { countExercises } from "@/utils/moduleUtils";
|
import { countExercises } from "@/utils/moduleUtils";
|
||||||
import PartDivider from "./Navigation/SectionDivider";
|
import PartDivider from "./Navigation/SectionDivider";
|
||||||
import { useEffect, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import { ExamProps } from "./types";
|
||||||
|
import useExamTimer from "@/hooks/useExamTimer";
|
||||||
|
import useExamNavigation from "./Navigation/useExamNavigation";
|
||||||
|
import SectionNavbar from "./Navigation/SectionNavbar";
|
||||||
|
import ProgressButtons from "./components/ProgressButtons";
|
||||||
|
|
||||||
interface Props {
|
const Writing: React.FC<ExamProps<WritingExam>> = ({ exam, showSolutions = false, preview = false }) => {
|
||||||
exam: WritingExam;
|
const updateTimers = useExamTimer(exam.module, preview);
|
||||||
showSolutions?: boolean;
|
const userSolutionRef = useRef<(() => UserSolution) | null>(null);
|
||||||
preview?: boolean;
|
const [solutionWasUpdated, setSolutionWasUpdated] = useState(false);
|
||||||
onFinish: (userSolutions: UserSolution[]) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function Writing({ exam, showSolutions = false, preview = false, onFinish }: Props) {
|
|
||||||
const writingBgColor = "bg-ielts-writing-light";
|
|
||||||
|
|
||||||
const examState = useExamStore((state) => state);
|
const examState = useExamStore((state) => state);
|
||||||
const persistentExamState = usePersistentExamStore((state) => state);
|
const persistentExamState = usePersistentExamStore((state) => state);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
userSolutions,
|
exerciseIndex, flags, setBgColor,
|
||||||
exerciseIndex,
|
setUserSolutions, setTimeIsUp, userSolutions,
|
||||||
hasExamEnded,
|
dispatch, navigation, timeSpentCurrentModule
|
||||||
setBgColor,
|
|
||||||
setUserSolutions,
|
|
||||||
setHasExamEnded,
|
|
||||||
setExerciseIndex,
|
|
||||||
} = !preview ? examState : persistentExamState;
|
} = !preview ? examState : persistentExamState;
|
||||||
|
|
||||||
const [seenParts, setSeenParts] = useState<Set<number>>(new Set(showSolutions ? exam.exercises.map((_, index) => index) : []));
|
const timer = useRef(exam.minTimer - timeSpentCurrentModule / 60);
|
||||||
const [showPartDivider, setShowPartDivider] = useState<boolean>(typeof exam.exercises[0].intro === "string" && exam.exercises[0].intro !== "");
|
|
||||||
|
const { finalizeModule, timeIsUp } = flags;
|
||||||
|
const { nextDisabled } = navigation;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (hasExamEnded && exerciseIndex === -1) {
|
if (finalizeModule || timeIsUp) {
|
||||||
setExerciseIndex(exerciseIndex + 1);
|
updateTimers();
|
||||||
}
|
|
||||||
}, [hasExamEnded, exerciseIndex, setExerciseIndex]);
|
|
||||||
|
|
||||||
const scrollToTop = () => Array.from(document.getElementsByTagName("body")).forEach((body) => body.scrollTo(0, 0));
|
if (timeIsUp) {
|
||||||
|
setTimeIsUp(false);
|
||||||
useEffect(() => {
|
}
|
||||||
if (!showSolutions && exam.exercises[exerciseIndex]?.intro !== undefined && exam.exercises[exerciseIndex]?.intro !== "" && !seenParts.has(exerciseIndex)) {
|
dispatch({ type: "FINALIZE_MODULE", payload: { updateTimers: false } })
|
||||||
setShowPartDivider(true);
|
|
||||||
setBgColor(writingBgColor);
|
|
||||||
}
|
}
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [exerciseIndex]);
|
}, [finalizeModule, timeIsUp])
|
||||||
|
const {
|
||||||
|
nextExercise, previousExercise,
|
||||||
|
showPartDivider, setShowPartDivider,
|
||||||
|
seenParts, setSeenParts,
|
||||||
|
} = useExamNavigation({ exam, module: "writing", showSolutions, preview });
|
||||||
|
|
||||||
|
const registerSolution = useCallback((updateSolution: () => UserSolution) => {
|
||||||
|
userSolutionRef.current = updateSolution;
|
||||||
|
setSolutionWasUpdated(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const nextExercise = (solution?: UserSolution) => {
|
useEffect(() => {
|
||||||
scrollToTop();
|
if (solutionWasUpdated && userSolutionRef.current) {
|
||||||
if (solution) {
|
const solution = userSolutionRef.current();
|
||||||
setUserSolutions([...userSolutions.filter((x) => x.exercise !== solution.exercise), { ...solution, module: "writing", exam: exam.id }]);
|
setUserSolutions([...userSolutions.filter((x) => x.exercise !== solution.exercise), { ...solution, module: "writing", exam: exam.id }]);
|
||||||
|
setSolutionWasUpdated(false);
|
||||||
}
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [solutionWasUpdated])
|
||||||
|
|
||||||
if (exerciseIndex + 1 < exam.exercises.length) {
|
|
||||||
setExerciseIndex(exerciseIndex + 1);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (exerciseIndex >= exam.exercises.length) return;
|
const currentExercise = useMemo<Exercise>(() => {
|
||||||
|
|
||||||
setHasExamEnded(false);
|
|
||||||
|
|
||||||
if (solution) {
|
|
||||||
onFinish([...userSolutions.filter((x) => x.exercise !== solution.exercise), { ...solution, module: "writing", exam: exam.id }]);
|
|
||||||
} else {
|
|
||||||
onFinish(userSolutions);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const previousExercise = (solution?: UserSolution) => {
|
|
||||||
scrollToTop();
|
|
||||||
if (solution) {
|
|
||||||
setUserSolutions([...userSolutions.filter((x) => x.exercise !== solution.exercise), { ...solution, module: "writing", exam: exam.id }]);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (exerciseIndex > 0) {
|
|
||||||
setExerciseIndex(exerciseIndex - 1);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const getExercise = () => {
|
|
||||||
const exercise = exam.exercises[exerciseIndex];
|
const exercise = exam.exercises[exerciseIndex];
|
||||||
return {
|
return {
|
||||||
...exercise,
|
...exercise,
|
||||||
userSolutions: userSolutions.find((x) => x.exercise === exercise.id)?.solutions || [],
|
userSolutions: userSolutions.find((x) => x.exercise === exercise.id)?.solutions || [],
|
||||||
};
|
};
|
||||||
};
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [exerciseIndex]);
|
||||||
|
|
||||||
|
const handlePartDividerClick = () => {
|
||||||
|
setShowPartDivider(false);
|
||||||
|
setBgColor("bg-white");
|
||||||
|
setSeenParts((prev) => new Set(prev).add(exerciseIndex));
|
||||||
|
}
|
||||||
|
|
||||||
|
const progressButtons = useMemo(() =>
|
||||||
|
// Do not remove the ()=> in handle next
|
||||||
|
<ProgressButtons handlePrevious={previousExercise} handleNext={() => nextExercise()} nextDisabled={nextDisabled}/>
|
||||||
|
, [nextExercise, previousExercise, nextDisabled]);
|
||||||
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -100,27 +93,32 @@ export default function Writing({ exam, showSolutions = false, preview = false,
|
|||||||
defaultTitle="Writing exam"
|
defaultTitle="Writing exam"
|
||||||
section={exam.exercises[exerciseIndex]}
|
section={exam.exercises[exerciseIndex]}
|
||||||
sectionIndex={exerciseIndex}
|
sectionIndex={exerciseIndex}
|
||||||
onNext={() => { setShowPartDivider(false); setBgColor("bg-white"); setSeenParts((prev) => new Set(prev).add(exerciseIndex))}}
|
onNext={handlePartDividerClick}
|
||||||
/> : (
|
/> : (
|
||||||
<div className="flex flex-col h-full w-full gap-8 items-center">
|
<div className="flex flex-col h-full w-full gap-8 items-center">
|
||||||
|
{exam.exercises.length > 1 && <SectionNavbar
|
||||||
|
module="writing"
|
||||||
|
sectionLabel="Part"
|
||||||
|
seenParts={seenParts}
|
||||||
|
setShowPartDivider={setShowPartDivider}
|
||||||
|
setSeenParts={setSeenParts}
|
||||||
|
preview={preview}
|
||||||
|
/>}
|
||||||
<ModuleTitle
|
<ModuleTitle
|
||||||
minTimer={exam.minTimer}
|
minTimer={timer.current}
|
||||||
exerciseIndex={exerciseIndex + 1}
|
exerciseIndex={exerciseIndex + 1}
|
||||||
module="writing"
|
module="writing"
|
||||||
totalExercises={countExercises(exam.exercises)}
|
totalExercises={countExercises(exam.exercises)}
|
||||||
disableTimer={showSolutions || preview}
|
disableTimer={showSolutions || preview}
|
||||||
|
preview={preview}
|
||||||
/>
|
/>
|
||||||
{exerciseIndex > -1 &&
|
{!showPartDivider && !showSolutions && renderExercise(currentExercise, exam.id, registerSolution, preview, progressButtons, progressButtons)}
|
||||||
exerciseIndex < exam.exercises.length &&
|
{showSolutions && renderSolution(currentExercise, progressButtons, progressButtons)}
|
||||||
!showSolutions &&
|
|
||||||
renderExercise(getExercise(), exam.id, nextExercise, previousExercise, preview)}
|
|
||||||
{exerciseIndex > -1 &&
|
|
||||||
exerciseIndex < exam.exercises.length &&
|
|
||||||
showSolutions &&
|
|
||||||
renderSolution(exam.exercises[exerciseIndex], nextExercise, previousExercise)}
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default Writing;
|
||||||
|
|||||||
56
src/exams/components/ProgressButtons.tsx
Normal file
56
src/exams/components/ProgressButtons.tsx
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
import Button from "@/components/Low/Button";
|
||||||
|
import clsx from "clsx";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
handlePrevious: () => void;
|
||||||
|
handleNext: () => void;
|
||||||
|
hidePrevious?: boolean;
|
||||||
|
nextLabel?: string;
|
||||||
|
isLoading?: boolean;
|
||||||
|
isDisabled?: boolean;
|
||||||
|
previousDisabled?: boolean;
|
||||||
|
nextDisabled?: boolean;
|
||||||
|
previousLoading?: boolean;
|
||||||
|
nextLoading?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ProgressButtons: React.FC<Props> = ({
|
||||||
|
handlePrevious,
|
||||||
|
handleNext,
|
||||||
|
hidePrevious = false,
|
||||||
|
isLoading = false,
|
||||||
|
isDisabled = false,
|
||||||
|
previousDisabled = false,
|
||||||
|
nextDisabled = false,
|
||||||
|
previousLoading = false,
|
||||||
|
nextLoading = false,
|
||||||
|
nextLabel = "Next Page"
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<div className={clsx("flex w-full gap-8", !hidePrevious ? "justify-between" : "flex-row-reverse")}>
|
||||||
|
{!hidePrevious && (
|
||||||
|
<Button
|
||||||
|
disabled={isDisabled || previousDisabled}
|
||||||
|
isLoading={isLoading || previousLoading}
|
||||||
|
color="purple"
|
||||||
|
variant="outline"
|
||||||
|
onClick={handlePrevious}
|
||||||
|
className="max-w-[200px] w-full"
|
||||||
|
>
|
||||||
|
Previous Page
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
disabled={isDisabled || nextDisabled}
|
||||||
|
isLoading={isLoading || nextLoading}
|
||||||
|
color="purple"
|
||||||
|
onClick={handleNext}
|
||||||
|
className="max-w-[200px] self-end w-full"
|
||||||
|
>
|
||||||
|
{nextLabel}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ProgressButtons;
|
||||||
68
src/exams/components/ReadingPassage.tsx
Normal file
68
src/exams/components/ReadingPassage.tsx
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
import { LevelExam, LevelPart, ReadingExam, ReadingPart } from "@/interfaces/exam";
|
||||||
|
import clsx from "clsx";
|
||||||
|
import { Fragment, useState } from "react";
|
||||||
|
import { BsChevronDown, BsChevronUp } from "react-icons/bs";
|
||||||
|
|
||||||
|
const TextComponent: React.FC<{ part: ReadingPart | LevelPart; exerciseType: string }> = ({ part, exerciseType }) => {
|
||||||
|
const numberToLetter = (number: number) => (number + 9).toString(36).toUpperCase();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-2 w-full">
|
||||||
|
<h3 className="text-xl font-semibold">{part.text?.title}</h3>
|
||||||
|
<div className="border border-mti-gray-dim w-full rounded-full opacity-10" />
|
||||||
|
{part.text?.content
|
||||||
|
.split(/\n|(\\n)/g)
|
||||||
|
.filter((x) => x && x.length > 0 && x !== "\\n")
|
||||||
|
.map((line, index) => (
|
||||||
|
<Fragment key={index}>
|
||||||
|
{exerciseType === "matchSentences" && (
|
||||||
|
<div className="flex gap-3 border border-transparent hover:border-mti-purple-light rounded-lg transition ease-in-out duration-300 p-2 px-3 cursor-pointer">
|
||||||
|
<span className="font-bold text-mti-purple-dark">{numberToLetter(index + 1)}</span>
|
||||||
|
<p>{line}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{exerciseType !== "matchSentences" && <p key={index}>{line}</p>}
|
||||||
|
</Fragment>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
exam: ReadingExam | LevelExam;
|
||||||
|
partIndex: number;
|
||||||
|
exerciseType: string;
|
||||||
|
isTextMinimized: boolean;
|
||||||
|
setIsTextMinimized: React.Dispatch<React.SetStateAction<boolean>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ReadingPassage: React.FC<Props> = ({exam, partIndex, exerciseType, isTextMinimized, setIsTextMinimized}) => {
|
||||||
|
return (
|
||||||
|
<div className={clsx("flex flex-col gap-6 w-full bg-mti-gray-seasalt rounded-xl mt-4 relative", isTextMinimized ? "p-2 px-8" : "py-8 px-16")}>
|
||||||
|
<button
|
||||||
|
data-tip={isTextMinimized ? "Maximise" : "Minimize"}
|
||||||
|
className={clsx("absolute right-8 tooltip", isTextMinimized ? "top-1/2 -translate-y-1/2" : "top-8")}
|
||||||
|
onClick={() => setIsTextMinimized((prev) => !prev)}>
|
||||||
|
{isTextMinimized ? (
|
||||||
|
<BsChevronDown className="text-mti-purple-dark text-lg" />
|
||||||
|
) : (
|
||||||
|
<BsChevronUp className="text-mti-purple-dark text-lg" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
{!isTextMinimized && (
|
||||||
|
<>
|
||||||
|
<div className="flex flex-col w-full gap-2">
|
||||||
|
<h4 className="text-xl font-semibold">
|
||||||
|
Please read the following excerpt attentively, you will then be asked questions about the text you've read.
|
||||||
|
</h4>
|
||||||
|
<span className="text-base">You will be allowed to read the text while doing the exercises</span>
|
||||||
|
</div>
|
||||||
|
<TextComponent part={exam.parts[partIndex]} exerciseType={exerciseType} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{isTextMinimized && <span className="font-semibold">Reading Passage</span>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ReadingPassage;
|
||||||
70
src/exams/components/ReadingPassageModal.tsx
Normal file
70
src/exams/components/ReadingPassageModal.tsx
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
import Button from "@/components/Low/Button";
|
||||||
|
import { Dialog, DialogPanel, DialogTitle, Transition, TransitionChild } from "@headlessui/react";
|
||||||
|
import { Fragment } from "react";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
isOpen: boolean;
|
||||||
|
text: {
|
||||||
|
title?: string;
|
||||||
|
content: string;
|
||||||
|
}
|
||||||
|
onClose: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const ReadingPassageModal: React.FC<Props> = ({ isOpen, text, onClose }) => {
|
||||||
|
return (
|
||||||
|
<Transition appear show={isOpen} as={Fragment}>
|
||||||
|
<Dialog as="div" className="relative z-10" onClose={onClose}>
|
||||||
|
<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 bg-opacity-25" />
|
||||||
|
</TransitionChild>
|
||||||
|
|
||||||
|
<div className="fixed inset-0 overflow-y-auto">
|
||||||
|
<div className="flex min-h-full items-center justify-center p-4 text-center">
|
||||||
|
<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">
|
||||||
|
<DialogPanel className="w-full relative max-w-4xl transform rounded-2xl bg-white p-6 text-left align-middle shadow-xl transition-all">
|
||||||
|
{text.title &&
|
||||||
|
<DialogTitle as="h3" className="text-lg font-medium leading-6 text-gray-900">
|
||||||
|
{text.title}
|
||||||
|
</DialogTitle>
|
||||||
|
}
|
||||||
|
<div className="mt-2 overflow-auto mb-28">
|
||||||
|
<p className="text-sm">
|
||||||
|
{text.content.split("\\n").map((line, index) => (
|
||||||
|
<Fragment key={index}>
|
||||||
|
{line}
|
||||||
|
<br />
|
||||||
|
</Fragment>
|
||||||
|
))}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="absolute bottom-8 right-8 max-w-[200px] self-end w-full">
|
||||||
|
<Button color="purple" variant="outline" className="max-w-[200px] self-end w-full" onClick={onClose}>
|
||||||
|
Close
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</DialogPanel>
|
||||||
|
</TransitionChild>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Dialog>
|
||||||
|
</Transition>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ReadingPassageModal;
|
||||||
19
src/exams/components/RenderAudioInstructionsPlayer.tsx
Normal file
19
src/exams/components/RenderAudioInstructionsPlayer.tsx
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import AudioPlayer from "@/components/Low/AudioPlayer";
|
||||||
|
import { v4 } from "uuid";
|
||||||
|
|
||||||
|
|
||||||
|
const INSTRUCTIONS_AUDIO_SRC =
|
||||||
|
"https://firebasestorage.googleapis.com/v0/b/storied-phalanx-349916.appspot.com/o/generic_listening_intro_v2.mp3?alt=media&token=16769f5f-1e9b-4a72-86a9-45a6f0fa9f82";
|
||||||
|
|
||||||
|
const RenderAudioInstructionsPlayer: React.FC = () => (
|
||||||
|
<div className="flex flex-col gap-8 w-full bg-mti-gray-seasalt rounded-xl py-8 px-16">
|
||||||
|
<div className="flex flex-col w-full gap-2">
|
||||||
|
<h4 className="text-xl font-semibold">Please listen to the instructions audio attentively.</h4>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-xl flex flex-col gap-4 items-center w-full h-fit">
|
||||||
|
<AudioPlayer key={v4()} src={INSTRUCTIONS_AUDIO_SRC} color="listening" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
export default RenderAudioInstructionsPlayer;
|
||||||
65
src/exams/components/RenderAudioPlayer.tsx
Normal file
65
src/exams/components/RenderAudioPlayer.tsx
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
import AudioPlayer from "@/components/Low/AudioPlayer";
|
||||||
|
import Button from "@/components/Low/Button";
|
||||||
|
import { Script } from "@/interfaces/exam";
|
||||||
|
import { Assignment } from "@/interfaces/results";
|
||||||
|
import { v4 } from "uuid";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
audioSource?: string;
|
||||||
|
repeatableTimes?: number;
|
||||||
|
timesListened: number;
|
||||||
|
script?: Script;
|
||||||
|
assignment?: Assignment;
|
||||||
|
setShowTextModal: React.Dispatch<React.SetStateAction<boolean>>;
|
||||||
|
setTimesListened: React.Dispatch<React.SetStateAction<number>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const RenderAudioPlayer: React.FC<Props> = ({
|
||||||
|
audioSource, repeatableTimes, timesListened,
|
||||||
|
script, assignment, setShowTextModal, setTimesListened
|
||||||
|
}) => (
|
||||||
|
<div className="flex flex-col gap-8 w-full bg-mti-gray-seasalt rounded-xl py-8 px-16">
|
||||||
|
{audioSource ? (
|
||||||
|
<>
|
||||||
|
<div className="w-full items-start flex justify-between">
|
||||||
|
<div className="flex flex-col w-full gap-2">
|
||||||
|
<h4 className="text-xl font-semibold">Please listen to the following audio attentively.</h4>
|
||||||
|
<span className="text-base">
|
||||||
|
{(() => {
|
||||||
|
return repeatableTimes && repeatableTimes > 0
|
||||||
|
? `You will only be allowed to listen to the audio ${repeatableTimes - timesListened} time(s).`
|
||||||
|
: "You may listen to the audio as many times as you would like.";
|
||||||
|
})()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{!assignment && !!script && (
|
||||||
|
<Button
|
||||||
|
onClick={() => setShowTextModal(true)}
|
||||||
|
variant="outline"
|
||||||
|
color="gray"
|
||||||
|
className="w-full max-w-[200px]"
|
||||||
|
>
|
||||||
|
View Transcript
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="rounded-xl flex flex-col gap-4 items-center w-full h-fit">
|
||||||
|
<AudioPlayer
|
||||||
|
key={v4()}
|
||||||
|
src={audioSource ?? ''}
|
||||||
|
color="listening"
|
||||||
|
onEnd={() => setTimesListened((prev) => prev + 1)}
|
||||||
|
disabled={repeatableTimes != null &&
|
||||||
|
timesListened === repeatableTimes}
|
||||||
|
disablePause
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<span>This section will display the audio once it has been generated.</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
export default RenderAudioPlayer;
|
||||||
72
src/exams/components/ScriptModal.tsx
Normal file
72
src/exams/components/ScriptModal.tsx
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
import Button from "@/components/Low/Button";
|
||||||
|
import { Script } from "@/interfaces/exam";
|
||||||
|
import { Dialog, DialogPanel, Transition, TransitionChild } from "@headlessui/react";
|
||||||
|
import { capitalize } from "lodash";
|
||||||
|
import { Fragment } from "react";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
isOpen: boolean;
|
||||||
|
script: Script;
|
||||||
|
onClose: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const ScriptModal: React.FC<Props> = ({ isOpen, script, onClose }) => {
|
||||||
|
return (
|
||||||
|
<Transition appear show={isOpen} as={Fragment}>
|
||||||
|
<Dialog as="div" className="relative z-10" onClose={onClose}>
|
||||||
|
<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 bg-opacity-25" />
|
||||||
|
</TransitionChild>
|
||||||
|
|
||||||
|
<div className="fixed inset-0 overflow-y-auto">
|
||||||
|
<div className="flex min-h-full items-center justify-center p-4 text-center">
|
||||||
|
<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">
|
||||||
|
<DialogPanel className="w-full relative max-w-4xl transform rounded-2xl bg-white p-6 text-left align-middle shadow-xl transition-all">
|
||||||
|
<div className="mt-2 overflow-auto mb-28">
|
||||||
|
<p className="text-sm">
|
||||||
|
{typeof script === "string" && script.split("\\n").map((line, index) => (
|
||||||
|
<Fragment key={index}>
|
||||||
|
{line}
|
||||||
|
<br />
|
||||||
|
</Fragment>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{typeof script === "object" && script.map((line, index) => (
|
||||||
|
<span key={index}>
|
||||||
|
<b>{line.name} ({capitalize(line.gender)})</b>: {line.text}
|
||||||
|
<br />
|
||||||
|
<br />
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="absolute bottom-8 right-8 max-w-[200px] self-end w-full">
|
||||||
|
<Button color="purple" variant="outline" className="max-w-[200px] self-end w-full" onClick={onClose}>
|
||||||
|
Close
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</DialogPanel>
|
||||||
|
</TransitionChild>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Dialog>
|
||||||
|
</Transition>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ScriptModal;
|
||||||
7
src/exams/types.ts
Normal file
7
src/exams/types.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { Exam } from "@/interfaces/exam";
|
||||||
|
|
||||||
|
export type ExamProps<T extends Exam> = {
|
||||||
|
exam: T;
|
||||||
|
showSolutions?: boolean;
|
||||||
|
preview?: boolean;
|
||||||
|
};
|
||||||
47
src/exams/utils/answeredEveryQuestion.ts
Normal file
47
src/exams/utils/answeredEveryQuestion.ts
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import { LevelExam, ListeningExam, ReadingExam, UserSolution } from "@/interfaces/exam";
|
||||||
|
|
||||||
|
// so that the compiler doesnt complain
|
||||||
|
interface Part {
|
||||||
|
exercises: Array<{
|
||||||
|
id: string;
|
||||||
|
type: string;
|
||||||
|
questions?: Array<any>;
|
||||||
|
score?: { total: number };
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
type PartExam = {
|
||||||
|
parts: Part[];
|
||||||
|
} & (ReadingExam | ListeningExam | LevelExam)
|
||||||
|
|
||||||
|
const answeredEveryQuestionInPart = (exam: PartExam, partIndex: number, userSolutions: UserSolution[]) => {
|
||||||
|
return exam.parts[partIndex].exercises.every((exercise) => {
|
||||||
|
const userSolution = userSolutions.find(x => x.exercise === exercise.id);
|
||||||
|
|
||||||
|
switch (exercise.type) {
|
||||||
|
case 'multipleChoice':
|
||||||
|
return userSolution?.solutions.length === exercise.questions!.length;
|
||||||
|
case 'fillBlanks':
|
||||||
|
return userSolution?.solutions.length === userSolution?.score.total;
|
||||||
|
case 'writeBlanks':
|
||||||
|
return userSolution?.solutions.length === userSolution?.score.total;
|
||||||
|
case 'matchSentences':
|
||||||
|
return userSolution?.solutions.length === userSolution?.score.total;
|
||||||
|
case 'trueFalse':
|
||||||
|
return userSolution?.solutions.length === userSolution?.score.total;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
const answeredEveryQuestion = (exam: PartExam, userSolutions: UserSolution[]) => {
|
||||||
|
return exam.parts.every((_, index) => {
|
||||||
|
return answeredEveryQuestionInPart(exam, index, userSolutions);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
answeredEveryQuestion,
|
||||||
|
answeredEveryQuestionInPart
|
||||||
|
};
|
||||||
61
src/exams/utils/calculateExerciseIndex.ts
Normal file
61
src/exams/utils/calculateExerciseIndex.ts
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
import { PartExam, SpeakingExam } from "@/interfaces/exam";
|
||||||
|
import { countCurrentExercises } from "@/utils/moduleUtils";
|
||||||
|
|
||||||
|
const calculateExerciseIndex = (
|
||||||
|
exam: PartExam,
|
||||||
|
partIndex: number,
|
||||||
|
exerciseIndex: number,
|
||||||
|
questionIndex: number
|
||||||
|
) => {
|
||||||
|
let total = 0;
|
||||||
|
// Count all exercises in previous parts
|
||||||
|
for (let i = 0; i < partIndex; i++) {
|
||||||
|
total += countCurrentExercises(
|
||||||
|
exam.parts[i].exercises,
|
||||||
|
exam.parts[i].exercises.length - 1
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Count previous exercises in current part
|
||||||
|
if (partIndex < exam.parts.length && exerciseIndex > 0) {
|
||||||
|
total += countCurrentExercises(
|
||||||
|
exam.parts[partIndex].exercises.slice(0, exerciseIndex),
|
||||||
|
exerciseIndex - 1
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only pass questionIndex if current exercise is multiple choice
|
||||||
|
const currentExercise = exam.parts[partIndex].exercises[exerciseIndex];
|
||||||
|
if (currentExercise.type === "multipleChoice") {
|
||||||
|
total += countCurrentExercises(
|
||||||
|
[currentExercise],
|
||||||
|
0,
|
||||||
|
questionIndex
|
||||||
|
);
|
||||||
|
return total;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add 1 for non-MC exercises
|
||||||
|
total += 1;
|
||||||
|
return total;
|
||||||
|
};
|
||||||
|
|
||||||
|
const calculateExerciseIndexSpeaking = (
|
||||||
|
exam: SpeakingExam,
|
||||||
|
exerciseIndex: number,
|
||||||
|
questionIndex: number
|
||||||
|
) => {
|
||||||
|
let total = 0;
|
||||||
|
|
||||||
|
for (let i = 0; i < exerciseIndex; i++) {
|
||||||
|
total += exam.exercises[i].type === "speaking" ? 1 : exam.exercises[i].prompts.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
total += exam.exercises[exerciseIndex].type === "speaking" ? 1 : questionIndex + 1;
|
||||||
|
|
||||||
|
return total;
|
||||||
|
};
|
||||||
|
|
||||||
|
export {
|
||||||
|
calculateExerciseIndex,
|
||||||
|
calculateExerciseIndexSpeaking
|
||||||
|
};
|
||||||
11
src/exams/utils/hasDivider.ts
Normal file
11
src/exams/utils/hasDivider.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import { Exam, ExerciseOnlyExam, PartExam } from "@/interfaces/exam";
|
||||||
|
|
||||||
|
const hasDivider = (exam: Exam, index: number) => {
|
||||||
|
const isPartExam = ["reading", "listening", "level"].includes(exam.module);
|
||||||
|
if (isPartExam) {
|
||||||
|
return typeof (exam as PartExam).parts[index].intro === "string" && (exam as PartExam).parts[index].intro !== "";
|
||||||
|
}
|
||||||
|
return typeof (exam as ExerciseOnlyExam).exercises[index].intro === "string" && (exam as ExerciseOnlyExam).exercises[index].intro !== ""
|
||||||
|
}
|
||||||
|
|
||||||
|
export default hasDivider;
|
||||||
7
src/exams/utils/scrollToTop.ts
Normal file
7
src/exams/utils/scrollToTop.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
const scrollToTop = () => {
|
||||||
|
Array.from(document.getElementsByTagName("body")).forEach((body) =>
|
||||||
|
body.scrollTo(0, 0)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default scrollToTop;
|
||||||
90
src/hooks/useExamTimer.ts
Normal file
90
src/hooks/useExamTimer.ts
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
import { Module } from '@/interfaces';
|
||||||
|
import useExamStore from '@/stores/exam';
|
||||||
|
import { useEffect, useRef } from 'react';
|
||||||
|
|
||||||
|
const useExamTimer = (module: Module, disabled: boolean = false) => {
|
||||||
|
const { dispatch, saveSession, timeSpent, inactivity } = useExamStore();
|
||||||
|
|
||||||
|
const initialTimeSpentRef = useRef(timeSpent);
|
||||||
|
const initialInactivityRef = useRef(inactivity);
|
||||||
|
|
||||||
|
const timeSpentRef = useRef(0);
|
||||||
|
const inactivityTimerRef = useRef(0);
|
||||||
|
const totalInactivityRef = useRef(0);
|
||||||
|
|
||||||
|
const resetInactivityTimer = () => {
|
||||||
|
if (inactivityTimerRef.current >= 120) {
|
||||||
|
totalInactivityRef.current += inactivityTimerRef.current;
|
||||||
|
}
|
||||||
|
inactivityTimerRef.current = 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!disabled) {
|
||||||
|
const timerInterval = setInterval(() => {
|
||||||
|
timeSpentRef.current += 1;
|
||||||
|
|
||||||
|
if (timeSpentRef.current % 20 === 0) {
|
||||||
|
dispatch({
|
||||||
|
type: "UPDATE_TIMERS",
|
||||||
|
payload: {
|
||||||
|
timeSpent: initialTimeSpentRef.current + timeSpentRef.current,
|
||||||
|
inactivity: initialInactivityRef.current + totalInactivityRef.current,
|
||||||
|
timeSpentCurrentModule: timeSpentRef.current
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (module !== "speaking") {
|
||||||
|
saveSession().catch(error => {
|
||||||
|
console.error('Failed to save session:', error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}, 1000);
|
||||||
|
|
||||||
|
return () => clearInterval(timerInterval);
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [dispatch, disabled, saveSession, module]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!disabled) {
|
||||||
|
const inactivityInterval = setInterval(() => {
|
||||||
|
inactivityTimerRef.current += 1;
|
||||||
|
}, 1000);
|
||||||
|
|
||||||
|
return () => clearInterval(inactivityInterval);
|
||||||
|
}
|
||||||
|
}, [disabled]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!disabled) {
|
||||||
|
document.addEventListener("keydown", resetInactivityTimer);
|
||||||
|
document.addEventListener("mousemove", resetInactivityTimer);
|
||||||
|
document.addEventListener("mousedown", resetInactivityTimer);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener("keydown", resetInactivityTimer);
|
||||||
|
document.removeEventListener("mousemove", resetInactivityTimer);
|
||||||
|
document.removeEventListener("mousedown", resetInactivityTimer);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}, [disabled]);
|
||||||
|
|
||||||
|
const updateTimers = () => {
|
||||||
|
if (!disabled) {
|
||||||
|
dispatch({
|
||||||
|
type: "UPDATE_TIMERS",
|
||||||
|
payload: {
|
||||||
|
timeSpent: initialTimeSpentRef.current + timeSpentRef.current,
|
||||||
|
inactivity: initialInactivityRef.current + totalInactivityRef.current,
|
||||||
|
timeSpentCurrentModule: 0
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return updateTimers;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default useExamTimer;
|
||||||
@@ -1,6 +1,4 @@
|
|||||||
import {Exam} from "@/interfaces/exam";
|
|
||||||
import {Permission, PermissionType} from "@/interfaces/permissions";
|
import {Permission, PermissionType} from "@/interfaces/permissions";
|
||||||
import {ExamState} from "@/stores/examStore";
|
|
||||||
import Axios from "axios";
|
import Axios from "axios";
|
||||||
import {setupCache} from "axios-cache-interceptor";
|
import {setupCache} from "axios-cache-interceptor";
|
||||||
import {useEffect, useState} from "react";
|
import {useEffect, useState} from "react";
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
import {Exam} from "@/interfaces/exam";
|
import {ExamState} from "@/stores/exam/types";
|
||||||
import {ExamState} from "@/stores/examStore";
|
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import {setupCache} from "axios-cache-interceptor";
|
|
||||||
import {useEffect, useState} from "react";
|
import {useEffect, useState} from "react";
|
||||||
|
|
||||||
export type Session = ExamState & {user: string; id: string; date: string};
|
export type Session = ExamState & {user: string; id: string; date: string};
|
||||||
|
|||||||
@@ -349,3 +349,5 @@ export interface Shuffles {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type ModuleExam = LevelExam | ReadingExam | ListeningExam | WritingExam | SpeakingExam;
|
export type ModuleExam = LevelExam | ReadingExam | ListeningExam | WritingExam | SpeakingExam;
|
||||||
|
export type PartExam = ReadingExam | ListeningExam | LevelExam;
|
||||||
|
export type ExerciseOnlyExam = SpeakingExam | WritingExam;
|
||||||
|
|||||||
@@ -1,50 +0,0 @@
|
|||||||
import Button from "@/components/Low/Button";
|
|
||||||
import Input from "@/components/Low/Input";
|
|
||||||
import {Module} from "@/interfaces";
|
|
||||||
import useExamStore from "@/stores/examStore";
|
|
||||||
import {getExamById} from "@/utils/exams";
|
|
||||||
import {MODULE_ARRAY} from "@/utils/moduleUtils";
|
|
||||||
import {RadioGroup} from "@headlessui/react";
|
|
||||||
import axios from "axios";
|
|
||||||
import clsx from "clsx";
|
|
||||||
import {capitalize} from "lodash";
|
|
||||||
import {useRouter} from "next/router";
|
|
||||||
import {FormEvent, useState} from "react";
|
|
||||||
import {toast} from "react-toastify";
|
|
||||||
|
|
||||||
export default function ExamGenerator() {
|
|
||||||
const [selectedModule, setSelectedModule] = useState<Module>();
|
|
||||||
const [examId, setExamId] = useState<string>();
|
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
|
||||||
|
|
||||||
const setExams = useExamStore((state) => state.setExams);
|
|
||||||
const setSelectedModules = useExamStore((state) => state.setSelectedModules);
|
|
||||||
|
|
||||||
const router = useRouter();
|
|
||||||
|
|
||||||
const generateExam = (module: Module) => {
|
|
||||||
axios.get(`/api/exam/${module}/generate`).then((result) => console.log(result.data));
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col gap-4 border p-4 border-mti-gray-platinum rounded-xl">
|
|
||||||
<label className="font-normal text-base text-mti-gray-dim">Exam Generator</label>
|
|
||||||
<div className="w-full grid grid-cols-2 gap-2">
|
|
||||||
{MODULE_ARRAY.map((module) => (
|
|
||||||
<Button
|
|
||||||
onClick={() => generateExam(module)}
|
|
||||||
key={module}
|
|
||||||
className={clsx(
|
|
||||||
"w-full min-w-[200px]",
|
|
||||||
module === "reading" && "!bg-ielts-reading/80 !border-ielts-reading hover:!bg-ielts-reading",
|
|
||||||
module === "listening" && "!bg-ielts-listening/80 !border-ielts-listening hover:!bg-ielts-listening",
|
|
||||||
module === "writing" && "!bg-ielts-writing/80 !border-ielts-writing hover:!bg-ielts-writing",
|
|
||||||
module === "speaking" && "!bg-ielts-speaking/80 !border-ielts-speaking hover:!bg-ielts-speaking",
|
|
||||||
)}>
|
|
||||||
{capitalize(module)}
|
|
||||||
</Button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import Button from "@/components/Low/Button";
|
import Button from "@/components/Low/Button";
|
||||||
import Input from "@/components/Low/Input";
|
import Input from "@/components/Low/Input";
|
||||||
import {Module} from "@/interfaces";
|
import {Module} from "@/interfaces";
|
||||||
import useExamStore from "@/stores/examStore";
|
import useExamStore from "@/stores/exam";
|
||||||
import {getExamById} from "@/utils/exams";
|
import {getExamById} from "@/utils/exams";
|
||||||
import {MODULE_ARRAY} from "@/utils/moduleUtils";
|
import {MODULE_ARRAY} from "@/utils/moduleUtils";
|
||||||
import {RadioGroup} from "@headlessui/react";
|
import {RadioGroup} from "@headlessui/react";
|
||||||
@@ -16,8 +16,7 @@ export default function ExamLoader() {
|
|||||||
const [examId, setExamId] = useState<string>();
|
const [examId, setExamId] = useState<string>();
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
const setExams = useExamStore((state) => state.setExams);
|
const dispatch = useExamStore((store) => store.dispatch);
|
||||||
const setSelectedModules = useExamStore((state) => state.setSelectedModules);
|
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
@@ -35,9 +34,7 @@ export default function ExamLoader() {
|
|||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
dispatch({type: 'INIT_EXAM', payload: {exams: [exam], modules: [selectedModule]}})
|
||||||
setExams([exam]);
|
|
||||||
setSelectedModules([selectedModule]);
|
|
||||||
|
|
||||||
router.push("/exam");
|
router.push("/exam");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import useUsers from "@/hooks/useUsers";
|
|||||||
import {Module} from "@/interfaces";
|
import {Module} from "@/interfaces";
|
||||||
import {Exam} from "@/interfaces/exam";
|
import {Exam} from "@/interfaces/exam";
|
||||||
import {Type, User} from "@/interfaces/user";
|
import {Type, User} from "@/interfaces/user";
|
||||||
import useExamStore from "@/stores/examStore";
|
import useExamStore from "@/stores/exam";
|
||||||
import {getExamById} from "@/utils/exams";
|
import {getExamById} from "@/utils/exams";
|
||||||
import {countExercises} from "@/utils/moduleUtils";
|
import {countExercises} from "@/utils/moduleUtils";
|
||||||
import {createColumnHelper, flexRender, getCoreRowModel, useReactTable} from "@tanstack/react-table";
|
import {createColumnHelper, flexRender, getCoreRowModel, useReactTable} from "@tanstack/react-table";
|
||||||
@@ -92,8 +92,7 @@ export default function ExamList({user, entities}: {user: User; entities: Entity
|
|||||||
|
|
||||||
const {rows: filteredRows, renderSearch} = useListSearch<Exam>(searchFields, parsedExams);
|
const {rows: filteredRows, renderSearch} = useListSearch<Exam>(searchFields, parsedExams);
|
||||||
|
|
||||||
const setExams = useExamStore((state) => state.setExams);
|
const dispatch = useExamStore((state) => state.dispatch);
|
||||||
const setSelectedModules = useExamStore((state) => state.setSelectedModules);
|
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
@@ -106,9 +105,7 @@ export default function ExamList({user, entities}: {user: User; entities: Entity
|
|||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
dispatch({type: "INIT_EXAM", payload: {exams: [exam], modules: [module]}})
|
||||||
setExams([exam]);
|
|
||||||
setSelectedModules([module]);
|
|
||||||
|
|
||||||
router.push("/exercises");
|
router.push("/exercises");
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,23 +1,14 @@
|
|||||||
import Input from "@/components/Low/Input";
|
import Input from "@/components/Low/Input";
|
||||||
import Modal from "@/components/Modal";
|
import Modal from "@/components/Modal";
|
||||||
import {PERMISSIONS} from "@/constants/userPermissions";
|
|
||||||
import useExams from "@/hooks/useExams";
|
|
||||||
import usePackages from "@/hooks/usePackages";
|
import usePackages from "@/hooks/usePackages";
|
||||||
import useUsers from "@/hooks/useUsers";
|
|
||||||
import {Module} from "@/interfaces";
|
import {Module} from "@/interfaces";
|
||||||
import {Exam} from "@/interfaces/exam";
|
|
||||||
import {Package} from "@/interfaces/paypal";
|
import {Package} from "@/interfaces/paypal";
|
||||||
import {Type, User} from "@/interfaces/user";
|
import {User} from "@/interfaces/user";
|
||||||
import useExamStore from "@/stores/examStore";
|
|
||||||
import {getExamById} from "@/utils/exams";
|
|
||||||
import {countExercises} from "@/utils/moduleUtils";
|
|
||||||
import {createColumnHelper, flexRender, getCoreRowModel, useReactTable} from "@tanstack/react-table";
|
import {createColumnHelper, flexRender, getCoreRowModel, useReactTable} from "@tanstack/react-table";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import clsx from "clsx";
|
|
||||||
import {capitalize} from "lodash";
|
import {capitalize} from "lodash";
|
||||||
import {useRouter} from "next/router";
|
|
||||||
import {useState} from "react";
|
import {useState} from "react";
|
||||||
import {BsCheck, BsPencil, BsTrash, BsUpload} from "react-icons/bs";
|
import {BsPencil, BsTrash} from "react-icons/bs";
|
||||||
import {toast} from "react-toastify";
|
import {toast} from "react-toastify";
|
||||||
import Select from "react-select";
|
import Select from "react-select";
|
||||||
import {CURRENCIES} from "@/resources/paypal";
|
import {CURRENCIES} from "@/resources/paypal";
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
/* eslint-disable @next/next/no-img-element */
|
/* eslint-disable @next/next/no-img-element */
|
||||||
import { Module } from "@/interfaces";
|
import { Module } from "@/interfaces";
|
||||||
import { useEffect, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
|
||||||
import AbandonPopup from "@/components/AbandonPopup";
|
import AbandonPopup from "@/components/AbandonPopup";
|
||||||
import Layout from "@/components/High/Layout";
|
import Layout from "@/components/High/Layout";
|
||||||
@@ -11,9 +11,8 @@ import Reading from "@/exams/Reading";
|
|||||||
import Selection from "@/exams/Selection";
|
import Selection from "@/exams/Selection";
|
||||||
import Speaking from "@/exams/Speaking";
|
import Speaking from "@/exams/Speaking";
|
||||||
import Writing from "@/exams/Writing";
|
import Writing from "@/exams/Writing";
|
||||||
import { Exam, LevelExam, UserSolution, Variant } from "@/interfaces/exam";
|
import { Exam, ExerciseOnlyExam, LevelExam, PartExam, UserSolution, Variant } from "@/interfaces/exam";
|
||||||
import { Stat, User } from "@/interfaces/user";
|
import { Stat, User } from "@/interfaces/user";
|
||||||
import useExamStore from "@/stores/examStore";
|
|
||||||
import { evaluateSpeakingAnswer, evaluateWritingAnswer } from "@/utils/evaluation";
|
import { evaluateSpeakingAnswer, evaluateWritingAnswer } from "@/utils/evaluation";
|
||||||
import { defaultExamUserSolutions, getExam } from "@/utils/exams";
|
import { defaultExamUserSolutions, getExam } from "@/utils/exams";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
@@ -21,6 +20,8 @@ import { useRouter } from "next/router";
|
|||||||
import { toast, ToastContainer } from "react-toastify";
|
import { toast, ToastContainer } from "react-toastify";
|
||||||
import { v4 as uuidv4 } from "uuid";
|
import { v4 as uuidv4 } from "uuid";
|
||||||
import ShortUniqueId from "short-unique-id";
|
import ShortUniqueId from "short-unique-id";
|
||||||
|
import { ExamProps } from "@/exams/types";
|
||||||
|
import useExamStore from "@/stores/exam";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
page: "exams" | "exercises";
|
page: "exams" | "exercises";
|
||||||
@@ -29,198 +30,64 @@ interface Props {
|
|||||||
hideSidebar?: boolean
|
hideSidebar?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ExamPage({ page, user, destination = "/exam", hideSidebar = false }: Props) {
|
const ExamPage: React.FC<Props> = ({ page, user, destination = "/exam", hideSidebar = false }) => {
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
const [variant, setVariant] = useState<Variant>("full");
|
const [variant, setVariant] = useState<Variant>("full");
|
||||||
const [avoidRepeated, setAvoidRepeated] = useState(false);
|
const [avoidRepeated, setAvoidRepeated] = useState(false);
|
||||||
const [hasBeenUploaded, setHasBeenUploaded] = useState(false);
|
const [hasBeenUploaded, setHasBeenUploaded] = useState(false);
|
||||||
const [showAbandonPopup, setShowAbandonPopup] = useState(false);
|
const [showAbandonPopup, setShowAbandonPopup] = useState(false);
|
||||||
const [isEvaluationLoading, setIsEvaluationLoading] = useState(false);
|
const [isEvaluationLoading, setIsEvaluationLoading] = useState(false);
|
||||||
const [statsAwaitingEvaluation, setStatsAwaitingEvaluation] = useState<string[]>([]);
|
const [statsAwaitingEvaluation, setStatsAwaitingEvaluation] = useState<string[]>([]);
|
||||||
const [inactivityTimer, setInactivityTimer] = useState(0);
|
|
||||||
const [totalInactivity, setTotalInactivity] = useState(0);
|
|
||||||
const [timeSpent, setTimeSpent] = useState(0);
|
|
||||||
|
|
||||||
const resetStore = useExamStore((state) => state.reset);
|
const {
|
||||||
const assignment = useExamStore((state) => state.assignment);
|
exam, setExam,
|
||||||
const initialTimeSpent = useExamStore((state) => state.timeSpent);
|
exams,
|
||||||
|
sessionId, setSessionId, setPartIndex,
|
||||||
|
moduleIndex, setModuleIndex,
|
||||||
|
setQuestionIndex, setExerciseIndex,
|
||||||
|
userSolutions, setUserSolutions,
|
||||||
|
showSolutions, setShowSolutions,
|
||||||
|
selectedModules, setSelectedModules,
|
||||||
|
setUser,
|
||||||
|
inactivity,
|
||||||
|
timeSpent,
|
||||||
|
assignment,
|
||||||
|
bgColor,
|
||||||
|
flags,
|
||||||
|
dispatch,
|
||||||
|
reset: resetStore,
|
||||||
|
saveStats,
|
||||||
|
saveSession,
|
||||||
|
setFlags,
|
||||||
|
setShuffles
|
||||||
|
} = useExamStore();
|
||||||
|
|
||||||
const { exam, setExam } = useExamStore((state) => state);
|
const { finalizeModule, finalizeExam } = flags;
|
||||||
const { exams, setExams } = useExamStore((state) => state);
|
|
||||||
const { sessionId, setSessionId } = useExamStore((state) => state);
|
|
||||||
const { partIndex, setPartIndex } = useExamStore((state) => state);
|
|
||||||
const { moduleIndex, setModuleIndex } = useExamStore((state) => state);
|
|
||||||
const { questionIndex, setQuestionIndex } = useExamStore((state) => state);
|
|
||||||
const { exerciseIndex, setExerciseIndex } = useExamStore((state) => state);
|
|
||||||
const { userSolutions, setUserSolutions } = useExamStore((state) => state);
|
|
||||||
const { showSolutions, setShowSolutions } = useExamStore((state) => state);
|
|
||||||
const { selectedModules, setSelectedModules } = useExamStore((state) => state);
|
|
||||||
const { inactivity, setInactivity } = useExamStore((state) => state);
|
|
||||||
const { bgColor, setBgColor } = useExamStore((state) => state);
|
|
||||||
const setShuffleMaps = useExamStore((state) => state.setShuffles);
|
|
||||||
|
|
||||||
const router = useRouter();
|
const [isFetchingExams, setIsFetchingExams] = useState(false);
|
||||||
|
const [isExamLoaded, setIsExamLoaded] = useState(moduleIndex < selectedModules.length);
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
const resetInactivityTimer = () => {
|
|
||||||
setInactivityTimer((prev) => {
|
|
||||||
if (moduleIndex >= selectedModules.length || moduleIndex === -1) return 0;
|
|
||||||
if (prev >= 120) setTotalInactivity((totalPrev) => totalPrev + prev);
|
|
||||||
return 0;
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const reset = () => {
|
|
||||||
resetStore();
|
|
||||||
setVariant("full");
|
|
||||||
setAvoidRepeated(false);
|
|
||||||
setHasBeenUploaded(false);
|
|
||||||
setShowAbandonPopup(false);
|
|
||||||
setIsEvaluationLoading(false);
|
|
||||||
setStatsAwaitingEvaluation([]);
|
|
||||||
setTimeSpent(0);
|
|
||||||
setInactivity(0);
|
|
||||||
|
|
||||||
document.removeEventListener("keydown", resetInactivityTimer);
|
|
||||||
document.removeEventListener("mousemove", resetInactivityTimer);
|
|
||||||
document.removeEventListener("mousedown", resetInactivityTimer);
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (moduleIndex >= selectedModules.length || moduleIndex === -1 || showSolutions) {
|
setIsExamLoaded(moduleIndex < selectedModules.length);
|
||||||
document.removeEventListener("keydown", resetInactivityTimer);
|
}, [showSolutions, moduleIndex, selectedModules]);
|
||||||
document.removeEventListener("mousemove", resetInactivityTimer);
|
|
||||||
document.removeEventListener("mousedown", resetInactivityTimer);
|
|
||||||
}
|
|
||||||
}, [moduleIndex, resetInactivityTimer, selectedModules.length, showSolutions]);
|
|
||||||
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
const saveSession = async () => {
|
|
||||||
console.log("Saving your session...");
|
|
||||||
|
|
||||||
await axios.post("/api/sessions", {
|
|
||||||
id: sessionId,
|
|
||||||
sessionId,
|
|
||||||
date: new Date().toISOString(),
|
|
||||||
userSolutions,
|
|
||||||
moduleIndex,
|
|
||||||
selectedModules,
|
|
||||||
assignment,
|
|
||||||
timeSpent,
|
|
||||||
inactivity: totalInactivity,
|
|
||||||
exams,
|
|
||||||
exam,
|
|
||||||
partIndex,
|
|
||||||
exerciseIndex,
|
|
||||||
questionIndex,
|
|
||||||
user: user?.id,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => setTimeSpent(initialTimeSpent), [initialTimeSpent]);
|
|
||||||
useEffect(() => setTotalInactivity(inactivity), [inactivity]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (userSolutions.length === 0 && exams.length > 0) {
|
if (!showSolutions && sessionId.length === 0 && user?.id) {
|
||||||
const defaultSolutions = exams.map(defaultExamUserSolutions).flat();
|
|
||||||
setUserSolutions(defaultSolutions);
|
|
||||||
}
|
|
||||||
}, [exams, setUserSolutions, userSolutions]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (
|
|
||||||
sessionId.length > 0 &&
|
|
||||||
userSolutions.length > 0 &&
|
|
||||||
selectedModules.length > 0 &&
|
|
||||||
exams.length > 0 &&
|
|
||||||
!!exam &&
|
|
||||||
timeSpent > 0 &&
|
|
||||||
!showSolutions &&
|
|
||||||
moduleIndex < selectedModules.length &&
|
|
||||||
selectedModules[moduleIndex] !== "speaking"
|
|
||||||
)
|
|
||||||
saveSession();
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [assignment, exam, exams, moduleIndex, selectedModules, sessionId, userSolutions, user, exerciseIndex, partIndex, questionIndex]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (
|
|
||||||
timeSpent % 20 === 0 &&
|
|
||||||
timeSpent > 0 &&
|
|
||||||
moduleIndex < selectedModules.length &&
|
|
||||||
selectedModules[moduleIndex] !== "speaking" &&
|
|
||||||
!showSolutions
|
|
||||||
)
|
|
||||||
saveSession();
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [timeSpent]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (selectedModules.length > 0 && sessionId.length === 0) {
|
|
||||||
const shortUID = new ShortUniqueId();
|
const shortUID = new ShortUniqueId();
|
||||||
|
setUser(user.id);
|
||||||
setSessionId(shortUID.randomUUID(8));
|
setSessionId(shortUID.randomUUID(8));
|
||||||
}
|
}
|
||||||
}, [setSessionId, selectedModules, sessionId]);
|
}, [setSessionId, isExamLoaded, sessionId, showSolutions, setUser, user?.id]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (user?.type === "developer") console.log(exam);
|
if (user?.type === "developer") console.log(exam);
|
||||||
}, [exam, user]);
|
}, [exam, user]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (selectedModules.length > 0 && timeSpent === 0 && !showSolutions) {
|
|
||||||
const timerInterval = setInterval(() => {
|
|
||||||
setTimeSpent((prev) => prev + 1);
|
|
||||||
}, 1000);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
clearInterval(timerInterval);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [selectedModules.length]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (selectedModules.length > 0 && !showSolutions && inactivityTimer === 0) {
|
|
||||||
const inactivityInterval = setInterval(() => {
|
|
||||||
setInactivityTimer((prev) => prev + 1);
|
|
||||||
}, 1000);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
clearInterval(inactivityInterval);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [selectedModules.length]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
document.addEventListener("keydown", resetInactivityTimer);
|
|
||||||
document.addEventListener("mousemove", resetInactivityTimer);
|
|
||||||
document.addEventListener("mousedown", resetInactivityTimer);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
document.removeEventListener("keydown", resetInactivityTimer);
|
|
||||||
document.removeEventListener("mousemove", resetInactivityTimer);
|
|
||||||
document.removeEventListener("mousedown", resetInactivityTimer);
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (showSolutions) setModuleIndex(-1);
|
|
||||||
}, [setModuleIndex, showSolutions]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (selectedModules.length > 0 && exams.length > 0 && moduleIndex < selectedModules.length) {
|
|
||||||
const nextExam = exams[moduleIndex];
|
|
||||||
|
|
||||||
if (partIndex === -1 && nextExam?.module !== "listening") setPartIndex(0);
|
|
||||||
if (exerciseIndex === -1 && !["reading", "listening"].includes(nextExam?.module)) setExerciseIndex(0);
|
|
||||||
setExam(nextExam ? updateExamWithUserSolutions(nextExam) : undefined);
|
|
||||||
}
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [selectedModules, moduleIndex, exams]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
(async () => {
|
(async () => {
|
||||||
if (selectedModules.length > 0 && exams.length === 0) {
|
if (selectedModules.length > 0 && exams.length === 0) {
|
||||||
|
setIsFetchingExams(true);
|
||||||
const examPromises = selectedModules.map((module) =>
|
const examPromises = selectedModules.map((module) =>
|
||||||
getExam(
|
getExam(
|
||||||
module,
|
module,
|
||||||
@@ -230,8 +97,9 @@ export default function ExamPage({ page, user, destination = "/exam", hideSideba
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
Promise.all(examPromises).then((values) => {
|
Promise.all(examPromises).then((values) => {
|
||||||
|
setIsFetchingExams(false);
|
||||||
if (values.every((x) => !!x)) {
|
if (values.every((x) => !!x)) {
|
||||||
setExams(values.map((x) => x!));
|
dispatch({ type: 'INIT_EXAM', payload: { exams: values.map((x) => x!), modules: selectedModules } })
|
||||||
} else {
|
} else {
|
||||||
toast.error("Something went wrong, please try again");
|
toast.error("Something went wrong, please try again");
|
||||||
setTimeout(router.reload, 500);
|
setTimeout(router.reload, 500);
|
||||||
@@ -240,100 +108,61 @@ export default function ExamPage({ page, user, destination = "/exam", hideSideba
|
|||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [selectedModules, setExams, exams]);
|
}, [selectedModules, exams]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (selectedModules.length > 0 && exams.length !== 0 && moduleIndex >= selectedModules.length && !hasBeenUploaded && !showSolutions) {
|
|
||||||
const newStats: Stat[] = userSolutions.map((solution) => ({
|
|
||||||
...solution,
|
|
||||||
id: solution.id || uuidv4(),
|
|
||||||
timeSpent,
|
|
||||||
inactivity: totalInactivity,
|
|
||||||
session: sessionId,
|
|
||||||
exam: solution.exam!,
|
|
||||||
module: solution.module!,
|
|
||||||
user: user?.id || "",
|
|
||||||
date: new Date().getTime(),
|
|
||||||
isDisabled: solution.isDisabled,
|
|
||||||
shuffleMaps: solution.shuffleMaps,
|
|
||||||
...(assignment ? { assignment: assignment.id } : {}),
|
|
||||||
isPractice: solution.isPractice
|
|
||||||
}));
|
|
||||||
|
|
||||||
axios
|
const reset = () => {
|
||||||
.post<{ ok: boolean }>("/api/stats", newStats)
|
resetStore();
|
||||||
.then((response) => setHasBeenUploaded(response.data.ok))
|
setVariant("full");
|
||||||
.catch(() => setHasBeenUploaded(false));
|
setAvoidRepeated(false);
|
||||||
}
|
setHasBeenUploaded(false);
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
setShowAbandonPopup(false);
|
||||||
}, [selectedModules, moduleIndex, hasBeenUploaded]);
|
setIsEvaluationLoading(false);
|
||||||
|
setStatsAwaitingEvaluation([]);
|
||||||
useEffect(() => {
|
|
||||||
setIsEvaluationLoading(statsAwaitingEvaluation.length !== 0);
|
|
||||||
}, [statsAwaitingEvaluation]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (statsAwaitingEvaluation.length > 0) checkIfStatsHaveBeenEvaluated(statsAwaitingEvaluation);
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [statsAwaitingEvaluation]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (exam && exam.module === "level" && !showSolutions) setBgColor("bg-ielts-level-light");
|
|
||||||
}, [exam, showSolutions, setBgColor]);
|
|
||||||
|
|
||||||
const checkIfStatsHaveBeenEvaluated = (ids: string[]) => {
|
|
||||||
setTimeout(async () => {
|
|
||||||
try {
|
|
||||||
const awaitedStats = await Promise.all(ids.map(async (id) => (await axios.get<Stat>(`/api/stats/${id}`)).data));
|
|
||||||
const solutionsEvaluated = awaitedStats.every((stat) => stat.solutions.every((x) => x.evaluation !== null));
|
|
||||||
if (solutionsEvaluated) {
|
|
||||||
const statsUserSolutions: UserSolution[] = awaitedStats.map((stat) => ({
|
|
||||||
id: stat.id,
|
|
||||||
exercise: stat.exercise,
|
|
||||||
score: stat.score,
|
|
||||||
solutions: stat.solutions,
|
|
||||||
type: stat.type,
|
|
||||||
exam: stat.exam,
|
|
||||||
module: stat.module,
|
|
||||||
}));
|
|
||||||
|
|
||||||
const updatedUserSolutions = userSolutions.map((x) => {
|
|
||||||
const respectiveSolution = statsUserSolutions.find((y) => y.exercise === x.exercise);
|
|
||||||
return respectiveSolution ? respectiveSolution : x;
|
|
||||||
});
|
|
||||||
|
|
||||||
setUserSolutions(updatedUserSolutions);
|
|
||||||
return setStatsAwaitingEvaluation((prev) => prev.filter((x) => !ids.includes(x)));
|
|
||||||
}
|
|
||||||
|
|
||||||
return checkIfStatsHaveBeenEvaluated(ids);
|
|
||||||
} catch {
|
|
||||||
return checkIfStatsHaveBeenEvaluated(ids);
|
|
||||||
}
|
|
||||||
}, 5 * 1000);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const updateExamWithUserSolutions = (exam: Exam): Exam => {
|
useEffect(() => {
|
||||||
if (exam.module === "reading" || exam.module === "listening" || exam.module === "level") {
|
if (finalizeModule && !showSolutions) {
|
||||||
const parts = exam.parts.map((p) =>
|
/*if (exam && (exam.module === "writing" || exam.module === "speaking") && userSolutions.length > 0 && !showSolutions) {
|
||||||
Object.assign(p, {
|
setIsEvaluationLoading(true);
|
||||||
exercises: p.exercises.map((x) =>
|
(async () => {
|
||||||
Object.assign(x, {
|
const responses: UserSolution[] = (
|
||||||
userSolutions: userSolutions.find((y) => x.id === y.exercise)?.solutions,
|
await Promise.all(
|
||||||
}),
|
exam.exercises.map(async (exercise, index) => {
|
||||||
),
|
const evaluationID = uuidv4();
|
||||||
}),
|
if (exercise.type === "writing")
|
||||||
);
|
return await evaluateWritingAnswer(exercise, index + 1, userSolutions.find((x) => x.exercise === exercise.id)!, evaluationID);
|
||||||
return Object.assign(exam, { parts });
|
|
||||||
}
|
|
||||||
|
|
||||||
const exercises = exam.exercises.map((x) =>
|
if (exercise.type === "interactiveSpeaking" || exercise.type === "speaking")
|
||||||
Object.assign(x, {
|
return await evaluateSpeakingAnswer(
|
||||||
userSolutions: userSolutions.find((y) => x.id === y.exercise)?.solutions,
|
exercise,
|
||||||
}),
|
userSolutions.find((x) => x.exercise === exercise.id)!,
|
||||||
);
|
evaluationID,
|
||||||
return Object.assign(exam, { exercises });
|
index + 1,
|
||||||
};
|
);
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
).filter((x) => !!x) as UserSolution[];
|
||||||
|
})();
|
||||||
|
}*/
|
||||||
|
}
|
||||||
|
}, [exam, finalizeModule, showSolutions, userSolutions]);
|
||||||
|
|
||||||
|
/*useEffect(() => {
|
||||||
|
// poll backend and setIsEvaluationLoading to false
|
||||||
|
|
||||||
|
}, []);*/
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (finalizeExam && !isEvaluationLoading) {
|
||||||
|
(async () => {
|
||||||
|
axios.get("/api/stats/update");
|
||||||
|
await saveStats();
|
||||||
|
setModuleIndex(-1);
|
||||||
|
setFlags({ finalizeExam: false });
|
||||||
|
})();
|
||||||
|
}
|
||||||
|
}, [finalizeExam, saveStats, setFlags, setModuleIndex, isEvaluationLoading]);
|
||||||
|
|
||||||
const onFinish = async (solutions: UserSolution[]) => {
|
const onFinish = async (solutions: UserSolution[]) => {
|
||||||
const solutionIds = solutions.map((x) => x.exercise);
|
const solutionIds = solutions.map((x) => x.exercise);
|
||||||
@@ -375,8 +204,8 @@ export default function ExamPage({ page, user, destination = "/exam", hideSideba
|
|||||||
setUserSolutions([...userSolutions.filter((x) => !solutionIds.includes(x.exercise)), ...newSolutions]);
|
setUserSolutions([...userSolutions.filter((x) => !solutionIds.includes(x.exercise)), ...newSolutions]);
|
||||||
setModuleIndex(moduleIndex + 1);
|
setModuleIndex(moduleIndex + 1);
|
||||||
|
|
||||||
setPartIndex(-1);
|
setPartIndex(0);
|
||||||
setExerciseIndex(-1);
|
setExerciseIndex(0);
|
||||||
setQuestionIndex(0);
|
setQuestionIndex(0);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -432,82 +261,19 @@ export default function ExamPage({ page, user, destination = "/exam", hideSideba
|
|||||||
.map((x) => ({ module: x as Module, ...scores[x as Module] }));
|
.map((x) => ({ module: x as Module, ...scores[x as Module] }));
|
||||||
};
|
};
|
||||||
|
|
||||||
const renderScreen = () => {
|
const ModuleExamMap: Record<Module, React.ComponentType<ExamProps<Exam>>> = {
|
||||||
if (selectedModules.length === 0) {
|
"reading": Reading as React.ComponentType<ExamProps<Exam>>,
|
||||||
return (
|
"listening": Listening as React.ComponentType<ExamProps<Exam>>,
|
||||||
<Selection
|
"writing": Writing as React.ComponentType<ExamProps<Exam>>,
|
||||||
page={page}
|
"speaking": Speaking as React.ComponentType<ExamProps<Exam>>,
|
||||||
user={user!}
|
"level": Level as React.ComponentType<ExamProps<Exam>>,
|
||||||
onStart={(modules: Module[], avoid: boolean, variant: Variant) => {
|
}
|
||||||
setModuleIndex(0);
|
|
||||||
setAvoidRepeated(avoid);
|
|
||||||
setSelectedModules(modules);
|
|
||||||
setVariant(variant);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (moduleIndex >= selectedModules.length || moduleIndex === -1) {
|
const CurrentExam = exam?.module ? ModuleExamMap[exam.module] : undefined;
|
||||||
return (
|
|
||||||
<Finish
|
|
||||||
isLoading={isEvaluationLoading}
|
|
||||||
user={user!}
|
|
||||||
modules={selectedModules}
|
|
||||||
solutions={userSolutions}
|
|
||||||
assignment={assignment}
|
|
||||||
information={{
|
|
||||||
timeSpent,
|
|
||||||
inactivity: totalInactivity,
|
|
||||||
}}
|
|
||||||
destination={destination}
|
|
||||||
onViewResults={(index?: number) => {
|
|
||||||
if (exams[0].module === "level") {
|
|
||||||
const levelExam = exams[0] as LevelExam;
|
|
||||||
const allExercises = levelExam.parts.flatMap((part) => part.exercises);
|
|
||||||
const exerciseOrderMap = new Map(allExercises.map((ex, index) => [ex.id, index]));
|
|
||||||
const orderedSolutions = userSolutions.slice().sort((a, b) => {
|
|
||||||
const indexA = exerciseOrderMap.get(a.exercise) ?? Infinity;
|
|
||||||
const indexB = exerciseOrderMap.get(b.exercise) ?? Infinity;
|
|
||||||
return indexA - indexB;
|
|
||||||
});
|
|
||||||
setUserSolutions(orderedSolutions);
|
|
||||||
} else {
|
|
||||||
setUserSolutions(userSolutions);
|
|
||||||
}
|
|
||||||
setShuffleMaps([]);
|
|
||||||
setShowSolutions(true);
|
|
||||||
setModuleIndex(index || 0);
|
|
||||||
setExerciseIndex(["reading", "listening"].includes(exams[0].module) ? -1 : 0);
|
|
||||||
setPartIndex(exams[0].module === "listening" ? -1 : 0);
|
|
||||||
setExam(exams[0]);
|
|
||||||
}}
|
|
||||||
scores={aggregateScoresByModule()}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (exam && exam.module === "reading") {
|
const onAbandon = async () => {
|
||||||
return <Reading exam={exam} onFinish={onFinish} showSolutions={showSolutions} />;
|
await saveSession();
|
||||||
}
|
reset();
|
||||||
|
|
||||||
if (exam && exam.module === "listening") {
|
|
||||||
return <Listening exam={exam} onFinish={onFinish} showSolutions={showSolutions} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (exam && exam.module === "writing") {
|
|
||||||
return <Writing exam={exam} onFinish={onFinish} showSolutions={showSolutions} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (exam && exam.module === "speaking") {
|
|
||||||
return <Speaking exam={exam} onFinish={onFinish} showSolutions={showSolutions} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (exam && exam.module === "level") {
|
|
||||||
return <Level exam={exam} onFinish={onFinish} showSolutions={showSolutions} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
return <>Loading...</>;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -522,18 +288,79 @@ export default function ExamPage({ page, user, destination = "/exam", hideSideba
|
|||||||
focusMode={selectedModules.length !== 0 && !showSolutions && moduleIndex < selectedModules.length}
|
focusMode={selectedModules.length !== 0 && !showSolutions && moduleIndex < selectedModules.length}
|
||||||
onFocusLayerMouseEnter={() => setShowAbandonPopup(true)}>
|
onFocusLayerMouseEnter={() => setShowAbandonPopup(true)}>
|
||||||
<>
|
<>
|
||||||
{renderScreen()}
|
{/* Modules weren't yet set by an INIT_EXAM or INIT_SOLUTIONS dispatch, show Selection component*/}
|
||||||
{!showSolutions && moduleIndex < selectedModules.length && (
|
{selectedModules.length === 0 && <Selection
|
||||||
<AbandonPopup
|
page={page}
|
||||||
isOpen={showAbandonPopup}
|
user={user!}
|
||||||
abandonPopupTitle="Leave Exercise"
|
onStart={(modules: Module[], avoid: boolean, variant: Variant) => {
|
||||||
abandonPopupDescription="Are you sure you want to leave the exercise? Your progress will be saved and this exam can be resumed on the Dashboard."
|
setModuleIndex(0);
|
||||||
abandonConfirmButtonText="Confirm"
|
setAvoidRepeated(avoid);
|
||||||
onAbandon={() => {
|
setSelectedModules(modules);
|
||||||
reset();
|
setVariant(variant);
|
||||||
|
}}
|
||||||
|
/>}
|
||||||
|
{isFetchingExams && (
|
||||||
|
<div className="flex flex-grow flex-col items-center justify-center animate-pulse">
|
||||||
|
<span className={`loading loading-infinity w-32 bg-ielts-${selectedModules[0]}`} />
|
||||||
|
<span className={`font-bold text-2xl text-ielts-${selectedModules[0]}`}>Loading Exam ...</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{(moduleIndex === -1 && selectedModules.length !== 0) &&
|
||||||
|
<Finish
|
||||||
|
isLoading={isEvaluationLoading}
|
||||||
|
user={user!}
|
||||||
|
modules={selectedModules}
|
||||||
|
solutions={userSolutions}
|
||||||
|
assignment={assignment}
|
||||||
|
information={{
|
||||||
|
timeSpent,
|
||||||
|
inactivity,
|
||||||
}}
|
}}
|
||||||
onCancel={() => setShowAbandonPopup(false)}
|
destination={destination}
|
||||||
/>
|
onViewResults={(index?: number) => {
|
||||||
|
if (exams[0].module === "level") {
|
||||||
|
const levelExam = exams[0] as LevelExam;
|
||||||
|
const allExercises = levelExam.parts.flatMap((part) => part.exercises);
|
||||||
|
const exerciseOrderMap = new Map(allExercises.map((ex, index) => [ex.id, index]));
|
||||||
|
const orderedSolutions = userSolutions.slice().sort((a, b) => {
|
||||||
|
const indexA = exerciseOrderMap.get(a.exercise) ?? Infinity;
|
||||||
|
const indexB = exerciseOrderMap.get(b.exercise) ?? Infinity;
|
||||||
|
return indexA - indexB;
|
||||||
|
});
|
||||||
|
setUserSolutions(orderedSolutions);
|
||||||
|
} else {
|
||||||
|
setUserSolutions(userSolutions);
|
||||||
|
}
|
||||||
|
setShuffles([]);
|
||||||
|
if (index === undefined) {
|
||||||
|
setFlags({ reviewAll: true });
|
||||||
|
setModuleIndex(0);
|
||||||
|
setExam(exams[0]);
|
||||||
|
} else {
|
||||||
|
setModuleIndex(index);
|
||||||
|
setExam(exams[index]);
|
||||||
|
}
|
||||||
|
setShowSolutions(true);
|
||||||
|
setQuestionIndex(0);
|
||||||
|
setExerciseIndex(0);
|
||||||
|
setPartIndex(0);
|
||||||
|
}}
|
||||||
|
scores={aggregateScoresByModule()}
|
||||||
|
/>}
|
||||||
|
{/* Exam is on going, display it and the abandon modal */}
|
||||||
|
{isExamLoaded && moduleIndex !== -1 && (
|
||||||
|
<>
|
||||||
|
{exam && CurrentExam && <CurrentExam exam={exam} showSolutions={showSolutions} />}
|
||||||
|
{!showSolutions && <AbandonPopup
|
||||||
|
isOpen={showAbandonPopup}
|
||||||
|
abandonPopupTitle="Leave Exercise"
|
||||||
|
abandonPopupDescription="Are you sure you want to leave the exercise? Your progress will be saved and this exam can be resumed on the Dashboard."
|
||||||
|
abandonConfirmButtonText="Confirm"
|
||||||
|
onAbandon={onAbandon}
|
||||||
|
onCancel={() => setShowAbandonPopup(false)}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
</Layout>
|
</Layout>
|
||||||
@@ -541,3 +368,5 @@ export default function ExamPage({ page, user, destination = "/exam", hideSideba
|
|||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default ExamPage;
|
||||||
|
|||||||
@@ -8,12 +8,11 @@ import "primeicons/primeicons.css";
|
|||||||
import "react-datepicker/dist/react-datepicker.css";
|
import "react-datepicker/dist/react-datepicker.css";
|
||||||
import {useRouter} from "next/router";
|
import {useRouter} from "next/router";
|
||||||
import {useEffect} from "react";
|
import {useEffect} from "react";
|
||||||
import useExamStore from "@/stores/examStore";
|
import useExamStore from "@/stores/exam";
|
||||||
import usePreferencesStore from "@/stores/preferencesStore";
|
import usePreferencesStore from "@/stores/preferencesStore";
|
||||||
import axios from "axios";
|
|
||||||
|
|
||||||
export default function App({Component, pageProps}: AppProps) {
|
export default function App({Component, pageProps}: AppProps) {
|
||||||
const {reset} = useExamStore((state) => state);
|
const {reset} = useExamStore();
|
||||||
const setIsSidebarMinimized = usePreferencesStore((state) => state.setSidebarMinimized);
|
const setIsSidebarMinimized = usePreferencesStore((state) => state.setSidebarMinimized);
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|||||||
@@ -90,7 +90,7 @@ async function evaluate(body: {answers: object[]}, variant?: "initial" | "final"
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (typeof backendRequest.data === "string") return evaluate(body);
|
if (backendRequest.status !== 200) return evaluate(body);
|
||||||
return backendRequest;
|
return backendRequest;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,11 +7,9 @@ import formidable from "formidable-serverless";
|
|||||||
import {getDownloadURL, ref, uploadBytes} from "firebase/storage";
|
import {getDownloadURL, ref, uploadBytes} from "firebase/storage";
|
||||||
import fs from "fs";
|
import fs from "fs";
|
||||||
import {storage} from "@/firebase";
|
import {storage} from "@/firebase";
|
||||||
import client from "@/lib/mongodb";
|
|
||||||
import {Stat} from "@/interfaces/user";
|
import {Stat} from "@/interfaces/user";
|
||||||
import {speakingReverseMarking} from "@/utils/score";
|
|
||||||
|
|
||||||
const db = client.db(process.env.MONGODB_DB);
|
|
||||||
export default withIronSessionApiRoute(handler, sessionOptions);
|
export default withIronSessionApiRoute(handler, sessionOptions);
|
||||||
|
|
||||||
function delay(ms: number) {
|
function delay(ms: number) {
|
||||||
@@ -30,54 +28,29 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
|
|
||||||
const audioFile = files.audio;
|
const audioFile = files.audio;
|
||||||
const audioFileRef = ref(storage, `speaking_recordings/${fields.id}.wav`);
|
const audioFileRef = ref(storage, `speaking_recordings/${fields.id}.wav`);
|
||||||
const task = parseInt(fields.task.toString());
|
|
||||||
|
|
||||||
const binary = fs.readFileSync((audioFile as any).path).buffer;
|
const binary = fs.readFileSync((audioFile as any).path).buffer;
|
||||||
|
|
||||||
const snapshot = await uploadBytes(audioFileRef, binary);
|
const snapshot = await uploadBytes(audioFileRef, binary);
|
||||||
const url = await getDownloadURL(snapshot.ref);
|
const url = await getDownloadURL(snapshot.ref);
|
||||||
const path = snapshot.metadata.fullPath;
|
const path = snapshot.metadata.fullPath;
|
||||||
|
|
||||||
res.status(200).json(null);
|
|
||||||
|
|
||||||
console.log("🌱 - Still processing");
|
|
||||||
const backendRequest = await evaluate({answer: path, question: fields.question}, task);
|
|
||||||
console.log("🌱 - Process complete");
|
|
||||||
|
|
||||||
const correspondingStat = await getCorrespondingStat(fields.id, 1);
|
/*const solutions = correspondingStat.solutions.map((x) => ({
|
||||||
|
|
||||||
const solutions = correspondingStat.solutions.map((x) => ({
|
|
||||||
...x,
|
...x,
|
||||||
evaluation: backendRequest.data,
|
evaluation: backendRequest.data,
|
||||||
solution: url,
|
solution: url,
|
||||||
}));
|
}));*/
|
||||||
|
|
||||||
await db.collection("stats").updateOne(
|
await axios.post(`${process.env.BACKEND_URL}/grade/speaking/2`, {answer: path, question: fields.question}, {
|
||||||
{ id: fields.id },
|
headers: {
|
||||||
{
|
Authorization: `Bearer ${process.env.BACKEND_JWT}`,
|
||||||
id: fields.id,
|
|
||||||
solutions,
|
|
||||||
score: {
|
|
||||||
correct: speakingReverseMarking[backendRequest.data.overall || 0] || 0,
|
|
||||||
total: 100,
|
|
||||||
missing: 0,
|
|
||||||
},
|
|
||||||
isDisabled: false,
|
|
||||||
},
|
},
|
||||||
{upsert: true},
|
});
|
||||||
);
|
|
||||||
console.log("🌱 - Updated the DB");
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getCorrespondingStat(id: string, index: number): Promise<Stat> {
|
|
||||||
console.log(`🌱 - Try number ${index} - ${id}`);
|
|
||||||
const correspondingStat = await db.collection("stats").findOne<Stat>({ id: id });
|
|
||||||
|
|
||||||
if (correspondingStat) return correspondingStat;
|
|
||||||
await delay(3 * 10000);
|
|
||||||
return getCorrespondingStat(id, index + 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function evaluate(body: {answer: string; question: string}, task: number): Promise<AxiosResponse> {
|
async function evaluate(body: {answer: string; question: string}, task: number): Promise<AxiosResponse> {
|
||||||
const backendRequest = await axios.post(`${process.env.BACKEND_URL}/grade/speaking/2`, body, {
|
const backendRequest = await axios.post(`${process.env.BACKEND_URL}/grade/speaking/2`, body, {
|
||||||
headers: {
|
headers: {
|
||||||
@@ -85,7 +58,7 @@ async function evaluate(body: {answer: string; question: string}, task: number):
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (typeof backendRequest.data === "string") return evaluate(body, task);
|
if (backendRequest.status !== 200) return evaluate(body, task);
|
||||||
return backendRequest;
|
return backendRequest;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,8 @@
|
|||||||
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
|
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
|
||||||
import type { NextApiRequest, NextApiResponse } from "next";
|
import type { NextApiRequest, NextApiResponse } from "next";
|
||||||
import client from "@/lib/mongodb";
|
|
||||||
import { withIronSessionApiRoute } from "iron-session/next";
|
import { withIronSessionApiRoute } from "iron-session/next";
|
||||||
import { sessionOptions } from "@/lib/session";
|
import { sessionOptions } from "@/lib/session";
|
||||||
import axios, { AxiosResponse } from "axios";
|
import axios from "axios";
|
||||||
import { Stat } from "@/interfaces/user";
|
|
||||||
import { writingReverseMarking } from "@/utils/score";
|
|
||||||
|
|
||||||
interface Body {
|
interface Body {
|
||||||
question: string;
|
question: string;
|
||||||
@@ -14,67 +11,22 @@ interface Body {
|
|||||||
id: string;
|
id: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
function delay(ms: number) {
|
|
||||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
||||||
}
|
|
||||||
|
|
||||||
const db = client.db(process.env.MONGODB_DB);
|
|
||||||
|
|
||||||
export default withIronSessionApiRoute(handler, sessionOptions);
|
export default withIronSessionApiRoute(handler, sessionOptions);
|
||||||
|
|
||||||
|
|
||||||
async function handler(req: NextApiRequest, res: NextApiResponse) {
|
async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||||
if (!req.session.user) {
|
if (!req.session.user) {
|
||||||
res.status(401).json({ ok: false });
|
res.status(401).json({ ok: false });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
res.status(200).json(null);
|
const body = req.body as Body;
|
||||||
|
|
||||||
console.log("🌱 - Still processing");
|
|
||||||
const backendRequest = await evaluate(req.body as Body);
|
|
||||||
console.log("🌱 - Process complete");
|
|
||||||
|
|
||||||
const correspondingStat = await getCorrespondingStat(req.body.id, 1);
|
|
||||||
|
|
||||||
const solutions = correspondingStat.solutions.map((x) => ({ ...x, evaluation: backendRequest.data }));
|
|
||||||
await db.collection("stats").updateOne(
|
|
||||||
{ id: (req.body as Body).id },
|
|
||||||
{
|
|
||||||
$set: {
|
|
||||||
id: (req.body as Body).id,
|
|
||||||
solutions,
|
|
||||||
score: {
|
|
||||||
correct: writingReverseMarking[backendRequest.data.overall],
|
|
||||||
total: 100,
|
|
||||||
missing: 0,
|
|
||||||
},
|
|
||||||
isDisabled: false,
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{ upsert: true },
|
|
||||||
);
|
|
||||||
console.log("🌱 - Updated the DB");
|
|
||||||
}
|
|
||||||
|
|
||||||
async function getCorrespondingStat(id: string, index: number): Promise<Stat> {
|
|
||||||
console.log(`🌱 - Try number ${index} - ${id}`);
|
|
||||||
const correspondingStat = await db.collection("stats").findOne<Stat>({ id: id });
|
|
||||||
|
|
||||||
if (correspondingStat) return correspondingStat;
|
|
||||||
|
|
||||||
await delay(3 * 10000);
|
|
||||||
return getCorrespondingStat(id, index + 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function evaluate(body: Body): Promise<AxiosResponse> {
|
|
||||||
const taskNumber = body.task.toString() !== "1" && body.task.toString() !== "2" ? "1" : body.task.toString();
|
const taskNumber = body.task.toString() !== "1" && body.task.toString() !== "2" ? "1" : body.task.toString();
|
||||||
|
|
||||||
const backendRequest = await axios.post(`${process.env.BACKEND_URL}/grade/writing/${taskNumber}`, body as Body, {
|
await axios.post(`${process.env.BACKEND_URL}/grade/writing/${taskNumber}`, body, {
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: `Bearer ${process.env.BACKEND_JWT}`,
|
Authorization: `Bearer ${process.env.BACKEND_JWT}`,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
res.status(200);
|
||||||
if (typeof backendRequest.data === "string") return evaluate(body);
|
|
||||||
return backendRequest;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,32 +2,31 @@ import Button from "@/components/Low/Button";
|
|||||||
import ProgressBar from "@/components/Low/ProgressBar";
|
import ProgressBar from "@/components/Low/ProgressBar";
|
||||||
import Modal from "@/components/Modal";
|
import Modal from "@/components/Modal";
|
||||||
import useUsers from "@/hooks/useUsers";
|
import useUsers from "@/hooks/useUsers";
|
||||||
import {Module} from "@/interfaces";
|
import { Module } from "@/interfaces";
|
||||||
import {Assignment} from "@/interfaces/results";
|
import { Assignment } from "@/interfaces/results";
|
||||||
import {Group, Stat, User} from "@/interfaces/user";
|
import { Group, Stat, User } from "@/interfaces/user";
|
||||||
import useExamStore from "@/stores/examStore";
|
import useExamStore from "@/stores/exam";
|
||||||
import {getExamById} from "@/utils/exams";
|
import { getExamById } from "@/utils/exams";
|
||||||
import {sortByModule} from "@/utils/moduleUtils";
|
import { sortByModule } from "@/utils/moduleUtils";
|
||||||
import {calculateBandScore} from "@/utils/score";
|
import { calculateBandScore } from "@/utils/score";
|
||||||
import {convertToUserSolutions} from "@/utils/stats";
|
import { convertToUserSolutions } from "@/utils/stats";
|
||||||
import {getUserName} from "@/utils/users";
|
import { getUserName } from "@/utils/users";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import {capitalize, uniqBy} from "lodash";
|
import { capitalize, uniqBy } from "lodash";
|
||||||
import moment from "moment";
|
import moment from "moment";
|
||||||
import {useRouter} from "next/router";
|
import { useRouter } from "next/router";
|
||||||
import {BsBook, BsBuilding, BsChevronLeft, BsClipboard, BsHeadphones, BsMegaphone, BsPen} from "react-icons/bs";
|
import { BsBook, BsBuilding, BsChevronLeft, BsClipboard, BsHeadphones, BsMegaphone, BsPen } from "react-icons/bs";
|
||||||
import {toast} from "react-toastify";
|
import { toast } from "react-toastify";
|
||||||
import {futureAssignmentFilter} from "@/utils/assignments";
|
import { futureAssignmentFilter } from "@/utils/assignments";
|
||||||
import {withIronSessionSsr} from "iron-session/next";
|
import { withIronSessionSsr } from "iron-session/next";
|
||||||
import {checkAccess, doesEntityAllow} from "@/utils/permissions";
|
import { checkAccess, doesEntityAllow } from "@/utils/permissions";
|
||||||
import {mapBy, redirect, serialize} from "@/utils";
|
import { mapBy, redirect, serialize } from "@/utils";
|
||||||
import {getAssignment} from "@/utils/assignments.be";
|
import { getAssignment } from "@/utils/assignments.be";
|
||||||
import {getEntitiesUsers, getEntityUsers, getUsers} from "@/utils/users.be";
|
import { getEntityUsers, getUsers } from "@/utils/users.be";
|
||||||
import {getEntitiesWithRoles, getEntityWithRoles} from "@/utils/entities.be";
|
import { getEntityWithRoles } from "@/utils/entities.be";
|
||||||
import {getGroups, getGroupsByEntities, getGroupsByEntity} from "@/utils/groups.be";
|
import { sessionOptions } from "@/lib/session";
|
||||||
import {sessionOptions} from "@/lib/session";
|
import { EntityWithRoles } from "@/interfaces/entity";
|
||||||
import {EntityWithRoles} from "@/interfaces/entity";
|
|
||||||
import Head from "next/head";
|
import Head from "next/head";
|
||||||
import Layout from "@/components/High/Layout";
|
import Layout from "@/components/High/Layout";
|
||||||
import Separator from "@/components/Low/Separator";
|
import Separator from "@/components/Low/Separator";
|
||||||
@@ -35,7 +34,7 @@ import Link from "next/link";
|
|||||||
import { requestUser } from "@/utils/api";
|
import { requestUser } from "@/utils/api";
|
||||||
import { useEntityPermission } from "@/hooks/useEntityPermissions";
|
import { useEntityPermission } from "@/hooks/useEntityPermissions";
|
||||||
|
|
||||||
export const getServerSideProps = withIronSessionSsr(async ({req, res, params}) => {
|
export const getServerSideProps = withIronSessionSsr(async ({ req, res, params }) => {
|
||||||
const user = await requestUser(req, res)
|
const user = await requestUser(req, res)
|
||||||
if (!user) return redirect("/login")
|
if (!user) return redirect("/login")
|
||||||
|
|
||||||
@@ -44,22 +43,22 @@ export const getServerSideProps = withIronSessionSsr(async ({req, res, params})
|
|||||||
|
|
||||||
res.setHeader("Cache-Control", "public, s-maxage=10, stale-while-revalidate=59");
|
res.setHeader("Cache-Control", "public, s-maxage=10, stale-while-revalidate=59");
|
||||||
|
|
||||||
const {id} = params as {id: string};
|
const { id } = params as { id: string };
|
||||||
|
|
||||||
const assignment = await getAssignment(id);
|
const assignment = await getAssignment(id);
|
||||||
if (!assignment) return redirect("/assignments")
|
if (!assignment) return redirect("/assignments")
|
||||||
|
|
||||||
const entity = await getEntityWithRoles(assignment.entity || "")
|
const entity = await getEntityWithRoles(assignment.entity || "")
|
||||||
if (!entity){
|
if (!entity) {
|
||||||
const users = await getUsers()
|
const users = await getUsers()
|
||||||
return {props: serialize({user, users, assignment})};
|
return { props: serialize({ user, users, assignment }) };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!doesEntityAllow(user, entity, 'view_assignments')) return redirect("/assignments")
|
if (!doesEntityAllow(user, entity, 'view_assignments')) return redirect("/assignments")
|
||||||
|
|
||||||
const users = await (checkAccess(user, ["developer", "admin"]) ? getUsers() : getEntityUsers(entity.id));
|
const users = await (checkAccess(user, ["developer", "admin"]) ? getUsers() : getEntityUsers(entity.id));
|
||||||
|
|
||||||
return {props: serialize({user, users, entity, assignment})};
|
return { props: serialize({ user, users, entity, assignment }) };
|
||||||
}, sessionOptions);
|
}, sessionOptions);
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -69,17 +68,14 @@ interface Props {
|
|||||||
entity?: EntityWithRoles
|
entity?: EntityWithRoles
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function AssignmentView({user, users, entity, assignment}: Props) {
|
export default function AssignmentView({ user, users, entity, assignment }: Props) {
|
||||||
const canDeleteAssignment = useEntityPermission(user, entity, 'delete_assignment')
|
const canDeleteAssignment = useEntityPermission(user, entity, 'delete_assignment')
|
||||||
const canStartAssignment = useEntityPermission(user, entity, 'start_assignment')
|
const canStartAssignment = useEntityPermission(user, entity, 'start_assignment')
|
||||||
|
|
||||||
const setExams = useExamStore((state) => state.setExams);
|
|
||||||
const setShowSolutions = useExamStore((state) => state.setShowSolutions);
|
|
||||||
const setUserSolutions = useExamStore((state) => state.setUserSolutions);
|
|
||||||
const setSelectedModules = useExamStore((state) => state.setSelectedModules);
|
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
|
const dispatch = useExamStore((state) => state.dispatch);
|
||||||
|
|
||||||
const deleteAssignment = async () => {
|
const deleteAssignment = async () => {
|
||||||
if (!canDeleteAssignment) return
|
if (!canDeleteAssignment) return
|
||||||
if (!confirm("Are you sure you want to delete this assignment?")) return;
|
if (!confirm("Are you sure you want to delete this assignment?")) return;
|
||||||
@@ -128,9 +124,9 @@ export default function AssignmentView({user, users, entity, assignment}: Props)
|
|||||||
return resultModuleBandScores.length === 0 ? -1 : resultModuleBandScores.reduce((acc, curr) => acc + curr, 0) / assignment.results.length;
|
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 aggregateScoresByModule = (stats: Stat[]): { module: Module; total: number; missing: number; correct: number }[] => {
|
||||||
const scores: {
|
const scores: {
|
||||||
[key in Module]: {total: number; missing: number; correct: number};
|
[key in Module]: { total: number; missing: number; correct: number };
|
||||||
} = {
|
} = {
|
||||||
reading: {
|
reading: {
|
||||||
total: 0,
|
total: 0,
|
||||||
@@ -169,7 +165,7 @@ export default function AssignmentView({user, users, entity, assignment}: Props)
|
|||||||
|
|
||||||
return Object.keys(scores)
|
return Object.keys(scores)
|
||||||
.filter((x) => scores[x as Module].total > 0)
|
.filter((x) => scores[x as Module].total > 0)
|
||||||
.map((x) => ({module: x as Module, ...scores[x as Module]}));
|
.map((x) => ({ module: x as Module, ...scores[x as Module] }));
|
||||||
};
|
};
|
||||||
|
|
||||||
const customContent = (stats: Stat[], user: string, focus: "academic" | "general") => {
|
const customContent = (stats: Stat[], user: string, focus: "academic" | "general") => {
|
||||||
@@ -189,15 +185,16 @@ export default function AssignmentView({user, users, entity, assignment}: Props)
|
|||||||
|
|
||||||
Promise.all(examPromises).then((exams) => {
|
Promise.all(examPromises).then((exams) => {
|
||||||
if (exams.every((x) => !!x)) {
|
if (exams.every((x) => !!x)) {
|
||||||
setUserSolutions(convertToUserSolutions(stats));
|
dispatch({
|
||||||
setShowSolutions(true);
|
type: 'INIT_SOLUTIONS', payload: {
|
||||||
setExams(exams.map((x) => x!).sort(sortByModule));
|
exams: exams.map((x) => x!).sort(sortByModule),
|
||||||
setSelectedModules(
|
modules: exams
|
||||||
exams
|
.map((x) => x!)
|
||||||
.map((x) => x!)
|
.sort(sortByModule)
|
||||||
.sort(sortByModule)
|
.map((x) => x!.module),
|
||||||
.map((x) => x!.module),
|
stats,
|
||||||
);
|
}
|
||||||
|
});
|
||||||
router.push("/exam");
|
router.push("/exam");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -228,7 +225,7 @@ export default function AssignmentView({user, users, entity, assignment}: Props)
|
|||||||
|
|
||||||
<div className="flex w-full flex-col gap-1">
|
<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">
|
<div className="-md:mt-2 grid w-full grid-cols-4 place-items-start gap-2">
|
||||||
{aggregatedLevels.map(({module, level}) => (
|
{aggregatedLevels.map(({ module, level }) => (
|
||||||
<div
|
<div
|
||||||
key={module}
|
key={module}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
@@ -306,7 +303,7 @@ export default function AssignmentView({user, users, entity, assignment}: Props)
|
|||||||
if (!confirm(`Are you sure you want to remove ${inactiveAssignees.length} assignees?`)) return
|
if (!confirm(`Are you sure you want to remove ${inactiveAssignees.length} assignees?`)) return
|
||||||
|
|
||||||
axios
|
axios
|
||||||
.patch(`/api/assignments/${assignment.id}`, {assignees: activeAssignees})
|
.patch(`/api/assignments/${assignment.id}`, { assignees: activeAssignees })
|
||||||
.then(() => {
|
.then(() => {
|
||||||
toast.success(`The assignment "${assignment.name}" has been updated successfully!`);
|
toast.success(`The assignment "${assignment.name}" has been updated successfully!`);
|
||||||
router.replace(router.asPath);
|
router.replace(router.asPath);
|
||||||
@@ -388,7 +385,7 @@ export default function AssignmentView({user, users, entity, assignment}: Props)
|
|||||||
<span className="text-xl font-bold">Average Scores</span>
|
<span className="text-xl font-bold">Average Scores</span>
|
||||||
<div className="-md:mt-2 flex w-full items-center gap-4">
|
<div className="-md:mt-2 flex w-full items-center gap-4">
|
||||||
{assignment &&
|
{assignment &&
|
||||||
uniqBy(assignment.exams, (x) => x.module).map(({module}) => (
|
uniqBy(assignment.exams, (x) => x.module).map(({ module }) => (
|
||||||
<div
|
<div
|
||||||
data-tip={capitalize(module)}
|
data-tip={capitalize(module)}
|
||||||
key={module}
|
key={module}
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import { InviteWithEntity } from "@/interfaces/invite";
|
|||||||
import {Assignment} from "@/interfaces/results";
|
import {Assignment} from "@/interfaces/results";
|
||||||
import {Stat, User} from "@/interfaces/user";
|
import {Stat, User} from "@/interfaces/user";
|
||||||
import {sessionOptions} from "@/lib/session";
|
import {sessionOptions} from "@/lib/session";
|
||||||
import useExamStore from "@/stores/examStore";
|
import useExamStore from "@/stores/exam";
|
||||||
import {findBy, mapBy, redirect, serialize} from "@/utils";
|
import {findBy, mapBy, redirect, serialize} from "@/utils";
|
||||||
import { requestUser } from "@/utils/api";
|
import { requestUser } from "@/utils/api";
|
||||||
import {activeAssignmentFilter} from "@/utils/assignments";
|
import {activeAssignmentFilter} from "@/utils/assignments";
|
||||||
@@ -81,11 +81,7 @@ export const getServerSideProps = withIronSessionSsr(async ({req, res}) => {
|
|||||||
export default function Dashboard({user, entities, assignments, stats, invites, grading, sessions, exams}: Props) {
|
export default function Dashboard({user, entities, assignments, stats, invites, grading, sessions, exams}: Props) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
const setExams = useExamStore((state) => state.setExams);
|
const dispatch = useExamStore((state) => state.dispatch);
|
||||||
const setShowSolutions = useExamStore((state) => state.setShowSolutions);
|
|
||||||
const setUserSolutions = useExamStore((state) => state.setUserSolutions);
|
|
||||||
const setSelectedModules = useExamStore((state) => state.setSelectedModules);
|
|
||||||
const setAssignment = useExamStore((state) => state.setAssignment);
|
|
||||||
|
|
||||||
const startAssignment = (assignment: Assignment) => {
|
const startAssignment = (assignment: Assignment) => {
|
||||||
const assignmentExams = exams.filter(e => {
|
const assignmentExams = exams.filter(e => {
|
||||||
@@ -94,11 +90,11 @@ export default function Dashboard({user, entities, assignments, stats, invites,
|
|||||||
})
|
})
|
||||||
|
|
||||||
if (assignmentExams.every((x) => !!x)) {
|
if (assignmentExams.every((x) => !!x)) {
|
||||||
setUserSolutions([]);
|
dispatch({type: "INIT_EXAM", payload: {
|
||||||
setShowSolutions(false);
|
exams: assignmentExams.sort(sortByModule),
|
||||||
setExams(assignmentExams.sort(sortByModule));
|
modules: mapBy(assignmentExams.sort(sortByModule), 'module'),
|
||||||
setSelectedModules(mapBy(assignmentExams.sort(sortByModule), 'module'));
|
assignment
|
||||||
setAssignment(assignment);
|
}})
|
||||||
|
|
||||||
router.push("/exam");
|
router.push("/exam");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
/* eslint-disable @next/next/no-img-element */
|
/* eslint-disable @next/next/no-img-element */
|
||||||
|
//import "@/utils/wdyr";
|
||||||
|
|
||||||
import { withIronSessionSsr } from "iron-session/next";
|
import { withIronSessionSsr } from "iron-session/next";
|
||||||
import { sessionOptions } from "@/lib/session";
|
import { sessionOptions } from "@/lib/session";
|
||||||
@@ -6,20 +7,19 @@ import { shouldRedirectHome } from "@/utils/navigation.disabled";
|
|||||||
import ExamPage from "./(exam)/ExamPage";
|
import ExamPage from "./(exam)/ExamPage";
|
||||||
import Head from "next/head";
|
import Head from "next/head";
|
||||||
import { User } from "@/interfaces/user";
|
import { User } from "@/interfaces/user";
|
||||||
import { filterBy, findBy, redirect, serialize } from "@/utils";
|
import { filterBy, redirect, serialize } from "@/utils";
|
||||||
import { requestUser } from "@/utils/api";
|
import { requestUser } from "@/utils/api";
|
||||||
import { getAssignment, getAssignments, getAssignmentsByAssignee } from "@/utils/assignments.be";
|
import { getAssignment } from "@/utils/assignments.be";
|
||||||
import { Assignment } from "@/interfaces/results";
|
import { Assignment } from "@/interfaces/results";
|
||||||
import useExamStore from "@/stores/examStore";
|
import useExamStore from "@/stores/exam";
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import { Exam } from "@/interfaces/exam";
|
import { Exam } from "@/interfaces/exam";
|
||||||
import { getExamsByIds } from "@/utils/exams.be";
|
import { getExamsByIds } from "@/utils/exams.be";
|
||||||
import { sortByModule } from "@/utils/moduleUtils";
|
import { sortByModule } from "@/utils/moduleUtils";
|
||||||
import { uniqBy } from "lodash";
|
import { uniqBy } from "lodash";
|
||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
import { getSessionByAssignment, getSessionsByUser } from "@/utils/sessions.be";
|
import { getSessionByAssignment } from "@/utils/sessions.be";
|
||||||
import { Session } from "@/hooks/useSessions";
|
import { Session } from "@/hooks/useSessions";
|
||||||
import moment from "moment";
|
|
||||||
import { activeAssignmentFilter } from "@/utils/assignments";
|
import { activeAssignmentFilter } from "@/utils/assignments";
|
||||||
|
|
||||||
export const getServerSideProps = withIronSessionSsr(async ({ req, res, query }) => {
|
export const getServerSideProps = withIronSessionSsr(async ({ req, res, query }) => {
|
||||||
@@ -63,25 +63,23 @@ interface Props {
|
|||||||
destinationURL?: string
|
destinationURL?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Page({ user, assignment, exams = [], destinationURL = "/exam", session }: Props) {
|
const Page: React.FC<Props> = ({ user, assignment, exams = [], destinationURL = "/exam", session }) => {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
const { assignment: storeAssignment, dispatch } = useExamStore();
|
||||||
const state = useExamStore((state) => state)
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (assignment && exams.length > 0 && !state.assignment && !session) {
|
if (assignment && exams.length > 0 && !storeAssignment && !session) {
|
||||||
if (!activeAssignmentFilter(assignment)) return
|
if (!activeAssignmentFilter(assignment)) return
|
||||||
|
dispatch({
|
||||||
state.setUserSolutions([]);
|
type: "INIT_EXAM", payload: {
|
||||||
state.setShowSolutions(false);
|
exams: exams.sort(sortByModule),
|
||||||
state.setAssignment(assignment);
|
modules: exams
|
||||||
state.setExams(exams.sort(sortByModule));
|
.map((x) => x!)
|
||||||
state.setSelectedModules(
|
.sort(sortByModule)
|
||||||
exams
|
.map((x) => x!.module),
|
||||||
.map((x) => x!)
|
assignment
|
||||||
.sort(sortByModule)
|
}
|
||||||
.map((x) => x!.module),
|
});
|
||||||
);
|
|
||||||
|
|
||||||
router.replace(router.asPath)
|
router.replace(router.asPath)
|
||||||
}
|
}
|
||||||
@@ -89,21 +87,8 @@ export default function Page({ user, assignment, exams = [], destinationURL = "/
|
|||||||
}, [assignment, exams, session])
|
}, [assignment, exams, session])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (assignment && exams.length > 0 && !state.assignment && !!session) {
|
if (assignment && exams.length > 0 && !storeAssignment && !!session) {
|
||||||
state.setShuffles(session.userSolutions.map((x) => ({ exerciseID: x.exercise, shuffles: x.shuffleMaps ? x.shuffleMaps : [] })));
|
dispatch({ type: "SET_SESSION", payload: { session } })
|
||||||
state.setSelectedModules(session.selectedModules);
|
|
||||||
state.setExam(session.exam);
|
|
||||||
state.setExams(session.exams);
|
|
||||||
state.setSessionId(session.sessionId);
|
|
||||||
state.setAssignment(session.assignment);
|
|
||||||
state.setExerciseIndex(session.exerciseIndex);
|
|
||||||
state.setPartIndex(session.partIndex);
|
|
||||||
state.setModuleIndex(session.moduleIndex);
|
|
||||||
state.setTimeSpent(session.timeSpent);
|
|
||||||
state.setUserSolutions(session.userSolutions);
|
|
||||||
state.setShowSolutions(false);
|
|
||||||
state.setQuestionIndex(session.questionIndex);
|
|
||||||
|
|
||||||
router.replace(router.asPath)
|
router.replace(router.asPath)
|
||||||
}
|
}
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
@@ -120,7 +105,10 @@ export default function Page({ user, assignment, exams = [], destinationURL = "/
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<link rel="icon" href="/favicon.ico" />
|
<link rel="icon" href="/favicon.ico" />
|
||||||
</Head>
|
</Head>
|
||||||
<ExamPage page="exams" destination={destinationURL} user={user} hideSidebar={!!assignment || !!state.assignment} />
|
<ExamPage page="exams" destination={destinationURL} user={user} hideSidebar={!!assignment || !!storeAssignment} />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
//Page.whyDidYouRender = true;
|
||||||
|
export default Page;
|
||||||
@@ -1,34 +1,34 @@
|
|||||||
/* eslint-disable @next/next/no-img-element */
|
/* eslint-disable @next/next/no-img-element */
|
||||||
|
|
||||||
import {withIronSessionSsr} from "iron-session/next";
|
import { withIronSessionSsr } from "iron-session/next";
|
||||||
import {sessionOptions} from "@/lib/session";
|
import { sessionOptions } from "@/lib/session";
|
||||||
import {shouldRedirectHome} from "@/utils/navigation.disabled";
|
import { shouldRedirectHome } from "@/utils/navigation.disabled";
|
||||||
import ExamPage from "./(exam)/ExamPage";
|
import ExamPage from "./(exam)/ExamPage";
|
||||||
import Head from "next/head";
|
import Head from "next/head";
|
||||||
import {User} from "@/interfaces/user";
|
import { User } from "@/interfaces/user";
|
||||||
import { filterBy, findBy, redirect, serialize } from "@/utils";
|
import { filterBy, redirect, serialize } from "@/utils";
|
||||||
import { requestUser } from "@/utils/api";
|
import { requestUser } from "@/utils/api";
|
||||||
import { getAssignment, getAssignments, getAssignmentsByAssignee } from "@/utils/assignments.be";
|
import { getAssignment } from "@/utils/assignments.be";
|
||||||
import { Assignment } from "@/interfaces/results";
|
import { Assignment } from "@/interfaces/results";
|
||||||
import useExamStore from "@/stores/examStore";
|
import useExamStore from "@/stores/exam";
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import { Exam } from "@/interfaces/exam";
|
import { Exam } from "@/interfaces/exam";
|
||||||
import { getExamsByIds } from "@/utils/exams.be";
|
import { getExamsByIds } from "@/utils/exams.be";
|
||||||
import { sortByModule } from "@/utils/moduleUtils";
|
import { sortByModule } from "@/utils/moduleUtils";
|
||||||
import { uniqBy } from "lodash";
|
import { uniqBy } from "lodash";
|
||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
import { getSessionByAssignment, getSessionsByUser } from "@/utils/sessions.be";
|
import { getSessionByAssignment } from "@/utils/sessions.be";
|
||||||
import { Session } from "@/hooks/useSessions";
|
import { Session } from "@/hooks/useSessions";
|
||||||
import moment from "moment";
|
import moment from "moment";
|
||||||
|
|
||||||
export const getServerSideProps = withIronSessionSsr(async ({req, res, query}) => {
|
export const getServerSideProps = withIronSessionSsr(async ({ req, res, query }) => {
|
||||||
const user = await requestUser(req, res)
|
const user = await requestUser(req, res)
|
||||||
const destination = Buffer.from(req.url || "/").toString("base64")
|
const destination = Buffer.from(req.url || "/").toString("base64")
|
||||||
if (!user) return redirect(`/login?destination=${destination}`)
|
if (!user) return redirect(`/login?destination=${destination}`)
|
||||||
|
|
||||||
if (shouldRedirectHome(user)) return redirect("/")
|
if (shouldRedirectHome(user)) return redirect("/")
|
||||||
|
|
||||||
const {assignment: assignmentID} = query as {assignment?: string}
|
const { assignment: assignmentID } = query as { assignment?: string }
|
||||||
|
|
||||||
if (assignmentID) {
|
if (assignmentID) {
|
||||||
const assignment = await getAssignment(assignmentID)
|
const assignment = await getAssignment(assignmentID)
|
||||||
@@ -47,12 +47,12 @@ export const getServerSideProps = withIronSessionSsr(async ({req, res, query}) =
|
|||||||
return redirect("/exam")
|
return redirect("/exam")
|
||||||
|
|
||||||
return {
|
return {
|
||||||
props: serialize({user, assignment, exams, session})
|
props: serialize({ user, assignment, exams, session })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
props: serialize({user}),
|
props: serialize({ user }),
|
||||||
};
|
};
|
||||||
}, sessionOptions);
|
}, sessionOptions);
|
||||||
|
|
||||||
@@ -63,48 +63,36 @@ interface Props {
|
|||||||
session?: Session
|
session?: Session
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Page({user, assignment, exams = [], session}: Props) {
|
export default function Page({ user, assignment, exams = [], session }: Props) {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
|
||||||
const state = useExamStore((state) => state)
|
const { assignment: storeAssignment, dispatch } = useExamStore()
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (assignment && exams.length > 0 && !state.assignment && !session) {
|
if (assignment && exams.length > 0 && !storeAssignment && !session) {
|
||||||
state.setUserSolutions([]);
|
dispatch({
|
||||||
state.setShowSolutions(false);
|
type: "INIT_EXAM", payload: {
|
||||||
state.setAssignment(assignment);
|
exams: exams.sort(sortByModule),
|
||||||
state.setExams(exams.sort(sortByModule));
|
modules: exams
|
||||||
state.setSelectedModules(
|
.map((x) => x!)
|
||||||
exams
|
.sort(sortByModule)
|
||||||
.map((x) => x!)
|
.map((x) => x!.module),
|
||||||
.sort(sortByModule)
|
assignment
|
||||||
.map((x) => x!.module),
|
}
|
||||||
);
|
})
|
||||||
|
|
||||||
router.replace(router.asPath)
|
router.replace(router.asPath)
|
||||||
}
|
}
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [assignment, exams, session])
|
}, [assignment, exams, session])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (assignment && exams.length > 0 && !state.assignment && !!session) {
|
if (assignment && exams.length > 0 && !storeAssignment && !!session) {
|
||||||
state.setShuffles(session.userSolutions.map((x) => ({exerciseID: x.exercise, shuffles: x.shuffleMaps ? x.shuffleMaps : []})));
|
dispatch({ type: "SET_SESSION", payload: { session } });
|
||||||
state.setSelectedModules(session.selectedModules);
|
|
||||||
state.setExam(session.exam);
|
|
||||||
state.setExams(session.exams);
|
|
||||||
state.setSessionId(session.sessionId);
|
|
||||||
state.setAssignment(session.assignment);
|
|
||||||
state.setExerciseIndex(session.exerciseIndex);
|
|
||||||
state.setPartIndex(session.partIndex);
|
|
||||||
state.setModuleIndex(session.moduleIndex);
|
|
||||||
state.setTimeSpent(session.timeSpent);
|
|
||||||
state.setUserSolutions(session.userSolutions);
|
|
||||||
state.setShowSolutions(false);
|
|
||||||
state.setQuestionIndex(session.questionIndex);
|
|
||||||
|
|
||||||
router.replace(router.asPath)
|
router.replace(router.asPath)
|
||||||
}
|
}
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [assignment, exams, session])
|
}, [assignment, exams, session])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import { InviteWithEntity } from "@/interfaces/invite";
|
|||||||
import { Assignment } from "@/interfaces/results";
|
import { Assignment } from "@/interfaces/results";
|
||||||
import { Stat, User } from "@/interfaces/user";
|
import { Stat, User } from "@/interfaces/user";
|
||||||
import { sessionOptions } from "@/lib/session";
|
import { sessionOptions } from "@/lib/session";
|
||||||
import useExamStore from "@/stores/examStore";
|
import useExamStore from "@/stores/exam";
|
||||||
import { filterBy, findBy, mapBy, redirect, serialize } from "@/utils";
|
import { filterBy, findBy, mapBy, redirect, serialize } from "@/utils";
|
||||||
import { requestUser } from "@/utils/api";
|
import { requestUser } from "@/utils/api";
|
||||||
import { activeAssignmentFilter, futureAssignmentFilter } from "@/utils/assignments";
|
import { activeAssignmentFilter, futureAssignmentFilter } from "@/utils/assignments";
|
||||||
@@ -75,7 +75,9 @@ export default function OfficialExam({ user, entities, assignments, sessions, ex
|
|||||||
const [isLoading, setIsLoading] = useState(false)
|
const [isLoading, setIsLoading] = useState(false)
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const state = useExamStore((state) => state);
|
|
||||||
|
const dispatch = useExamStore((state) => state.dispatch);
|
||||||
|
|
||||||
const reload = () => {
|
const reload = () => {
|
||||||
setIsLoading(true)
|
setIsLoading(true)
|
||||||
router.replace(router.asPath)
|
router.replace(router.asPath)
|
||||||
@@ -89,31 +91,19 @@ export default function OfficialExam({ user, entities, assignments, sessions, ex
|
|||||||
})
|
})
|
||||||
|
|
||||||
if (assignmentExams.every((x) => !!x)) {
|
if (assignmentExams.every((x) => !!x)) {
|
||||||
state.setUserSolutions([]);
|
dispatch({
|
||||||
state.setShowSolutions(false);
|
type: "INIT_EXAM", payload: {
|
||||||
state.setExams(assignmentExams.sort(sortByModule));
|
exams: assignmentExams.sort(sortByModule),
|
||||||
state.setSelectedModules(mapBy(assignmentExams.sort(sortByModule), 'module'));
|
modules: mapBy(assignmentExams.sort(sortByModule), 'module'),
|
||||||
state.setAssignment(assignment);
|
assignment
|
||||||
|
}
|
||||||
|
})
|
||||||
router.push(`/exam?assignment=${assignment.id}&destination=${destination}`);
|
router.push(`/exam?assignment=${assignment.id}&destination=${destination}`);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const loadSession = async (session: Session) => {
|
const loadSession = async (session: Session) => {
|
||||||
state.setShuffles(session.userSolutions.map((x) => ({ exerciseID: x.exercise, shuffles: x.shuffleMaps ? x.shuffleMaps : [] })));
|
dispatch({type: "SET_SESSION", payload: {session}});
|
||||||
state.setSelectedModules(session.selectedModules);
|
|
||||||
state.setExam(session.exam);
|
|
||||||
state.setExams(session.exams);
|
|
||||||
state.setSessionId(session.sessionId);
|
|
||||||
state.setAssignment(session.assignment);
|
|
||||||
state.setExerciseIndex(session.exerciseIndex);
|
|
||||||
state.setPartIndex(session.partIndex);
|
|
||||||
state.setModuleIndex(session.moduleIndex);
|
|
||||||
state.setTimeSpent(session.timeSpent);
|
|
||||||
state.setUserSolutions(session.userSolutions);
|
|
||||||
state.setShowSolutions(false);
|
|
||||||
state.setQuestionIndex(session.questionIndex);
|
|
||||||
|
|
||||||
router.push(`/exam?assignment=${session.assignment?.id}&destination=${destination}`);
|
router.push(`/exam?assignment=${session.assignment?.id}&destination=${destination}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import { useEffect, useMemo, useState } from "react";
|
|||||||
import useFilterRecordsByUser from "@/hooks/useFilterRecordsByUser";
|
import useFilterRecordsByUser from "@/hooks/useFilterRecordsByUser";
|
||||||
import { groupByDate } from "@/utils/stats";
|
import { groupByDate } from "@/utils/stats";
|
||||||
import moment from "moment";
|
import moment from "moment";
|
||||||
import useExamStore from "@/stores/examStore";
|
|
||||||
import { ToastContainer } from "react-toastify";
|
import { ToastContainer } from "react-toastify";
|
||||||
import Layout from "@/components/High/Layout";
|
import Layout from "@/components/High/Layout";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
@@ -75,12 +74,6 @@ export default function History({ user, users, assignments, entities }: Props) {
|
|||||||
const { data: stats, isLoading: isStatsLoading } = useFilterRecordsByUser<Stat[]>(statsUserId || user?.id);
|
const { data: stats, isLoading: isStatsLoading } = useFilterRecordsByUser<Stat[]>(statsUserId || user?.id);
|
||||||
const { gradingSystem } = useGradingSystem();
|
const { gradingSystem } = useGradingSystem();
|
||||||
|
|
||||||
const setExams = useExamStore((state) => state.setExams);
|
|
||||||
const setShowSolutions = useExamStore((state) => state.setShowSolutions);
|
|
||||||
const setUserSolutions = useExamStore((state) => state.setUserSolutions);
|
|
||||||
const setSelectedModules = useExamStore((state) => state.setSelectedModules);
|
|
||||||
const setInactivity = useExamStore((state) => state.setInactivity);
|
|
||||||
const setTimeSpent = useExamStore((state) => state.setTimeSpent);
|
|
||||||
const renderPdfIcon = usePDFDownload("stats");
|
const renderPdfIcon = usePDFDownload("stats");
|
||||||
|
|
||||||
const [selectedTrainingExams, setSelectedTrainingExams] = useState<string[]>([]);
|
const [selectedTrainingExams, setSelectedTrainingExams] = useState<string[]>([]);
|
||||||
@@ -174,13 +167,7 @@ export default function History({ user, users, assignments, entities }: Props) {
|
|||||||
selectedTrainingExams={selectedTrainingExams}
|
selectedTrainingExams={selectedTrainingExams}
|
||||||
setSelectedTrainingExams={setSelectedTrainingExams}
|
setSelectedTrainingExams={setSelectedTrainingExams}
|
||||||
maxTrainingExams={MAX_TRAINING_EXAMS}
|
maxTrainingExams={MAX_TRAINING_EXAMS}
|
||||||
setExams={setExams}
|
|
||||||
gradingSystem={gradingSystem?.steps}
|
gradingSystem={gradingSystem?.steps}
|
||||||
setShowSolutions={setShowSolutions}
|
|
||||||
setUserSolutions={setUserSolutions}
|
|
||||||
setSelectedModules={setSelectedModules}
|
|
||||||
setInactivity={setInactivity}
|
|
||||||
setTimeSpent={setTimeSpent}
|
|
||||||
renderPdfIcon={renderPdfIcon}
|
renderPdfIcon={renderPdfIcon}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ import clsx from "clsx";
|
|||||||
import Lists from "./(admin)/Lists";
|
import Lists from "./(admin)/Lists";
|
||||||
import BatchCodeGenerator from "./(admin)/BatchCodeGenerator";
|
import BatchCodeGenerator from "./(admin)/BatchCodeGenerator";
|
||||||
import { shouldRedirectHome } from "@/utils/navigation.disabled";
|
import { shouldRedirectHome } from "@/utils/navigation.disabled";
|
||||||
import ExamGenerator from "./(admin)/ExamGenerator";
|
|
||||||
import BatchCreateUser from "./(admin)/Lists/BatchCreateUser";
|
import BatchCreateUser from "./(admin)/Lists/BatchCreateUser";
|
||||||
import { checkAccess, getTypesOfUser } from "@/utils/permissions";
|
import { checkAccess, getTypesOfUser } from "@/utils/permissions";
|
||||||
import usePermissions from "@/hooks/usePermissions";
|
import usePermissions from "@/hooks/usePermissions";
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ import {shouldRedirectHome} from "@/utils/navigation.disabled";
|
|||||||
import {sessionOptions} from "@/lib/session";
|
import {sessionOptions} from "@/lib/session";
|
||||||
import qs from "qs";
|
import qs from "qs";
|
||||||
import StatsGridItem from "@/components/Medium/StatGridItem";
|
import StatsGridItem from "@/components/Medium/StatGridItem";
|
||||||
import useExamStore from "@/stores/examStore";
|
import useExamStore from "@/stores/exam";
|
||||||
import {usePDFDownload} from "@/hooks/usePDFDownload";
|
import {usePDFDownload} from "@/hooks/usePDFDownload";
|
||||||
import useAssignments from "@/hooks/useAssignments";
|
import useAssignments from "@/hooks/useAssignments";
|
||||||
import useUsers from "@/hooks/useUsers";
|
import useUsers from "@/hooks/useUsers";
|
||||||
@@ -29,7 +29,6 @@ import InfiniteCarousel from "@/components/InfiniteCarousel";
|
|||||||
import {LuExternalLink} from "react-icons/lu";
|
import {LuExternalLink} from "react-icons/lu";
|
||||||
import {uniqBy} from "lodash";
|
import {uniqBy} from "lodash";
|
||||||
import {getExamById} from "@/utils/exams";
|
import {getExamById} from "@/utils/exams";
|
||||||
import {convertToUserSolutions} from "@/utils/stats";
|
|
||||||
import {sortByModule} from "@/utils/moduleUtils";
|
import {sortByModule} from "@/utils/moduleUtils";
|
||||||
import { requestUser } from "@/utils/api";
|
import { requestUser } from "@/utils/api";
|
||||||
import { redirect, serialize } from "@/utils";
|
import { redirect, serialize } from "@/utils";
|
||||||
@@ -46,15 +45,10 @@ export const getServerSideProps = withIronSessionSsr(async ({req, res}) => {
|
|||||||
}, sessionOptions);
|
}, sessionOptions);
|
||||||
|
|
||||||
const TrainingContent: React.FC<{user: User}> = ({user}) => {
|
const TrainingContent: React.FC<{user: User}> = ({user}) => {
|
||||||
// Record stuff
|
|
||||||
const setExams = useExamStore((state) => state.setExams);
|
|
||||||
const setShowSolutions = useExamStore((state) => state.setShowSolutions);
|
|
||||||
const setUserSolutions = useExamStore((state) => state.setUserSolutions);
|
|
||||||
const setSelectedModules = useExamStore((state) => state.setSelectedModules);
|
|
||||||
const setInactivity = useExamStore((state) => state.setInactivity);
|
|
||||||
const setTimeSpent = useExamStore((state) => state.setTimeSpent);
|
|
||||||
const renderPdfIcon = usePDFDownload("stats");
|
const renderPdfIcon = usePDFDownload("stats");
|
||||||
|
|
||||||
|
const dispatch = useExamStore((s) => s.dispatch);
|
||||||
|
|
||||||
const [trainingContent, setTrainingContent] = useState<ITrainingContent | null>(null);
|
const [trainingContent, setTrainingContent] = useState<ITrainingContent | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [trainingTips, setTrainingTips] = useState<ITrainingTip[]>([]);
|
const [trainingTips, setTrainingTips] = useState<ITrainingTip[]>([]);
|
||||||
@@ -125,17 +119,18 @@ const TrainingContent: React.FC<{user: User}> = ({user}) => {
|
|||||||
|
|
||||||
Promise.all(examPromises).then((exams) => {
|
Promise.all(examPromises).then((exams) => {
|
||||||
if (exams.every((x) => !!x)) {
|
if (exams.every((x) => !!x)) {
|
||||||
if (!!timeSpent) setTimeSpent(timeSpent);
|
dispatch({
|
||||||
if (!!inactivity) setInactivity(inactivity);
|
type: 'INIT_SOLUTIONS', payload: {
|
||||||
setUserSolutions(convertToUserSolutions(stats));
|
exams: exams.map((x) => x!).sort(sortByModule),
|
||||||
setShowSolutions(true);
|
modules: exams
|
||||||
setExams(exams.map((x) => x!).sort(sortByModule));
|
.map((x) => x!)
|
||||||
setSelectedModules(
|
.sort(sortByModule)
|
||||||
exams
|
.map((x) => x!.module),
|
||||||
.map((x) => x!)
|
stats,
|
||||||
.sort(sortByModule)
|
timeSpent,
|
||||||
.map((x) => x!.module),
|
inactivity
|
||||||
);
|
}
|
||||||
|
});
|
||||||
router.push("/exam");
|
router.push("/exam");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -185,12 +180,6 @@ const TrainingContent: React.FC<{user: User}> = ({user}) => {
|
|||||||
user={user}
|
user={user}
|
||||||
assignments={assignments}
|
assignments={assignments}
|
||||||
users={users}
|
users={users}
|
||||||
setExams={setExams}
|
|
||||||
setShowSolutions={setShowSolutions}
|
|
||||||
setUserSolutions={setUserSolutions}
|
|
||||||
setSelectedModules={setSelectedModules}
|
|
||||||
setInactivity={setInactivity}
|
|
||||||
setTimeSpent={setTimeSpent}
|
|
||||||
renderPdfIcon={renderPdfIcon}
|
renderPdfIcon={renderPdfIcon}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|||||||
180
src/stores/exam/index.ts
Normal file
180
src/stores/exam/index.ts
Normal file
@@ -0,0 +1,180 @@
|
|||||||
|
import { create } from "zustand";
|
||||||
|
import { createJSONStorage, persist } from "zustand/middleware";
|
||||||
|
import { immer } from "zustand/middleware/immer";
|
||||||
|
import { ExamFunctions, ExamState, Navigation, StateFlags } from "./types";
|
||||||
|
import { rootReducer } from "./reducers";
|
||||||
|
import axios from "axios";
|
||||||
|
import { v4 } from "uuid";
|
||||||
|
import { Stat } from "@/interfaces/user";
|
||||||
|
import { Exam, Shuffles, UserSolution } from "@/interfaces/exam";
|
||||||
|
import { Module } from "@/interfaces";
|
||||||
|
|
||||||
|
export const initialState: ExamState = {
|
||||||
|
exams: [],
|
||||||
|
userSolutions: [],
|
||||||
|
showSolutions: false,
|
||||||
|
selectedModules: [],
|
||||||
|
assignment: undefined,
|
||||||
|
timeSpent: 0,
|
||||||
|
timeSpentCurrentModule: 0,
|
||||||
|
sessionId: "",
|
||||||
|
exam: undefined,
|
||||||
|
moduleIndex: 0,
|
||||||
|
partIndex: 0,
|
||||||
|
exerciseIndex: 0,
|
||||||
|
questionIndex: 0,
|
||||||
|
inactivity: 0,
|
||||||
|
shuffles: [],
|
||||||
|
bgColor: "bg-white",
|
||||||
|
currentSolution: undefined,
|
||||||
|
user: undefined,
|
||||||
|
navigation: {
|
||||||
|
previousDisabled: false,
|
||||||
|
nextDisabled: false,
|
||||||
|
previousLoading: false,
|
||||||
|
nextLoading: false,
|
||||||
|
},
|
||||||
|
flags: {
|
||||||
|
timeIsUp: false,
|
||||||
|
reviewAll: false,
|
||||||
|
finalizeModule: false,
|
||||||
|
finalizeExam: false,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const useExamStore = create<ExamState & ExamFunctions>((set, get) => ({
|
||||||
|
...initialState,
|
||||||
|
setUser: (user: string) => set(() => ({ user })),
|
||||||
|
setShowSolutions: (showSolutions: boolean) => set(() => ({ showSolutions })),
|
||||||
|
setExams: (exams: Exam[]) => set(() => ({ exams })),
|
||||||
|
setExam: (exam?: Exam) => set(() => ({ exam })),
|
||||||
|
setModuleIndex: (moduleIndex: number) => set(() => ({ moduleIndex })),
|
||||||
|
setSelectedModules: (modules: Module[]) => set(() => ({ selectedModules: modules })),
|
||||||
|
|
||||||
|
setSessionId: (sessionId: string) => set(() => ({ sessionId })),
|
||||||
|
setUserSolutions: (userSolutions: UserSolution[]) => set(() => ({ userSolutions })),
|
||||||
|
|
||||||
|
setShuffles: (shuffles: Shuffles[]) => set(() => ({ shuffles })),
|
||||||
|
|
||||||
|
setPartIndex: (partIndex: number) => set(() => ({ partIndex })),
|
||||||
|
setExerciseIndex: (exerciseIndex: number) => set(() => ({ exerciseIndex })),
|
||||||
|
setQuestionIndex: (questionIndex: number) => set(() => ({ questionIndex })),
|
||||||
|
setBgColor: (bgColor: string) => set(() => ({ bgColor })),
|
||||||
|
|
||||||
|
setNavigation: (updates: Partial<Navigation>) => set((state) => ({
|
||||||
|
navigation: {
|
||||||
|
...state.navigation,
|
||||||
|
...updates
|
||||||
|
}
|
||||||
|
})),
|
||||||
|
|
||||||
|
setFlags: (updates: Partial<StateFlags>) => set((state) => ({
|
||||||
|
flags: {
|
||||||
|
...state.flags,
|
||||||
|
...updates
|
||||||
|
}
|
||||||
|
})),
|
||||||
|
|
||||||
|
|
||||||
|
setTimeIsUp: (timeIsUp: boolean) => set((state) => ({ flags: { ...state.flags, timeIsUp } })),
|
||||||
|
|
||||||
|
reset: () => set(() => initialState),
|
||||||
|
|
||||||
|
saveSession: async () => {
|
||||||
|
console.log("Saving your session...");
|
||||||
|
const state = get();
|
||||||
|
await axios.post("/api/sessions", {
|
||||||
|
id: state.sessionId,
|
||||||
|
sessionId: state.sessionId,
|
||||||
|
date: new Date().toISOString(),
|
||||||
|
userSolutions: state.userSolutions.filter((s) => s.type !== "speaking" && s.type !== "interactiveSpeaking"),
|
||||||
|
moduleIndex: state.moduleIndex,
|
||||||
|
selectedModules: state.selectedModules,
|
||||||
|
assignment: state.assignment,
|
||||||
|
timeSpent: state.timeSpent,
|
||||||
|
timeSpentCurrentModule: state.timeSpentCurrentModule,
|
||||||
|
inactivity: state.inactivity,
|
||||||
|
exams: state.exams,
|
||||||
|
exam: state.exam,
|
||||||
|
partIndex: state.partIndex,
|
||||||
|
exerciseIndex: state.exerciseIndex,
|
||||||
|
questionIndex: state.questionIndex,
|
||||||
|
user: state.user,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
saveStats: async () => {
|
||||||
|
const state = get();
|
||||||
|
const newStats: Stat[] = state.userSolutions.map((solution) => ({
|
||||||
|
...solution,
|
||||||
|
id: solution.id || v4(),
|
||||||
|
timeSpent: state.timeSpent,
|
||||||
|
inactivity: state.inactivity,
|
||||||
|
session: state.sessionId,
|
||||||
|
exam: solution.exam!,
|
||||||
|
module: solution.module!,
|
||||||
|
user: state.user || "",
|
||||||
|
date: new Date().getTime(),
|
||||||
|
isDisabled: solution.isDisabled,
|
||||||
|
shuffleMaps: solution.shuffleMaps,
|
||||||
|
...(state.assignment ? { assignment: state.assignment.id } : {}),
|
||||||
|
isPractice: solution.isPractice
|
||||||
|
}));
|
||||||
|
await axios.post<{ ok: boolean }>("/api/stats", newStats);
|
||||||
|
},
|
||||||
|
dispatch: (action) => set((state) => rootReducer(state, action))
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const usePersistentExamStore = create<ExamState & ExamFunctions>()(
|
||||||
|
persist(
|
||||||
|
immer((set) => ({
|
||||||
|
...initialState,
|
||||||
|
setUser: (user: string) => set(() => ({ user })),
|
||||||
|
setShowSolutions: (showSolutions: boolean) => set(() => ({ showSolutions })),
|
||||||
|
setExams: (exams: Exam[]) => set(() => ({ exams })),
|
||||||
|
setExam: (exam?: Exam) => set(() => ({ exam })),
|
||||||
|
setModuleIndex: (moduleIndex: number) => set(() => ({ moduleIndex })),
|
||||||
|
setSelectedModules: (modules: Module[]) => set(() => ({ selectedModules: modules })),
|
||||||
|
|
||||||
|
setSessionId: (sessionId: string) => set(() => ({ sessionId })),
|
||||||
|
setUserSolutions: (userSolutions: UserSolution[]) => set(() => ({ userSolutions })),
|
||||||
|
|
||||||
|
setShuffles: (shuffles: Shuffles[]) => set(() => ({ shuffles })),
|
||||||
|
|
||||||
|
setPartIndex: (partIndex: number) => set(() => ({ partIndex })),
|
||||||
|
setExerciseIndex: (exerciseIndex: number) => set(() => ({ exerciseIndex })),
|
||||||
|
setQuestionIndex: (questionIndex: number) => set(() => ({ questionIndex })),
|
||||||
|
setBgColor: (bgColor: string) => set(() => ({ bgColor })),
|
||||||
|
|
||||||
|
setNavigation: (updates: Partial<Navigation>) => set((state) => ({
|
||||||
|
navigation: {
|
||||||
|
...state.navigation,
|
||||||
|
...updates
|
||||||
|
}
|
||||||
|
})),
|
||||||
|
|
||||||
|
setFlags: (updates: Partial<StateFlags>) => set((state) => ({
|
||||||
|
flags: {
|
||||||
|
...state.flags,
|
||||||
|
...updates
|
||||||
|
}
|
||||||
|
})),
|
||||||
|
|
||||||
|
|
||||||
|
setTimeIsUp: (timeIsUp: boolean) => set((state) => ({ flags: { ...state.flags, timeIsUp } })),
|
||||||
|
|
||||||
|
saveStats: async () => {},
|
||||||
|
saveSession: async () => {},
|
||||||
|
|
||||||
|
reset: () => set(() => initialState),
|
||||||
|
dispatch: (action) => set((state) => rootReducer(state, action))
|
||||||
|
|
||||||
|
})),
|
||||||
|
{
|
||||||
|
name: 'persistent-exam-store',
|
||||||
|
storage: createJSONStorage(() => localStorage),
|
||||||
|
partialize: (state) => ({ ...state }),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
export default useExamStore;
|
||||||
161
src/stores/exam/reducers/index.ts
Normal file
161
src/stores/exam/reducers/index.ts
Normal file
@@ -0,0 +1,161 @@
|
|||||||
|
import { Module } from "@/interfaces";
|
||||||
|
import { ExamState } from "../types";
|
||||||
|
import { SESSION_ACTIONS, SessionActions, sessionReducer } from "./session";
|
||||||
|
import { Exam, UserSolution } from "@/interfaces/exam";
|
||||||
|
import { updateExamWithUserSolutions } from "../utils";
|
||||||
|
import { defaultExamUserSolutions } from "@/utils/exams";
|
||||||
|
import { Assignment } from "@/interfaces/results";
|
||||||
|
import { Stat } from "@/interfaces/user";
|
||||||
|
import { convertToUserSolutions } from "@/utils/stats";
|
||||||
|
|
||||||
|
export type RootActions =
|
||||||
|
{ type: 'INIT_EXAM'; payload: { exams: Exam[], modules: Module[], assignment?: Assignment } } |
|
||||||
|
{ type: 'INIT_SOLUTIONS'; payload: { exams: Exam[], modules: Module[], stats: Stat[], timeSpent?: number, inactivity?: number } } |
|
||||||
|
{ type: 'UPDATE_TIMERS'; payload: { timeSpent: number; inactivity: number; timeSpentCurrentModule: number;} } |
|
||||||
|
{ type: 'FINALIZE_MODULE'; payload: { updateTimers: boolean } } |
|
||||||
|
{ type: 'FINALIZE_MODULE_SOLUTIONS' }
|
||||||
|
|
||||||
|
|
||||||
|
export type Action = RootActions | SessionActions;
|
||||||
|
|
||||||
|
export const rootReducer = (
|
||||||
|
state: ExamState,
|
||||||
|
action: Action
|
||||||
|
): Partial<ExamState> => {
|
||||||
|
|
||||||
|
if (SESSION_ACTIONS.includes(action.type as any)) {
|
||||||
|
return sessionReducer(action as SessionActions);
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (action.type) {
|
||||||
|
case 'INIT_EXAM': {
|
||||||
|
const { exams, modules, assignment } = action.payload;
|
||||||
|
|
||||||
|
let examAndSolutions = {}
|
||||||
|
|
||||||
|
// A new exam is about to start,
|
||||||
|
// fill the first module with defaultUserSolutions
|
||||||
|
let defaultSolutions = exams.map(defaultExamUserSolutions).flat();
|
||||||
|
examAndSolutions = {
|
||||||
|
userSolutions: defaultSolutions,
|
||||||
|
exam: updateExamWithUserSolutions(exams[0], defaultSolutions)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (assignment) {
|
||||||
|
examAndSolutions = { ...examAndSolutions, assignment }
|
||||||
|
}
|
||||||
|
|
||||||
|
// now all the modules start at 0 since navigation
|
||||||
|
// is now handled at the module page's and no re-renders
|
||||||
|
// reset the initial render caused by the timers
|
||||||
|
// no need to do all that weird chainning with -1
|
||||||
|
// on some modules and triggering next() to update final solution
|
||||||
|
// with hasExamEnded flag
|
||||||
|
return {
|
||||||
|
moduleIndex: 0,
|
||||||
|
partIndex: 0,
|
||||||
|
exerciseIndex: 0,
|
||||||
|
questionIndex: 0,
|
||||||
|
exams: exams,
|
||||||
|
selectedModules: modules,
|
||||||
|
showSolutions: false,
|
||||||
|
...examAndSolutions
|
||||||
|
}
|
||||||
|
};
|
||||||
|
case 'INIT_SOLUTIONS': {
|
||||||
|
const { exams, modules, stats, timeSpent, inactivity } = action.payload;
|
||||||
|
|
||||||
|
let time = {}
|
||||||
|
if (timeSpent) time = { timeSpent }
|
||||||
|
if (inactivity) time = { ...time, inactivity }
|
||||||
|
|
||||||
|
return {
|
||||||
|
moduleIndex: -1,
|
||||||
|
partIndex: 0,
|
||||||
|
exerciseIndex: 0,
|
||||||
|
questionIndex: 0,
|
||||||
|
exams: exams,
|
||||||
|
selectedModules: modules,
|
||||||
|
showSolutions: true,
|
||||||
|
userSolutions: convertToUserSolutions(stats),
|
||||||
|
...time
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case 'UPDATE_TIMERS': {
|
||||||
|
// Just assigning the timers at once instead of two different calls
|
||||||
|
const { timeSpent, inactivity, timeSpentCurrentModule } = action.payload;
|
||||||
|
return {
|
||||||
|
timeSpentCurrentModule,
|
||||||
|
timeSpent,
|
||||||
|
inactivity
|
||||||
|
}
|
||||||
|
};
|
||||||
|
case 'FINALIZE_MODULE': {
|
||||||
|
const { updateTimers } = action.payload;
|
||||||
|
|
||||||
|
// To finalize a module first flag the timers to be updated
|
||||||
|
if (updateTimers) {
|
||||||
|
return {
|
||||||
|
flags: { ...state.flags, finalizeModule: true }
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// then check whether there are more modules in the exam, if there are
|
||||||
|
// setup the next module
|
||||||
|
if (state.moduleIndex + 1 < state.selectedModules.length) {
|
||||||
|
return {
|
||||||
|
moduleIndex: state.moduleIndex + 1,
|
||||||
|
partIndex: 0,
|
||||||
|
exerciseIndex: 0,
|
||||||
|
questionIndex: 0,
|
||||||
|
exam: updateExamWithUserSolutions(state.exams[state.moduleIndex + 1], state.userSolutions),
|
||||||
|
flags: {
|
||||||
|
...state.flags,
|
||||||
|
finalizeModule: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// if there are no modules left, flag finalizeExam
|
||||||
|
// so that the stats are uploaded in ExamPage
|
||||||
|
// and the Finish view is set there, no need to
|
||||||
|
// dispatch another init
|
||||||
|
return {
|
||||||
|
flags: {
|
||||||
|
...state.flags,
|
||||||
|
finalizeModule: false,
|
||||||
|
finalizeExam: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case 'FINALIZE_MODULE_SOLUTIONS': {
|
||||||
|
if (state.flags.reviewAll) {
|
||||||
|
const notLastModule = state.moduleIndex < state.selectedModules.length;
|
||||||
|
const moduleIndex = notLastModule ? state.moduleIndex + 1 : -1;
|
||||||
|
if (notLastModule) {
|
||||||
|
return {
|
||||||
|
questionIndex: 0,
|
||||||
|
exerciseIndex: 0,
|
||||||
|
partIndex: 0,
|
||||||
|
exam: state.exams[moduleIndex + 1],
|
||||||
|
moduleIndex: moduleIndex
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return {
|
||||||
|
questionIndex: 0,
|
||||||
|
exerciseIndex: 0,
|
||||||
|
partIndex: 0,
|
||||||
|
moduleIndex: -1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
} else {
|
||||||
|
return {
|
||||||
|
moduleIndex: -1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
};
|
||||||
34
src/stores/exam/reducers/session.ts
Normal file
34
src/stores/exam/reducers/session.ts
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import { Session } from "@/hooks/useSessions";
|
||||||
|
import { ExamState } from "../types";
|
||||||
|
|
||||||
|
export type SessionActions =
|
||||||
|
{ type: 'SET_SESSION'; payload: { session: Session } }
|
||||||
|
|
||||||
|
export const SESSION_ACTIONS = [
|
||||||
|
'SET_SESSION'
|
||||||
|
];
|
||||||
|
|
||||||
|
export const sessionReducer = (action: SessionActions): Partial<ExamState> => {
|
||||||
|
switch (action.type) {
|
||||||
|
case 'SET_SESSION':
|
||||||
|
const { session } = action.payload;
|
||||||
|
return {
|
||||||
|
shuffles: session.userSolutions.map((x) => ({ exerciseID: x.exercise, shuffles: x.shuffleMaps ? x.shuffleMaps : [] })),
|
||||||
|
selectedModules: session.selectedModules,
|
||||||
|
exam: session.exam,
|
||||||
|
exams: session.exams,
|
||||||
|
sessionId: session.sessionId,
|
||||||
|
assignment: session.assignment,
|
||||||
|
exerciseIndex: session.exerciseIndex,
|
||||||
|
partIndex: session.partIndex,
|
||||||
|
moduleIndex: session.moduleIndex,
|
||||||
|
timeSpent: session.timeSpent,
|
||||||
|
userSolutions: session.userSolutions,
|
||||||
|
timeSpentCurrentModule: session.timeSpentCurrentModule !== undefined ? session.timeSpentCurrentModule : session.timeSpent,
|
||||||
|
showSolutions: false,
|
||||||
|
questionIndex: session.questionIndex
|
||||||
|
};
|
||||||
|
default:
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
76
src/stores/exam/types.ts
Normal file
76
src/stores/exam/types.ts
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
import { Module } from "@/interfaces";
|
||||||
|
import { Exam, Shuffles, UserSolution } from "@/interfaces/exam";
|
||||||
|
import { Assignment } from "@/interfaces/results";
|
||||||
|
import { Action } from "./reducers";
|
||||||
|
|
||||||
|
|
||||||
|
export interface Navigation {
|
||||||
|
previousDisabled: boolean;
|
||||||
|
nextDisabled: boolean;
|
||||||
|
previousLoading: boolean;
|
||||||
|
nextLoading: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StateFlags {
|
||||||
|
timeIsUp: boolean;
|
||||||
|
reviewAll: boolean;
|
||||||
|
finalizeModule: boolean;
|
||||||
|
finalizeExam: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExamState {
|
||||||
|
exams: Exam[];
|
||||||
|
userSolutions: UserSolution[];
|
||||||
|
showSolutions: boolean;
|
||||||
|
selectedModules: Module[];
|
||||||
|
assignment?: Assignment;
|
||||||
|
timeSpent: number;
|
||||||
|
timeSpentCurrentModule: number;
|
||||||
|
sessionId: string;
|
||||||
|
moduleIndex: number;
|
||||||
|
exam?: Exam;
|
||||||
|
partIndex: number;
|
||||||
|
exerciseIndex: number;
|
||||||
|
questionIndex: number;
|
||||||
|
inactivity: number;
|
||||||
|
shuffles: Shuffles[];
|
||||||
|
bgColor: string;
|
||||||
|
user: undefined | string;
|
||||||
|
currentSolution?: UserSolution | undefined;
|
||||||
|
navigation: Navigation;
|
||||||
|
flags: StateFlags,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export interface ExamFunctions {
|
||||||
|
setUser: (user: string) => void;
|
||||||
|
|
||||||
|
setShowSolutions: (showSolutions: boolean) => void;
|
||||||
|
setExams: (exams: Exam[]) => void;
|
||||||
|
setSelectedModules: (modules: Module[]) => void;
|
||||||
|
setModuleIndex: (moduleIndex: number) => void;
|
||||||
|
setExam: (exam?: Exam) => void;
|
||||||
|
|
||||||
|
setPartIndex: (partIndex: number) => void;
|
||||||
|
setExerciseIndex: (exerciseIndex: number) => void;
|
||||||
|
setQuestionIndex: (questionIndex: number) => void;
|
||||||
|
setBgColor: (bgColor: string) => void;
|
||||||
|
|
||||||
|
setShuffles: (shuffles: Shuffles[]) => void;
|
||||||
|
|
||||||
|
setSessionId: (sessionId: string) => void;
|
||||||
|
setUserSolutions: (userSolutions: UserSolution[]) => void;
|
||||||
|
|
||||||
|
setTimeIsUp: (timeIsUp: boolean) => void;
|
||||||
|
|
||||||
|
saveSession: () => Promise<void>;
|
||||||
|
|
||||||
|
saveStats: () => Promise<void>;
|
||||||
|
|
||||||
|
setNavigation: (updates: Partial<Navigation>) => void;
|
||||||
|
setFlags: (updates: Partial<StateFlags>) => void;
|
||||||
|
|
||||||
|
reset: () => void;
|
||||||
|
|
||||||
|
dispatch: (action: Action) => void;
|
||||||
|
}
|
||||||
27
src/stores/exam/utils.ts
Normal file
27
src/stores/exam/utils.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import { Exam, ExerciseOnlyExam, PartExam, UserSolution } from "@/interfaces/exam";
|
||||||
|
|
||||||
|
const updateExamWithUserSolutions = (exam: Exam, userSolutions: UserSolution[]): Exam => {
|
||||||
|
if (["reading", "listening", "level"].includes(exam.module)) {
|
||||||
|
const parts = (exam as PartExam).parts.map((p) =>
|
||||||
|
Object.assign(p, {
|
||||||
|
exercises: p.exercises.map((x) =>
|
||||||
|
Object.assign(x, {
|
||||||
|
userSolutions: userSolutions.find((y) => x.id === y.exercise)?.solutions,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return Object.assign(exam, { parts });
|
||||||
|
}
|
||||||
|
|
||||||
|
const exercises = (exam as ExerciseOnlyExam).exercises.map((x) =>
|
||||||
|
Object.assign(x, {
|
||||||
|
userSolutions: userSolutions.find((y) => x.id === y.exercise)?.solutions,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return Object.assign(exam, { exercises });
|
||||||
|
};
|
||||||
|
|
||||||
|
export {
|
||||||
|
updateExamWithUserSolutions,
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user