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/uuid": "^9.0.1",
|
||||
"@types/wavesurfer.js": "^6.0.6",
|
||||
"@welldone-software/why-did-you-render": "^8.0.3",
|
||||
"@wixc3/react-board": "^2.2.0",
|
||||
"autoprefixer": "^10.4.13",
|
||||
"husky": "^8.0.3",
|
||||
@@ -3615,6 +3616,18 @@
|
||||
"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": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@wixc3/board-core/-/board-core-2.2.0.tgz",
|
||||
@@ -14582,6 +14595,15 @@
|
||||
"@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": {
|
||||
"version": "2.2.0",
|
||||
"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/uuid": "^9.0.1",
|
||||
"@types/wavesurfer.js": "^6.0.6",
|
||||
"@welldone-software/why-did-you-render": "^8.0.3",
|
||||
"@wixc3/react-board": "^2.2.0",
|
||||
"autoprefixer": "^10.4.13",
|
||||
"husky": "^8.0.3",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import {infoButtonStyle} from "@/constants/buttonStyles";
|
||||
import {Module} from "@/interfaces";
|
||||
import {User} from "@/interfaces/user";
|
||||
import useExamStore from "@/stores/examStore";
|
||||
import useExamStore from "@/stores/exam";
|
||||
import {getExam, getExamById} from "@/utils/exams";
|
||||
import {MODULE_ARRAY} from "@/utils/moduleUtils";
|
||||
import {writingMarking} from "@/utils/score";
|
||||
@@ -28,8 +28,7 @@ export default function Diagnostic({onFinish}: Props) {
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const setExams = useExamStore((state) => state.setExams);
|
||||
const setSelectedModules = useExamStore((state) => state.setSelectedModules);
|
||||
const dispatch = useExamStore((state) => state.dispatch);
|
||||
|
||||
const isNextDisabled = () => {
|
||||
if (!focus) return true;
|
||||
@@ -41,8 +40,7 @@ export default function Diagnostic({onFinish}: Props) {
|
||||
|
||||
Promise.all(examPromises).then((exams) => {
|
||||
if (exams.every((x) => !!x)) {
|
||||
setExams(exams.map((x) => x!));
|
||||
setSelectedModules(exams.map((x) => x!.module));
|
||||
dispatch({type: 'INIT_EXAM', payload: {exams: exams.map((x) => x!), modules: exams.map((x) => x!.module)}})
|
||||
router.push("/exam");
|
||||
}
|
||||
});
|
||||
|
||||
@@ -12,6 +12,7 @@ import { AlertItem } from "../../Shared/Alert";
|
||||
import validateBlanks from "../validateBlanks";
|
||||
import { toast } from "react-toastify";
|
||||
import setEditingAlert from "../../Shared/setEditingAlert";
|
||||
import PromptEdit from "../../Shared/PromptEdit";
|
||||
|
||||
interface Word {
|
||||
letter: string;
|
||||
@@ -38,6 +39,12 @@ const FillBlanksLetters: React.FC<{ exercise: FillBlanksExercise; sectionId: num
|
||||
|
||||
const [editing, setEditing] = useState(false);
|
||||
|
||||
const updateLocal = (exercise: FillBlanksExercise) => {
|
||||
setLocal(exercise);
|
||||
setEditingAlert(true, setAlerts);
|
||||
setEditing(true);
|
||||
};
|
||||
|
||||
const [blanksState, blanksDispatcher] = useReducer(blanksReducer, {
|
||||
text: exercise.text || "",
|
||||
blanks: [],
|
||||
@@ -266,6 +273,8 @@ const FillBlanksLetters: React.FC<{ exercise: FillBlanksExercise; sectionId: num
|
||||
setEditing={setEditing}
|
||||
onPractice={handlePractice}
|
||||
isEvaluationEnabled={!local.isPractice}
|
||||
prompt={local.prompt}
|
||||
updatePrompt={(prompt: string) => updateLocal({...local, prompt})}
|
||||
>
|
||||
<>
|
||||
{!blanksState.textMode && <Card className="p-4">
|
||||
|
||||
@@ -36,6 +36,12 @@ const FillBlanksMC: React.FC<{ exercise: FillBlanksExercise; sectionId: number }
|
||||
const [isEditMode, setIsEditMode] = useState(false);
|
||||
const [editing, setEditing] = useState(false);
|
||||
|
||||
const updateLocal = (exercise: FillBlanksExercise) => {
|
||||
setLocal(exercise);
|
||||
setEditingAlert(true, setAlerts);
|
||||
setEditing(true);
|
||||
};
|
||||
|
||||
const [blanksState, blanksDispatcher] = useReducer(blanksReducer, {
|
||||
text: exercise.text || "",
|
||||
blanks: [],
|
||||
@@ -268,6 +274,8 @@ const FillBlanksMC: React.FC<{ exercise: FillBlanksExercise; sectionId: number }
|
||||
setEditing={setEditing}
|
||||
onBlankRemove={handleBlankRemove}
|
||||
isEvaluationEnabled={!local.isPractice}
|
||||
prompt={local.prompt}
|
||||
updatePrompt={(prompt: string) => updateLocal({...local, prompt})}
|
||||
>
|
||||
{!blanksState.textMode && selectedBlankId && (
|
||||
<Card className="p-4">
|
||||
|
||||
@@ -24,6 +24,12 @@ const WriteBlanksFill: React.FC<{ exercise: WriteBlanksExercise; sectionId: numb
|
||||
const [selectedBlankId, setSelectedBlankId] = useState<string | null>(null);
|
||||
const [editing, setEditing] = useState(false);
|
||||
|
||||
const updateLocal = (exercise: WriteBlanksExercise) => {
|
||||
setLocal(exercise);
|
||||
setEditingAlert(true, setAlerts);
|
||||
setEditing(true);
|
||||
};
|
||||
|
||||
const [blanksState, blanksDispatcher] = useReducer(blanksReducer, {
|
||||
text: exercise.text || "",
|
||||
blanks: [],
|
||||
@@ -175,6 +181,8 @@ const WriteBlanksFill: React.FC<{ exercise: WriteBlanksExercise; sectionId: numb
|
||||
onPractice={handlePractice}
|
||||
setEditing={setEditing}
|
||||
isEvaluationEnabled={!local.isPractice}
|
||||
prompt={local.prompt}
|
||||
updatePrompt={(prompt: string) => updateLocal({ ...local, prompt })}
|
||||
>
|
||||
{!blanksState.textMode && (
|
||||
<Card>
|
||||
|
||||
@@ -19,6 +19,7 @@ import clsx from "clsx";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Blank, DropZone } from "./DragNDrop";
|
||||
import { getTextSegments, BlankState, BlanksState, BlanksAction, BlankToken } from "./BlanksReducer";
|
||||
import PromptEdit from "../Shared/PromptEdit";
|
||||
|
||||
|
||||
interface Props {
|
||||
@@ -30,6 +31,8 @@ interface Props {
|
||||
editing: boolean;
|
||||
showBlankBank: boolean;
|
||||
alerts: AlertItem[];
|
||||
prompt: string;
|
||||
updatePrompt: (prompt: string) => void;
|
||||
setEditing: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
blanksDispatcher: React.Dispatch<BlanksAction>
|
||||
onBlankSelect?: (blankId: number | null) => void;
|
||||
@@ -60,7 +63,9 @@ const BlanksEditor: React.FC<Props> = ({
|
||||
onDelete,
|
||||
onPractice,
|
||||
isEvaluationEnabled,
|
||||
setEditing
|
||||
setEditing,
|
||||
prompt,
|
||||
updatePrompt
|
||||
}) => {
|
||||
|
||||
useEffect(() => {
|
||||
@@ -171,6 +176,7 @@ const BlanksEditor: React.FC<Props> = ({
|
||||
isEvaluationEnabled={isEvaluationEnabled}
|
||||
/>
|
||||
{alerts.length > 0 && <Alert alerts={alerts} />}
|
||||
<PromptEdit value={prompt} onChange={(text: string) => updatePrompt(text)} />
|
||||
<Card>
|
||||
<CardContent className="p-4 text-white font-semibold flex gap-2">
|
||||
<button
|
||||
|
||||
@@ -10,6 +10,7 @@ import useExamEditorStore from "@/stores/examEditor";
|
||||
import { useEffect, useState } from "react";
|
||||
import { MdAdd } from "react-icons/md";
|
||||
import Alert, { AlertItem } from "../../Shared/Alert";
|
||||
import PromptEdit from "../../Shared/PromptEdit";
|
||||
|
||||
|
||||
const UnderlineMultipleChoice: React.FC<{exercise: MultipleChoiceExercise, sectionId: number}> = ({
|
||||
@@ -125,7 +126,7 @@ const UnderlineMultipleChoice: React.FC<{exercise: MultipleChoiceExercise, secti
|
||||
isEvaluationEnabled={!local.isPractice}
|
||||
/>
|
||||
{alerts.length > 0 && <Alert className="mb-6" alerts={alerts} />}
|
||||
|
||||
<PromptEdit value={local.prompt} onChange={(prompt: string) => updateLocal({...local, prompt})} />
|
||||
<div className="space-y-4">
|
||||
<QuestionsList
|
||||
ids={local.questions.map(q => q.id)}
|
||||
|
||||
@@ -17,6 +17,7 @@ import QuestionsList from '../../Shared/QuestionsList';
|
||||
import SortableQuestion from '../../Shared/SortableQuestion';
|
||||
import setEditingAlert from '../../Shared/setEditingAlert';
|
||||
import { handleMultipleChoiceReorder } from '@/stores/examEditor/reorder/local';
|
||||
import PromptEdit from '../../Shared/PromptEdit';
|
||||
|
||||
interface MultipleChoiceProps {
|
||||
exercise: MultipleChoiceExercise;
|
||||
@@ -214,36 +215,7 @@ const MultipleChoice: React.FC<MultipleChoiceProps> = ({ exercise, sectionId, op
|
||||
isEvaluationEnabled={!local.isPractice}
|
||||
/>
|
||||
{alerts.length > 0 && <Alert className="mb-6" alerts={alerts} />}
|
||||
<Card className="mb-6">
|
||||
<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>
|
||||
|
||||
<PromptEdit value={local.prompt} onChange={(prompt: string) => updateLocal({...local, prompt})} />
|
||||
<div className="space-y-4">
|
||||
<QuestionsList
|
||||
ids={local.questions.map(q => q.id)}
|
||||
|
||||
@@ -6,44 +6,70 @@ import { MdEdit, MdEditOff } from "react-icons/md";
|
||||
interface Props {
|
||||
value: string;
|
||||
onChange: (text: string) => void;
|
||||
wrapperCard?: boolean;
|
||||
}
|
||||
|
||||
|
||||
const PromptEdit: React.FC<Props> = ({ value, onChange }) => {
|
||||
const PromptEdit: React.FC<Props> = ({ value, onChange, wrapperCard = true }) => {
|
||||
const [editing, setEditing] = useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card className="mb-6">
|
||||
<CardContent className="p-4">
|
||||
const renderTextWithLineBreaks = (text: string) => {
|
||||
const unescapedText = text.replace(/\\n/g, '\n');
|
||||
return unescapedText.split('\n').map((line, index, array) => (
|
||||
<span key={index}>
|
||||
{line}
|
||||
{index < array.length - 1 && <br />}
|
||||
</span>
|
||||
));
|
||||
};
|
||||
|
||||
const handleTextChange = (text: string) => {
|
||||
const escapedText = text.replace(/\n/g, '\\n');
|
||||
onChange(escapedText);
|
||||
};
|
||||
|
||||
const promptEditTsx = (
|
||||
<div className="flex justify-between items-start gap-4">
|
||||
{editing ? (
|
||||
<AutoExpandingTextArea
|
||||
className="flex-1 p-3 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:outline-none min-h-[100px]"
|
||||
value={value}
|
||||
onChange={(text) => onChange(text)}
|
||||
value={value.replace(/\\n/g, '\n')}
|
||||
onChange={handleTextChange}
|
||||
onBlur={() => setEditing(false)}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex-1">
|
||||
<h3 className="font-medium text-gray-800 mb-2">Question/Instructions displayed to the student:</h3>
|
||||
<p className="text-gray-600">{value}</p>
|
||||
<h3 className="font-medium text-gray-800 mb-2">
|
||||
Question/Instructions displayed to the student:
|
||||
</h3>
|
||||
<p className="text-gray-600">
|
||||
{renderTextWithLineBreaks(value)}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
onClick={() => setEditing(!editing)}
|
||||
className="p-2 hover:bg-gray-100 rounded-lg transition-colors"
|
||||
>
|
||||
{editing ?
|
||||
<MdEditOff size={20} className="text-gray-500" /> :
|
||||
{editing ? (
|
||||
<MdEditOff size={20} className="text-gray-500" />
|
||||
) : (
|
||||
<MdEdit size={20} className="text-gray-500" />
|
||||
}
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</>
|
||||
);
|
||||
|
||||
if (!wrapperCard) {
|
||||
return promptEditTsx;
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="mb-6">
|
||||
<CardContent className="p-4">
|
||||
{promptEditTsx}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default PromptEdit;
|
||||
@@ -21,6 +21,7 @@ import { toast } from 'react-toastify';
|
||||
import { validateEmptySolutions, validateQuestionText, validateWordCount } from './validation';
|
||||
import { handleWriteBlanksReorder } from '@/stores/examEditor/reorder/local';
|
||||
import { ParsedQuestion, parseText, reconstructText } from './parsing';
|
||||
import PromptEdit from '../Shared/PromptEdit';
|
||||
|
||||
|
||||
const WriteBlanks: React.FC<{ sectionId: number; exercise: WriteBlanksExercise; }> = ({ sectionId, exercise }) => {
|
||||
@@ -246,30 +247,7 @@ const WriteBlanks: React.FC<{ sectionId: number; exercise: WriteBlanksExercise;
|
||||
{alerts.length > 0 && <Alert alerts={alerts} />}
|
||||
<Card className="mb-6">
|
||||
<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 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>
|
||||
<PromptEdit value={local.prompt} onChange={(prompt: string) => updateLocal({ ...local, prompt })} wrapperCard={false}/>
|
||||
<div className="flex justify-between items-start gap-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<label className="flex items-center gap-2">
|
||||
|
||||
@@ -16,6 +16,7 @@ import { ParsedQuestion, parseLine, reconstructLine } from "./parsing";
|
||||
import { validateQuestions, validateEmptySolutions, validateWordCount } from "./validation";
|
||||
import Header from "../../Shared/Header";
|
||||
import BlanksFormEditor from "./BlanksFormEditor";
|
||||
import PromptEdit from "../Shared/PromptEdit";
|
||||
|
||||
|
||||
const WriteBlanksForm: React.FC<{ sectionId: number; exercise: WriteBlanksExercise }> = ({ sectionId, exercise }) => {
|
||||
@@ -220,49 +221,7 @@ const WriteBlanksForm: React.FC<{ sectionId: number; exercise: WriteBlanksExerci
|
||||
/>
|
||||
<div className="space-y-4">
|
||||
{alerts.length > 0 && <Alert alerts={alerts} />}
|
||||
<Card className="mb-6">
|
||||
<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>
|
||||
|
||||
<PromptEdit value={local.prompt} onChange={(prompt: string) => updateLocal({ ...local, prompt })}/>
|
||||
<div className="space-y-4">
|
||||
<QuestionsList
|
||||
ids={parsedQuestions.map(q => q.id)}
|
||||
|
||||
@@ -6,12 +6,12 @@ import clsx from "clsx";
|
||||
import ExercisePicker from "../ExercisePicker";
|
||||
import useExamEditorStore from "@/stores/examEditor";
|
||||
import useSettingsState from "../Hooks/useSettingsState";
|
||||
import { LevelSectionSettings, SectionSettings } from "@/stores/examEditor/types";
|
||||
import { LevelSectionSettings } from "@/stores/examEditor/types";
|
||||
import { toast } from "react-toastify";
|
||||
import axios from "axios";
|
||||
import { playSound } from "@/utils/sound";
|
||||
import { useRouter } from "next/router";
|
||||
import { usePersistentExamStore } from "@/stores/examStore";
|
||||
import { usePersistentExamStore } from "@/stores/exam";
|
||||
import openDetachedTab from "@/utils/popout";
|
||||
import ListeningComponents from "./listening/components";
|
||||
import ReadingComponents from "./reading/components";
|
||||
|
||||
@@ -13,7 +13,7 @@ import Input from "@/components/Low/Input";
|
||||
import openDetachedTab from "@/utils/popout";
|
||||
import { useRouter } from "next/router";
|
||||
import axios from "axios";
|
||||
import { usePersistentExamStore } from "@/stores/examStore";
|
||||
import { usePersistentExamStore } from "@/stores/exam";
|
||||
import { playSound } from "@/utils/sound";
|
||||
import { toast } from "react-toastify";
|
||||
import ListeningComponents from "./components";
|
||||
@@ -98,16 +98,16 @@ const ListeningSettings: React.FC = () => {
|
||||
const { urls } = response.data;
|
||||
|
||||
const exam: ListeningExam = {
|
||||
parts: sections.map((s) => {
|
||||
const exercise = s.state as ListeningPart;
|
||||
parts: sectionsWithAudio.map((s) => {
|
||||
const part = s.state as ListeningPart;
|
||||
const index = Array.from(sectionMap.entries())
|
||||
.findIndex(([id]) => id === s.sectionId);
|
||||
|
||||
return {
|
||||
...exercise,
|
||||
audio: exercise.audio ? {
|
||||
...exercise.audio,
|
||||
source: index !== -1 ? urls[index] : exercise.audio.source
|
||||
...part,
|
||||
audio: part.audio ? {
|
||||
...part.audio,
|
||||
source: index !== -1 ? urls[index] : part.audio.source
|
||||
} : undefined,
|
||||
intro: s.settings.currentIntro,
|
||||
category: s.settings.category
|
||||
|
||||
@@ -7,7 +7,7 @@ import { ReadingSectionSettings } from "@/stores/examEditor/types";
|
||||
import useExamEditorStore from "@/stores/examEditor";
|
||||
import openDetachedTab from "@/utils/popout";
|
||||
import { useRouter } from "next/router";
|
||||
import { usePersistentExamStore } from "@/stores/examStore";
|
||||
import { usePersistentExamStore } from "@/stores/exam";
|
||||
import axios from "axios";
|
||||
import { playSound } from "@/utils/sound";
|
||||
import { toast } from "react-toastify";
|
||||
@@ -98,9 +98,9 @@ const ReadingSettings: React.FC = () => {
|
||||
const preview = () => {
|
||||
setExam({
|
||||
parts: sections.map((s) => {
|
||||
const exercise = s.state as ReadingPart;
|
||||
const exercises = s.state as ReadingPart;
|
||||
return {
|
||||
...exercise,
|
||||
...exercises,
|
||||
intro: s.settings.currentIntro,
|
||||
category: s.settings.category
|
||||
};
|
||||
|
||||
@@ -5,7 +5,7 @@ import Option from "@/interfaces/option";
|
||||
import SettingsEditor from "..";
|
||||
import { InteractiveSpeakingExercise, SpeakingExam, SpeakingExercise } from "@/interfaces/exam";
|
||||
import { toast } from "react-toastify";
|
||||
import { usePersistentExamStore } from "@/stores/examStore";
|
||||
import { usePersistentExamStore } from "@/stores/exam";
|
||||
import { useRouter } from "next/router";
|
||||
import openDetachedTab from "@/utils/popout";
|
||||
import axios from "axios";
|
||||
|
||||
@@ -1,15 +1,11 @@
|
||||
import React, { useCallback, useEffect, useState } from "react";
|
||||
import SettingsEditor from "..";
|
||||
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 GenerateBtn from "../Shared/GenerateBtn";
|
||||
import { WritingSectionSettings } from "@/stores/examEditor/types";
|
||||
import useExamEditorStore from "@/stores/examEditor";
|
||||
import { useRouter } from "next/router";
|
||||
import { usePersistentExamStore } from "@/stores/examStore";
|
||||
import { usePersistentExamStore } from "@/stores/exam";
|
||||
import openDetachedTab from "@/utils/popout";
|
||||
import { WritingExam, WritingExercise } from "@/interfaces/exam";
|
||||
import { v4 } from "uuid";
|
||||
|
||||
@@ -39,7 +39,7 @@ const MCDropdown: React.FC<MCDropdownProps> = ({
|
||||
});
|
||||
|
||||
return (
|
||||
<div className={`${className} inline-block`} style={{ width: `${width}px` }}>
|
||||
<div key={`dropdown-${id}`} className={`${className} inline-block`} style={{ width: `${width}px` }}>
|
||||
<button
|
||||
onClick={() => onToggle(id)}
|
||||
className={
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
//import "@/utils/wdyr";
|
||||
|
||||
import { FillBlanksExercise, FillBlanksMCOption } from "@/interfaces/exam";
|
||||
import useExamStore, { usePersistentExamStore } from "@/stores/examStore";
|
||||
import clsx from "clsx";
|
||||
import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import reactStringReplace from "react-string-replace";
|
||||
import { CommonProps } from "..";
|
||||
import Button from "../../Low/Button";
|
||||
import { CommonProps } from "../types";
|
||||
import { v4 } from "uuid";
|
||||
import MCDropdown from "./MCDropdown";
|
||||
import useExamStore, { usePersistentExamStore } from "@/stores/exam";
|
||||
|
||||
const FillBlanks: React.FC<FillBlanksExercise & CommonProps> = ({
|
||||
id,
|
||||
@@ -18,43 +19,29 @@ const FillBlanks: React.FC<FillBlanksExercise & CommonProps> = ({
|
||||
words,
|
||||
userSolutions,
|
||||
variant,
|
||||
registerSolution,
|
||||
headerButtons,
|
||||
footerButtons,
|
||||
preview,
|
||||
onNext,
|
||||
onBack,
|
||||
disableProgressButtons = false
|
||||
}) => {
|
||||
|
||||
const examState = useExamStore((state) => state);
|
||||
const persistentExamState = usePersistentExamStore((state) => state);
|
||||
|
||||
const {
|
||||
hasExamEnded,
|
||||
exerciseIndex,
|
||||
partIndex,
|
||||
questionIndex,
|
||||
shuffles,
|
||||
exam,
|
||||
setCurrentSolution,
|
||||
} = !preview ? examState : persistentExamState;
|
||||
} = examState; !preview ? examState : persistentExamState;
|
||||
|
||||
const [answers, setAnswers] = useState<{ id: string; solution: string }[]>(userSolutions);
|
||||
const shuffleMaps = shuffles.find((x) => x.exerciseID == id)?.shuffles;
|
||||
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) => {
|
||||
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;
|
||||
if (exam && (exam.module === "level" || exam.module === "reading") && exam.parts[partIndex].exercises[exerciseIndex].type === "fillBlanks") {
|
||||
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 correct = answers!.filter((x) => {
|
||||
const solution = solutions.find((y) => x.id.toString() === y.id.toString())?.solution;
|
||||
@@ -100,7 +87,19 @@ const FillBlanks: React.FC<FillBlanksExercise & CommonProps> = ({
|
||||
}).length;
|
||||
const missing = total - answers!.filter((x) => solutions.find((y) => x.id.toString() === y.id.toString())).length;
|
||||
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);
|
||||
|
||||
@@ -137,6 +136,7 @@ const FillBlanks: React.FC<FillBlanksExercise & CommonProps> = ({
|
||||
/>
|
||||
) : (
|
||||
<input
|
||||
key={`input-${id}`}
|
||||
className={styles}
|
||||
onChange={(e) => setAnswers((prev) => [...prev.filter((x) => x.id !== id), { id, solution: e.target.value }])}
|
||||
value={userSolution?.solution}
|
||||
@@ -151,10 +151,10 @@ const FillBlanks: React.FC<FillBlanksExercise & CommonProps> = ({
|
||||
|
||||
const memoizedLines = useMemo(() => {
|
||||
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)}
|
||||
<br />
|
||||
</p>
|
||||
</div>
|
||||
));
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [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 }]);
|
||||
};
|
||||
|
||||
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 (
|
||||
<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")}>
|
||||
{headerButtons}
|
||||
<div className={clsx("flex flex-col gap-4 mt-4 h-full w-full", (!headerButtons && !footerButtons) && "mb-20")}>
|
||||
{variant !== "mc" && (
|
||||
<span className="text-sm w-full leading-6">
|
||||
{prompt.split("\\n").map((line, index) => (
|
||||
@@ -234,11 +204,12 @@ const FillBlanks: React.FC<FillBlanksExercise & CommonProps> = ({
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{footerButtons}
|
||||
</div>
|
||||
|
||||
{!disableProgressButtons && progressButtons()}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
//FillBlanks.whyDidYouRender = true
|
||||
|
||||
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 { CommonProps } from ".";
|
||||
import { CommonProps } from "../types";
|
||||
import { useEffect, useState } from "react";
|
||||
import { BsCheckCircleFill, BsMicFill, BsPauseCircle, BsPlayCircle, BsTrashFill } from "react-icons/bs";
|
||||
import dynamic from "next/dynamic";
|
||||
import Button from "../Low/Button";
|
||||
import useExamStore from "@/stores/examStore";
|
||||
import { downloadBlob } from "@/utils/evaluation";
|
||||
import axios from "axios";
|
||||
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), {
|
||||
ssr: false,
|
||||
});
|
||||
|
||||
export default function InteractiveSpeaking({
|
||||
const InteractiveSpeaking: React.FC<InteractiveSpeakingExercise & CommonProps> = ({
|
||||
id,
|
||||
title,
|
||||
first_title,
|
||||
@@ -22,65 +19,40 @@ export default function InteractiveSpeaking({
|
||||
type,
|
||||
prompts,
|
||||
userSolutions,
|
||||
onNext,
|
||||
onBack,
|
||||
isPractice = false,
|
||||
preview = false
|
||||
}: InteractiveSpeakingExercise & CommonProps) {
|
||||
registerSolution,
|
||||
headerButtons,
|
||||
footerButtons,
|
||||
preview,
|
||||
}) => {
|
||||
const [recordingDuration, setRecordingDuration] = useState(0);
|
||||
const [isRecording, setIsRecording] = useState(false);
|
||||
const [mediaBlob, setMediaBlob] = useState<string>();
|
||||
const [answers, setAnswers] = useState<{ prompt: string; blob: string; questionIndex: number }[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const { questionIndex, setQuestionIndex } = useExamStore((state) => state);
|
||||
const { userSolutions: storeUserSolutions, setUserSolutions } = useExamStore((state) => state);
|
||||
const examState = useExamStore((state) => state);
|
||||
const persistentExamState = usePersistentExamStore((state) => state);
|
||||
|
||||
const hasExamEnded = useExamStore((state) => state.hasExamEnded);
|
||||
const { questionIndex } = !preview ? examState : persistentExamState;
|
||||
|
||||
const back = async () => {
|
||||
setIsLoading(true);
|
||||
useEffect(() => {
|
||||
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);
|
||||
if (questionIndex - 1 >= 0) {
|
||||
setQuestionIndex(questionIndex - 1);
|
||||
setIsLoading(false);
|
||||
|
||||
return;
|
||||
}
|
||||
setIsLoading(false);
|
||||
|
||||
onBack({
|
||||
useEffect(() => {
|
||||
registerSolution(() => ({
|
||||
exercise: id,
|
||||
solutions: [...answers.filter((x) => x.questionIndex !== questionIndex), answer],
|
||||
solutions: answers,
|
||||
score: { correct: 100, total: 100, missing: 0 },
|
||||
type,
|
||||
isPractice
|
||||
});
|
||||
};
|
||||
|
||||
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
|
||||
});
|
||||
};
|
||||
}));
|
||||
}, [id, answers, mediaBlob, type, isPractice, prompts, registerSolution]);
|
||||
|
||||
useEffect(() => {
|
||||
if (userSolutions.length > 0 && answers.length === 0) {
|
||||
@@ -92,24 +64,6 @@ export default function InteractiveSpeaking({
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [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(() => {
|
||||
let recordingInterval: NodeJS.Timer | undefined = undefined;
|
||||
if (isRecording) {
|
||||
@@ -123,50 +77,9 @@ export default function InteractiveSpeaking({
|
||||
};
|
||||
}, [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 (
|
||||
<div className="flex flex-col gap-4 mt-4 w-full">
|
||||
<div className="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>
|
||||
<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>
|
||||
|
||||
{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">
|
||||
@@ -298,22 +211,10 @@ export default function InteractiveSpeaking({
|
||||
</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>
|
||||
{footerButtons}
|
||||
</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 { CommonProps } from ".";
|
||||
import { CommonProps } from "./types";
|
||||
import { Fragment, useEffect, useState } from "react";
|
||||
import { BsCheckCircleFill, BsMicFill, BsPauseCircle, BsPlayCircle, BsTrashFill } from "react-icons/bs";
|
||||
import dynamic from "next/dynamic";
|
||||
import Button from "../Low/Button";
|
||||
import useExamStore from "@/stores/examStore";
|
||||
import { downloadBlob } from "@/utils/evaluation";
|
||||
import axios from "axios";
|
||||
import Modal from "../Modal";
|
||||
import useExamStore, { usePersistentExamStore } from "@/stores/exam";
|
||||
|
||||
const Waveform = dynamic(() => import("../Waveform"), { ssr: false });
|
||||
const ReactMediaRecorder = dynamic(() => import("react-media-recorder").then((mod) => mod.ReactMediaRecorder), {
|
||||
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 [isRecording, setIsRecording] = useState(false);
|
||||
const [mediaBlob, setMediaBlob] = useState<string>();
|
||||
const [audioURL, setAudioURL] = useState<string>();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isPromptsModalOpen, setIsPromptsModalOpen] = useState(false);
|
||||
const [inputText, setInputText] = useState("");
|
||||
|
||||
const hasExamEnded = useExamStore((state) => state.hasExamEnded);
|
||||
const examState = useExamStore((state) => state);
|
||||
const persistentExamState = usePersistentExamStore((state) => state);
|
||||
|
||||
const saveToStorage = async () => {
|
||||
if (mediaBlob && mediaBlob.startsWith("blob")) {
|
||||
const blobBuffer = await downloadBlob(mediaBlob);
|
||||
const audioFile = new File([blobBuffer], "audio.wav", { type: "audio/wav" });
|
||||
const {setNavigation} = !preview ? examState : persistentExamState;
|
||||
|
||||
const seed = Math.random().toString().replace("0.", "");
|
||||
useEffect(()=> { if (!preview) setNavigation({nextDisabled: true}) }, [setNavigation, preview])
|
||||
|
||||
const formData = new FormData();
|
||||
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(() => {
|
||||
/*useEffect(() => {
|
||||
if (userSolutions.length > 0) {
|
||||
const { solution } = userSolutions[0] as { solution?: string };
|
||||
if (solution && !mediaBlob) setMediaBlob(solution);
|
||||
if (solution && !solution.startsWith("blob")) setAudioURL(solution);
|
||||
}
|
||||
}, [userSolutions, mediaBlob]);
|
||||
|
||||
useEffect(() => {
|
||||
if (hasExamEnded) next();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [hasExamEnded]);
|
||||
}, [userSolutions, mediaBlob]);*/
|
||||
|
||||
useEffect(() => {
|
||||
let recordingInterval: NodeJS.Timer | undefined = undefined;
|
||||
@@ -76,23 +50,14 @@ export default function Speaking({ id, title, text, video_url, type, prompts, su
|
||||
};
|
||||
}, [isRecording]);
|
||||
|
||||
const next = async () => {
|
||||
onNext({
|
||||
useEffect(() => {
|
||||
registerSolution(() => ({
|
||||
exercise: id,
|
||||
solutions: mediaBlob ? [{ id, solution: mediaBlob }] : [],
|
||||
score: { correct: 0, total: 100, missing: 0 },
|
||||
type, isPractice
|
||||
});
|
||||
};
|
||||
|
||||
const back = async () => {
|
||||
onBack({
|
||||
exercise: id,
|
||||
solutions: mediaBlob ? [{ id, solution: mediaBlob }] : [],
|
||||
score: { correct: 0, total: 100, missing: 0 },
|
||||
type, isPractice
|
||||
});
|
||||
};
|
||||
}));
|
||||
}, [id, isPractice, mediaBlob, registerSolution, type]);
|
||||
|
||||
const handleNoteWriting = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
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 (
|
||||
<div className="flex flex-col gap-4 mt-4 w-full">
|
||||
<div className="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>
|
||||
<Button color="purple" isLoading={isLoading} disabled={!mediaBlob} onClick={next} className="max-w-[200px] self-end w-full">
|
||||
Next
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{headerButtons}
|
||||
<div className="flex flex-col h-full w-full gap-9">
|
||||
<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">
|
||||
@@ -302,22 +263,10 @@ export default function Speaking({ id, title, text, video_url, type, prompts, su
|
||||
</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">
|
||||
Next
|
||||
</Button>
|
||||
) : (
|
||||
<Button color="purple" isLoading={isLoading} disabled={!mediaBlob} onClick={next} className="max-w-[200px] self-end w-full">
|
||||
Next
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{footerButtons}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Speaking;
|
||||
|
||||
@@ -1,32 +1,23 @@
|
||||
import { TrueFalseExercise } from "@/interfaces/exam";
|
||||
import useExamStore from "@/stores/examStore";
|
||||
import clsx from "clsx";
|
||||
import { Fragment, useEffect, useState } from "react";
|
||||
import { CommonProps } from ".";
|
||||
import { Fragment, useCallback, useEffect, useState } from "react";
|
||||
import { CommonProps } from "./types";
|
||||
import Button from "../Low/Button";
|
||||
|
||||
export default function TrueFalse({
|
||||
const TrueFalse: React.FC<TrueFalseExercise & CommonProps> = ({
|
||||
id,
|
||||
type,
|
||||
prompt,
|
||||
questions,
|
||||
userSolutions,
|
||||
isPractice = false,
|
||||
onNext,
|
||||
onBack,
|
||||
disableProgressButtons = false
|
||||
}: TrueFalseExercise & CommonProps) {
|
||||
registerSolution,
|
||||
headerButtons,
|
||||
footerButtons,
|
||||
}) => {
|
||||
const [answers, setAnswers] = useState<{ id: string; solution: "true" | "false" | "not_given" }[]>(userSolutions);
|
||||
|
||||
const hasExamEnded = useExamStore((state) => state.hasExamEnded);
|
||||
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 calculateScore = useCallback(() => {
|
||||
const total = questions.length || 0;
|
||||
const correct = answers.filter(
|
||||
(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;
|
||||
|
||||
return { total, correct, missing };
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
setCurrentSolution({ exercise: id, solutions: answers, score: calculateScore(), type, isPractice });
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [answers, setAnswers]);
|
||||
}, [answers, questions]);
|
||||
|
||||
const toggleAnswer = (solution: "true" | "false" | "not_given", questionId: string) => {
|
||||
const answer = answers.find((x) => x.id === questionId);
|
||||
@@ -56,34 +42,20 @@ export default function TrueFalse({
|
||||
};
|
||||
|
||||
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
|
||||
}, [answers, disableProgressButtons])
|
||||
|
||||
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>
|
||||
)
|
||||
}, [id, answers, type, isPractice, calculateScore]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4 mt-4">
|
||||
{!disableProgressButtons && progressButtons()}
|
||||
|
||||
<div className={clsx("flex flex-col gap-4 mt-4 h-full w-full", !disableProgressButtons && "mb-20")}>
|
||||
{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) => (
|
||||
<Fragment key={index}>
|
||||
@@ -141,9 +113,11 @@ export default function TrueFalse({
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{footerButtons}
|
||||
</div>
|
||||
|
||||
{!disableProgressButtons && progressButtons()}
|
||||
</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 */
|
||||
import { WritingExercise } from "@/interfaces/exam";
|
||||
import { CommonProps } from ".";
|
||||
import React, { Fragment, useEffect, useRef, useState } from "react";
|
||||
import React, { Fragment, useEffect, 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 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,
|
||||
prompt,
|
||||
prefix,
|
||||
@@ -17,17 +16,24 @@ export default function Writing({
|
||||
attachment,
|
||||
userSolutions,
|
||||
isPractice = false,
|
||||
onNext,
|
||||
onBack,
|
||||
enableNavigation = false
|
||||
}: WritingExercise & CommonProps) {
|
||||
registerSolution,
|
||||
headerButtons,
|
||||
footerButtons,
|
||||
preview,
|
||||
}) => {
|
||||
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [inputText, setInputText] = useState(userSolutions.length === 1 ? userSolutions[0].solution : "");
|
||||
const [isSubmitEnabled, setIsSubmitEnabled] = useState(false);
|
||||
const [saveTimer, setSaveTimer] = useState(0);
|
||||
|
||||
const { userSolutions: storeUserSolutions, setUserSolutions } = useExamStore((state) => state);
|
||||
const hasExamEnded = useExamStore((state) => state.hasExamEnded);
|
||||
const examState = useExamStore((state) => state);
|
||||
const persistentExamState = usePersistentExamStore((state) => state);
|
||||
|
||||
const { userSolutions: storeUserSolutions, setUserSolutions, setNavigation } = !preview ? examState : persistentExamState;
|
||||
|
||||
useEffect(() => {
|
||||
if (!preview) setNavigation({ nextDisabled: true });
|
||||
}, [setNavigation, preview]);
|
||||
|
||||
useEffect(() => {
|
||||
const saveTimerInterval = setInterval(() => {
|
||||
@@ -43,7 +49,7 @@ export default function Writing({
|
||||
if (inputText.length > 0 && saveTimer % 10 === 0) {
|
||||
setUserSolutions([
|
||||
...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
|
||||
@@ -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(() => {
|
||||
const words = inputText.split(" ").filter((x) => x !== "");
|
||||
|
||||
if (wordCounter.type === "min") {
|
||||
setIsSubmitEnabled(wordCounter.limit <= words.length || enableNavigation);
|
||||
setNavigation({ nextDisabled: !(wordCounter.limit <= words.length) });
|
||||
} else {
|
||||
setIsSubmitEnabled(true);
|
||||
setNavigation({ nextDisabled: false });
|
||||
|
||||
if (wordCounter.limit < words.length) {
|
||||
toast.warning(`You have reached your word limit of ${wordCounter.limit} words!`, { toastId: "word-limit" });
|
||||
setInputText(words.slice(0, words.length - 1).join(" "));
|
||||
}
|
||||
}
|
||||
}, [enableNavigation, inputText, wordCounter]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4 mt-4">
|
||||
<div className="flex justify-between w-full gap-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({
|
||||
}, [inputText, setNavigation, wordCounter]);
|
||||
|
||||
useEffect(() => {
|
||||
registerSolution(() => ({
|
||||
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>
|
||||
isPractice
|
||||
}));
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [id, type, isPractice, inputText]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4 mt-4">
|
||||
{headerButtons}
|
||||
{attachment && (
|
||||
<Transition show={isModalOpen} as={Fragment}>
|
||||
<Dialog onClose={() => setIsModalOpen(false)} className="relative z-50">
|
||||
<Transition.Child
|
||||
<TransitionChild
|
||||
as={Fragment}
|
||||
enter="ease-out duration-300"
|
||||
enterFrom="opacity-0"
|
||||
@@ -126,9 +112,9 @@ export default function Writing({
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0">
|
||||
<div className="fixed inset-0 bg-black/30" />
|
||||
</Transition.Child>
|
||||
</TransitionChild>
|
||||
|
||||
<Transition.Child
|
||||
<TransitionChild
|
||||
as={Fragment}
|
||||
enter="ease-out duration-300"
|
||||
enterFrom="opacity-0 scale-95"
|
||||
@@ -137,11 +123,11 @@ export default function Writing({
|
||||
leaveFrom="opacity-100 scale-100"
|
||||
leaveTo="opacity-0 scale-95">
|
||||
<div className="fixed inset-0 flex items-center justify-center p-4">
|
||||
<Dialog.Panel className="w-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" />
|
||||
</Dialog.Panel>
|
||||
</DialogPanel>
|
||||
</div>
|
||||
</Transition.Child>
|
||||
</TransitionChild>
|
||||
</Dialog>
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
{footerButtons}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Writing;
|
||||
|
||||
@@ -21,49 +21,38 @@ import InteractiveSpeaking from "./InteractiveSpeaking";
|
||||
|
||||
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 = (
|
||||
exercise: Exercise,
|
||||
examID: string,
|
||||
onNext: (userSolutions: UserSolution) => void,
|
||||
onBack: (userSolutions: UserSolution) => void,
|
||||
enableNavigation?: boolean,
|
||||
disableProgressButtons?: boolean,
|
||||
preview?: boolean,
|
||||
registerSolution: (updateSolution: () => UserSolution) => void,
|
||||
preview: boolean,
|
||||
headerButtons?: React.ReactNode,
|
||||
footerButtons?: React.ReactNode,
|
||||
) => {
|
||||
const sharedProps = {
|
||||
key: exercise.id,
|
||||
registerSolution,
|
||||
headerButtons,
|
||||
footerButtons,
|
||||
examID,
|
||||
preview
|
||||
}
|
||||
switch (exercise.type) {
|
||||
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":
|
||||
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":
|
||||
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":
|
||||
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":
|
||||
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":
|
||||
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":
|
||||
return <Speaking key={exercise.id} {...(exercise as SpeakingExercise)} onNext={onNext} onBack={onBack} examID={examID} preview={preview} />;
|
||||
return <Speaking {...(exercise as SpeakingExercise)} {...sharedProps}/>;
|
||||
case "interactiveSpeaking":
|
||||
return (
|
||||
<InteractiveSpeaking
|
||||
key={exercise.id}
|
||||
{...(exercise as InteractiveSpeakingExercise)}
|
||||
examID={examID}
|
||||
onNext={onNext}
|
||||
onBack={onBack}
|
||||
preview={preview}
|
||||
/>
|
||||
);
|
||||
return <InteractiveSpeaking {...(exercise as InteractiveSpeakingExercise)} {...sharedProps}/>;
|
||||
}
|
||||
};
|
||||
|
||||
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 {User} from "@/interfaces/user";
|
||||
import useExamStore from "@/stores/examStore";
|
||||
import useExamStore from "@/stores/exam";
|
||||
import axios from "axios";
|
||||
import {useState} from "react";
|
||||
import {toast} from "react-toastify";
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import Button from "@/components/Low/Button";
|
||||
import Modal from "@/components/Modal";
|
||||
import { Exam, LevelExam, MultipleChoiceExercise, ShuffleMap } from "@/interfaces/exam";
|
||||
import useExamStore from "@/stores/examStore";
|
||||
import useExamStore, { usePersistentExamStore } from "@/stores/exam";
|
||||
import clsx from "clsx";
|
||||
import { useMemo, useState } from "react";
|
||||
import { BsFillGrid3X3GapFill } from "react-icons/bs";
|
||||
@@ -10,16 +10,20 @@ interface Props {
|
||||
exam: LevelExam
|
||||
showSolutions: boolean;
|
||||
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 examState = useExamStore((state) => state);
|
||||
const persistentExamState = usePersistentExamStore((state) => state);
|
||||
|
||||
const {
|
||||
userSolutions,
|
||||
partIndex: sectionIndex,
|
||||
exerciseIndex,
|
||||
} = useExamStore((state) => state);
|
||||
} = !preview ? examState : persistentExamState;
|
||||
|
||||
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])
|
||||
|
||||
@@ -6,7 +6,7 @@ import { BsBook, BsClipboard, BsHeadphones, BsMegaphone, BsPen } from "react-ico
|
||||
import ProgressBar from "../../Low/ProgressBar";
|
||||
import Timer from "../Timer";
|
||||
import { Exercise, LevelExam } from "@/interfaces/exam";
|
||||
import useExamStore from "@/stores/examStore";
|
||||
import useExamStore, { usePersistentExamStore } from "@/stores/exam";
|
||||
import React from "react";
|
||||
import MCQuestionGrid from "./MCQuestionGrid";
|
||||
|
||||
@@ -23,7 +23,8 @@ interface Props {
|
||||
showSolutions?: boolean;
|
||||
currentExercise?: Exercise;
|
||||
runOnClick?: ((questionIndex: number) => void) | undefined;
|
||||
indexLabel?: string
|
||||
indexLabel?: string,
|
||||
preview: boolean,
|
||||
}
|
||||
|
||||
export default function ModuleTitle({
|
||||
@@ -38,9 +39,13 @@ export default function ModuleTitle({
|
||||
showTimer = true,
|
||||
showSolutions = false,
|
||||
runOnClick = undefined,
|
||||
indexLabel = "Question"
|
||||
indexLabel = "Question",
|
||||
preview,
|
||||
}: 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 } = {
|
||||
reading: <BsBook className="text-ielts-reading w-6 h-6" />,
|
||||
@@ -52,7 +57,6 @@ export default function ModuleTitle({
|
||||
|
||||
const showGrid = useMemo(() =>
|
||||
exam?.module === "level"
|
||||
&& partIndex > -1
|
||||
&& exam.parts[partIndex].exercises[examExerciseIndex].type === "multipleChoice"
|
||||
&& !!userSolutions,
|
||||
[exam, examExerciseIndex, partIndex, userSolutions]
|
||||
@@ -95,7 +99,7 @@ export default function ModuleTitle({
|
||||
</div>
|
||||
<ProgressBar color={module} label="" percentage={(exerciseIndex * 100) / totalExercises} className="h-2 w-full" />
|
||||
</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>
|
||||
</>
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import {Session} from "@/hooks/useSessions";
|
||||
import useExamStore from "@/stores/examStore";
|
||||
import {sortByModuleName} from "@/utils/moduleUtils";
|
||||
import axios from "axios";
|
||||
import clsx from "clsx";
|
||||
|
||||
@@ -11,10 +11,10 @@ import { uuidv4 } from "@firebase/util";
|
||||
import { useRouter } from "next/router";
|
||||
import { uniqBy } from "lodash";
|
||||
import { sortByModule } from "@/utils/moduleUtils";
|
||||
import { convertToUserSolutions } from "@/utils/stats";
|
||||
import { getExamById } from "@/utils/exams";
|
||||
import { Exam, UserSolution } from "@/interfaces/exam";
|
||||
import ModuleBadge from "../ModuleBadge";
|
||||
import useExamStore from "@/stores/exam";
|
||||
|
||||
const formatTimestamp = (timestamp: string | number) => {
|
||||
const time = typeof timestamp === "string" ? parseInt(timestamp) : timestamp;
|
||||
@@ -81,12 +81,6 @@ interface StatsGridItemProps {
|
||||
selectedTrainingExams?: string[];
|
||||
maxTrainingExams?: number;
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -100,12 +94,6 @@ const StatsGridItem: React.FC<StatsGridItemProps> = ({
|
||||
selectedTrainingExams,
|
||||
gradingSystem,
|
||||
setSelectedTrainingExams,
|
||||
setExams,
|
||||
setShowSolutions,
|
||||
setUserSolutions,
|
||||
setSelectedModules,
|
||||
setInactivity,
|
||||
setTimeSpent,
|
||||
renderPdfIcon,
|
||||
width = undefined,
|
||||
height = undefined,
|
||||
@@ -113,6 +101,8 @@ const StatsGridItem: React.FC<StatsGridItemProps> = ({
|
||||
maxTrainingExams = undefined,
|
||||
}) => {
|
||||
const router = useRouter();
|
||||
const dispatch = useExamStore((s) => s.dispatch);
|
||||
|
||||
const correct = stats.reduce((accumulator, current) => accumulator + current.score.correct, 0);
|
||||
const total = stats.reduce((accumulator, current) => accumulator + current.score.total, 0);
|
||||
const aggregatedScores = aggregateScoresByModule(stats).filter((x) => x.total > 0);
|
||||
@@ -171,17 +161,18 @@ const StatsGridItem: React.FC<StatsGridItemProps> = ({
|
||||
|
||||
Promise.all(examPromises).then((exams) => {
|
||||
if (exams.every((x) => !!x)) {
|
||||
if (!!timeSpent) setTimeSpent(timeSpent);
|
||||
if (!!inactivity) setInactivity(inactivity);
|
||||
setUserSolutions(convertToUserSolutions(stats));
|
||||
setShowSolutions(true);
|
||||
setExams(exams.map((x) => x!).sort(sortByModule));
|
||||
setSelectedModules(
|
||||
exams
|
||||
dispatch({
|
||||
type: 'INIT_SOLUTIONS', payload: {
|
||||
exams: exams.map((x) => x!).sort(sortByModule),
|
||||
modules: exams
|
||||
.map((x) => x!)
|
||||
.sort(sortByModule)
|
||||
.map((x) => x!.module),
|
||||
);
|
||||
stats,
|
||||
timeSpent,
|
||||
inactivity
|
||||
}
|
||||
});
|
||||
router.push("/exam");
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import useExamStore from "@/stores/examStore";
|
||||
import useExamStore from "@/stores/exam";
|
||||
import {useEffect, useState} from "react";
|
||||
import {motion} from "framer-motion";
|
||||
import TimerEndedModal from "../TimerEndedModal";
|
||||
@@ -16,10 +16,7 @@ const Timer: React.FC<Props> = ({minTimer, disableTimer, standalone = false}) =>
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [warningMode, setWarningMode] = useState(false);
|
||||
|
||||
const setHasExamEnded = useExamStore((state) => state.setHasExamEnded);
|
||||
const {timeSpent} = useExamStore((state) => state);
|
||||
|
||||
useEffect(() => setTimer((prev) => prev - timeSpent), [timeSpent]);
|
||||
const setTimeIsUp = useExamStore((state) => state.setTimeIsUp);
|
||||
|
||||
useEffect(() => {
|
||||
if (!disableTimer) {
|
||||
@@ -44,7 +41,7 @@ const Timer: React.FC<Props> = ({minTimer, disableTimer, standalone = false}) =>
|
||||
<TimerEndedModal
|
||||
isOpen={showModal}
|
||||
onClose={() => {
|
||||
setHasExamEnded(true);
|
||||
setTimeIsUp(true);
|
||||
setShowModal(false);
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -6,10 +6,10 @@ import Writing from "@/exams/Writing";
|
||||
import { usePersistentStorage } from "@/hooks/usePersistentStorage";
|
||||
import { LevelExam, ListeningExam, ReadingExam, SpeakingExam, WritingExam } from "@/interfaces/exam";
|
||||
import { User } from "@/interfaces/user";
|
||||
import { usePersistentExamStore } from "@/stores/examStore";
|
||||
import { usePersistentExamStore } from "@/stores/exam";
|
||||
import clsx from "clsx";
|
||||
|
||||
// todo: perms
|
||||
// TODO: perms
|
||||
|
||||
const Popout: React.FC<{ user: User }> = ({ user }) => {
|
||||
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={clsx("relative flex p-20 justify-center flex-1")}>
|
||||
{state.exam?.module == "level" && state.exam.parts && state.partIndex >= 0 &&
|
||||
<Level exam={state.exam as LevelExam} onFinish={() => {
|
||||
state.setPartIndex(0);
|
||||
state.setExerciseIndex(0);
|
||||
state.setQuestionIndex(0);
|
||||
}} preview={true} />
|
||||
<Level exam={state.exam as LevelExam} preview={true} />
|
||||
}
|
||||
{state.exam?.module == "writing" && state.exam.exercises && state.partIndex >= 0 &&
|
||||
<Writing exam={state.exam as WritingExam} onFinish={() => {
|
||||
state.setExerciseIndex(0);
|
||||
}} preview={true} />
|
||||
<Writing exam={state.exam as WritingExam} preview={true} />
|
||||
}
|
||||
{state.exam?.module == "reading" && state.exam.parts.length > 0 &&
|
||||
<Reading exam={state.exam as ReadingExam} onFinish={() => {
|
||||
state.setPartIndex(0);
|
||||
state.setExerciseIndex(-1);
|
||||
state.setQuestionIndex(0);
|
||||
}} preview={true} />
|
||||
<Reading exam={state.exam as ReadingExam} preview={true} />
|
||||
}
|
||||
{state.exam?.module == "listening" && state.exam.parts.length > 0 &&
|
||||
<Listening exam={state.exam as ListeningExam} onFinish={() => {
|
||||
state.setPartIndex(0);
|
||||
state.setExerciseIndex(-1);
|
||||
state.setQuestionIndex(0);
|
||||
}} preview={true} />
|
||||
<Listening exam={state.exam as ListeningExam} preview={true} />
|
||||
}
|
||||
{state.exam?.module == "speaking" && state.exam.exercises.length > 0 &&
|
||||
<Speaking exam={state.exam as SpeakingExam} onFinish={() => {
|
||||
state.setExerciseIndex(-1);
|
||||
}} preview={true} />
|
||||
<Speaking exam={state.exam as SpeakingExam} preview={true} />
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2,48 +2,12 @@ import { FillBlanksExercise, FillBlanksMCOption, ShuffleMap } from "@/interfaces
|
||||
import clsx from "clsx";
|
||||
import reactStringReplace from "react-string-replace";
|
||||
import { CommonProps } from ".";
|
||||
import { Fragment } from "react";
|
||||
import Button from "../Low/Button";
|
||||
import useExamStore from "@/stores/examStore";
|
||||
import useExamStore from "@/stores/exam";
|
||||
import { typeCheckWordsMC } from "@/utils/type.check";
|
||||
|
||||
export default function FillBlanksSolutions({ id, type, prompt, solutions, words, text, onNext, onBack, disableProgressButtons = false }: FillBlanksExercise & CommonProps) {
|
||||
const storeUserSolutions = useExamStore((state) => state.userSolutions);
|
||||
const { questionIndex, setQuestionIndex, partIndex, exam } = useExamStore((state) => state);
|
||||
|
||||
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 FillBlanksSolutions: React.FC<FillBlanksExercise & CommonProps> = ({ id, solutions, words, text, headerButtons, footerButtons}) => {
|
||||
const {userSolutions, shuffles} = useExamStore();
|
||||
const correctUserSolutions = userSolutions.find((solution) => solution.exercise === id)?.solutions;
|
||||
|
||||
const renderLines = (line: string) => {
|
||||
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 (
|
||||
<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")}>
|
||||
{headerButtons}
|
||||
<div className={clsx("flex flex-col gap-4 mt-4 h-full w-full", (!headerButtons || !footerButtons) && "mb-20")}>
|
||||
<span className="bg-mti-gray-smoke rounded-xl px-5 py-6">
|
||||
{correctUserSolutions &&
|
||||
text.split("\\n").map((line, index) => (
|
||||
@@ -197,9 +140,10 @@ export default function FillBlanksSolutions({ id, type, prompt, solutions, words
|
||||
Wrong
|
||||
</div>
|
||||
</div>
|
||||
{footerButtons}
|
||||
</div>
|
||||
|
||||
{!disableProgressButtons && progressButtons()}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default FillBlanksSolutions;
|
||||
|
||||
@@ -14,14 +14,11 @@ import ReactDiffViewer, {DiffMethod} from "react-diff-viewer";
|
||||
const Waveform = dynamic(() => import("../Waveform"), {ssr: false});
|
||||
|
||||
export default function InteractiveSpeaking({
|
||||
id,
|
||||
type,
|
||||
title,
|
||||
text,
|
||||
prompts,
|
||||
userSolutions,
|
||||
onNext,
|
||||
onBack,
|
||||
headerButtons,
|
||||
footerButtons,
|
||||
}: InteractiveSpeakingExercise & CommonProps) {
|
||||
const [solutionsURL, setSolutionsURL] = useState<string[]>([]);
|
||||
const [diffNumber, setDiffNumber] = useState(0);
|
||||
@@ -56,40 +53,7 @@ export default function InteractiveSpeaking({
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4 mt-4 w-full">
|
||||
<div className="flex justify-between w-full gap-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>
|
||||
|
||||
{headerButtons}
|
||||
<Modal title={`Correction (Prompt ${diffNumber})`} isOpen={diffNumber !== 0} onClose={() => setDiffNumber(0)}>
|
||||
<>
|
||||
{userSolutions &&
|
||||
@@ -291,40 +255,7 @@ export default function InteractiveSpeaking({
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
{footerButtons}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,14 +1,7 @@
|
||||
import { MatchSentenceExerciseSentence, MatchSentencesExercise } from "@/interfaces/exam";
|
||||
import clsx from "clsx";
|
||||
import LineTo from "react-lineto";
|
||||
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 Button from "../Low/Button";
|
||||
import Xarrow from "react-xarrows";
|
||||
import useExamStore from "@/stores/examStore";
|
||||
|
||||
function QuestionSolutionArea({
|
||||
question,
|
||||
@@ -22,7 +15,7 @@ function QuestionSolutionArea({
|
||||
<div className="flex items-center gap-3 cursor-pointer col-span-2">
|
||||
<button
|
||||
className={clsx(
|
||||
"text-white w-8 h-8 rounded-full z-10",
|
||||
"text-white p-2 rounded-full z-10",
|
||||
!userSolution
|
||||
? "bg-mti-gray-davy"
|
||||
: userSolution.option.toString() === question.solution.toString()
|
||||
@@ -55,51 +48,16 @@ function QuestionSolutionArea({
|
||||
export default function MatchSentencesSolutions({
|
||||
id,
|
||||
type,
|
||||
options,
|
||||
prompt,
|
||||
sentences,
|
||||
userSolutions,
|
||||
onNext,
|
||||
onBack,
|
||||
disableProgressButtons = false
|
||||
headerButtons,
|
||||
footerButtons,
|
||||
}: 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 (
|
||||
<div className="flex flex-col gap-4 mt-4">
|
||||
{!disableProgressButtons && progressButtons()}
|
||||
|
||||
<div className={clsx("flex flex-col gap-4 mt-4 h-full w-full", !disableProgressButtons && "mb-20")}>
|
||||
{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) => (
|
||||
<Fragment key={index}>
|
||||
@@ -131,9 +89,8 @@ export default function MatchSentencesSolutions({
|
||||
<div className="w-4 h-4 rounded-full bg-mti-rose" /> Wrong
|
||||
</div>
|
||||
</div>
|
||||
{footerButtons}
|
||||
</div>
|
||||
|
||||
{!disableProgressButtons && progressButtons()}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/* eslint-disable @next/next/no-img-element */
|
||||
import { MultipleChoiceExercise, MultipleChoiceQuestion, ShuffleMap } from "@/interfaces/exam";
|
||||
import useExamStore from "@/stores/examStore";
|
||||
import useExamStore from "@/stores/exam";
|
||||
import clsx from "clsx";
|
||||
import reactStringReplace from "react-string-replace";
|
||||
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 stats = useExamStore((state) => state.userSolutions);
|
||||
@@ -110,39 +110,6 @@ export default function MultipleChoice({ id, type, prompt, questions, userSoluti
|
||||
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 = () =>
|
||||
questions.map(question => (
|
||||
<div
|
||||
@@ -178,10 +145,10 @@ export default function MultipleChoice({ id, type, prompt, questions, userSoluti
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
{!disableProgressButtons && progressButtons()}
|
||||
{headerButtons}
|
||||
|
||||
<div className={clsx("flex flex-col gap-4 mt-4", !disableProgressButtons && "mb-20")}>
|
||||
{disableProgressButtons ? renderAllQuestions() : renderTwoQuestions()}
|
||||
<div className={clsx("flex flex-col gap-4 mt-4", (!headerButtons || !footerButtons) && "mb-20")}>
|
||||
{(!headerButtons || !footerButtons) ? renderAllQuestions() : renderTwoQuestions()}
|
||||
|
||||
<div className="flex gap-4 items-center">
|
||||
<div className="flex gap-2 items-center">
|
||||
@@ -197,9 +164,8 @@ export default function MultipleChoice({ id, type, prompt, questions, userSoluti
|
||||
Wrong
|
||||
</div>
|
||||
</div>
|
||||
{footerButtons}
|
||||
</div>
|
||||
|
||||
{!disableProgressButtons && progressButtons()}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ import ReactDiffViewer, {DiffMethod} from "react-diff-viewer";
|
||||
|
||||
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 [showDiff, setShowDiff] = useState(false);
|
||||
|
||||
@@ -45,40 +45,7 @@ export default function Speaking({id, type, title, video_url, text, prompts, use
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4 mt-4 w-full">
|
||||
<div className="flex justify-between w-full gap-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>
|
||||
|
||||
{headerButtons}
|
||||
<Modal title="Correction" isOpen={showDiff} onClose={() => setShowDiff(false)}>
|
||||
<>
|
||||
{userSolutions &&
|
||||
@@ -275,40 +242,7 @@ export default function Speaking({id, type, title, video_url, text, prompts, use
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
{footerButtons}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,23 +4,10 @@ import reactStringReplace from "react-string-replace";
|
||||
import { CommonProps } from ".";
|
||||
import { Fragment } from "react";
|
||||
import Button from "../Low/Button";
|
||||
import useExamStore from "@/stores/examStore";
|
||||
|
||||
type Solution = "true" | "false" | "not_given";
|
||||
|
||||
export default function TrueFalseSolution({ prompt, type, id, questions, userSolutions, onNext, onBack, disableProgressButtons = false }: 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 };
|
||||
};
|
||||
|
||||
export default function TrueFalseSolution({ prompt, type, id, questions, userSolutions, headerButtons, footerButtons }: TrueFalseExercise & CommonProps) {
|
||||
const getButtonColor = (buttonSolution: Solution, solution: Solution, userSolution: Solution | undefined) => {
|
||||
if (buttonSolution !== userSolution && buttonSolution !== solution) return "purple";
|
||||
|
||||
@@ -39,31 +26,11 @@ export default function TrueFalseSolution({ prompt, type, id, questions, userSol
|
||||
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 (
|
||||
<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">
|
||||
{prompt.split("\\n").map((line, index) => (
|
||||
<Fragment key={index}>
|
||||
@@ -139,9 +106,8 @@ export default function TrueFalseSolution({ prompt, type, id, questions, userSol
|
||||
Wrong
|
||||
</div>
|
||||
</div>
|
||||
{footerButtons}
|
||||
</div>
|
||||
|
||||
{!disableProgressButtons && progressButtons()}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,9 +6,6 @@ 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,
|
||||
@@ -50,11 +47,11 @@ function Blank({
|
||||
{userSolution && !isUserSolutionCorrect() && (
|
||||
<div
|
||||
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}
|
||||
</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(" / ")}
|
||||
</div>
|
||||
</span>
|
||||
@@ -62,33 +59,14 @@ function Blank({
|
||||
}
|
||||
|
||||
export default function WriteBlanksSolutions({
|
||||
id,
|
||||
type,
|
||||
prompt,
|
||||
maxWords,
|
||||
solutions,
|
||||
userSolutions,
|
||||
text,
|
||||
onNext,
|
||||
onBack,
|
||||
disableProgressButtons = false
|
||||
headerButtons,
|
||||
footerButtons,
|
||||
}: 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) => {
|
||||
return (
|
||||
<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 (
|
||||
<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">
|
||||
{prompt.split("\\n").map((line, index) => (
|
||||
<Fragment key={index}>
|
||||
@@ -161,9 +119,8 @@ export default function WriteBlanksSolutions({
|
||||
Wrong
|
||||
</div>
|
||||
</div>
|
||||
{footerButtons}
|
||||
</div>
|
||||
|
||||
{!disableProgressButtons && progressButtons()}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ import ReactDiffViewer, {DiffMethod} from "react-diff-viewer";
|
||||
import useUser from "@/hooks/useUser";
|
||||
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 [showDiff, setShowDiff] = useState(false);
|
||||
|
||||
@@ -31,40 +31,7 @@ export default function Writing({id, type, prompt, attachment, userSolutions, on
|
||||
|
||||
return (
|
||||
<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">
|
||||
<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>
|
||||
|
||||
{headerButtons}
|
||||
{attachment && (
|
||||
<Transition show={isModalOpen} as={Fragment}>
|
||||
<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 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>
|
||||
{footerButtons}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -22,29 +22,36 @@ import Writing from "./Writing";
|
||||
const MatchSentences = dynamic(() => import("@/components/Solutions/MatchSentences"), { ssr: false });
|
||||
|
||||
export interface CommonProps {
|
||||
onNext: (userSolutions: UserSolution) => void;
|
||||
onBack: (userSolutions: UserSolution) => void;
|
||||
disableProgressButtons?: boolean,
|
||||
headerButtons?: React.ReactNode,
|
||||
footerButtons?: React.ReactNode,
|
||||
}
|
||||
|
||||
export const renderSolution = (exercise: Exercise, onNext: () => void, onBack: () => void, updateIndex?: (internalIndex: number) => void,
|
||||
disableProgressButtons?: boolean) => {
|
||||
export const renderSolution = (
|
||||
exercise: Exercise,
|
||||
headerButtons?: React.ReactNode,
|
||||
footerButtons?: React.ReactNode,
|
||||
) => {
|
||||
const sharedProps = {
|
||||
key: exercise.id,
|
||||
headerButtons,
|
||||
footerButtons,
|
||||
}
|
||||
switch (exercise.type) {
|
||||
case "fillBlanks":
|
||||
return <FillBlanks disableProgressButtons={disableProgressButtons} key={exercise.id} {...(exercise as FillBlanksExercise)} onNext={onNext} onBack={onBack} />;
|
||||
return <FillBlanks {...(exercise as FillBlanksExercise)} {...sharedProps}/>;
|
||||
case "trueFalse":
|
||||
return <TrueFalseSolution disableProgressButtons={disableProgressButtons} key={exercise.id} {...(exercise as TrueFalseExercise)} onNext={onNext} onBack={onBack} />;
|
||||
return <TrueFalseSolution {...(exercise as TrueFalseExercise)} {...sharedProps}/>;
|
||||
case "matchSentences":
|
||||
return <MatchSentences disableProgressButtons={disableProgressButtons} key={exercise.id} {...(exercise as MatchSentencesExercise)} onNext={onNext} onBack={onBack} />;
|
||||
return <MatchSentences {...(exercise as MatchSentencesExercise)} {...sharedProps}/>;
|
||||
case "multipleChoice":
|
||||
return <MultipleChoice disableProgressButtons={disableProgressButtons} key={exercise.id} {...(exercise as MultipleChoiceExercise)} onNext={onNext} onBack={onBack} />;
|
||||
return <MultipleChoice {...(exercise as MultipleChoiceExercise)} {...sharedProps}/>;
|
||||
case "writeBlanks":
|
||||
return <WriteBlanks disableProgressButtons={disableProgressButtons} key={exercise.id} {...(exercise as WriteBlanksExercise)} onNext={onNext} onBack={onBack} />;
|
||||
return <WriteBlanks {...(exercise as WriteBlanksExercise)} {...sharedProps}/>;
|
||||
case "writing":
|
||||
return <Writing key={exercise.id} {...(exercise as WritingExercise)} onNext={onNext} onBack={onBack} />;
|
||||
return <Writing {...(exercise as WritingExercise)} {...sharedProps}/>;
|
||||
case "speaking":
|
||||
return <Speaking key={exercise.id} {...(exercise as SpeakingExercise)} onNext={onNext} onBack={onBack} />;
|
||||
return <Speaking {...(exercise as SpeakingExercise)} {...sharedProps}/>;
|
||||
case "interactiveSpeaking":
|
||||
return <InteractiveSpeaking key={exercise.id} {...(exercise as InteractiveSpeakingExercise)} onNext={onNext} onBack={onBack} />;
|
||||
return <InteractiveSpeaking {...(exercise as InteractiveSpeakingExercise)} {...sharedProps}/>;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -5,11 +5,10 @@ import useUsers from "@/hooks/useUsers";
|
||||
import { Module } from "@/interfaces";
|
||||
import { Assignment } from "@/interfaces/results";
|
||||
import { Stat, User } from "@/interfaces/user";
|
||||
import useExamStore from "@/stores/examStore";
|
||||
import useExamStore from "@/stores/exam";
|
||||
import { getExamById } from "@/utils/exams";
|
||||
import { sortByModule } from "@/utils/moduleUtils";
|
||||
import { calculateBandScore } from "@/utils/score";
|
||||
import {convertToUserSolutions} from "@/utils/stats";
|
||||
import { getUserName } from "@/utils/users";
|
||||
import axios from "axios";
|
||||
import clsx from "clsx";
|
||||
@@ -30,10 +29,7 @@ interface Props {
|
||||
export default function AssignmentView({ isOpen, users, assignment, onClose }: Props) {
|
||||
const router = useRouter();
|
||||
|
||||
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 dispatch = useExamStore((s) => s.dispatch);
|
||||
|
||||
const deleteAssignment = async () => {
|
||||
if (!confirm("Are you sure you want to delete this assignment?")) return;
|
||||
@@ -141,15 +137,16 @@ export default function AssignmentView({isOpen, users, assignment, onClose}: Pro
|
||||
|
||||
Promise.all(examPromises).then((exams) => {
|
||||
if (exams.every((x) => !!x)) {
|
||||
setUserSolutions(convertToUserSolutions(stats));
|
||||
setShowSolutions(true);
|
||||
setExams(exams.map((x) => x!).sort(sortByModule));
|
||||
setSelectedModules(
|
||||
exams
|
||||
dispatch({
|
||||
type: 'INIT_SOLUTIONS', payload: {
|
||||
exams: exams.map((x) => x!).sort(sortByModule),
|
||||
modules: exams
|
||||
.map((x) => x!)
|
||||
.sort(sortByModule)
|
||||
.map((x) => x!.module),
|
||||
);
|
||||
stats
|
||||
}
|
||||
});
|
||||
router.push("/exam");
|
||||
}
|
||||
});
|
||||
|
||||
@@ -10,7 +10,7 @@ import useUsers, { userHashStudent, userHashTeacher, userHashCorporate} from "@/
|
||||
import { Invite } from "@/interfaces/invite";
|
||||
import { Assignment } from "@/interfaces/results";
|
||||
import { CorporateUser, MasterCorporateUser, Stat, User } from "@/interfaces/user";
|
||||
import useExamStore from "@/stores/examStore";
|
||||
import useExamStore from "@/stores/exam";
|
||||
import { getExamById } from "@/utils/exams";
|
||||
import { getUserCorporate } from "@/utils/groups";
|
||||
import { countExamModules, countFullExams, MODULE_ARRAY, sortByModule, sortByModuleName } from "@/utils/moduleUtils";
|
||||
@@ -49,28 +49,23 @@ export default function StudentDashboard({user, linkedCorporate}: Props) {
|
||||
const users = useMemo(() => [...teachers, ...corporates], [teachers, corporates]);
|
||||
const router = useRouter();
|
||||
|
||||
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 setAssignment = useExamStore((state) => state.setAssignment);
|
||||
const dispatch = useExamStore((state) => state.dispatch);
|
||||
|
||||
const startAssignment = (assignment: Assignment) => {
|
||||
const examPromises = assignment.exams.filter((e) => e.assignee === user.id).map((e) => getExamById(e.module, e.id));
|
||||
|
||||
Promise.all(examPromises).then((exams) => {
|
||||
if (exams.every((x) => !!x)) {
|
||||
setUserSolutions([]);
|
||||
setShowSolutions(false);
|
||||
setExams(exams.map((x) => x!).sort(sortByModule));
|
||||
setSelectedModules(
|
||||
exams
|
||||
dispatch({
|
||||
type: "INIT_EXAM", payload: {
|
||||
exams: exams.map((x) => x!).sort(sortByModule),
|
||||
modules: exams
|
||||
.map((x) => x!)
|
||||
.sort(sortByModule)
|
||||
.map((x) => x!.module),
|
||||
);
|
||||
setAssignment(assignment);
|
||||
|
||||
assignment
|
||||
}
|
||||
})
|
||||
router.push("/exam");
|
||||
}
|
||||
});
|
||||
|
||||
@@ -3,12 +3,11 @@ import ModuleTitle from "@/components/Medium/ModuleTitle";
|
||||
import { moduleResultText } from "@/constants/ielts";
|
||||
import { Module } from "@/interfaces";
|
||||
import { User } from "@/interfaces/user";
|
||||
import useExamStore from "@/stores/examStore";
|
||||
import useExamStore from "@/stores/exam";
|
||||
import { calculateBandScore, getGradingLabel } from "@/utils/score";
|
||||
import clsx from "clsx";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
import { Fragment, useEffect, useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
BsArrowCounterclockwise,
|
||||
BsBan,
|
||||
@@ -56,9 +55,9 @@ export default function Finish({ user, scores, modules, information, solutions,
|
||||
const [selectedModule, setSelectedModule] = useState(modules[0]);
|
||||
const [selectedScore, setSelectedScore] = useState<Score>(scores.find((x) => x.module === modules[0])!);
|
||||
const [isExtraInformationOpen, setIsExtraInformationOpen] = useState(false);
|
||||
const {selectedModules, exams, dispatch} = useExamStore((s) => s);
|
||||
|
||||
const aiUsage = Math.round(ai_usage(solutions) * 100);
|
||||
const exams = useExamStore((state) => state.exams);
|
||||
const { gradingSystem } = useGradingSystem();
|
||||
|
||||
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>;
|
||||
};
|
||||
|
||||
const handlePlayAgain = () => {
|
||||
dispatch({type: "INIT_EXAM", payload: {exams, modules: selectedModules}})
|
||||
router.push(destination || "/exam")
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Modal title="Extra Information" isOpen={isExtraInformationOpen} onClose={() => setIsExtraInformationOpen(false)}>
|
||||
@@ -135,6 +139,7 @@ export default function Finish({ user, scores, modules, information, solutions,
|
||||
totalExercises={getTotalExercises()}
|
||||
exerciseIndex={getTotalExercises()}
|
||||
minTimer={exams.find((x) => x.module === selectedModule)!.minTimer}
|
||||
preview={false}
|
||||
disableTimer
|
||||
/>
|
||||
<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 w-fit cursor-pointer flex-col items-center gap-1">
|
||||
<button
|
||||
onClick={() => router.push(destination || "/exam")}
|
||||
onClick={handlePlayAgain}
|
||||
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">
|
||||
<BsArrowCounterclockwise className="h-7 w-7 text-white" />
|
||||
|
||||
@@ -5,11 +5,10 @@ import ModuleTitle from "@/components/Medium/ModuleTitle";
|
||||
import { renderSolution } from "@/components/Solutions";
|
||||
import { Module } from "@/interfaces";
|
||||
import { Exercise, FillBlanksMCOption, LevelExam, MultipleChoiceExercise, UserSolution } from "@/interfaces/exam";
|
||||
import useExamStore from "@/stores/examStore";
|
||||
import { usePersistentExamStore } from "@/stores/examStore";
|
||||
import useExamStore, { usePersistentExamStore } from "@/stores/exam";
|
||||
import { countExercises } from "@/utils/moduleUtils";
|
||||
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 PartDivider from "../Navigation/SectionDivider";
|
||||
import Timer from "@/components/Medium/Timer";
|
||||
@@ -19,39 +18,44 @@ import Modal from "@/components/Modal";
|
||||
import { typeCheckWordsMC } from "@/utils/type.check";
|
||||
import SectionNavbar from "../Navigation/SectionNavbar";
|
||||
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 updateTimers = useExamTimer(exam.module, preview || showSolutions);
|
||||
const userSolutionRef = useRef<(() => UserSolution) | null>(null);
|
||||
const [solutionWasUpdated, setSolutionWasUpdated] = useState(false);
|
||||
|
||||
const examState = useExamStore((state) => state);
|
||||
const persistentExamState = usePersistentExamStore((state) => state);
|
||||
|
||||
const {
|
||||
userSolutions,
|
||||
hasExamEnded,
|
||||
partIndex,
|
||||
exerciseIndex,
|
||||
questionIndex,
|
||||
shuffles,
|
||||
currentSolution,
|
||||
setTimeIsUp,
|
||||
setBgColor,
|
||||
setUserSolutions,
|
||||
setHasExamEnded,
|
||||
setPartIndex,
|
||||
setExerciseIndex,
|
||||
setQuestionIndex,
|
||||
setShuffles,
|
||||
setCurrentSolution
|
||||
flags,
|
||||
timeSpentCurrentModule,
|
||||
dispatch,
|
||||
} = !preview ? examState : persistentExamState;
|
||||
|
||||
const { finalizeModule, timeIsUp } = flags;
|
||||
|
||||
const timer = useRef(exam.minTimer - timeSpentCurrentModule / 60);
|
||||
|
||||
// In case client want to switch back
|
||||
const textRenderDisabled = true;
|
||||
|
||||
@@ -61,8 +65,6 @@ export default function Level({ exam, showSolutions = false, onFinish, preview =
|
||||
const [continueAnyways, setContinueAnyways] = useState(false);
|
||||
const [textRender, setTextRender] = 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]));
|
||||
|
||||
@@ -72,8 +74,6 @@ export default function Level({ exam, showSolutions = false, onFinish, preview =
|
||||
type: "blankQuestions",
|
||||
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 [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
|
||||
}, [exerciseIndex]);
|
||||
|
||||
useEffect(() => {
|
||||
if (currentExercise === undefined && partIndex === 0 && exerciseIndex === 0) {
|
||||
setCurrentExercise(exam.parts[0].exercises[0]);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [currentExercise, partIndex, exerciseIndex]);
|
||||
const registerSolution = useCallback((updateSolution: () => UserSolution) => {
|
||||
userSolutionRef.current = updateSolution;
|
||||
setSolutionWasUpdated(true);
|
||||
}, []);
|
||||
|
||||
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 [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(() => {
|
||||
if (showSolutions) {
|
||||
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
|
||||
}, []);
|
||||
|
||||
const getExercise = () => {
|
||||
let exercise = exam.parts[partIndex]?.exercises[exerciseIndex];
|
||||
|
||||
const currentExercise = useMemo<Exercise>(() => {
|
||||
let exercise = exam.parts[partIndex].exercises[exerciseIndex];
|
||||
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);
|
||||
return exercise;
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [partIndex, exerciseIndex]);
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
setCurrentExercise(getExercise());
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [partIndex, exerciseIndex, questionIndex]);
|
||||
|
||||
const next = () => {
|
||||
setNextExerciseCalled(true);
|
||||
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
|
||||
}, [solutionWasUpdated]);
|
||||
|
||||
useEffect(() => {
|
||||
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 = () => {
|
||||
scrollToTop();
|
||||
|
||||
if (exerciseIndex + 1 < exam.parts[partIndex].exercises.length && !hasExamEnded) {
|
||||
if (exerciseIndex + 1 < exam.parts[partIndex].exercises.length) {
|
||||
setExerciseIndex(exerciseIndex + 1);
|
||||
setCurrentSolutionSet(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (partIndex + 1 === exam.parts.length && !hasExamEnded && !showQuestionsModal && !showSolutions && !continueAnyways) {
|
||||
if (partIndex + 1 === exam.parts.length && !showQuestionsModal && !showSolutions && !continueAnyways) {
|
||||
modalKwargs();
|
||||
setShowQuestionsModal(true);
|
||||
return;
|
||||
}
|
||||
|
||||
if (partIndex + 1 < exam.parts.length && !hasExamEnded) {
|
||||
if (!answeredEveryQuestion(partIndex) && !continueAnyways && !showSolutions && !seenParts.has(partIndex + 1)) {
|
||||
if (partIndex + 1 < exam.parts.length) {
|
||||
if (!answeredEveryQuestionInPart(exam, partIndex, userSolutions) && !continueAnyways && !showSolutions && !seenParts.has(partIndex + 1)) {
|
||||
modalKwargs();
|
||||
setShowQuestionsModal(true);
|
||||
return;
|
||||
@@ -181,7 +173,6 @@ export default function Level({ exam, showSolutions = false, onFinish, preview =
|
||||
setPartIndex(partIndex + 1);
|
||||
setExerciseIndex(0);
|
||||
setQuestionIndex(0);
|
||||
setCurrentSolutionSet(false);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -190,24 +181,14 @@ export default function Level({ exam, showSolutions = false, onFinish, preview =
|
||||
setShowQuestionsModal(true);
|
||||
}
|
||||
|
||||
setHasExamEnded(false);
|
||||
setCurrentSolutionSet(false);
|
||||
if (typeof showSolutionsSave !== "undefined") {
|
||||
onFinish(showSolutionsSave);
|
||||
if (!showSolutions) {
|
||||
dispatch({ type: "FINALIZE_MODULE", payload: { updateTimers: true } })
|
||||
} else {
|
||||
onFinish(userSolutions);
|
||||
dispatch({ type: "FINALIZE_MODULE_SOLUTIONS"})
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (nextExerciseCalled && currentSolutionSet) {
|
||||
nextExercise();
|
||||
setNextExerciseCalled(false);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [nextExerciseCalled, currentSolutionSet])
|
||||
|
||||
const previousExercise = (solution?: UserSolution) => {
|
||||
const previousExercise = () => {
|
||||
scrollToTop();
|
||||
|
||||
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(() => {
|
||||
const regex = /.*?['"](.*?)['"] in line (\d+)\?$/;
|
||||
|
||||
@@ -408,8 +370,7 @@ export default function Level({ exam, showSolutions = false, onFinish, preview =
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
exerciseIndex !== -1 && currentExercise &&
|
||||
currentExercise.type === "multipleChoice" &&
|
||||
currentExercise && currentExercise.type === "multipleChoice" &&
|
||||
exam.parts[partIndex].context && contextWordLines
|
||||
) {
|
||||
if (contextWordLines.length > 0) {
|
||||
@@ -446,7 +407,7 @@ export default function Level({ exam, showSolutions = false, onFinish, preview =
|
||||
|
||||
if (partIndex === exam.parts.length - 1) {
|
||||
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) } };
|
||||
}
|
||||
setQuestionModalKwargs(kwargs);
|
||||
@@ -462,6 +423,7 @@ export default function Level({ exam, showSolutions = false, onFinish, preview =
|
||||
runOnClick: setQuestionIndex
|
||||
}
|
||||
|
||||
const progressButtons = <ProgressButtons handlePrevious={previousExercise} handleNext={nextExercise} />;
|
||||
|
||||
const memoizedRender = useMemo(() => {
|
||||
setChangedPrompt(false);
|
||||
@@ -473,8 +435,8 @@ export default function Level({ exam, showSolutions = false, onFinish, preview =
|
||||
{exam.parts[partIndex]?.context && renderText()}
|
||||
{exam.parts[partIndex]?.audio && renderAudioPlayer()}
|
||||
{(showSolutions) ?
|
||||
currentExercise && renderSolution(currentExercise, nextExercise, previousExercise) :
|
||||
currentExercise && renderExercise(currentExercise, exam.id, next, previousExercise)
|
||||
currentExercise && renderSolution(currentExercise, progressButtons, progressButtons) :
|
||||
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)); }}
|
||||
/> : (
|
||||
<>
|
||||
{exam.parts[0].intro && (
|
||||
<SectionNavbar
|
||||
module="level"
|
||||
sections={exam.parts}
|
||||
sectionLabel="Part"
|
||||
sectionIndex={partIndex}
|
||||
setSectionIndex={setPartIndex}
|
||||
onClick={
|
||||
(index: number) => {
|
||||
setExerciseIndex(0);
|
||||
setQuestionIndex(0);
|
||||
if (!seenParts.has(index)) {
|
||||
setShowPartDivider(true);
|
||||
setBgColor(levelBgColor);
|
||||
setSeenParts(prev => new Set(prev).add(index));
|
||||
}
|
||||
}
|
||||
} />
|
||||
)}
|
||||
seenParts={seenParts}
|
||||
setShowPartDivider={setShowPartDivider}
|
||||
setSeenParts={setSeenParts}
|
||||
preview={preview}
|
||||
/>
|
||||
<ModuleTitle
|
||||
examLabel={exam.label}
|
||||
partLabel={partLabel()}
|
||||
minTimer={exam.minTimer}
|
||||
minTimer={timer.current}
|
||||
exerciseIndex={calculateExerciseIndex()}
|
||||
module="level"
|
||||
totalExercises={countExercises(exam.parts.flatMap((x) => x.exercises))}
|
||||
disableTimer={showSolutions}
|
||||
showTimer={false}
|
||||
preview={preview}
|
||||
{...mcNavKwargs}
|
||||
/>
|
||||
<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 { Fragment, useEffect, useState } from "react";
|
||||
import { Exercise, ListeningExam, MultipleChoiceExercise, Script, UserSolution } from "@/interfaces/exam";
|
||||
import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { renderExercise } from "@/components/Exercises";
|
||||
import { renderSolution } from "@/components/Solutions";
|
||||
import ModuleTitle from "@/components/Medium/ModuleTitle";
|
||||
import AudioPlayer from "@/components/Low/AudioPlayer";
|
||||
import Button from "@/components/Low/Button";
|
||||
import BlankQuestionsModal from "@/components/QuestionsModal";
|
||||
import useExamStore, { usePersistentExamStore } from "@/stores/examStore";
|
||||
import useExamStore, { usePersistentExamStore } from "@/stores/exam";
|
||||
import PartDivider from "./Navigation/SectionDivider";
|
||||
import { Dialog, Transition } from "@headlessui/react";
|
||||
import { capitalize } from "lodash";
|
||||
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 }) {
|
||||
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">
|
||||
<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 Listening: React.FC<ExamProps<ListeningExam>> = ({ exam, showSolutions = false, preview = false }) => {
|
||||
const updateTimers = useExamTimer(exam.module, preview || showSolutions);
|
||||
const userSolutionRef = useRef<(() => UserSolution) | null>(null);
|
||||
const [solutionWasUpdated, setSolutionWasUpdated] = useState(false);
|
||||
|
||||
const [showTextModal, setShowTextModal] = useState(false);
|
||||
const [timesListened, setTimesListened] = useState(0);
|
||||
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 persistentExamState = usePersistentExamStore((state) => state);
|
||||
|
||||
const {
|
||||
hasExamEnded,
|
||||
userSolutions,
|
||||
exerciseIndex,
|
||||
partIndex,
|
||||
questionIndex: storeQuestionIndex,
|
||||
setBgColor,
|
||||
setUserSolutions,
|
||||
setHasExamEnded,
|
||||
setExerciseIndex,
|
||||
setPartIndex,
|
||||
setQuestionIndex: setStoreQuestionIndex
|
||||
exerciseIndex, partIndex, assignment,
|
||||
userSolutions, flags, timeSpentCurrentModule,
|
||||
questionIndex,
|
||||
setBgColor, setUserSolutions, setTimeIsUp,
|
||||
dispatch
|
||||
} = !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(() => {
|
||||
if (!showSolutions && exam.parts[partIndex]?.intro !== undefined && exam.parts[partIndex]?.intro !== "" && !seenParts.has(exerciseIndex)) {
|
||||
setShowPartDivider(true);
|
||||
setBgColor(listeningBgColor);
|
||||
if (finalizeModule || timeIsUp) {
|
||||
updateTimers();
|
||||
if (timeIsUp) setTimeIsUp(false);
|
||||
dispatch({ type: "FINALIZE_MODULE", payload: { updateTimers: false } })
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [partIndex]);
|
||||
}, [finalizeModule, timeIsUp])
|
||||
|
||||
useEffect(() => {
|
||||
if (showSolutions) return setExerciseIndex(-1);
|
||||
}, [setExerciseIndex, showSolutions]);
|
||||
|
||||
useEffect(() => {
|
||||
if (partIndex === -1 && exam.variant === "partial") {
|
||||
setPartIndex(0);
|
||||
}
|
||||
}, [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
|
||||
const registerSolution = useCallback((updateSolution: () => UserSolution) => {
|
||||
userSolutionRef.current = updateSolution;
|
||||
setSolutionWasUpdated(true);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (hasExamEnded && exerciseIndex === -1) {
|
||||
setExerciseIndex(exerciseIndex + 1);
|
||||
if (solutionWasUpdated && userSolutionRef.current) {
|
||||
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) => {
|
||||
if (!keepGoing) {
|
||||
setShowBlankModal(false);
|
||||
return;
|
||||
} else {
|
||||
nextExercise(true);
|
||||
setShowBlankModal(false);
|
||||
}
|
||||
|
||||
onFinish(userSolutions);
|
||||
};
|
||||
|
||||
const nextExercise = (solution?: UserSolution) => {
|
||||
if (solution)
|
||||
setUserSolutions([
|
||||
...userSolutions.filter((x) => x.exercise !== solution.exercise),
|
||||
{ ...solution, module: "listening", exam: exam.id }
|
||||
]);
|
||||
};
|
||||
const memoizedExerciseIndex = useMemo(() =>
|
||||
calculateExerciseIndex(exam, partIndex, exerciseIndex, questionIndex)
|
||||
|
||||
const previousExercise = (solution?: UserSolution) => { };
|
||||
|
||||
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>
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
, [partIndex, exerciseIndex, questionIndex]
|
||||
);
|
||||
|
||||
const renderAudioPlayer = () => (
|
||||
<div className="flex flex-col gap-8 w-full bg-mti-gray-seasalt rounded-xl py-8 px-16">
|
||||
{exam?.parts[partIndex]?.audio?.source ? (
|
||||
<>
|
||||
<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">
|
||||
{(() => {
|
||||
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>
|
||||
)}
|
||||
const handlePartDividerClick = () => {
|
||||
setShowPartDivider(false);
|
||||
setBgColor("bg-white");
|
||||
setSeenParts((prev) => new Set(prev).add(partIndex));
|
||||
if (isFirstTimeRender) setIsFirstTimeRender(false);
|
||||
}
|
||||
|
||||
</div>
|
||||
);
|
||||
useEffect(() => {
|
||||
setTimesListened(0);
|
||||
}, [partIndex])
|
||||
|
||||
const progressButtons = () => (
|
||||
<div className="flex justify-between w-full gap-8">
|
||||
<Button
|
||||
color="purple"
|
||||
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>
|
||||
)
|
||||
const progressButtons = useMemo(() =>
|
||||
// Do not remove the ()=> in handle next
|
||||
<ProgressButtons handlePrevious={previousExercise} handleNext={() => nextExercise()} />
|
||||
, [nextExercise, previousExercise]);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -292,73 +132,64 @@ export default function Listening({ exam, showSolutions = false, preview = false
|
||||
defaultTitle="Listening exam"
|
||||
section={exam.parts[partIndex]}
|
||||
sectionIndex={partIndex}
|
||||
onNext={() => { setShowPartDivider(false); setBgColor("bg-white"); setSeenParts((prev) => new Set(prev).add(exerciseIndex)) }}
|
||||
onNext={handlePartDividerClick}
|
||||
/> : (
|
||||
<>
|
||||
<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)} />
|
||||
}
|
||||
<div className="flex flex-col h-full w-full gap-8 justify-between">
|
||||
<ModuleTitle
|
||||
exerciseIndex={partIndex + 1}
|
||||
minTimer={exam.minTimer}
|
||||
{exam.parts.length > 1 && <SectionNavbar
|
||||
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}
|
||||
indexLabel="Part"
|
||||
indexLabel="Exercise"
|
||||
preview={preview}
|
||||
/>
|
||||
|
||||
{/* Audio Player for the Instructions */}
|
||||
{partIndex === -1 && renderAudioInstructionsPlayer()}
|
||||
{isFirstTimeRender && <RenderAudioInstructionsPlayer />}
|
||||
|
||||
{/* 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 */}
|
||||
|
||||
{exerciseIndex > -1 && partIndex > -1 && (
|
||||
<>
|
||||
{progressButtons()}
|
||||
{renderPartExercises()}
|
||||
{progressButtons()}
|
||||
</>
|
||||
)}
|
||||
{!isFirstTimeRender && !showPartDivider && !showSolutions && renderExercise(currentExercise, exam.id, registerSolution, preview, progressButtons, progressButtons)}
|
||||
{showSolutions && renderSolution(currentExercise, progressButtons, progressButtons)}
|
||||
</div>
|
||||
|
||||
{exerciseIndex === -1 && partIndex > -1 && exam.variant !== "partial" && (
|
||||
<div className="self-end flex justify-between w-full gap-8 absolute bottom-8 left-0 px-8">
|
||||
<Button
|
||||
color="purple"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
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>
|
||||
)}
|
||||
{((isFirstTimeRender) && !showPartDivider && !showSolutions) &&
|
||||
<ProgressButtons
|
||||
hidePrevious={partIndex == 0 && isFirstTimeRender}
|
||||
nextLabel={isFirstTimeRender ? "Start now" : "Next Page"}
|
||||
handlePrevious={previousExercise}
|
||||
handleNext={() => isFirstTimeRender ? setIsFirstTimeRender(false) : nextExercise()} />
|
||||
}
|
||||
</>)
|
||||
}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default Listening;
|
||||
|
||||
@@ -1,26 +1,64 @@
|
||||
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 clsx from "clsx";
|
||||
import React from "react";
|
||||
import hasDivider from "../utils/hasDivider";
|
||||
|
||||
interface Props {
|
||||
module: Module;
|
||||
sections: LevelPart[] | ReadingPart[] | ListeningPart[] | WritingExercise[] | SpeakingExercise[];
|
||||
sectionIndex: number;
|
||||
sectionLabel: string;
|
||||
setSectionIndex: (index: number) => void;
|
||||
onClick: (index: number) => void;
|
||||
seenParts: Set<number>;
|
||||
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, 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));
|
||||
}
|
||||
}
|
||||
|
||||
const SectionNavbar: React.FC<Props> = ({module, sections, sectionIndex, sectionLabel, setSectionIndex, onClick}) => {
|
||||
return (
|
||||
|
||||
<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`}>
|
||||
{sections.map((_, index) =>
|
||||
<Tab key={index} onClick={() => onClick(index)}
|
||||
<Tab key={index} onClick={() => handleClick(index)}
|
||||
className={({ selected }) =>
|
||||
clsx(
|
||||
`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 { Fragment, useEffect, useState } from "react";
|
||||
import { Exercise, ReadingExam, UserSolution } from "@/interfaces/exam";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import clsx from "clsx";
|
||||
import { convertCamelCaseToReadable } from "@/utils/string";
|
||||
import { Dialog, Transition } from "@headlessui/react";
|
||||
import { renderExercise } from "@/components/Exercises";
|
||||
import { renderSolution } from "@/components/Solutions";
|
||||
import { BsChevronDown, BsChevronUp } from "react-icons/bs";
|
||||
import ModuleTitle from "@/components/Medium/ModuleTitle";
|
||||
import Button from "@/components/Low/Button";
|
||||
import BlankQuestionsModal from "@/components/QuestionsModal";
|
||||
import useExamStore, { usePersistentExamStore } from "@/stores/examStore";
|
||||
import { countExercises } from "@/utils/moduleUtils";
|
||||
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 {
|
||||
exam: ReadingExam;
|
||||
showSolutions?: boolean;
|
||||
preview?: boolean;
|
||||
onFinish: (userSolutions: UserSolution[]) => void;
|
||||
}
|
||||
const Reading: React.FC<ExamProps<ReadingExam>> = ({ exam, showSolutions = false, preview = false }) => {
|
||||
const updateTimers = useExamTimer(exam.module, preview || showSolutions);
|
||||
const userSolutionRef = useRef<(() => UserSolution) | null>(null);
|
||||
const [solutionWasUpdated, setSolutionWasUpdated] = useState(false);
|
||||
|
||||
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 [multipleChoicesDone, setMultipleChoicesDone] = useState<{ id: string; amount: number }[]>([]);
|
||||
//const [showTextModal, setShowTextModal] = 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 persistentExamState = usePersistentExamStore((state) => state);
|
||||
|
||||
const {
|
||||
hasExamEnded,
|
||||
userSolutions,
|
||||
exerciseIndex,
|
||||
partIndex,
|
||||
questionIndex: storeQuestionIndex,
|
||||
setBgColor,
|
||||
setUserSolutions,
|
||||
setHasExamEnded,
|
||||
setExerciseIndex,
|
||||
setPartIndex,
|
||||
setQuestionIndex: setStoreQuestionIndex
|
||||
exerciseIndex, partIndex, questionIndex,
|
||||
userSolutions, flags, timeSpentCurrentModule,
|
||||
setBgColor, setUserSolutions, setTimeIsUp,
|
||||
dispatch
|
||||
} = !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(() => {
|
||||
if (!showSolutions && exam.parts[partIndex]?.intro !== undefined && exam.parts[partIndex]?.intro !== "" && !seenParts.has(exerciseIndex)) {
|
||||
setShowPartDivider(true);
|
||||
setBgColor(readingBgColor);
|
||||
if (finalizeModule || timeIsUp) {
|
||||
updateTimers();
|
||||
|
||||
if (timeIsUp) {
|
||||
setTimeIsUp(false);
|
||||
}
|
||||
dispatch({ type: "FINALIZE_MODULE", payload: { updateTimers: false } })
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [partIndex]);
|
||||
|
||||
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
|
||||
}, []);
|
||||
}, [finalizeModule, timeIsUp])
|
||||
|
||||
useEffect(() => {
|
||||
const listener = (e: KeyboardEvent) => {
|
||||
@@ -164,144 +68,70 @@ export default function Reading({ exam, showSolutions = false, preview = false,
|
||||
e.preventDefault();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("keydown", listener);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("keydown", listener);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const registerSolution = useCallback((updateSolution: () => UserSolution) => {
|
||||
userSolutionRef.current = updateSolution;
|
||||
setSolutionWasUpdated(true);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (hasExamEnded && exerciseIndex === -1) {
|
||||
setExerciseIndex(exerciseIndex + 1);
|
||||
}
|
||||
}, [hasExamEnded, exerciseIndex, setExerciseIndex]);
|
||||
|
||||
const confirmFinishModule = (keepGoing?: boolean) => {
|
||||
if (!keepGoing) {
|
||||
setShowBlankModal(false);
|
||||
return;
|
||||
}
|
||||
|
||||
onFinish(userSolutions);
|
||||
};
|
||||
|
||||
const nextExercise = (solution?: UserSolution) => {
|
||||
scrollToTop();
|
||||
if (solution) {
|
||||
if (solutionWasUpdated && userSolutionRef.current) {
|
||||
const solution = userSolutionRef.current();
|
||||
setUserSolutions([...userSolutions.filter((x) => x.exercise !== solution.exercise), { ...solution, module: "reading", exam: exam.id }]);
|
||||
setSolutionWasUpdated(false);
|
||||
}
|
||||
if (storeQuestionIndex > 0) {
|
||||
const exercise = getExercise();
|
||||
setExerciseType(exercise.type);
|
||||
setMultipleChoicesDone((prev) => [...prev.filter((x) => x.id !== exercise.id), { id: exercise.id, amount: storeQuestionIndex }]);
|
||||
}
|
||||
setStoreQuestionIndex(0);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [solutionWasUpdated])
|
||||
|
||||
if (exerciseIndex + 1 < exam.parts[partIndex].exercises.length && !hasExamEnded) {
|
||||
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 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) => {
|
||||
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(() => {
|
||||
if (partIndex > -1 && exerciseIndex > -1) {
|
||||
const exercise = getExercise();
|
||||
setExerciseType(exercise.type);
|
||||
setMultipleChoicesDone((prev) => prev.filter((x) => x.id !== exercise.id));
|
||||
if (partIndex !== 0 && !showSolutions) {
|
||||
setIsBetweenParts(true);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [exerciseIndex, partIndex]);
|
||||
}, [partIndex, setIsBetweenParts, showSolutions])
|
||||
|
||||
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 exercisesDone = exercisesPerPart.filter((_, index) => index < partIndex).reduce((acc, curr) => curr + acc, 0);
|
||||
return (
|
||||
exercisesDone +
|
||||
(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>
|
||||
);
|
||||
const progressButtons = useMemo(() =>
|
||||
// Do not remove the ()=> in handle next
|
||||
<ProgressButtons handlePrevious={previousExercise} handleNext={() => nextExercise()} />
|
||||
, [nextExercise, previousExercise]);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -313,42 +143,47 @@ export default function Reading({ exam, showSolutions = false, preview = false,
|
||||
defaultTitle="Reading exam"
|
||||
section={exam.parts[partIndex]}
|
||||
sectionIndex={partIndex}
|
||||
onNext={() => { setShowPartDivider(false); setBgColor("bg-white"); setSeenParts((prev) => new Set(prev).add(exerciseIndex)) }}
|
||||
onNext={() => handlePartDividerClick()}
|
||||
/>
|
||||
</div> : (
|
||||
<>
|
||||
<div className="flex flex-col h-full w-full gap-8">
|
||||
<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
|
||||
minTimer={exam.minTimer}
|
||||
exerciseIndex={calculateExerciseIndex()}
|
||||
minTimer={timer.current}
|
||||
exerciseIndex={memoizedExerciseIndex}
|
||||
module="reading"
|
||||
totalExercises={countExercises(exam.parts.flatMap((x) => x.exercises))}
|
||||
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
|
||||
className={clsx(
|
||||
"mb-20 w-full",
|
||||
exerciseIndex > -1 && !isTextMinimized && "grid grid-cols-2 gap-4",
|
||||
exerciseIndex > -1 && isTextMinimized && "flex flex-col gap-2",
|
||||
((isFirstTimeRender || isBetweenParts) && !showSolutions) ? "flex flex-col gap-2" : "grid grid-cols-2 gap-4",
|
||||
)}>
|
||||
{partIndex > -1 && renderText()}
|
||||
|
||||
{exerciseIndex > -1 &&
|
||||
partIndex > -1 &&
|
||||
exerciseIndex < exam.parts[partIndex].exercises.length &&
|
||||
!showSolutions &&
|
||||
renderExercise(getExercise(), exam.id, nextExercise, previousExercise, undefined, preview)}
|
||||
|
||||
{exerciseIndex > -1 &&
|
||||
partIndex > -1 &&
|
||||
exerciseIndex < exam.parts[partIndex].exercises.length &&
|
||||
showSolutions &&
|
||||
renderSolution(exam.parts[partIndex].exercises[exerciseIndex], nextExercise, previousExercise)}
|
||||
<ReadingPassage
|
||||
exam={exam}
|
||||
partIndex={partIndex}
|
||||
exerciseType={currentExercise.type}
|
||||
isTextMinimized={isTextMinimized}
|
||||
setIsTextMinimized={setIsTextMinimzed}
|
||||
/>
|
||||
{!isFirstTimeRender && !showPartDivider && !showSolutions && !isBetweenParts && renderExercise(currentExercise, exam.id, registerSolution, preview, progressButtons, progressButtons)}
|
||||
{showSolutions && renderSolution(currentExercise, progressButtons, progressButtons)}
|
||||
</div>
|
||||
{exerciseIndex > -1 && partIndex > -1 && exerciseIndex < exam.parts[partIndex].exercises.length && (
|
||||
{/*exerciseIndex > -1 && partIndex > -1 && exerciseIndex < exam.parts[partIndex].exercises.length && (
|
||||
<Button
|
||||
color="purple"
|
||||
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">
|
||||
Read text
|
||||
</Button>
|
||||
)}
|
||||
)*/}
|
||||
</div>
|
||||
{exerciseIndex === -1 && partIndex > 0 && (
|
||||
<div className="self-end flex justify-between w-full gap-8 absolute bottom-8 left-0 px-8">
|
||||
<Button
|
||||
color="purple"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
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>
|
||||
)}
|
||||
{((isFirstTimeRender || isBetweenParts) && !showPartDivider && !showSolutions) &&
|
||||
<ProgressButtons
|
||||
hidePrevious={partIndex == 0 && isBetweenParts || isFirstTimeRender}
|
||||
nextLabel={isFirstTimeRender ? "Start now" : "Next Page"}
|
||||
handlePrevious={previousExercise}
|
||||
handleNext={() => isFirstTimeRender ? setIsFirstTimeRender(false) : nextExercise()} />
|
||||
}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default Reading;
|
||||
|
||||
@@ -15,7 +15,7 @@ import ProfileSummary from "@/components/ProfileSummary";
|
||||
import {ShuffleMap, Shuffles, Variant} from "@/interfaces/exam";
|
||||
import useSessions, {Session} from "@/hooks/useSessions";
|
||||
import SessionCard from "@/components/Medium/SessionCard";
|
||||
import useExamStore from "@/stores/examStore";
|
||||
import useExamStore from "@/stores/exam";
|
||||
import moment from "moment";
|
||||
|
||||
interface Props {
|
||||
@@ -32,7 +32,7 @@ export default function Selection({user, page, onStart}: Props) {
|
||||
const {data: stats} = useFilterRecordsByUser<Stat[]>(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 modules = selectedModules.filter((x) => x !== module);
|
||||
@@ -44,19 +44,7 @@ export default function Selection({user, page, onStart}: Props) {
|
||||
)
|
||||
|
||||
const loadSession = async (session: Session) => {
|
||||
state.setShuffles(session.userSolutions.map((x) => ({exerciseID: x.exercise, shuffles: x.shuffleMaps ? x.shuffleMaps : []})));
|
||||
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);
|
||||
dispatch({type: "SET_SESSION", payload: { session }})
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,112 +1,104 @@
|
||||
import { renderExercise } from "@/components/Exercises";
|
||||
import ModuleTitle from "@/components/Medium/ModuleTitle";
|
||||
import { renderSolution } from "@/components/Solutions";
|
||||
import { infoButtonStyle } from "@/constants/buttonStyles";
|
||||
import { UserSolution, SpeakingExam, SpeakingExercise, InteractiveSpeakingExercise } from "@/interfaces/exam";
|
||||
import useExamStore, { usePersistentExamStore } from "@/stores/examStore";
|
||||
import { defaultUserSolutions } from "@/utils/exams";
|
||||
import { UserSolution, SpeakingExam, SpeakingExercise, InteractiveSpeakingExercise, Exercise } from "@/interfaces/exam";
|
||||
import useExamStore, { usePersistentExamStore } from "@/stores/exam";
|
||||
import { countExercises } from "@/utils/moduleUtils";
|
||||
import { convertCamelCaseToReadable } from "@/utils/string";
|
||||
import { mdiArrowRight } from "@mdi/js";
|
||||
import Icon from "@mdi/react";
|
||||
import clsx from "clsx";
|
||||
import { Fragment, useEffect, useState } from "react";
|
||||
import { toast } from "react-toastify";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
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 [speakingPromptsDone, setSpeakingPromptsDone] = useState<{ id: string; amount: number }[]>([]);
|
||||
|
||||
const speakingBgColor = "bg-ielts-speaking-light";
|
||||
const Speaking: React.FC<ExamProps<SpeakingExam>> = ({ exam, showSolutions = false, preview = false }) => {
|
||||
const updateTimers = useExamTimer(exam.module, preview);
|
||||
const userSolutionRef = useRef<(() => UserSolution) | null>(null);
|
||||
const [solutionWasUpdated, setSolutionWasUpdated] = useState(false);
|
||||
|
||||
const examState = useExamStore((state) => state);
|
||||
const persistentExamState = usePersistentExamStore((state) => state);
|
||||
|
||||
const {
|
||||
userSolutions,
|
||||
questionIndex,
|
||||
exerciseIndex,
|
||||
hasExamEnded,
|
||||
setBgColor,
|
||||
setUserSolutions,
|
||||
setHasExamEnded,
|
||||
setQuestionIndex,
|
||||
setExerciseIndex,
|
||||
exerciseIndex, userSolutions, flags,
|
||||
timeSpentCurrentModule, questionIndex,
|
||||
setBgColor, setUserSolutions, setTimeIsUp,
|
||||
dispatch,
|
||||
} = !preview ? examState : persistentExamState;
|
||||
|
||||
const [seenParts, setSeenParts] = useState<Set<number>>(new Set(showSolutions ? exam.exercises.map((_, index) => index) : []));
|
||||
const [showPartDivider, setShowPartDivider] = useState<boolean>(typeof exam.exercises[0].intro === "string" && exam.exercises[0].intro !== "");
|
||||
const { finalizeModule, timeIsUp } = flags;
|
||||
|
||||
const timer = useRef(exam.minTimer - timeSpentCurrentModule / 60);
|
||||
|
||||
const {
|
||||
nextExercise, previousExercise,
|
||||
showPartDivider, setShowPartDivider,
|
||||
setSeenParts,
|
||||
} = useExamNavigation({ exam, module: "speaking", showSolutions, preview, disableBetweenParts: true });
|
||||
|
||||
useEffect(() => {
|
||||
if (!showSolutions && exam.exercises[exerciseIndex]?.intro !== undefined && exam.exercises[exerciseIndex]?.intro !== "" && !seenParts.has(exerciseIndex)) {
|
||||
setShowPartDivider(true);
|
||||
setBgColor(speakingBgColor);
|
||||
if (finalizeModule || timeIsUp) {
|
||||
updateTimers();
|
||||
|
||||
if (timeIsUp) {
|
||||
setTimeIsUp(false);
|
||||
}
|
||||
dispatch({ type: "FINALIZE_MODULE", payload: { updateTimers: false } })
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [exerciseIndex]);
|
||||
}, [finalizeModule, timeIsUp])
|
||||
|
||||
|
||||
const registerSolution = useCallback((updateSolution: () => UserSolution) => {
|
||||
userSolutionRef.current = updateSolution;
|
||||
setSolutionWasUpdated(true);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (hasExamEnded && exerciseIndex === -1) {
|
||||
setExerciseIndex(exerciseIndex + 1);
|
||||
}
|
||||
}, [hasExamEnded, exerciseIndex, setExerciseIndex]);
|
||||
|
||||
const scrollToTop = () => Array.from(document.getElementsByTagName("body")).forEach((body) => body.scrollTo(0, 0));
|
||||
|
||||
const nextExercise = (solution?: UserSolution) => {
|
||||
scrollToTop();
|
||||
if (solution) {
|
||||
if (solutionWasUpdated && userSolutionRef.current) {
|
||||
const solution = userSolutionRef.current();
|
||||
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) {
|
||||
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 currentExercise = useMemo<Exercise>(() => {
|
||||
const exercise = exam.exercises[exerciseIndex];
|
||||
return {
|
||||
...exercise,
|
||||
variant: exerciseIndex < 2 && exercise.type === "interactiveSpeaking" ? "initial" : undefined,
|
||||
userSolutions: userSolutions.find((x) => x.exercise === exercise.id)?.solutions || [],
|
||||
} 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 (
|
||||
<>
|
||||
@@ -117,27 +109,24 @@ export default function Speaking({ exam, showSolutions = false, onFinish, previe
|
||||
defaultTitle="Speaking exam"
|
||||
section={exam.exercises[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">
|
||||
<ModuleTitle
|
||||
label={convertCamelCaseToReadable(exam.exercises[exerciseIndex].type)}
|
||||
minTimer={exam.minTimer}
|
||||
exerciseIndex={exerciseIndex + 1 + questionIndex + speakingPromptsDone.reduce((acc, curr) => acc + curr.amount, 0)}
|
||||
minTimer={timer.current}
|
||||
exerciseIndex={memoizedExerciseIndex}
|
||||
module="speaking"
|
||||
totalExercises={countExercises(exam.exercises)}
|
||||
disableTimer={showSolutions || preview}
|
||||
preview={preview}
|
||||
/>
|
||||
{exerciseIndex > -1 &&
|
||||
exerciseIndex < exam.exercises.length &&
|
||||
!showSolutions &&
|
||||
renderExercise(getExercise(), exam.id, nextExercise, previousExercise, undefined, preview)}
|
||||
{exerciseIndex > -1 &&
|
||||
exerciseIndex < exam.exercises.length &&
|
||||
showSolutions &&
|
||||
renderSolution(exam.exercises[exerciseIndex], nextExercise, previousExercise)}
|
||||
{!showPartDivider && !showSolutions && renderExercise(currentExercise, exam.id, registerSolution, preview, progressButtons, progressButtons)}
|
||||
{showSolutions && renderSolution(currentExercise, progressButtons, progressButtons)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default Speaking;
|
||||
|
||||
@@ -1,95 +1,88 @@
|
||||
import { renderExercise } from "@/components/Exercises";
|
||||
import ModuleTitle from "@/components/Medium/ModuleTitle";
|
||||
import { renderSolution } from "@/components/Solutions";
|
||||
import { UserSolution, WritingExam } from "@/interfaces/exam";
|
||||
import useExamStore, { usePersistentExamStore } from "@/stores/examStore";
|
||||
import { Exercise, UserSolution, WritingExam } from "@/interfaces/exam";
|
||||
import useExamStore, { usePersistentExamStore } from "@/stores/exam";
|
||||
import { countExercises } from "@/utils/moduleUtils";
|
||||
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 {
|
||||
exam: WritingExam;
|
||||
showSolutions?: boolean;
|
||||
preview?: boolean;
|
||||
onFinish: (userSolutions: UserSolution[]) => void;
|
||||
}
|
||||
|
||||
export default function Writing({ exam, showSolutions = false, preview = false, onFinish }: Props) {
|
||||
const writingBgColor = "bg-ielts-writing-light";
|
||||
const Writing: React.FC<ExamProps<WritingExam>> = ({ exam, showSolutions = false, preview = false }) => {
|
||||
const updateTimers = useExamTimer(exam.module, preview);
|
||||
const userSolutionRef = useRef<(() => UserSolution) | null>(null);
|
||||
const [solutionWasUpdated, setSolutionWasUpdated] = useState(false);
|
||||
|
||||
const examState = useExamStore((state) => state);
|
||||
const persistentExamState = usePersistentExamStore((state) => state);
|
||||
|
||||
const {
|
||||
userSolutions,
|
||||
exerciseIndex,
|
||||
hasExamEnded,
|
||||
setBgColor,
|
||||
setUserSolutions,
|
||||
setHasExamEnded,
|
||||
setExerciseIndex,
|
||||
exerciseIndex, flags, setBgColor,
|
||||
setUserSolutions, setTimeIsUp, userSolutions,
|
||||
dispatch, navigation, timeSpentCurrentModule
|
||||
} = !preview ? examState : persistentExamState;
|
||||
|
||||
const [seenParts, setSeenParts] = useState<Set<number>>(new Set(showSolutions ? exam.exercises.map((_, index) => index) : []));
|
||||
const [showPartDivider, setShowPartDivider] = useState<boolean>(typeof exam.exercises[0].intro === "string" && exam.exercises[0].intro !== "");
|
||||
const timer = useRef(exam.minTimer - timeSpentCurrentModule / 60);
|
||||
|
||||
const { finalizeModule, timeIsUp } = flags;
|
||||
const { nextDisabled } = navigation;
|
||||
|
||||
useEffect(() => {
|
||||
if (hasExamEnded && exerciseIndex === -1) {
|
||||
setExerciseIndex(exerciseIndex + 1);
|
||||
if (finalizeModule || timeIsUp) {
|
||||
updateTimers();
|
||||
|
||||
if (timeIsUp) {
|
||||
setTimeIsUp(false);
|
||||
}
|
||||
}, [hasExamEnded, exerciseIndex, setExerciseIndex]);
|
||||
|
||||
const scrollToTop = () => Array.from(document.getElementsByTagName("body")).forEach((body) => body.scrollTo(0, 0));
|
||||
|
||||
useEffect(() => {
|
||||
if (!showSolutions && exam.exercises[exerciseIndex]?.intro !== undefined && exam.exercises[exerciseIndex]?.intro !== "" && !seenParts.has(exerciseIndex)) {
|
||||
setShowPartDivider(true);
|
||||
setBgColor(writingBgColor);
|
||||
dispatch({ type: "FINALIZE_MODULE", payload: { updateTimers: false } })
|
||||
}
|
||||
// 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) => {
|
||||
scrollToTop();
|
||||
if (solution) {
|
||||
useEffect(() => {
|
||||
if (solutionWasUpdated && userSolutionRef.current) {
|
||||
const solution = userSolutionRef.current();
|
||||
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;
|
||||
|
||||
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 currentExercise = useMemo<Exercise>(() => {
|
||||
const exercise = exam.exercises[exerciseIndex];
|
||||
return {
|
||||
...exercise,
|
||||
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 (
|
||||
<>
|
||||
@@ -100,27 +93,32 @@ export default function Writing({ exam, showSolutions = false, preview = false,
|
||||
defaultTitle="Writing exam"
|
||||
section={exam.exercises[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">
|
||||
{exam.exercises.length > 1 && <SectionNavbar
|
||||
module="writing"
|
||||
sectionLabel="Part"
|
||||
seenParts={seenParts}
|
||||
setShowPartDivider={setShowPartDivider}
|
||||
setSeenParts={setSeenParts}
|
||||
preview={preview}
|
||||
/>}
|
||||
<ModuleTitle
|
||||
minTimer={exam.minTimer}
|
||||
minTimer={timer.current}
|
||||
exerciseIndex={exerciseIndex + 1}
|
||||
module="writing"
|
||||
totalExercises={countExercises(exam.exercises)}
|
||||
disableTimer={showSolutions || preview}
|
||||
preview={preview}
|
||||
/>
|
||||
{exerciseIndex > -1 &&
|
||||
exerciseIndex < exam.exercises.length &&
|
||||
!showSolutions &&
|
||||
renderExercise(getExercise(), exam.id, nextExercise, previousExercise, preview)}
|
||||
{exerciseIndex > -1 &&
|
||||
exerciseIndex < exam.exercises.length &&
|
||||
showSolutions &&
|
||||
renderSolution(exam.exercises[exerciseIndex], nextExercise, previousExercise)}
|
||||
{!showPartDivider && !showSolutions && renderExercise(currentExercise, exam.id, registerSolution, preview, progressButtons, progressButtons)}
|
||||
{showSolutions && renderSolution(currentExercise, progressButtons, progressButtons)}
|
||||
</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 {ExamState} from "@/stores/examStore";
|
||||
import Axios from "axios";
|
||||
import {setupCache} from "axios-cache-interceptor";
|
||||
import {useEffect, useState} from "react";
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import {Exam} from "@/interfaces/exam";
|
||||
import {ExamState} from "@/stores/examStore";
|
||||
import {ExamState} from "@/stores/exam/types";
|
||||
import axios from "axios";
|
||||
import {setupCache} from "axios-cache-interceptor";
|
||||
import {useEffect, useState} from "react";
|
||||
|
||||
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 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 Input from "@/components/Low/Input";
|
||||
import {Module} from "@/interfaces";
|
||||
import useExamStore from "@/stores/examStore";
|
||||
import useExamStore from "@/stores/exam";
|
||||
import {getExamById} from "@/utils/exams";
|
||||
import {MODULE_ARRAY} from "@/utils/moduleUtils";
|
||||
import {RadioGroup} from "@headlessui/react";
|
||||
@@ -16,8 +16,7 @@ export default function ExamLoader() {
|
||||
const [examId, setExamId] = useState<string>();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const setExams = useExamStore((state) => state.setExams);
|
||||
const setSelectedModules = useExamStore((state) => state.setSelectedModules);
|
||||
const dispatch = useExamStore((store) => store.dispatch);
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
@@ -35,9 +34,7 @@ export default function ExamLoader() {
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setExams([exam]);
|
||||
setSelectedModules([selectedModule]);
|
||||
dispatch({type: 'INIT_EXAM', payload: {exams: [exam], modules: [selectedModule]}})
|
||||
|
||||
router.push("/exam");
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import useUsers from "@/hooks/useUsers";
|
||||
import {Module} from "@/interfaces";
|
||||
import {Exam} from "@/interfaces/exam";
|
||||
import {Type, User} from "@/interfaces/user";
|
||||
import useExamStore from "@/stores/examStore";
|
||||
import useExamStore from "@/stores/exam";
|
||||
import {getExamById} from "@/utils/exams";
|
||||
import {countExercises} from "@/utils/moduleUtils";
|
||||
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 setExams = useExamStore((state) => state.setExams);
|
||||
const setSelectedModules = useExamStore((state) => state.setSelectedModules);
|
||||
const dispatch = useExamStore((state) => state.dispatch);
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
@@ -106,9 +105,7 @@ export default function ExamList({user, entities}: {user: User; entities: Entity
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
setExams([exam]);
|
||||
setSelectedModules([module]);
|
||||
dispatch({type: "INIT_EXAM", payload: {exams: [exam], modules: [module]}})
|
||||
|
||||
router.push("/exercises");
|
||||
};
|
||||
|
||||
@@ -1,23 +1,14 @@
|
||||
import Input from "@/components/Low/Input";
|
||||
import Modal from "@/components/Modal";
|
||||
import {PERMISSIONS} from "@/constants/userPermissions";
|
||||
import useExams from "@/hooks/useExams";
|
||||
import usePackages from "@/hooks/usePackages";
|
||||
import useUsers from "@/hooks/useUsers";
|
||||
import {Module} from "@/interfaces";
|
||||
import {Exam} from "@/interfaces/exam";
|
||||
import {Package} from "@/interfaces/paypal";
|
||||
import {Type, User} from "@/interfaces/user";
|
||||
import useExamStore from "@/stores/examStore";
|
||||
import {getExamById} from "@/utils/exams";
|
||||
import {countExercises} from "@/utils/moduleUtils";
|
||||
import {User} from "@/interfaces/user";
|
||||
import {createColumnHelper, flexRender, getCoreRowModel, useReactTable} from "@tanstack/react-table";
|
||||
import axios from "axios";
|
||||
import clsx from "clsx";
|
||||
import {capitalize} from "lodash";
|
||||
import {useRouter} from "next/router";
|
||||
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 Select from "react-select";
|
||||
import {CURRENCIES} from "@/resources/paypal";
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/* eslint-disable @next/next/no-img-element */
|
||||
import { Module } from "@/interfaces";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
import AbandonPopup from "@/components/AbandonPopup";
|
||||
import Layout from "@/components/High/Layout";
|
||||
@@ -11,9 +11,8 @@ import Reading from "@/exams/Reading";
|
||||
import Selection from "@/exams/Selection";
|
||||
import Speaking from "@/exams/Speaking";
|
||||
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 useExamStore from "@/stores/examStore";
|
||||
import { evaluateSpeakingAnswer, evaluateWritingAnswer } from "@/utils/evaluation";
|
||||
import { defaultExamUserSolutions, getExam } from "@/utils/exams";
|
||||
import axios from "axios";
|
||||
@@ -21,6 +20,8 @@ import { useRouter } from "next/router";
|
||||
import { toast, ToastContainer } from "react-toastify";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import ShortUniqueId from "short-unique-id";
|
||||
import { ExamProps } from "@/exams/types";
|
||||
import useExamStore from "@/stores/exam";
|
||||
|
||||
interface Props {
|
||||
page: "exams" | "exercises";
|
||||
@@ -29,198 +30,64 @@ interface Props {
|
||||
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 [avoidRepeated, setAvoidRepeated] = useState(false);
|
||||
const [hasBeenUploaded, setHasBeenUploaded] = useState(false);
|
||||
const [showAbandonPopup, setShowAbandonPopup] = useState(false);
|
||||
const [isEvaluationLoading, setIsEvaluationLoading] = useState(false);
|
||||
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 assignment = useExamStore((state) => state.assignment);
|
||||
const initialTimeSpent = useExamStore((state) => state.timeSpent);
|
||||
|
||||
const { exam, setExam } = useExamStore((state) => state);
|
||||
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();
|
||||
|
||||
// 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(() => {
|
||||
if (moduleIndex >= selectedModules.length || moduleIndex === -1 || showSolutions) {
|
||||
document.removeEventListener("keydown", resetInactivityTimer);
|
||||
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,
|
||||
const {
|
||||
exam, setExam,
|
||||
exams,
|
||||
exam,
|
||||
partIndex,
|
||||
exerciseIndex,
|
||||
questionIndex,
|
||||
user: user?.id,
|
||||
});
|
||||
};
|
||||
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();
|
||||
|
||||
useEffect(() => setTimeSpent(initialTimeSpent), [initialTimeSpent]);
|
||||
useEffect(() => setTotalInactivity(inactivity), [inactivity]);
|
||||
const { finalizeModule, finalizeExam } = flags;
|
||||
|
||||
const [isFetchingExams, setIsFetchingExams] = useState(false);
|
||||
const [isExamLoaded, setIsExamLoaded] = useState(moduleIndex < selectedModules.length);
|
||||
|
||||
useEffect(() => {
|
||||
if (userSolutions.length === 0 && exams.length > 0) {
|
||||
const defaultSolutions = exams.map(defaultExamUserSolutions).flat();
|
||||
setUserSolutions(defaultSolutions);
|
||||
}
|
||||
}, [exams, setUserSolutions, userSolutions]);
|
||||
setIsExamLoaded(moduleIndex < selectedModules.length);
|
||||
}, [showSolutions, moduleIndex, selectedModules]);
|
||||
|
||||
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) {
|
||||
if (!showSolutions && sessionId.length === 0 && user?.id) {
|
||||
const shortUID = new ShortUniqueId();
|
||||
setUser(user.id);
|
||||
setSessionId(shortUID.randomUUID(8));
|
||||
}
|
||||
}, [setSessionId, selectedModules, sessionId]);
|
||||
}, [setSessionId, isExamLoaded, sessionId, showSolutions, setUser, user?.id]);
|
||||
|
||||
useEffect(() => {
|
||||
if (user?.type === "developer") console.log(exam);
|
||||
}, [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(() => {
|
||||
(async () => {
|
||||
if (selectedModules.length > 0 && exams.length === 0) {
|
||||
setIsFetchingExams(true);
|
||||
const examPromises = selectedModules.map((module) =>
|
||||
getExam(
|
||||
module,
|
||||
@@ -230,8 +97,9 @@ export default function ExamPage({ page, user, destination = "/exam", hideSideba
|
||||
),
|
||||
);
|
||||
Promise.all(examPromises).then((values) => {
|
||||
setIsFetchingExams(false);
|
||||
if (values.every((x) => !!x)) {
|
||||
setExams(values.map((x) => x!));
|
||||
dispatch({ type: 'INIT_EXAM', payload: { exams: values.map((x) => x!), modules: selectedModules } })
|
||||
} else {
|
||||
toast.error("Something went wrong, please try again");
|
||||
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
|
||||
}, [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
|
||||
.post<{ ok: boolean }>("/api/stats", newStats)
|
||||
.then((response) => setHasBeenUploaded(response.data.ok))
|
||||
.catch(() => setHasBeenUploaded(false));
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [selectedModules, moduleIndex, hasBeenUploaded]);
|
||||
|
||||
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 reset = () => {
|
||||
resetStore();
|
||||
setVariant("full");
|
||||
setAvoidRepeated(false);
|
||||
setHasBeenUploaded(false);
|
||||
setShowAbandonPopup(false);
|
||||
setIsEvaluationLoading(false);
|
||||
setStatsAwaitingEvaluation([]);
|
||||
};
|
||||
|
||||
const updateExamWithUserSolutions = (exam: Exam): Exam => {
|
||||
if (exam.module === "reading" || exam.module === "listening" || exam.module === "level") {
|
||||
const parts = exam.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 });
|
||||
}
|
||||
useEffect(() => {
|
||||
if (finalizeModule && !showSolutions) {
|
||||
/*if (exam && (exam.module === "writing" || exam.module === "speaking") && userSolutions.length > 0 && !showSolutions) {
|
||||
setIsEvaluationLoading(true);
|
||||
(async () => {
|
||||
const responses: UserSolution[] = (
|
||||
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);
|
||||
|
||||
const exercises = exam.exercises.map((x) =>
|
||||
Object.assign(x, {
|
||||
userSolutions: userSolutions.find((y) => x.id === y.exercise)?.solutions,
|
||||
}),
|
||||
if (exercise.type === "interactiveSpeaking" || exercise.type === "speaking")
|
||||
return await evaluateSpeakingAnswer(
|
||||
exercise,
|
||||
userSolutions.find((x) => x.exercise === exercise.id)!,
|
||||
evaluationID,
|
||||
index + 1,
|
||||
);
|
||||
return Object.assign(exam, { exercises });
|
||||
};
|
||||
}),
|
||||
)
|
||||
).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 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]);
|
||||
setModuleIndex(moduleIndex + 1);
|
||||
|
||||
setPartIndex(-1);
|
||||
setExerciseIndex(-1);
|
||||
setPartIndex(0);
|
||||
setExerciseIndex(0);
|
||||
setQuestionIndex(0);
|
||||
};
|
||||
|
||||
@@ -432,10 +261,35 @@ export default function ExamPage({ page, user, destination = "/exam", hideSideba
|
||||
.map((x) => ({ module: x as Module, ...scores[x as Module] }));
|
||||
};
|
||||
|
||||
const renderScreen = () => {
|
||||
if (selectedModules.length === 0) {
|
||||
const ModuleExamMap: Record<Module, React.ComponentType<ExamProps<Exam>>> = {
|
||||
"reading": Reading as React.ComponentType<ExamProps<Exam>>,
|
||||
"listening": Listening as React.ComponentType<ExamProps<Exam>>,
|
||||
"writing": Writing as React.ComponentType<ExamProps<Exam>>,
|
||||
"speaking": Speaking as React.ComponentType<ExamProps<Exam>>,
|
||||
"level": Level as React.ComponentType<ExamProps<Exam>>,
|
||||
}
|
||||
|
||||
const CurrentExam = exam?.module ? ModuleExamMap[exam.module] : undefined;
|
||||
|
||||
const onAbandon = async () => {
|
||||
await saveSession();
|
||||
reset();
|
||||
};
|
||||
|
||||
return (
|
||||
<Selection
|
||||
<>
|
||||
<ToastContainer />
|
||||
{user && (
|
||||
<Layout
|
||||
user={user}
|
||||
bgColor={bgColor}
|
||||
hideSidebar={hideSidebar}
|
||||
className="justify-between"
|
||||
focusMode={selectedModules.length !== 0 && !showSolutions && moduleIndex < selectedModules.length}
|
||||
onFocusLayerMouseEnter={() => setShowAbandonPopup(true)}>
|
||||
<>
|
||||
{/* Modules weren't yet set by an INIT_EXAM or INIT_SOLUTIONS dispatch, show Selection component*/}
|
||||
{selectedModules.length === 0 && <Selection
|
||||
page={page}
|
||||
user={user!}
|
||||
onStart={(modules: Module[], avoid: boolean, variant: Variant) => {
|
||||
@@ -444,12 +298,14 @@ export default function ExamPage({ page, user, destination = "/exam", hideSideba
|
||||
setSelectedModules(modules);
|
||||
setVariant(variant);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (moduleIndex >= selectedModules.length || moduleIndex === -1) {
|
||||
return (
|
||||
/>}
|
||||
{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!}
|
||||
@@ -458,7 +314,7 @@ export default function ExamPage({ page, user, destination = "/exam", hideSideba
|
||||
assignment={assignment}
|
||||
information={{
|
||||
timeSpent,
|
||||
inactivity: totalInactivity,
|
||||
inactivity,
|
||||
}}
|
||||
destination={destination}
|
||||
onViewResults={(index?: number) => {
|
||||
@@ -475,65 +331,36 @@ export default function ExamPage({ page, user, destination = "/exam", hideSideba
|
||||
} 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);
|
||||
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()}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (exam && exam.module === "reading") {
|
||||
return <Reading exam={exam} onFinish={onFinish} showSolutions={showSolutions} />;
|
||||
}
|
||||
|
||||
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 (
|
||||
/>}
|
||||
{/* Exam is on going, display it and the abandon modal */}
|
||||
{isExamLoaded && moduleIndex !== -1 && (
|
||||
<>
|
||||
<ToastContainer />
|
||||
{user && (
|
||||
<Layout
|
||||
user={user}
|
||||
bgColor={bgColor}
|
||||
hideSidebar={hideSidebar}
|
||||
className="justify-between"
|
||||
focusMode={selectedModules.length !== 0 && !showSolutions && moduleIndex < selectedModules.length}
|
||||
onFocusLayerMouseEnter={() => setShowAbandonPopup(true)}>
|
||||
<>
|
||||
{renderScreen()}
|
||||
{!showSolutions && moduleIndex < selectedModules.length && (
|
||||
<AbandonPopup
|
||||
{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={() => {
|
||||
reset();
|
||||
}}
|
||||
onAbandon={onAbandon}
|
||||
onCancel={() => setShowAbandonPopup(false)}
|
||||
/>
|
||||
}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
</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 {useRouter} from "next/router";
|
||||
import {useEffect} from "react";
|
||||
import useExamStore from "@/stores/examStore";
|
||||
import useExamStore from "@/stores/exam";
|
||||
import usePreferencesStore from "@/stores/preferencesStore";
|
||||
import axios from "axios";
|
||||
|
||||
export default function App({Component, pageProps}: AppProps) {
|
||||
const {reset} = useExamStore((state) => state);
|
||||
const {reset} = useExamStore();
|
||||
const setIsSidebarMinimized = usePreferencesStore((state) => state.setSidebarMinimized);
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
@@ -7,11 +7,9 @@ import formidable from "formidable-serverless";
|
||||
import {getDownloadURL, ref, uploadBytes} from "firebase/storage";
|
||||
import fs from "fs";
|
||||
import {storage} from "@/firebase";
|
||||
import client from "@/lib/mongodb";
|
||||
import {Stat} from "@/interfaces/user";
|
||||
import {speakingReverseMarking} from "@/utils/score";
|
||||
|
||||
const db = client.db(process.env.MONGODB_DB);
|
||||
|
||||
export default withIronSessionApiRoute(handler, sessionOptions);
|
||||
|
||||
function delay(ms: number) {
|
||||
@@ -30,52 +28,27 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
|
||||
const audioFile = files.audio;
|
||||
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 snapshot = await uploadBytes(audioFileRef, binary);
|
||||
const url = await getDownloadURL(snapshot.ref);
|
||||
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,
|
||||
evaluation: backendRequest.data,
|
||||
solution: url,
|
||||
}));
|
||||
}));*/
|
||||
|
||||
await db.collection("stats").updateOne(
|
||||
{ id: fields.id },
|
||||
{
|
||||
id: fields.id,
|
||||
solutions,
|
||||
score: {
|
||||
correct: speakingReverseMarking[backendRequest.data.overall || 0] || 0,
|
||||
total: 100,
|
||||
missing: 0,
|
||||
await axios.post(`${process.env.BACKEND_URL}/grade/speaking/2`, {answer: path, question: fields.question}, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${process.env.BACKEND_JWT}`,
|
||||
},
|
||||
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> {
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,11 +1,8 @@
|
||||
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import client from "@/lib/mongodb";
|
||||
import { withIronSessionApiRoute } from "iron-session/next";
|
||||
import { sessionOptions } from "@/lib/session";
|
||||
import axios, { AxiosResponse } from "axios";
|
||||
import { Stat } from "@/interfaces/user";
|
||||
import { writingReverseMarking } from "@/utils/score";
|
||||
import axios from "axios";
|
||||
|
||||
interface Body {
|
||||
question: string;
|
||||
@@ -14,67 +11,22 @@ interface Body {
|
||||
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);
|
||||
|
||||
|
||||
async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
if (!req.session.user) {
|
||||
res.status(401).json({ ok: false });
|
||||
return;
|
||||
}
|
||||
|
||||
res.status(200).json(null);
|
||||
|
||||
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 body = req.body as Body;
|
||||
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: {
|
||||
Authorization: `Bearer ${process.env.BACKEND_JWT}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (typeof backendRequest.data === "string") return evaluate(body);
|
||||
return backendRequest;
|
||||
res.status(200);
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import useUsers from "@/hooks/useUsers";
|
||||
import { Module } from "@/interfaces";
|
||||
import { Assignment } from "@/interfaces/results";
|
||||
import { Group, Stat, User } from "@/interfaces/user";
|
||||
import useExamStore from "@/stores/examStore";
|
||||
import useExamStore from "@/stores/exam";
|
||||
import { getExamById } from "@/utils/exams";
|
||||
import { sortByModule } from "@/utils/moduleUtils";
|
||||
import { calculateBandScore } from "@/utils/score";
|
||||
@@ -23,9 +23,8 @@ import {withIronSessionSsr} from "iron-session/next";
|
||||
import { checkAccess, doesEntityAllow } from "@/utils/permissions";
|
||||
import { mapBy, redirect, serialize } from "@/utils";
|
||||
import { getAssignment } from "@/utils/assignments.be";
|
||||
import {getEntitiesUsers, getEntityUsers, getUsers} from "@/utils/users.be";
|
||||
import {getEntitiesWithRoles, getEntityWithRoles} from "@/utils/entities.be";
|
||||
import {getGroups, getGroupsByEntities, getGroupsByEntity} from "@/utils/groups.be";
|
||||
import { getEntityUsers, getUsers } from "@/utils/users.be";
|
||||
import { getEntityWithRoles } from "@/utils/entities.be";
|
||||
import { sessionOptions } from "@/lib/session";
|
||||
import { EntityWithRoles } from "@/interfaces/entity";
|
||||
import Head from "next/head";
|
||||
@@ -73,13 +72,10 @@ export default function AssignmentView({user, users, entity, assignment}: Props)
|
||||
const canDeleteAssignment = useEntityPermission(user, entity, 'delete_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 dispatch = useExamStore((state) => state.dispatch);
|
||||
|
||||
const deleteAssignment = async () => {
|
||||
if (!canDeleteAssignment) return
|
||||
if (!confirm("Are you sure you want to delete this assignment?")) return;
|
||||
@@ -189,15 +185,16 @@ export default function AssignmentView({user, users, entity, assignment}: Props)
|
||||
|
||||
Promise.all(examPromises).then((exams) => {
|
||||
if (exams.every((x) => !!x)) {
|
||||
setUserSolutions(convertToUserSolutions(stats));
|
||||
setShowSolutions(true);
|
||||
setExams(exams.map((x) => x!).sort(sortByModule));
|
||||
setSelectedModules(
|
||||
exams
|
||||
dispatch({
|
||||
type: 'INIT_SOLUTIONS', payload: {
|
||||
exams: exams.map((x) => x!).sort(sortByModule),
|
||||
modules: exams
|
||||
.map((x) => x!)
|
||||
.sort(sortByModule)
|
||||
.map((x) => x!.module),
|
||||
);
|
||||
stats,
|
||||
}
|
||||
});
|
||||
router.push("/exam");
|
||||
}
|
||||
});
|
||||
|
||||
@@ -13,7 +13,7 @@ import { InviteWithEntity } from "@/interfaces/invite";
|
||||
import {Assignment} from "@/interfaces/results";
|
||||
import {Stat, User} from "@/interfaces/user";
|
||||
import {sessionOptions} from "@/lib/session";
|
||||
import useExamStore from "@/stores/examStore";
|
||||
import useExamStore from "@/stores/exam";
|
||||
import {findBy, mapBy, redirect, serialize} from "@/utils";
|
||||
import { requestUser } from "@/utils/api";
|
||||
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) {
|
||||
const router = useRouter();
|
||||
|
||||
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 setAssignment = useExamStore((state) => state.setAssignment);
|
||||
const dispatch = useExamStore((state) => state.dispatch);
|
||||
|
||||
const startAssignment = (assignment: Assignment) => {
|
||||
const assignmentExams = exams.filter(e => {
|
||||
@@ -94,11 +90,11 @@ export default function Dashboard({user, entities, assignments, stats, invites,
|
||||
})
|
||||
|
||||
if (assignmentExams.every((x) => !!x)) {
|
||||
setUserSolutions([]);
|
||||
setShowSolutions(false);
|
||||
setExams(assignmentExams.sort(sortByModule));
|
||||
setSelectedModules(mapBy(assignmentExams.sort(sortByModule), 'module'));
|
||||
setAssignment(assignment);
|
||||
dispatch({type: "INIT_EXAM", payload: {
|
||||
exams: assignmentExams.sort(sortByModule),
|
||||
modules: mapBy(assignmentExams.sort(sortByModule), 'module'),
|
||||
assignment
|
||||
}})
|
||||
|
||||
router.push("/exam");
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
/* eslint-disable @next/next/no-img-element */
|
||||
//import "@/utils/wdyr";
|
||||
|
||||
import { withIronSessionSsr } from "iron-session/next";
|
||||
import { sessionOptions } from "@/lib/session";
|
||||
@@ -6,20 +7,19 @@ import { shouldRedirectHome } from "@/utils/navigation.disabled";
|
||||
import ExamPage from "./(exam)/ExamPage";
|
||||
import Head from "next/head";
|
||||
import { User } from "@/interfaces/user";
|
||||
import { filterBy, findBy, redirect, serialize } from "@/utils";
|
||||
import { filterBy, redirect, serialize } from "@/utils";
|
||||
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 useExamStore from "@/stores/examStore";
|
||||
import useExamStore from "@/stores/exam";
|
||||
import { useEffect } from "react";
|
||||
import { Exam } from "@/interfaces/exam";
|
||||
import { getExamsByIds } from "@/utils/exams.be";
|
||||
import { sortByModule } from "@/utils/moduleUtils";
|
||||
import { uniqBy } from "lodash";
|
||||
import { useRouter } from "next/router";
|
||||
import { getSessionByAssignment, getSessionsByUser } from "@/utils/sessions.be";
|
||||
import { getSessionByAssignment } from "@/utils/sessions.be";
|
||||
import { Session } from "@/hooks/useSessions";
|
||||
import moment from "moment";
|
||||
import { activeAssignmentFilter } from "@/utils/assignments";
|
||||
|
||||
export const getServerSideProps = withIronSessionSsr(async ({ req, res, query }) => {
|
||||
@@ -63,25 +63,23 @@ interface Props {
|
||||
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 state = useExamStore((state) => state)
|
||||
const { assignment: storeAssignment, dispatch } = useExamStore();
|
||||
|
||||
useEffect(() => {
|
||||
if (assignment && exams.length > 0 && !state.assignment && !session) {
|
||||
if (assignment && exams.length > 0 && !storeAssignment && !session) {
|
||||
if (!activeAssignmentFilter(assignment)) return
|
||||
|
||||
state.setUserSolutions([]);
|
||||
state.setShowSolutions(false);
|
||||
state.setAssignment(assignment);
|
||||
state.setExams(exams.sort(sortByModule));
|
||||
state.setSelectedModules(
|
||||
exams
|
||||
dispatch({
|
||||
type: "INIT_EXAM", payload: {
|
||||
exams: exams.sort(sortByModule),
|
||||
modules: exams
|
||||
.map((x) => x!)
|
||||
.sort(sortByModule)
|
||||
.map((x) => x!.module),
|
||||
);
|
||||
assignment
|
||||
}
|
||||
});
|
||||
|
||||
router.replace(router.asPath)
|
||||
}
|
||||
@@ -89,21 +87,8 @@ export default function Page({ user, assignment, exams = [], destinationURL = "/
|
||||
}, [assignment, exams, session])
|
||||
|
||||
useEffect(() => {
|
||||
if (assignment && exams.length > 0 && !state.assignment && !!session) {
|
||||
state.setShuffles(session.userSolutions.map((x) => ({ exerciseID: x.exercise, shuffles: x.shuffleMaps ? x.shuffleMaps : [] })));
|
||||
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);
|
||||
|
||||
if (assignment && exams.length > 0 && !storeAssignment && !!session) {
|
||||
dispatch({ type: "SET_SESSION", payload: { session } })
|
||||
router.replace(router.asPath)
|
||||
}
|
||||
// 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" />
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
</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;
|
||||
@@ -6,18 +6,18 @@ import {shouldRedirectHome} from "@/utils/navigation.disabled";
|
||||
import ExamPage from "./(exam)/ExamPage";
|
||||
import Head from "next/head";
|
||||
import { User } from "@/interfaces/user";
|
||||
import { filterBy, findBy, redirect, serialize } from "@/utils";
|
||||
import { filterBy, redirect, serialize } from "@/utils";
|
||||
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 useExamStore from "@/stores/examStore";
|
||||
import useExamStore from "@/stores/exam";
|
||||
import { useEffect } from "react";
|
||||
import { Exam } from "@/interfaces/exam";
|
||||
import { getExamsByIds } from "@/utils/exams.be";
|
||||
import { sortByModule } from "@/utils/moduleUtils";
|
||||
import { uniqBy } from "lodash";
|
||||
import { useRouter } from "next/router";
|
||||
import { getSessionByAssignment, getSessionsByUser } from "@/utils/sessions.be";
|
||||
import { getSessionByAssignment } from "@/utils/sessions.be";
|
||||
import { Session } from "@/hooks/useSessions";
|
||||
import moment from "moment";
|
||||
|
||||
@@ -66,20 +66,20 @@ interface Props {
|
||||
export default function Page({ user, assignment, exams = [], session }: Props) {
|
||||
const router = useRouter()
|
||||
|
||||
const state = useExamStore((state) => state)
|
||||
const { assignment: storeAssignment, dispatch } = useExamStore()
|
||||
|
||||
useEffect(() => {
|
||||
if (assignment && exams.length > 0 && !state.assignment && !session) {
|
||||
state.setUserSolutions([]);
|
||||
state.setShowSolutions(false);
|
||||
state.setAssignment(assignment);
|
||||
state.setExams(exams.sort(sortByModule));
|
||||
state.setSelectedModules(
|
||||
exams
|
||||
if (assignment && exams.length > 0 && !storeAssignment && !session) {
|
||||
dispatch({
|
||||
type: "INIT_EXAM", payload: {
|
||||
exams: exams.sort(sortByModule),
|
||||
modules: exams
|
||||
.map((x) => x!)
|
||||
.sort(sortByModule)
|
||||
.map((x) => x!.module),
|
||||
);
|
||||
assignment
|
||||
}
|
||||
})
|
||||
|
||||
router.replace(router.asPath)
|
||||
}
|
||||
@@ -87,20 +87,8 @@ export default function Page({user, assignment, exams = [], session}: Props) {
|
||||
}, [assignment, exams, session])
|
||||
|
||||
useEffect(() => {
|
||||
if (assignment && exams.length > 0 && !state.assignment && !!session) {
|
||||
state.setShuffles(session.userSolutions.map((x) => ({exerciseID: x.exercise, shuffles: x.shuffleMaps ? x.shuffleMaps : []})));
|
||||
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);
|
||||
if (assignment && exams.length > 0 && !storeAssignment && !!session) {
|
||||
dispatch({ type: "SET_SESSION", payload: { session } });
|
||||
|
||||
router.replace(router.asPath)
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ import { InviteWithEntity } from "@/interfaces/invite";
|
||||
import { Assignment } from "@/interfaces/results";
|
||||
import { Stat, User } from "@/interfaces/user";
|
||||
import { sessionOptions } from "@/lib/session";
|
||||
import useExamStore from "@/stores/examStore";
|
||||
import useExamStore from "@/stores/exam";
|
||||
import { filterBy, findBy, mapBy, redirect, serialize } from "@/utils";
|
||||
import { requestUser } from "@/utils/api";
|
||||
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 router = useRouter();
|
||||
const state = useExamStore((state) => state);
|
||||
|
||||
const dispatch = useExamStore((state) => state.dispatch);
|
||||
|
||||
const reload = () => {
|
||||
setIsLoading(true)
|
||||
router.replace(router.asPath)
|
||||
@@ -89,31 +91,19 @@ export default function OfficialExam({ user, entities, assignments, sessions, ex
|
||||
})
|
||||
|
||||
if (assignmentExams.every((x) => !!x)) {
|
||||
state.setUserSolutions([]);
|
||||
state.setShowSolutions(false);
|
||||
state.setExams(assignmentExams.sort(sortByModule));
|
||||
state.setSelectedModules(mapBy(assignmentExams.sort(sortByModule), 'module'));
|
||||
state.setAssignment(assignment);
|
||||
|
||||
dispatch({
|
||||
type: "INIT_EXAM", payload: {
|
||||
exams: assignmentExams.sort(sortByModule),
|
||||
modules: mapBy(assignmentExams.sort(sortByModule), 'module'),
|
||||
assignment
|
||||
}
|
||||
})
|
||||
router.push(`/exam?assignment=${assignment.id}&destination=${destination}`);
|
||||
}
|
||||
};
|
||||
|
||||
const loadSession = async (session: Session) => {
|
||||
state.setShuffles(session.userSolutions.map((x) => ({ exerciseID: x.exercise, shuffles: x.shuffleMaps ? x.shuffleMaps : [] })));
|
||||
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);
|
||||
|
||||
dispatch({type: "SET_SESSION", payload: {session}});
|
||||
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 { groupByDate } from "@/utils/stats";
|
||||
import moment from "moment";
|
||||
import useExamStore from "@/stores/examStore";
|
||||
import { ToastContainer } from "react-toastify";
|
||||
import Layout from "@/components/High/Layout";
|
||||
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 { 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 [selectedTrainingExams, setSelectedTrainingExams] = useState<string[]>([]);
|
||||
@@ -174,13 +167,7 @@ export default function History({ user, users, assignments, entities }: Props) {
|
||||
selectedTrainingExams={selectedTrainingExams}
|
||||
setSelectedTrainingExams={setSelectedTrainingExams}
|
||||
maxTrainingExams={MAX_TRAINING_EXAMS}
|
||||
setExams={setExams}
|
||||
gradingSystem={gradingSystem?.steps}
|
||||
setShowSolutions={setShowSolutions}
|
||||
setUserSolutions={setUserSolutions}
|
||||
setSelectedModules={setSelectedModules}
|
||||
setInactivity={setInactivity}
|
||||
setTimeSpent={setTimeSpent}
|
||||
renderPdfIcon={renderPdfIcon}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -11,7 +11,6 @@ import clsx from "clsx";
|
||||
import Lists from "./(admin)/Lists";
|
||||
import BatchCodeGenerator from "./(admin)/BatchCodeGenerator";
|
||||
import { shouldRedirectHome } from "@/utils/navigation.disabled";
|
||||
import ExamGenerator from "./(admin)/ExamGenerator";
|
||||
import BatchCreateUser from "./(admin)/Lists/BatchCreateUser";
|
||||
import { checkAccess, getTypesOfUser } from "@/utils/permissions";
|
||||
import usePermissions from "@/hooks/usePermissions";
|
||||
|
||||
@@ -20,7 +20,7 @@ import {shouldRedirectHome} from "@/utils/navigation.disabled";
|
||||
import {sessionOptions} from "@/lib/session";
|
||||
import qs from "qs";
|
||||
import StatsGridItem from "@/components/Medium/StatGridItem";
|
||||
import useExamStore from "@/stores/examStore";
|
||||
import useExamStore from "@/stores/exam";
|
||||
import {usePDFDownload} from "@/hooks/usePDFDownload";
|
||||
import useAssignments from "@/hooks/useAssignments";
|
||||
import useUsers from "@/hooks/useUsers";
|
||||
@@ -29,7 +29,6 @@ import InfiniteCarousel from "@/components/InfiniteCarousel";
|
||||
import {LuExternalLink} from "react-icons/lu";
|
||||
import {uniqBy} from "lodash";
|
||||
import {getExamById} from "@/utils/exams";
|
||||
import {convertToUserSolutions} from "@/utils/stats";
|
||||
import {sortByModule} from "@/utils/moduleUtils";
|
||||
import { requestUser } from "@/utils/api";
|
||||
import { redirect, serialize } from "@/utils";
|
||||
@@ -46,15 +45,10 @@ export const getServerSideProps = withIronSessionSsr(async ({req, res}) => {
|
||||
}, sessionOptions);
|
||||
|
||||
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 dispatch = useExamStore((s) => s.dispatch);
|
||||
|
||||
const [trainingContent, setTrainingContent] = useState<ITrainingContent | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [trainingTips, setTrainingTips] = useState<ITrainingTip[]>([]);
|
||||
@@ -125,17 +119,18 @@ const TrainingContent: React.FC<{user: User}> = ({user}) => {
|
||||
|
||||
Promise.all(examPromises).then((exams) => {
|
||||
if (exams.every((x) => !!x)) {
|
||||
if (!!timeSpent) setTimeSpent(timeSpent);
|
||||
if (!!inactivity) setInactivity(inactivity);
|
||||
setUserSolutions(convertToUserSolutions(stats));
|
||||
setShowSolutions(true);
|
||||
setExams(exams.map((x) => x!).sort(sortByModule));
|
||||
setSelectedModules(
|
||||
exams
|
||||
dispatch({
|
||||
type: 'INIT_SOLUTIONS', payload: {
|
||||
exams: exams.map((x) => x!).sort(sortByModule),
|
||||
modules: exams
|
||||
.map((x) => x!)
|
||||
.sort(sortByModule)
|
||||
.map((x) => x!.module),
|
||||
);
|
||||
stats,
|
||||
timeSpent,
|
||||
inactivity
|
||||
}
|
||||
});
|
||||
router.push("/exam");
|
||||
}
|
||||
});
|
||||
@@ -185,12 +180,6 @@ const TrainingContent: React.FC<{user: User}> = ({user}) => {
|
||||
user={user}
|
||||
assignments={assignments}
|
||||
users={users}
|
||||
setExams={setExams}
|
||||
setShowSolutions={setShowSolutions}
|
||||
setUserSolutions={setUserSolutions}
|
||||
setSelectedModules={setSelectedModules}
|
||||
setInactivity={setInactivity}
|
||||
setTimeSpent={setTimeSpent}
|
||||
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