Navigation rework, added prompt edit to components that were missing

This commit is contained in:
Carlos-Mesquita
2024-11-25 16:50:46 +00:00
parent e9b7bd14cc
commit 114da173be
105 changed files with 3761 additions and 3728 deletions

22
package-lock.json generated
View File

@@ -105,6 +105,7 @@
"@types/react-datepicker": "^4.15.1", "@types/react-datepicker": "^4.15.1",
"@types/uuid": "^9.0.1", "@types/uuid": "^9.0.1",
"@types/wavesurfer.js": "^6.0.6", "@types/wavesurfer.js": "^6.0.6",
"@welldone-software/why-did-you-render": "^8.0.3",
"@wixc3/react-board": "^2.2.0", "@wixc3/react-board": "^2.2.0",
"autoprefixer": "^10.4.13", "autoprefixer": "^10.4.13",
"husky": "^8.0.3", "husky": "^8.0.3",
@@ -3615,6 +3616,18 @@
"react": ">= 16.8.0" "react": ">= 16.8.0"
} }
}, },
"node_modules/@welldone-software/why-did-you-render": {
"version": "8.0.3",
"resolved": "https://registry.npmjs.org/@welldone-software/why-did-you-render/-/why-did-you-render-8.0.3.tgz",
"integrity": "sha512-bb5bKPMStYnocyTBVBu4UTegZdBqzV1mPhxc0UIV/S43KFUSRflux9gvzJfu2aM4EWLJ3egTvdjOi+viK+LKGA==",
"dev": true,
"dependencies": {
"lodash": "^4"
},
"peerDependencies": {
"react": "^18"
}
},
"node_modules/@wixc3/board-core": { "node_modules/@wixc3/board-core": {
"version": "2.2.0", "version": "2.2.0",
"resolved": "https://registry.npmjs.org/@wixc3/board-core/-/board-core-2.2.0.tgz", "resolved": "https://registry.npmjs.org/@wixc3/board-core/-/board-core-2.2.0.tgz",
@@ -14582,6 +14595,15 @@
"@use-gesture/core": "10.3.1" "@use-gesture/core": "10.3.1"
} }
}, },
"@welldone-software/why-did-you-render": {
"version": "8.0.3",
"resolved": "https://registry.npmjs.org/@welldone-software/why-did-you-render/-/why-did-you-render-8.0.3.tgz",
"integrity": "sha512-bb5bKPMStYnocyTBVBu4UTegZdBqzV1mPhxc0UIV/S43KFUSRflux9gvzJfu2aM4EWLJ3egTvdjOi+viK+LKGA==",
"dev": true,
"requires": {
"lodash": "^4"
}
},
"@wixc3/board-core": { "@wixc3/board-core": {
"version": "2.2.0", "version": "2.2.0",
"resolved": "https://registry.npmjs.org/@wixc3/board-core/-/board-core-2.2.0.tgz", "resolved": "https://registry.npmjs.org/@wixc3/board-core/-/board-core-2.2.0.tgz",

View File

@@ -107,6 +107,7 @@
"@types/react-datepicker": "^4.15.1", "@types/react-datepicker": "^4.15.1",
"@types/uuid": "^9.0.1", "@types/uuid": "^9.0.1",
"@types/wavesurfer.js": "^6.0.6", "@types/wavesurfer.js": "^6.0.6",
"@welldone-software/why-did-you-render": "^8.0.3",
"@wixc3/react-board": "^2.2.0", "@wixc3/react-board": "^2.2.0",
"autoprefixer": "^10.4.13", "autoprefixer": "^10.4.13",
"husky": "^8.0.3", "husky": "^8.0.3",

View File

@@ -1,7 +1,7 @@
import {infoButtonStyle} from "@/constants/buttonStyles"; import {infoButtonStyle} from "@/constants/buttonStyles";
import {Module} from "@/interfaces"; import {Module} from "@/interfaces";
import {User} from "@/interfaces/user"; import {User} from "@/interfaces/user";
import useExamStore from "@/stores/examStore"; import useExamStore from "@/stores/exam";
import {getExam, getExamById} from "@/utils/exams"; import {getExam, getExamById} from "@/utils/exams";
import {MODULE_ARRAY} from "@/utils/moduleUtils"; import {MODULE_ARRAY} from "@/utils/moduleUtils";
import {writingMarking} from "@/utils/score"; import {writingMarking} from "@/utils/score";
@@ -28,8 +28,7 @@ export default function Diagnostic({onFinish}: Props) {
const router = useRouter(); const router = useRouter();
const setExams = useExamStore((state) => state.setExams); const dispatch = useExamStore((state) => state.dispatch);
const setSelectedModules = useExamStore((state) => state.setSelectedModules);
const isNextDisabled = () => { const isNextDisabled = () => {
if (!focus) return true; if (!focus) return true;
@@ -41,8 +40,7 @@ export default function Diagnostic({onFinish}: Props) {
Promise.all(examPromises).then((exams) => { Promise.all(examPromises).then((exams) => {
if (exams.every((x) => !!x)) { if (exams.every((x) => !!x)) {
setExams(exams.map((x) => x!)); dispatch({type: 'INIT_EXAM', payload: {exams: exams.map((x) => x!), modules: exams.map((x) => x!.module)}})
setSelectedModules(exams.map((x) => x!.module));
router.push("/exam"); router.push("/exam");
} }
}); });

View File

@@ -12,6 +12,7 @@ import { AlertItem } from "../../Shared/Alert";
import validateBlanks from "../validateBlanks"; import validateBlanks from "../validateBlanks";
import { toast } from "react-toastify"; import { toast } from "react-toastify";
import setEditingAlert from "../../Shared/setEditingAlert"; import setEditingAlert from "../../Shared/setEditingAlert";
import PromptEdit from "../../Shared/PromptEdit";
interface Word { interface Word {
letter: string; letter: string;
@@ -38,6 +39,12 @@ const FillBlanksLetters: React.FC<{ exercise: FillBlanksExercise; sectionId: num
const [editing, setEditing] = useState(false); const [editing, setEditing] = useState(false);
const updateLocal = (exercise: FillBlanksExercise) => {
setLocal(exercise);
setEditingAlert(true, setAlerts);
setEditing(true);
};
const [blanksState, blanksDispatcher] = useReducer(blanksReducer, { const [blanksState, blanksDispatcher] = useReducer(blanksReducer, {
text: exercise.text || "", text: exercise.text || "",
blanks: [], blanks: [],
@@ -266,6 +273,8 @@ const FillBlanksLetters: React.FC<{ exercise: FillBlanksExercise; sectionId: num
setEditing={setEditing} setEditing={setEditing}
onPractice={handlePractice} onPractice={handlePractice}
isEvaluationEnabled={!local.isPractice} isEvaluationEnabled={!local.isPractice}
prompt={local.prompt}
updatePrompt={(prompt: string) => updateLocal({...local, prompt})}
> >
<> <>
{!blanksState.textMode && <Card className="p-4"> {!blanksState.textMode && <Card className="p-4">

View File

@@ -36,6 +36,12 @@ const FillBlanksMC: React.FC<{ exercise: FillBlanksExercise; sectionId: number }
const [isEditMode, setIsEditMode] = useState(false); const [isEditMode, setIsEditMode] = useState(false);
const [editing, setEditing] = useState(false); const [editing, setEditing] = useState(false);
const updateLocal = (exercise: FillBlanksExercise) => {
setLocal(exercise);
setEditingAlert(true, setAlerts);
setEditing(true);
};
const [blanksState, blanksDispatcher] = useReducer(blanksReducer, { const [blanksState, blanksDispatcher] = useReducer(blanksReducer, {
text: exercise.text || "", text: exercise.text || "",
blanks: [], blanks: [],
@@ -268,6 +274,8 @@ const FillBlanksMC: React.FC<{ exercise: FillBlanksExercise; sectionId: number }
setEditing={setEditing} setEditing={setEditing}
onBlankRemove={handleBlankRemove} onBlankRemove={handleBlankRemove}
isEvaluationEnabled={!local.isPractice} isEvaluationEnabled={!local.isPractice}
prompt={local.prompt}
updatePrompt={(prompt: string) => updateLocal({...local, prompt})}
> >
{!blanksState.textMode && selectedBlankId && ( {!blanksState.textMode && selectedBlankId && (
<Card className="p-4"> <Card className="p-4">

View File

@@ -24,6 +24,12 @@ const WriteBlanksFill: React.FC<{ exercise: WriteBlanksExercise; sectionId: numb
const [selectedBlankId, setSelectedBlankId] = useState<string | null>(null); const [selectedBlankId, setSelectedBlankId] = useState<string | null>(null);
const [editing, setEditing] = useState(false); const [editing, setEditing] = useState(false);
const updateLocal = (exercise: WriteBlanksExercise) => {
setLocal(exercise);
setEditingAlert(true, setAlerts);
setEditing(true);
};
const [blanksState, blanksDispatcher] = useReducer(blanksReducer, { const [blanksState, blanksDispatcher] = useReducer(blanksReducer, {
text: exercise.text || "", text: exercise.text || "",
blanks: [], blanks: [],
@@ -79,7 +85,7 @@ const WriteBlanksFill: React.FC<{ exercise: WriteBlanksExercise; sectionId: numb
newState.exercises = newState.exercises.map((ex) => newState.exercises = newState.exercises.map((ex) =>
ex.id === exercise.id ? updatedExercise : ex ex.id === exercise.id ? updatedExercise : ex
); );
setLocal((prev) => ({...prev, isPractice: !local.isPractice})) setLocal((prev) => ({ ...prev, isPractice: !local.isPractice }))
dispatch({ type: 'UPDATE_SECTION_STATE', payload: { sectionId, update: newState, module: currentModule } }); dispatch({ type: 'UPDATE_SECTION_STATE', payload: { sectionId, update: newState, module: currentModule } });
} }
}); });
@@ -175,6 +181,8 @@ const WriteBlanksFill: React.FC<{ exercise: WriteBlanksExercise; sectionId: numb
onPractice={handlePractice} onPractice={handlePractice}
setEditing={setEditing} setEditing={setEditing}
isEvaluationEnabled={!local.isPractice} isEvaluationEnabled={!local.isPractice}
prompt={local.prompt}
updatePrompt={(prompt: string) => updateLocal({ ...local, prompt })}
> >
{!blanksState.textMode && ( {!blanksState.textMode && (
<Card> <Card>

View File

@@ -19,6 +19,7 @@ import clsx from "clsx";
import { Card, CardContent } from "@/components/ui/card"; import { Card, CardContent } from "@/components/ui/card";
import { Blank, DropZone } from "./DragNDrop"; import { Blank, DropZone } from "./DragNDrop";
import { getTextSegments, BlankState, BlanksState, BlanksAction, BlankToken } from "./BlanksReducer"; import { getTextSegments, BlankState, BlanksState, BlanksAction, BlankToken } from "./BlanksReducer";
import PromptEdit from "../Shared/PromptEdit";
interface Props { interface Props {
@@ -30,6 +31,8 @@ interface Props {
editing: boolean; editing: boolean;
showBlankBank: boolean; showBlankBank: boolean;
alerts: AlertItem[]; alerts: AlertItem[];
prompt: string;
updatePrompt: (prompt: string) => void;
setEditing: React.Dispatch<React.SetStateAction<boolean>>; setEditing: React.Dispatch<React.SetStateAction<boolean>>;
blanksDispatcher: React.Dispatch<BlanksAction> blanksDispatcher: React.Dispatch<BlanksAction>
onBlankSelect?: (blankId: number | null) => void; onBlankSelect?: (blankId: number | null) => void;
@@ -60,7 +63,9 @@ const BlanksEditor: React.FC<Props> = ({
onDelete, onDelete,
onPractice, onPractice,
isEvaluationEnabled, isEvaluationEnabled,
setEditing setEditing,
prompt,
updatePrompt
}) => { }) => {
useEffect(() => { useEffect(() => {
@@ -171,10 +176,11 @@ const BlanksEditor: React.FC<Props> = ({
isEvaluationEnabled={isEvaluationEnabled} isEvaluationEnabled={isEvaluationEnabled}
/> />
{alerts.length > 0 && <Alert alerts={alerts} />} {alerts.length > 0 && <Alert alerts={alerts} />}
<PromptEdit value={prompt} onChange={(text: string) => updatePrompt(text)} />
<Card> <Card>
<CardContent className="p-4 text-white font-semibold flex gap-2"> <CardContent className="p-4 text-white font-semibold flex gap-2">
<button <button
onClick={() => blanksDispatcher({ type: "ADD_BLANK" }) } onClick={() => blanksDispatcher({ type: "ADD_BLANK" })}
className={`px-3 py-1.5 bg-ielts-${module} rounded-md hover:bg-ielts-${module}/50 transition-colors`} className={`px-3 py-1.5 bg-ielts-${module} rounded-md hover:bg-ielts-${module}/50 transition-colors`}
> >
Add Blank Add Blank
@@ -203,7 +209,7 @@ const BlanksEditor: React.FC<Props> = ({
{state.textMode ? ( {state.textMode ? (
<AutoExpandingTextArea <AutoExpandingTextArea
value={state.text.replace(/{{(\d+)}}/g, "[$1]")} value={state.text.replace(/{{(\d+)}}/g, "[$1]")}
onChange={(text) => { handleTextChange(text); if (!editing) setEditing(true) } } onChange={(text) => { handleTextChange(text); if (!editing) setEditing(true) }}
className="w-full h-full min-h-[200px] p-2 bg-white border rounded-md" className="w-full h-full min-h-[200px] p-2 bg-white border rounded-md"
placeholder="Enter text here. Use [1], [2], etc. for blanks..." placeholder="Enter text here. Use [1], [2], etc. for blanks..."
/> />

View File

@@ -10,6 +10,7 @@ import useExamEditorStore from "@/stores/examEditor";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { MdAdd } from "react-icons/md"; import { MdAdd } from "react-icons/md";
import Alert, { AlertItem } from "../../Shared/Alert"; import Alert, { AlertItem } from "../../Shared/Alert";
import PromptEdit from "../../Shared/PromptEdit";
const UnderlineMultipleChoice: React.FC<{exercise: MultipleChoiceExercise, sectionId: number}> = ({ const UnderlineMultipleChoice: React.FC<{exercise: MultipleChoiceExercise, sectionId: number}> = ({
@@ -125,7 +126,7 @@ const UnderlineMultipleChoice: React.FC<{exercise: MultipleChoiceExercise, secti
isEvaluationEnabled={!local.isPractice} isEvaluationEnabled={!local.isPractice}
/> />
{alerts.length > 0 && <Alert className="mb-6" alerts={alerts} />} {alerts.length > 0 && <Alert className="mb-6" alerts={alerts} />}
<PromptEdit value={local.prompt} onChange={(prompt: string) => updateLocal({...local, prompt})} />
<div className="space-y-4"> <div className="space-y-4">
<QuestionsList <QuestionsList
ids={local.questions.map(q => q.id)} ids={local.questions.map(q => q.id)}

View File

@@ -17,6 +17,7 @@ import QuestionsList from '../../Shared/QuestionsList';
import SortableQuestion from '../../Shared/SortableQuestion'; import SortableQuestion from '../../Shared/SortableQuestion';
import setEditingAlert from '../../Shared/setEditingAlert'; import setEditingAlert from '../../Shared/setEditingAlert';
import { handleMultipleChoiceReorder } from '@/stores/examEditor/reorder/local'; import { handleMultipleChoiceReorder } from '@/stores/examEditor/reorder/local';
import PromptEdit from '../../Shared/PromptEdit';
interface MultipleChoiceProps { interface MultipleChoiceProps {
exercise: MultipleChoiceExercise; exercise: MultipleChoiceExercise;
@@ -214,36 +215,7 @@ const MultipleChoice: React.FC<MultipleChoiceProps> = ({ exercise, sectionId, op
isEvaluationEnabled={!local.isPractice} isEvaluationEnabled={!local.isPractice}
/> />
{alerts.length > 0 && <Alert className="mb-6" alerts={alerts} />} {alerts.length > 0 && <Alert className="mb-6" alerts={alerts} />}
<Card className="mb-6"> <PromptEdit value={local.prompt} onChange={(prompt: string) => updateLocal({...local, prompt})} />
<CardContent className="p-4">
<div className="flex justify-between items-start gap-4">
{editingPrompt ? (
<textarea
className="flex-1 p-3 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:outline-none min-h-[100px]"
value={local.prompt}
onChange={(e) => updateLocal({ ...local, prompt: e.target.value })}
onBlur={() => setEditingPrompt(false)}
autoFocus
/>
) : (
<div className="flex-1">
<h3 className="font-medium text-gray-800 mb-2">Question/Instructions displayed to the student:</h3>
<p className="text-gray-600">{local.prompt}</p>
</div>
)}
<button
onClick={() => setEditingPrompt(!editingPrompt)}
className="p-2 hover:bg-gray-100 rounded-lg transition-colors"
>
{editingPrompt ?
<MdEditOff size={20} className="text-gray-500" /> :
<MdEdit size={20} className="text-gray-500" />
}
</button>
</div>
</CardContent>
</Card>
<div className="space-y-4"> <div className="space-y-4">
<QuestionsList <QuestionsList
ids={local.questions.map(q => q.id)} ids={local.questions.map(q => q.id)}

View File

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

View File

@@ -21,6 +21,7 @@ import { toast } from 'react-toastify';
import { validateEmptySolutions, validateQuestionText, validateWordCount } from './validation'; import { validateEmptySolutions, validateQuestionText, validateWordCount } from './validation';
import { handleWriteBlanksReorder } from '@/stores/examEditor/reorder/local'; import { handleWriteBlanksReorder } from '@/stores/examEditor/reorder/local';
import { ParsedQuestion, parseText, reconstructText } from './parsing'; import { ParsedQuestion, parseText, reconstructText } from './parsing';
import PromptEdit from '../Shared/PromptEdit';
const WriteBlanks: React.FC<{ sectionId: number; exercise: WriteBlanksExercise; }> = ({ sectionId, exercise }) => { const WriteBlanks: React.FC<{ sectionId: number; exercise: WriteBlanksExercise; }> = ({ sectionId, exercise }) => {
@@ -85,7 +86,7 @@ const WriteBlanks: React.FC<{ sectionId: number; exercise: WriteBlanksExercise;
newState.exercises = newState.exercises.map((ex) => newState.exercises = newState.exercises.map((ex) =>
ex.id === exercise.id ? updatedExercise : ex ex.id === exercise.id ? updatedExercise : ex
); );
setLocal((prev) => ({...prev, isPractice: !local.isPractice})) setLocal((prev) => ({ ...prev, isPractice: !local.isPractice }))
dispatch({ type: 'UPDATE_SECTION_STATE', payload: { sectionId, update: newState, module: currentModule } }); dispatch({ type: 'UPDATE_SECTION_STATE', payload: { sectionId, update: newState, module: currentModule } });
} }
}); });
@@ -246,30 +247,7 @@ const WriteBlanks: React.FC<{ sectionId: number; exercise: WriteBlanksExercise;
{alerts.length > 0 && <Alert alerts={alerts} />} {alerts.length > 0 && <Alert alerts={alerts} />}
<Card className="mb-6"> <Card className="mb-6">
<CardContent className="p-4 space-y-4"> <CardContent className="p-4 space-y-4">
<div className="flex justify-between items-start gap-4 mb-6"> <PromptEdit value={local.prompt} onChange={(prompt: string) => updateLocal({ ...local, prompt })} wrapperCard={false}/>
{editingPrompt ? (
<AutoExpandingTextArea
className="flex-1 p-3 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:outline-none min-h-[100px]"
value={local.prompt}
onChange={(text) => updateLocal({ ...local, prompt: text })}
onBlur={() => setEditingPrompt(false)}
/>
) : (
<div className="flex-1">
<h3 className="font-medium text-gray-800 mb-2">Question/Instructions displayed to the student:</h3>
<p className="text-gray-600">{local.prompt}</p>
</div>
)}
<button
onClick={() => setEditingPrompt(!editingPrompt)}
className="p-2 hover:bg-gray-100 rounded-lg transition-colors"
>
{editingPrompt ?
<MdEditOff size={20} className="text-gray-500" /> :
<MdEdit size={20} className="text-gray-500" />
}
</button>
</div>
<div className="flex justify-between items-start gap-4"> <div className="flex justify-between items-start gap-4">
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<label className="flex items-center gap-2"> <label className="flex items-center gap-2">

View File

@@ -16,6 +16,7 @@ import { ParsedQuestion, parseLine, reconstructLine } from "./parsing";
import { validateQuestions, validateEmptySolutions, validateWordCount } from "./validation"; import { validateQuestions, validateEmptySolutions, validateWordCount } from "./validation";
import Header from "../../Shared/Header"; import Header from "../../Shared/Header";
import BlanksFormEditor from "./BlanksFormEditor"; import BlanksFormEditor from "./BlanksFormEditor";
import PromptEdit from "../Shared/PromptEdit";
const WriteBlanksForm: React.FC<{ sectionId: number; exercise: WriteBlanksExercise }> = ({ sectionId, exercise }) => { const WriteBlanksForm: React.FC<{ sectionId: number; exercise: WriteBlanksExercise }> = ({ sectionId, exercise }) => {
@@ -220,49 +221,7 @@ const WriteBlanksForm: React.FC<{ sectionId: number; exercise: WriteBlanksExerci
/> />
<div className="space-y-4"> <div className="space-y-4">
{alerts.length > 0 && <Alert alerts={alerts} />} {alerts.length > 0 && <Alert alerts={alerts} />}
<Card className="mb-6"> <PromptEdit value={local.prompt} onChange={(prompt: string) => updateLocal({ ...local, prompt })}/>
<CardContent className="p-4 space-y-4">
<div className="flex justify-between items-start gap-4 mb-6">
{editingPrompt ? (
<AutoExpandingTextArea
className="flex-1 p-3 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:outline-none min-h-[100px]"
value={local.prompt}
onChange={(text) => updateLocal({ ...local, prompt: text })}
onBlur={() => setEditingPrompt(false)}
/>
) : (
<div className="flex-1">
<h3 className="font-medium text-gray-800 mb-2">Question/Instructions:</h3>
<p className="text-gray-600">{local.prompt}</p>
</div>
)}
<button
onClick={() => setEditingPrompt(!editingPrompt)}
className="p-2 hover:bg-gray-100 rounded-lg transition-colors"
>
{editingPrompt ?
<MdEditOff size={20} className="text-gray-500" /> :
<MdEdit size={20} className="text-gray-500" />
}
</button>
</div>
<div className="flex justify-between items-start gap-4">
<div className="flex items-center gap-4">
<label className="flex items-center gap-2">
<span className="font-medium text-gray-800">Maximum words per solution:</span>
<input
type="number"
value={local.maxWords}
onChange={(e) => updateLocal({ ...local, maxWords: parseInt(e.target.value) })}
className="w-20 p-2 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:outline-none"
min="1"
/>
</label>
</div>
</div>
</CardContent>
</Card>
<div className="space-y-4"> <div className="space-y-4">
<QuestionsList <QuestionsList
ids={parsedQuestions.map(q => q.id)} ids={parsedQuestions.map(q => q.id)}

View File

@@ -6,12 +6,12 @@ import clsx from "clsx";
import ExercisePicker from "../ExercisePicker"; import ExercisePicker from "../ExercisePicker";
import useExamEditorStore from "@/stores/examEditor"; import useExamEditorStore from "@/stores/examEditor";
import useSettingsState from "../Hooks/useSettingsState"; import useSettingsState from "../Hooks/useSettingsState";
import { LevelSectionSettings, SectionSettings } from "@/stores/examEditor/types"; import { LevelSectionSettings } from "@/stores/examEditor/types";
import { toast } from "react-toastify"; import { toast } from "react-toastify";
import axios from "axios"; import axios from "axios";
import { playSound } from "@/utils/sound"; import { playSound } from "@/utils/sound";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { usePersistentExamStore } from "@/stores/examStore"; import { usePersistentExamStore } from "@/stores/exam";
import openDetachedTab from "@/utils/popout"; import openDetachedTab from "@/utils/popout";
import ListeningComponents from "./listening/components"; import ListeningComponents from "./listening/components";
import ReadingComponents from "./reading/components"; import ReadingComponents from "./reading/components";

View File

@@ -13,7 +13,7 @@ import Input from "@/components/Low/Input";
import openDetachedTab from "@/utils/popout"; import openDetachedTab from "@/utils/popout";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import axios from "axios"; import axios from "axios";
import { usePersistentExamStore } from "@/stores/examStore"; import { usePersistentExamStore } from "@/stores/exam";
import { playSound } from "@/utils/sound"; import { playSound } from "@/utils/sound";
import { toast } from "react-toastify"; import { toast } from "react-toastify";
import ListeningComponents from "./components"; import ListeningComponents from "./components";
@@ -98,16 +98,16 @@ const ListeningSettings: React.FC = () => {
const { urls } = response.data; const { urls } = response.data;
const exam: ListeningExam = { const exam: ListeningExam = {
parts: sections.map((s) => { parts: sectionsWithAudio.map((s) => {
const exercise = s.state as ListeningPart; const part = s.state as ListeningPart;
const index = Array.from(sectionMap.entries()) const index = Array.from(sectionMap.entries())
.findIndex(([id]) => id === s.sectionId); .findIndex(([id]) => id === s.sectionId);
return { return {
...exercise, ...part,
audio: exercise.audio ? { audio: part.audio ? {
...exercise.audio, ...part.audio,
source: index !== -1 ? urls[index] : exercise.audio.source source: index !== -1 ? urls[index] : part.audio.source
} : undefined, } : undefined,
intro: s.settings.currentIntro, intro: s.settings.currentIntro,
category: s.settings.category category: s.settings.category

View File

@@ -7,7 +7,7 @@ import { ReadingSectionSettings } from "@/stores/examEditor/types";
import useExamEditorStore from "@/stores/examEditor"; import useExamEditorStore from "@/stores/examEditor";
import openDetachedTab from "@/utils/popout"; import openDetachedTab from "@/utils/popout";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { usePersistentExamStore } from "@/stores/examStore"; import { usePersistentExamStore } from "@/stores/exam";
import axios from "axios"; import axios from "axios";
import { playSound } from "@/utils/sound"; import { playSound } from "@/utils/sound";
import { toast } from "react-toastify"; import { toast } from "react-toastify";
@@ -98,9 +98,9 @@ const ReadingSettings: React.FC = () => {
const preview = () => { const preview = () => {
setExam({ setExam({
parts: sections.map((s) => { parts: sections.map((s) => {
const exercise = s.state as ReadingPart; const exercises = s.state as ReadingPart;
return { return {
...exercise, ...exercises,
intro: s.settings.currentIntro, intro: s.settings.currentIntro,
category: s.settings.category category: s.settings.category
}; };

View File

@@ -5,7 +5,7 @@ import Option from "@/interfaces/option";
import SettingsEditor from ".."; import SettingsEditor from "..";
import { InteractiveSpeakingExercise, SpeakingExam, SpeakingExercise } from "@/interfaces/exam"; import { InteractiveSpeakingExercise, SpeakingExam, SpeakingExercise } from "@/interfaces/exam";
import { toast } from "react-toastify"; import { toast } from "react-toastify";
import { usePersistentExamStore } from "@/stores/examStore"; import { usePersistentExamStore } from "@/stores/exam";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import openDetachedTab from "@/utils/popout"; import openDetachedTab from "@/utils/popout";
import axios from "axios"; import axios from "axios";

View File

@@ -1,15 +1,11 @@
import React, { useCallback, useEffect, useState } from "react"; import React, { useCallback, useEffect, useState } from "react";
import SettingsEditor from ".."; import SettingsEditor from "..";
import Option from "@/interfaces/option"; import Option from "@/interfaces/option";
import Dropdown from "../Shared/SettingsDropdown";
import Input from "@/components/Low/Input";
import { generate } from "../Shared/Generate";
import useSettingsState from "../../Hooks/useSettingsState"; import useSettingsState from "../../Hooks/useSettingsState";
import GenerateBtn from "../Shared/GenerateBtn";
import { WritingSectionSettings } from "@/stores/examEditor/types"; import { WritingSectionSettings } from "@/stores/examEditor/types";
import useExamEditorStore from "@/stores/examEditor"; import useExamEditorStore from "@/stores/examEditor";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { usePersistentExamStore } from "@/stores/examStore"; import { usePersistentExamStore } from "@/stores/exam";
import openDetachedTab from "@/utils/popout"; import openDetachedTab from "@/utils/popout";
import { WritingExam, WritingExercise } from "@/interfaces/exam"; import { WritingExam, WritingExercise } from "@/interfaces/exam";
import { v4 } from "uuid"; import { v4 } from "uuid";

View File

@@ -39,7 +39,7 @@ const MCDropdown: React.FC<MCDropdownProps> = ({
}); });
return ( return (
<div className={`${className} inline-block`} style={{ width: `${width}px` }}> <div key={`dropdown-${id}`} className={`${className} inline-block`} style={{ width: `${width}px` }}>
<button <button
onClick={() => onToggle(id)} onClick={() => onToggle(id)}
className={ className={

View File

@@ -1,12 +1,13 @@
//import "@/utils/wdyr";
import { FillBlanksExercise, FillBlanksMCOption } from "@/interfaces/exam"; import { FillBlanksExercise, FillBlanksMCOption } from "@/interfaces/exam";
import useExamStore, { usePersistentExamStore } from "@/stores/examStore";
import clsx from "clsx"; import clsx from "clsx";
import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from "react";
import reactStringReplace from "react-string-replace"; import reactStringReplace from "react-string-replace";
import { CommonProps } from ".."; import { CommonProps } from "../types";
import Button from "../../Low/Button";
import { v4 } from "uuid"; import { v4 } from "uuid";
import MCDropdown from "./MCDropdown"; import MCDropdown from "./MCDropdown";
import useExamStore, { usePersistentExamStore } from "@/stores/exam";
const FillBlanks: React.FC<FillBlanksExercise & CommonProps> = ({ const FillBlanks: React.FC<FillBlanksExercise & CommonProps> = ({
id, id,
@@ -18,43 +19,29 @@ const FillBlanks: React.FC<FillBlanksExercise & CommonProps> = ({
words, words,
userSolutions, userSolutions,
variant, variant,
registerSolution,
headerButtons,
footerButtons,
preview, preview,
onNext,
onBack,
disableProgressButtons = false
}) => { }) => {
const examState = useExamStore((state) => state); const examState = useExamStore((state) => state);
const persistentExamState = usePersistentExamStore((state) => state); const persistentExamState = usePersistentExamStore((state) => state);
const { const {
hasExamEnded,
exerciseIndex, exerciseIndex,
partIndex, partIndex,
questionIndex,
shuffles, shuffles,
exam, exam,
setCurrentSolution, } = examState; !preview ? examState : persistentExamState;
} = !preview ? examState : persistentExamState;
const [answers, setAnswers] = useState<{ id: string; solution: string }[]>(userSolutions); const [answers, setAnswers] = useState<{ id: string; solution: string }[]>(userSolutions);
const shuffleMaps = shuffles.find((x) => x.exerciseID == id)?.shuffles; const shuffleMaps = shuffles.find((x) => x.exerciseID == id)?.shuffles;
const dropdownRef = useRef<HTMLDivElement>(null); const dropdownRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (disableProgressButtons) onNext({ exercise: id, solutions: answers, score: calculateScore(), type, isPractice });
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [answers, disableProgressButtons])
const excludeWordMCType = (x: any) => { const excludeWordMCType = (x: any) => {
return typeof x === "string" ? x : (x as { letter: string; word: string }); return typeof x === "string" ? x : (x as { letter: string; word: string });
}; };
useEffect(() => {
if (hasExamEnded) onNext({ exercise: id, solutions: answers, score: calculateScore(), type });
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [hasExamEnded]);
let correctWords: any; let correctWords: any;
if (exam && (exam.module === "level" || exam.module === "reading") && exam.parts[partIndex].exercises[exerciseIndex].type === "fillBlanks") { if (exam && (exam.module === "level" || exam.module === "reading") && exam.parts[partIndex].exercises[exerciseIndex].type === "fillBlanks") {
correctWords = (exam.parts[partIndex].exercises[exerciseIndex] as FillBlanksExercise).words; correctWords = (exam.parts[partIndex].exercises[exerciseIndex] as FillBlanksExercise).words;
@@ -73,7 +60,7 @@ const FillBlanks: React.FC<FillBlanksExercise & CommonProps> = ({
}; };
}, []); }, []);
const calculateScore = () => { const calculateScore = useCallback(() => {
const total = text.match(/({{\d+}})/g)?.length || 0; const total = text.match(/({{\d+}})/g)?.length || 0;
const correct = answers!.filter((x) => { const correct = answers!.filter((x) => {
const solution = solutions.find((y) => x.id.toString() === y.id.toString())?.solution; const solution = solutions.find((y) => x.id.toString() === y.id.toString())?.solution;
@@ -100,7 +87,19 @@ const FillBlanks: React.FC<FillBlanksExercise & CommonProps> = ({
}).length; }).length;
const missing = total - answers!.filter((x) => solutions.find((y) => x.id.toString() === y.id.toString())).length; const missing = total - answers!.filter((x) => solutions.find((y) => x.id.toString() === y.id.toString())).length;
return { total, correct, missing }; return { total, correct, missing };
}; }, [answers, correctWords, solutions, text]);
useEffect(() => {
registerSolution(() => ({
exercise: id,
solutions: answers,
score: calculateScore(),
type,
shuffleMaps,
isPractice
}));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [id, answers, type, isPractice, shuffleMaps, calculateScore]);
const [openDropdownId, setOpenDropdownId] = useState<string | null>(null); const [openDropdownId, setOpenDropdownId] = useState<string | null>(null);
@@ -137,6 +136,7 @@ const FillBlanks: React.FC<FillBlanksExercise & CommonProps> = ({
/> />
) : ( ) : (
<input <input
key={`input-${id}`}
className={styles} className={styles}
onChange={(e) => setAnswers((prev) => [...prev.filter((x) => x.id !== id), { id, solution: e.target.value }])} onChange={(e) => setAnswers((prev) => [...prev.filter((x) => x.id !== id), { id, solution: e.target.value }])}
value={userSolution?.solution} value={userSolution?.solution}
@@ -151,10 +151,10 @@ const FillBlanks: React.FC<FillBlanksExercise & CommonProps> = ({
const memoizedLines = useMemo(() => { const memoizedLines = useMemo(() => {
return text.split("\\n").map((line, index) => ( return text.split("\\n").map((line, index) => (
<p key={index} className={clsx(variant === "mc" && "whitespace-pre-wrap")}> <div key={index} className={clsx(variant === "mc" && "whitespace-pre-wrap")}>
{renderLines(line)} {renderLines(line)}
<br /> <br />
</p> </div>
)); ));
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [text, variant, renderLines]); }, [text, variant, renderLines]);
@@ -163,40 +163,10 @@ const FillBlanks: React.FC<FillBlanksExercise & CommonProps> = ({
setAnswers((prev) => [...prev.filter((x) => x.id !== questionID), { id: questionID, solution: value }]); setAnswers((prev) => [...prev.filter((x) => x.id !== questionID), { id: questionID, solution: value }]);
}; };
useEffect(() => {
if (variant === "mc") {
setCurrentSolution({ exercise: id, solutions: answers, score: calculateScore(), type, shuffleMaps: shuffleMaps });
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [answers]);
const progressButtons = () => (
<div className="flex justify-between w-full gap-8">
<Button
color="purple"
variant="outline"
onClick={() => onBack({ exercise: id, solutions: answers, score: calculateScore(), type, shuffleMaps: shuffleMaps, isPractice })}
className="max-w-[200px] w-full"
disabled={exam && exam.module === "level" && partIndex === 0 && questionIndex === 0}>
Previous Page
</Button>
<Button
color="purple"
onClick={() => {
onNext({ exercise: id, solutions: answers, score: calculateScore(), type, shuffleMaps: shuffleMaps, isPractice });
}}
className="max-w-[200px] self-end w-full">
Next Page
</Button>
</div>
)
return ( return (
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
{!disableProgressButtons && progressButtons()} {headerButtons}
<div className={clsx("flex flex-col gap-4 mt-4 h-full w-full", (!headerButtons && !footerButtons) && "mb-20")}>
<div className={clsx("flex flex-col gap-4 mt-4 h-full w-full", !disableProgressButtons && "mb-20")}>
{variant !== "mc" && ( {variant !== "mc" && (
<span className="text-sm w-full leading-6"> <span className="text-sm w-full leading-6">
{prompt.split("\\n").map((line, index) => ( {prompt.split("\\n").map((line, index) => (
@@ -234,11 +204,12 @@ const FillBlanks: React.FC<FillBlanksExercise & CommonProps> = ({
</div> </div>
</div> </div>
)} )}
{footerButtons}
</div> </div>
{!disableProgressButtons && progressButtons()}
</div> </div>
); );
}; };
//FillBlanks.whyDidYouRender = true
export default FillBlanks; export default FillBlanks;

View 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;

View File

@@ -1,19 +1,16 @@
import { InteractiveSpeakingExercise } from "@/interfaces/exam"; import { InteractiveSpeakingExercise } from "@/interfaces/exam";
import { CommonProps } from "."; import { CommonProps } from "../types";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { BsCheckCircleFill, BsMicFill, BsPauseCircle, BsPlayCircle, BsTrashFill } from "react-icons/bs"; import { BsCheckCircleFill, BsMicFill, BsPauseCircle, BsPlayCircle, BsTrashFill } from "react-icons/bs";
import dynamic from "next/dynamic"; import dynamic from "next/dynamic";
import Button from "../Low/Button"; import useExamStore, { usePersistentExamStore } from "@/stores/exam";
import useExamStore from "@/stores/examStore";
import { downloadBlob } from "@/utils/evaluation";
import axios from "axios";
const Waveform = dynamic(() => import("../Waveform"), { ssr: false }); const Waveform = dynamic(() => import("../../Waveform"), { ssr: false });
const ReactMediaRecorder = dynamic(() => import("react-media-recorder").then((mod) => mod.ReactMediaRecorder), { const ReactMediaRecorder = dynamic(() => import("react-media-recorder").then((mod) => mod.ReactMediaRecorder), {
ssr: false, ssr: false,
}); });
export default function InteractiveSpeaking({ const InteractiveSpeaking: React.FC<InteractiveSpeakingExercise & CommonProps> = ({
id, id,
title, title,
first_title, first_title,
@@ -22,65 +19,40 @@ export default function InteractiveSpeaking({
type, type,
prompts, prompts,
userSolutions, userSolutions,
onNext,
onBack,
isPractice = false, isPractice = false,
preview = false registerSolution,
}: InteractiveSpeakingExercise & CommonProps) { headerButtons,
footerButtons,
preview,
}) => {
const [recordingDuration, setRecordingDuration] = useState(0); const [recordingDuration, setRecordingDuration] = useState(0);
const [isRecording, setIsRecording] = useState(false); const [isRecording, setIsRecording] = useState(false);
const [mediaBlob, setMediaBlob] = useState<string>(); const [mediaBlob, setMediaBlob] = useState<string>();
const [answers, setAnswers] = useState<{ prompt: string; blob: string; questionIndex: number }[]>([]); const [answers, setAnswers] = useState<{ prompt: string; blob: string; questionIndex: number }[]>([]);
const [isLoading, setIsLoading] = useState(false);
const { questionIndex, setQuestionIndex } = useExamStore((state) => state); const examState = useExamStore((state) => state);
const { userSolutions: storeUserSolutions, setUserSolutions } = useExamStore((state) => state); const persistentExamState = usePersistentExamStore((state) => state);
const hasExamEnded = useExamStore((state) => state.hasExamEnded); const { questionIndex } = !preview ? examState : persistentExamState;
const back = async () => { useEffect(() => {
setIsLoading(true); setAnswers((prev) => [...prev.filter(x => x.questionIndex !== questionIndex), {
questionIndex: questionIndex,
prompt: prompts[questionIndex].text,
blob: mediaBlob!,
}]);
setMediaBlob(undefined);
}, [answers, mediaBlob, prompts, questionIndex]);
const answer = await saveAnswer(questionIndex); useEffect(() => {
if (questionIndex - 1 >= 0) { registerSolution(() => ({
setQuestionIndex(questionIndex - 1);
setIsLoading(false);
return;
}
setIsLoading(false);
onBack({
exercise: id, exercise: id,
solutions: [...answers.filter((x) => x.questionIndex !== questionIndex), answer], solutions: answers,
score: { correct: 100, total: 100, missing: 0 }, score: { correct: 100, total: 100, missing: 0 },
type, type,
isPractice isPractice
}); }));
}; }, [id, answers, mediaBlob, type, isPractice, prompts, registerSolution]);
const next = async () => {
setIsLoading(true);
const answer = await saveAnswer(questionIndex);
if (questionIndex + 1 < prompts.length) {
setQuestionIndex(questionIndex + 1);
setIsLoading(false);
return;
}
setIsLoading(false);
setQuestionIndex(0);
onNext({
exercise: id,
solutions: [...answers.filter((x) => x.questionIndex !== questionIndex), answer],
score: { correct: 100, total: 100, missing: 0 },
type,
isPractice
});
};
useEffect(() => { useEffect(() => {
if (userSolutions.length > 0 && answers.length === 0) { if (userSolutions.length > 0 && answers.length === 0) {
@@ -92,24 +64,6 @@ export default function InteractiveSpeaking({
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [userSolutions, mediaBlob, answers]); }, [userSolutions, mediaBlob, answers]);
useEffect(() => {
if (hasExamEnded) {
const answer = {
questionIndex,
prompt: prompts[questionIndex].text,
blob: mediaBlob!,
};
onNext({
exercise: id,
solutions: [...answers.filter((x) => x.questionIndex !== questionIndex), answer],
score: { correct: 100, total: 100, missing: 0 },
type,
});
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [hasExamEnded]);
useEffect(() => { useEffect(() => {
let recordingInterval: NodeJS.Timer | undefined = undefined; let recordingInterval: NodeJS.Timer | undefined = undefined;
if (isRecording) { if (isRecording) {
@@ -123,50 +77,9 @@ export default function InteractiveSpeaking({
}; };
}, [isRecording]); }, [isRecording]);
useEffect(() => {
if (questionIndex <= answers.length - 1) {
const blob = answers.find((x) => x.questionIndex === questionIndex)?.blob;
setMediaBlob(blob);
}
}, [answers, questionIndex]);
const saveAnswer = async (index: number) => {
const answer = {
questionIndex,
prompt: prompts[questionIndex].text,
blob: mediaBlob!,
};
setAnswers((prev) => [...prev.filter((x) => x.questionIndex !== index), answer]);
setMediaBlob(undefined);
setUserSolutions([
...storeUserSolutions.filter((x) => x.exercise !== id),
{
exercise: id,
solutions: [...answers.filter((x) => x.questionIndex !== questionIndex), answer],
score: { correct: 100, total: 100, missing: 0 },
module: "speaking",
exam: examID,
type,
isPractice
},
]);
return answer;
};
return ( return (
<div className="flex flex-col gap-4 mt-4 w-full"> <div className="flex flex-col gap-4 mt-4 w-full">
<div className="flex justify-between w-full gap-8"> {headerButtons}
<Button color="purple" variant="outline" isLoading={isLoading} onClick={back} className="max-w-[200px] self-end w-full">
Back
</Button>
<Button color="purple" disabled={!mediaBlob} isLoading={isLoading} onClick={next} className="max-w-[200px] self-end w-full">
{questionIndex + 1 < prompts.length ? "Next Prompt" : "Submit"}
</Button>
</div>
<div className="flex flex-col h-full w-full gap-9"> <div className="flex flex-col h-full w-full gap-9">
<div className="flex flex-col w-full gap-8 bg-mti-gray-smoke rounded-xl py-8 pb-12 px-16"> <div className="flex flex-col w-full gap-8 bg-mti-gray-smoke rounded-xl py-8 pb-12 px-16">
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
@@ -298,22 +211,10 @@ export default function InteractiveSpeaking({
</div> </div>
)} )}
/> />
<div className="self-end flex justify-between w-full gap-8">
<Button color="purple" variant="outline" isLoading={isLoading} onClick={back} className="max-w-[200px] self-end w-full">
Back
</Button>
{preview ? (
<Button color="purple" isLoading={isLoading} onClick={next} className="max-w-[200px] self-end w-full">
{questionIndex + 1 < prompts.length ? "Next Prompt" : "Submit"}
</Button>
) : (
<Button color="purple" disabled={!mediaBlob} isLoading={isLoading} onClick={next} className="max-w-[200px] self-end w-full">
{questionIndex + 1 < prompts.length ? "Next Prompt" : "Submit"}
</Button>
)}
</div>
</div> </div>
{footerButtons}
</div> </div>
); );
} }
export default InteractiveSpeaking;

View 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;

View File

@@ -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>
);
}

View 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,
}

View 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;

View File

@@ -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>
);
}

View 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;

View 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;

View File

@@ -1,67 +1,41 @@
import { SpeakingExercise } from "@/interfaces/exam"; import { SpeakingExercise } from "@/interfaces/exam";
import { CommonProps } from "."; import { CommonProps } from "./types";
import { Fragment, useEffect, useState } from "react"; import { Fragment, useEffect, useState } from "react";
import { BsCheckCircleFill, BsMicFill, BsPauseCircle, BsPlayCircle, BsTrashFill } from "react-icons/bs"; import { BsCheckCircleFill, BsMicFill, BsPauseCircle, BsPlayCircle, BsTrashFill } from "react-icons/bs";
import dynamic from "next/dynamic"; import dynamic from "next/dynamic";
import Button from "../Low/Button"; import Button from "../Low/Button";
import useExamStore from "@/stores/examStore";
import { downloadBlob } from "@/utils/evaluation";
import axios from "axios";
import Modal from "../Modal"; import Modal from "../Modal";
import useExamStore, { usePersistentExamStore } from "@/stores/exam";
const Waveform = dynamic(() => import("../Waveform"), { ssr: false }); const Waveform = dynamic(() => import("../Waveform"), { ssr: false });
const ReactMediaRecorder = dynamic(() => import("react-media-recorder").then((mod) => mod.ReactMediaRecorder), { const ReactMediaRecorder = dynamic(() => import("react-media-recorder").then((mod) => mod.ReactMediaRecorder), {
ssr: false, ssr: false,
}); });
export default function Speaking({ id, title, text, video_url, type, prompts, suffix, userSolutions, isPractice = false, onNext, onBack, preview = false }: SpeakingExercise & CommonProps) { const Speaking: React.FC<SpeakingExercise & CommonProps> = ({
id, title, text, video_url, type, prompts,
suffix, userSolutions, isPractice = false,
registerSolution, headerButtons, footerButtons, preview
}) => {
const [recordingDuration, setRecordingDuration] = useState(0); const [recordingDuration, setRecordingDuration] = useState(0);
const [isRecording, setIsRecording] = useState(false); const [isRecording, setIsRecording] = useState(false);
const [mediaBlob, setMediaBlob] = useState<string>(); const [mediaBlob, setMediaBlob] = useState<string>();
const [audioURL, setAudioURL] = useState<string>();
const [isLoading, setIsLoading] = useState(false);
const [isPromptsModalOpen, setIsPromptsModalOpen] = useState(false); const [isPromptsModalOpen, setIsPromptsModalOpen] = useState(false);
const [inputText, setInputText] = useState(""); const [inputText, setInputText] = useState("");
const hasExamEnded = useExamStore((state) => state.hasExamEnded); const examState = useExamStore((state) => state);
const persistentExamState = usePersistentExamStore((state) => state);
const saveToStorage = async () => { const {setNavigation} = !preview ? examState : persistentExamState;
if (mediaBlob && mediaBlob.startsWith("blob")) {
const blobBuffer = await downloadBlob(mediaBlob);
const audioFile = new File([blobBuffer], "audio.wav", { type: "audio/wav" });
const seed = Math.random().toString().replace("0.", ""); useEffect(()=> { if (!preview) setNavigation({nextDisabled: true}) }, [setNavigation, preview])
const formData = new FormData(); /*useEffect(() => {
formData.append("audio", audioFile, `${seed}.wav`);
formData.append("root", "speaking_recordings");
const config = {
headers: {
"Content-Type": "audio/wav",
},
};
const response = await axios.post<{ path: string }>("/api/storage/insert", formData, config);
if (audioURL) await axios.post("/api/storage/delete", { path: audioURL });
return response.data.path;
}
return undefined;
};
useEffect(() => {
if (userSolutions.length > 0) { if (userSolutions.length > 0) {
const { solution } = userSolutions[0] as { solution?: string }; const { solution } = userSolutions[0] as { solution?: string };
if (solution && !mediaBlob) setMediaBlob(solution); if (solution && !mediaBlob) setMediaBlob(solution);
if (solution && !solution.startsWith("blob")) setAudioURL(solution);
} }
}, [userSolutions, mediaBlob]); }, [userSolutions, mediaBlob]);*/
useEffect(() => {
if (hasExamEnded) next();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [hasExamEnded]);
useEffect(() => { useEffect(() => {
let recordingInterval: NodeJS.Timer | undefined = undefined; let recordingInterval: NodeJS.Timer | undefined = undefined;
@@ -76,23 +50,14 @@ export default function Speaking({ id, title, text, video_url, type, prompts, su
}; };
}, [isRecording]); }, [isRecording]);
const next = async () => { useEffect(() => {
onNext({ registerSolution(() => ({
exercise: id, exercise: id,
solutions: mediaBlob ? [{ id, solution: mediaBlob }] : [], solutions: mediaBlob ? [{ id, solution: mediaBlob }] : [],
score: { correct: 0, total: 100, missing: 0 }, score: { correct: 0, total: 100, missing: 0 },
type, isPractice type, isPractice
}); }));
}; }, [id, isPractice, mediaBlob, registerSolution, type]);
const back = async () => {
onBack({
exercise: id,
solutions: mediaBlob ? [{ id, solution: mediaBlob }] : [],
score: { correct: 0, total: 100, missing: 0 },
type, isPractice
});
};
const handleNoteWriting = (e: React.ChangeEvent<HTMLTextAreaElement>) => { const handleNoteWriting = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
const newText = e.target.value; const newText = e.target.value;
@@ -115,17 +80,13 @@ export default function Speaking({ id, title, text, video_url, type, prompts, su
} }
}; };
useEffect(()=> {
if(mediaBlob) setNavigation({nextDisabled: false});
}, [mediaBlob, setNavigation])
return ( return (
<div className="flex flex-col gap-4 mt-4 w-full"> <div className="flex flex-col gap-4 mt-4 w-full">
<div className="flex justify-between w-full gap-8"> {headerButtons}
<Button color="purple" variant="outline" isLoading={isLoading} onClick={back} className="max-w-[200px] self-end w-full">
Back
</Button>
<Button color="purple" isLoading={isLoading} disabled={!mediaBlob} onClick={next} className="max-w-[200px] self-end w-full">
Next
</Button>
</div>
<div className="flex flex-col h-full w-full gap-9"> <div className="flex flex-col h-full w-full gap-9">
<Modal title="Prompts" className="!w-96 aspect-square" isOpen={isPromptsModalOpen} onClose={() => setIsPromptsModalOpen(false)}> <Modal title="Prompts" className="!w-96 aspect-square" isOpen={isPromptsModalOpen} onClose={() => setIsPromptsModalOpen(false)}>
<div className="flex flex-col items-center justify-center gap-4 w-full h-full"> <div className="flex flex-col items-center justify-center gap-4 w-full h-full">
@@ -302,22 +263,10 @@ export default function Speaking({ id, title, text, video_url, type, prompts, su
</div> </div>
)} )}
/> />
{footerButtons}
<div className="self-end flex justify-between w-full gap-8">
<Button color="purple" variant="outline" isLoading={isLoading} onClick={back} className="max-w-[200px] self-end w-full">
Back
</Button>
{preview ? (
<Button color="purple" isLoading={isLoading} onClick={next} className="max-w-[200px] self-end w-full">
Next
</Button>
) : (
<Button color="purple" isLoading={isLoading} disabled={!mediaBlob} onClick={next} className="max-w-[200px] self-end w-full">
Next
</Button>
)}
</div>
</div> </div>
</div> </div>
); );
} }
export default Speaking;

View File

@@ -1,32 +1,23 @@
import { TrueFalseExercise } from "@/interfaces/exam"; import { TrueFalseExercise } from "@/interfaces/exam";
import useExamStore from "@/stores/examStore";
import clsx from "clsx"; import clsx from "clsx";
import { Fragment, useEffect, useState } from "react"; import { Fragment, useCallback, useEffect, useState } from "react";
import { CommonProps } from "."; import { CommonProps } from "./types";
import Button from "../Low/Button"; import Button from "../Low/Button";
export default function TrueFalse({ const TrueFalse: React.FC<TrueFalseExercise & CommonProps> = ({
id, id,
type, type,
prompt, prompt,
questions, questions,
userSolutions, userSolutions,
isPractice = false, isPractice = false,
onNext, registerSolution,
onBack, headerButtons,
disableProgressButtons = false footerButtons,
}: TrueFalseExercise & CommonProps) { }) => {
const [answers, setAnswers] = useState<{ id: string; solution: "true" | "false" | "not_given" }[]>(userSolutions); const [answers, setAnswers] = useState<{ id: string; solution: "true" | "false" | "not_given" }[]>(userSolutions);
const hasExamEnded = useExamStore((state) => state.hasExamEnded); const calculateScore = useCallback(() => {
const setCurrentSolution = useExamStore((state) => state.setCurrentSolution);
useEffect(() => {
if (hasExamEnded) onNext({ exercise: id, solutions: answers, score: calculateScore(), type, isPractice });
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [hasExamEnded]);
const calculateScore = () => {
const total = questions.length || 0; const total = questions.length || 0;
const correct = answers.filter( const correct = answers.filter(
(x) => (x) =>
@@ -38,12 +29,7 @@ export default function TrueFalse({
const missing = total - answers.filter((x) => questions.find((y) => x.id.toString() === y.id.toString())).length; const missing = total - answers.filter((x) => questions.find((y) => x.id.toString() === y.id.toString())).length;
return { total, correct, missing }; return { total, correct, missing };
}; }, [answers, questions]);
useEffect(() => {
setCurrentSolution({ exercise: id, solutions: answers, score: calculateScore(), type, isPractice });
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [answers, setAnswers]);
const toggleAnswer = (solution: "true" | "false" | "not_given", questionId: string) => { const toggleAnswer = (solution: "true" | "false" | "not_given", questionId: string) => {
const answer = answers.find((x) => x.id === questionId); const answer = answers.find((x) => x.id === questionId);
@@ -56,34 +42,20 @@ export default function TrueFalse({
}; };
useEffect(() => { useEffect(() => {
if (disableProgressButtons) onNext({ exercise: id, solutions: answers, score: calculateScore(), type, isPractice }); registerSolution(() => ({
exercise: id,
solutions: answers,
score: calculateScore(),
type,
isPractice
}));
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [answers, disableProgressButtons]) }, [id, answers, type, isPractice, calculateScore]);
const progressButtons = () => (
<div className="flex justify-between w-full gap-8">
<Button
color="purple"
variant="outline"
onClick={() => onBack({ exercise: id, solutions: answers, score: calculateScore(), type, isPractice })}
className="max-w-[200px] w-full">
Back
</Button>
<Button
color="purple"
onClick={() => onNext({ exercise: id, solutions: answers, score: calculateScore(), type, isPractice })}
className="max-w-[200px] self-end w-full">
Next
</Button>
</div>
)
return ( return (
<div className="flex flex-col gap-4 mt-4"> <div className="flex flex-col gap-4 mt-4">
{!disableProgressButtons && progressButtons()} {headerButtons}
<div className={clsx("flex flex-col gap-4 mt-4 h-full w-full", (headerButtons && footerButtons) && "mb-20")}>
<div className={clsx("flex flex-col gap-4 mt-4 h-full w-full", !disableProgressButtons && "mb-20")}>
<span className="text-sm w-full leading-6"> <span className="text-sm w-full leading-6">
{prompt.split("\\n").map((line, index) => ( {prompt.split("\\n").map((line, index) => (
<Fragment key={index}> <Fragment key={index}>
@@ -141,9 +113,11 @@ export default function TrueFalse({
); );
})} })}
</div> </div>
{footerButtons}
</div> </div>
{!disableProgressButtons && progressButtons()}
</div> </div>
); );
} }
export default TrueFalse;

View File

@@ -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>
);
}

View 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;

View 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;

View File

@@ -1,13 +1,12 @@
/* eslint-disable @next/next/no-img-element */ /* eslint-disable @next/next/no-img-element */
import { WritingExercise } from "@/interfaces/exam"; import { WritingExercise } from "@/interfaces/exam";
import { CommonProps } from "."; import React, { Fragment, useEffect, useState } from "react";
import React, { Fragment, useEffect, useRef, useState } from "react"; import { Dialog, DialogPanel, Transition, TransitionChild } from "@headlessui/react";
import useExamStore, { usePersistentExamStore } from "@/stores/exam";
import { CommonProps } from "./types";
import { toast } from "react-toastify"; import { toast } from "react-toastify";
import Button from "../Low/Button";
import { Dialog, Transition } from "@headlessui/react";
import useExamStore from "@/stores/examStore";
export default function Writing({ const Writing: React.FC<WritingExercise & CommonProps> = ({
id, id,
prompt, prompt,
prefix, prefix,
@@ -17,17 +16,24 @@ export default function Writing({
attachment, attachment,
userSolutions, userSolutions,
isPractice = false, isPractice = false,
onNext, registerSolution,
onBack, headerButtons,
enableNavigation = false footerButtons,
}: WritingExercise & CommonProps) { preview,
}) => {
const [isModalOpen, setIsModalOpen] = useState(false); const [isModalOpen, setIsModalOpen] = useState(false);
const [inputText, setInputText] = useState(userSolutions.length === 1 ? userSolutions[0].solution : ""); const [inputText, setInputText] = useState(userSolutions.length === 1 ? userSolutions[0].solution : "");
const [isSubmitEnabled, setIsSubmitEnabled] = useState(false);
const [saveTimer, setSaveTimer] = useState(0); const [saveTimer, setSaveTimer] = useState(0);
const { userSolutions: storeUserSolutions, setUserSolutions } = useExamStore((state) => state); const examState = useExamStore((state) => state);
const hasExamEnded = useExamStore((state) => state.hasExamEnded); const persistentExamState = usePersistentExamStore((state) => state);
const { userSolutions: storeUserSolutions, setUserSolutions, setNavigation } = !preview ? examState : persistentExamState;
useEffect(() => {
if (!preview) setNavigation({ nextDisabled: true });
}, [setNavigation, preview]);
useEffect(() => { useEffect(() => {
const saveTimerInterval = setInterval(() => { const saveTimerInterval = setInterval(() => {
@@ -43,7 +49,7 @@ export default function Writing({
if (inputText.length > 0 && saveTimer % 10 === 0) { if (inputText.length > 0 && saveTimer % 10 === 0) {
setUserSolutions([ setUserSolutions([
...storeUserSolutions.filter((x) => x.exercise !== id), ...storeUserSolutions.filter((x) => x.exercise !== id),
{ exercise: id, solutions: [{ id, solution: inputText }], score: { correct: 100, total: 100, missing: 0 }, type, module: "writing" }, { exercise: id, solutions: [{ id, solution: inputText.replaceAll(/\s{2,}/g, " ") }], score: { correct: 100, total: 100, missing: 0 }, type, module: "writing" },
]); ]);
} }
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
@@ -65,59 +71,39 @@ export default function Writing({
}; };
}, []); }, []);
useEffect(() => {
if (hasExamEnded)
onNext({ exercise: id, solutions: [{ id, solution: inputText }], score: { correct: 100, total: 100, missing: 0 }, type, module: "writing", isPractice });
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [hasExamEnded]);
useEffect(() => { useEffect(() => {
const words = inputText.split(" ").filter((x) => x !== ""); const words = inputText.split(" ").filter((x) => x !== "");
if (wordCounter.type === "min") { if (wordCounter.type === "min") {
setIsSubmitEnabled(wordCounter.limit <= words.length || enableNavigation); setNavigation({ nextDisabled: !(wordCounter.limit <= words.length) });
} else { } else {
setIsSubmitEnabled(true); setNavigation({ nextDisabled: false });
if (wordCounter.limit < words.length) { if (wordCounter.limit < words.length) {
toast.warning(`You have reached your word limit of ${wordCounter.limit} words!`, { toastId: "word-limit" }); toast.warning(`You have reached your word limit of ${wordCounter.limit} words!`, { toastId: "word-limit" });
setInputText(words.slice(0, words.length - 1).join(" ")); setInputText(words.slice(0, words.length - 1).join(" "));
} }
} }
}, [enableNavigation, inputText, wordCounter]); }, [inputText, setNavigation, wordCounter]);
useEffect(() => {
registerSolution(() => ({
exercise: id,
solutions: [{ id, solution: inputText.replaceAll(/\s{2,}/g, " ") }],
score: { correct: 100, total: 100, missing: 0 },
type,
isPractice
}));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [id, type, isPractice, inputText]);
return ( return (
<div className="flex flex-col gap-4 mt-4"> <div className="flex flex-col gap-4 mt-4">
<div className="flex justify-between w-full gap-8"> {headerButtons}
<Button
color="purple"
variant="outline"
onClick={() =>
onBack({ exercise: id, solutions: [{ id, solution: inputText }], score: { correct: 100, total: 100, missing: 0 }, type, isPractice })
}
className="max-w-[200px] self-end w-full">
Back
</Button>
<Button
color="purple"
disabled={!isSubmitEnabled}
onClick={() =>
onNext({
exercise: id,
solutions: [{ id, solution: inputText.replaceAll(/\s{2,}/g, " ") }],
score: { correct: 100, total: 100, missing: 0 },
type,
module: "writing", isPractice
})
}
className="max-w-[200px] self-end w-full">
Next
</Button>
</div>
{attachment && ( {attachment && (
<Transition show={isModalOpen} as={Fragment}> <Transition show={isModalOpen} as={Fragment}>
<Dialog onClose={() => setIsModalOpen(false)} className="relative z-50"> <Dialog onClose={() => setIsModalOpen(false)} className="relative z-50">
<Transition.Child <TransitionChild
as={Fragment} as={Fragment}
enter="ease-out duration-300" enter="ease-out duration-300"
enterFrom="opacity-0" enterFrom="opacity-0"
@@ -126,9 +112,9 @@ export default function Writing({
leaveFrom="opacity-100" leaveFrom="opacity-100"
leaveTo="opacity-0"> leaveTo="opacity-0">
<div className="fixed inset-0 bg-black/30" /> <div className="fixed inset-0 bg-black/30" />
</Transition.Child> </TransitionChild>
<Transition.Child <TransitionChild
as={Fragment} as={Fragment}
enter="ease-out duration-300" enter="ease-out duration-300"
enterFrom="opacity-0 scale-95" enterFrom="opacity-0 scale-95"
@@ -137,11 +123,11 @@ export default function Writing({
leaveFrom="opacity-100 scale-100" leaveFrom="opacity-100 scale-100"
leaveTo="opacity-0 scale-95"> leaveTo="opacity-0 scale-95">
<div className="fixed inset-0 flex items-center justify-center p-4"> <div className="fixed inset-0 flex items-center justify-center p-4">
<Dialog.Panel className="w-fit h-fit rounded-xl bg-white"> <DialogPanel className="w-fit h-fit rounded-xl bg-white">
<img src={attachment.url} alt={attachment.description} className="max-w-4xl w-full self-center rounded-xl p-4" /> <img src={attachment.url} alt={attachment.description} className="max-w-4xl w-full self-center rounded-xl p-4" />
</Dialog.Panel> </DialogPanel>
</div> </div>
</Transition.Child> </TransitionChild>
</Dialog> </Dialog>
</Transition> </Transition>
)} )}
@@ -172,33 +158,9 @@ export default function Writing({
<span className="text-base self-end text-mti-gray-cool">Word Count: {inputText.split(" ").filter((x) => x !== "").length}</span> <span className="text-base self-end text-mti-gray-cool">Word Count: {inputText.split(" ").filter((x) => x !== "").length}</span>
</div> </div>
</div> </div>
{footerButtons}
<div className="self-end flex justify-between w-full gap-8 absolute bottom-8 left-0 px-8">
<Button
color="purple"
variant="outline"
onClick={() =>
onBack({ exercise: id, solutions: [{ id, solution: inputText }], score: { correct: 100, total: 100, missing: 0 }, type, isPractice })
}
className="max-w-[200px] self-end w-full">
Back
</Button>
<Button
color="purple"
disabled={!isSubmitEnabled}
onClick={() =>
onNext({
exercise: id,
solutions: [{ id, solution: inputText.replaceAll(/\s{2,}/g, " ") }],
score: { correct: 100, total: 100, missing: 0 },
type,
module: "writing", isPractice
})
}
className="max-w-[200px] self-end w-full">
Next
</Button>
</div>
</div> </div>
); );
} }
export default Writing;

View File

@@ -21,49 +21,38 @@ import InteractiveSpeaking from "./InteractiveSpeaking";
const MatchSentences = dynamic(() => import("@/components/Exercises/MatchSentences"), { ssr: false }); const MatchSentences = dynamic(() => import("@/components/Exercises/MatchSentences"), { ssr: false });
export interface CommonProps {
examID?: string;
onNext: (userSolutions: UserSolution) => void;
onBack: (userSolutions: UserSolution) => void;
enableNavigation?: boolean;
disableProgressButtons?: boolean
preview?: boolean;
}
export const renderExercise = ( export const renderExercise = (
exercise: Exercise, exercise: Exercise,
examID: string, examID: string,
onNext: (userSolutions: UserSolution) => void, registerSolution: (updateSolution: () => UserSolution) => void,
onBack: (userSolutions: UserSolution) => void, preview: boolean,
enableNavigation?: boolean, headerButtons?: React.ReactNode,
disableProgressButtons?: boolean, footerButtons?: React.ReactNode,
preview?: boolean,
) => { ) => {
const sharedProps = {
key: exercise.id,
registerSolution,
headerButtons,
footerButtons,
examID,
preview
}
switch (exercise.type) { switch (exercise.type) {
case "fillBlanks": case "fillBlanks":
return <FillBlanks disableProgressButtons={disableProgressButtons} key={exercise.id} {...(exercise as FillBlanksExercise)} onNext={onNext} onBack={onBack} examID={examID} preview={preview} />; return <FillBlanks {...(exercise as FillBlanksExercise)} {...sharedProps}/>;
case "trueFalse": case "trueFalse":
return <TrueFalse disableProgressButtons={disableProgressButtons} key={exercise.id} {...(exercise as TrueFalseExercise)} onNext={onNext} onBack={onBack} examID={examID} preview={preview} />; return <TrueFalse {...(exercise as TrueFalseExercise)} {...sharedProps}/>;
case "matchSentences": case "matchSentences":
return <MatchSentences disableProgressButtons={disableProgressButtons} key={exercise.id} {...(exercise as MatchSentencesExercise)} onNext={onNext} onBack={onBack} examID={examID} preview={preview} />; return <MatchSentences {...(exercise as MatchSentencesExercise)} {...sharedProps}/>;
case "multipleChoice": case "multipleChoice":
return <MultipleChoice disableProgressButtons={disableProgressButtons} key={exercise.id} {...(exercise as MultipleChoiceExercise)} onNext={onNext} onBack={onBack} examID={examID} preview={preview} />; return <MultipleChoice {...(exercise as MultipleChoiceExercise)} {...sharedProps}/>;
case "writeBlanks": case "writeBlanks":
return <WriteBlanks disableProgressButtons={disableProgressButtons} key={exercise.id} {...(exercise as WriteBlanksExercise)} onNext={onNext} onBack={onBack} examID={examID} preview={preview} />; return <WriteBlanks {...(exercise as WriteBlanksExercise)} {...sharedProps}/>;
case "writing": case "writing":
return <Writing key={exercise.id} {...(exercise as WritingExercise)} onNext={onNext} onBack={onBack} examID={examID} enableNavigation={enableNavigation} preview={preview} />; return <Writing {...(exercise as WritingExercise)} {...sharedProps}/>;
case "speaking": case "speaking":
return <Speaking key={exercise.id} {...(exercise as SpeakingExercise)} onNext={onNext} onBack={onBack} examID={examID} preview={preview} />; return <Speaking {...(exercise as SpeakingExercise)} {...sharedProps}/>;
case "interactiveSpeaking": case "interactiveSpeaking":
return ( return <InteractiveSpeaking {...(exercise as InteractiveSpeakingExercise)} {...sharedProps}/>;
<InteractiveSpeaking
key={exercise.id}
{...(exercise as InteractiveSpeakingExercise)}
examID={examID}
onNext={onNext}
onBack={onBack}
preview={preview}
/>
);
} }
}; };

View 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,
}

View File

@@ -1,6 +1,6 @@
import {Ticket, TicketType, TicketTypeLabel} from "@/interfaces/ticket"; import {Ticket, TicketType, TicketTypeLabel} from "@/interfaces/ticket";
import {User} from "@/interfaces/user"; import {User} from "@/interfaces/user";
import useExamStore from "@/stores/examStore"; import useExamStore from "@/stores/exam";
import axios from "axios"; import axios from "axios";
import {useState} from "react"; import {useState} from "react";
import {toast} from "react-toastify"; import {toast} from "react-toastify";

View File

@@ -1,7 +1,7 @@
import Button from "@/components/Low/Button"; import Button from "@/components/Low/Button";
import Modal from "@/components/Modal"; import Modal from "@/components/Modal";
import { Exam, LevelExam, MultipleChoiceExercise, ShuffleMap } from "@/interfaces/exam"; import { Exam, LevelExam, MultipleChoiceExercise, ShuffleMap } from "@/interfaces/exam";
import useExamStore from "@/stores/examStore"; import useExamStore, { usePersistentExamStore } from "@/stores/exam";
import clsx from "clsx"; import clsx from "clsx";
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
import { BsFillGrid3X3GapFill } from "react-icons/bs"; import { BsFillGrid3X3GapFill } from "react-icons/bs";
@@ -10,16 +10,20 @@ interface Props {
exam: LevelExam exam: LevelExam
showSolutions: boolean; showSolutions: boolean;
runOnClick: ((index: number) => void) | undefined; runOnClick: ((index: number) => void) | undefined;
preview: boolean,
} }
const MCQuestionGrid: React.FC<Props> = ({ exam, showSolutions, runOnClick }) => { const MCQuestionGrid: React.FC<Props> = ({ exam, showSolutions, runOnClick, preview }) => {
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const examState = useExamStore((state) => state);
const persistentExamState = usePersistentExamStore((state) => state);
const { const {
userSolutions, userSolutions,
partIndex: sectionIndex, partIndex: sectionIndex,
exerciseIndex, exerciseIndex,
} = useExamStore((state) => state); } = !preview ? examState : persistentExamState;
const currentExercise = useMemo(() => (exam as LevelExam).parts[sectionIndex!].exercises[exerciseIndex] as MultipleChoiceExercise, [exam, exerciseIndex, sectionIndex]) const currentExercise = useMemo(() => (exam as LevelExam).parts[sectionIndex!].exercises[exerciseIndex] as MultipleChoiceExercise, [exam, exerciseIndex, sectionIndex])
const userSolution = useMemo(() => userSolutions!.find((x) => x.exercise.toString() == currentExercise.id.toString())!, [currentExercise.id, userSolutions]) const userSolution = useMemo(() => userSolutions!.find((x) => x.exercise.toString() == currentExercise.id.toString())!, [currentExercise.id, userSolutions])

View File

@@ -6,7 +6,7 @@ import { BsBook, BsClipboard, BsHeadphones, BsMegaphone, BsPen } from "react-ico
import ProgressBar from "../../Low/ProgressBar"; import ProgressBar from "../../Low/ProgressBar";
import Timer from "../Timer"; import Timer from "../Timer";
import { Exercise, LevelExam } from "@/interfaces/exam"; import { Exercise, LevelExam } from "@/interfaces/exam";
import useExamStore from "@/stores/examStore"; import useExamStore, { usePersistentExamStore } from "@/stores/exam";
import React from "react"; import React from "react";
import MCQuestionGrid from "./MCQuestionGrid"; import MCQuestionGrid from "./MCQuestionGrid";
@@ -23,7 +23,8 @@ interface Props {
showSolutions?: boolean; showSolutions?: boolean;
currentExercise?: Exercise; currentExercise?: Exercise;
runOnClick?: ((questionIndex: number) => void) | undefined; runOnClick?: ((questionIndex: number) => void) | undefined;
indexLabel?: string indexLabel?: string,
preview: boolean,
} }
export default function ModuleTitle({ export default function ModuleTitle({
@@ -38,9 +39,13 @@ export default function ModuleTitle({
showTimer = true, showTimer = true,
showSolutions = false, showSolutions = false,
runOnClick = undefined, runOnClick = undefined,
indexLabel = "Question" indexLabel = "Question",
preview,
}: Props) { }: Props) {
const { exam, partIndex, exerciseIndex: examExerciseIndex, userSolutions } = useExamStore((state) => state); const examState = useExamStore((state) => state);
const persistentExamState = usePersistentExamStore((state) => state);
const { exam, partIndex, exerciseIndex: examExerciseIndex, userSolutions } = !preview ? examState : persistentExamState;
const moduleIcon: { [key in Module]: ReactNode } = { const moduleIcon: { [key in Module]: ReactNode } = {
reading: <BsBook className="text-ielts-reading w-6 h-6" />, reading: <BsBook className="text-ielts-reading w-6 h-6" />,
@@ -52,7 +57,6 @@ export default function ModuleTitle({
const showGrid = useMemo(() => const showGrid = useMemo(() =>
exam?.module === "level" exam?.module === "level"
&& partIndex > -1
&& exam.parts[partIndex].exercises[examExerciseIndex].type === "multipleChoice" && exam.parts[partIndex].exercises[examExerciseIndex].type === "multipleChoice"
&& !!userSolutions, && !!userSolutions,
[exam, examExerciseIndex, partIndex, userSolutions] [exam, examExerciseIndex, partIndex, userSolutions]
@@ -95,7 +99,7 @@ export default function ModuleTitle({
</div> </div>
<ProgressBar color={module} label="" percentage={(exerciseIndex * 100) / totalExercises} className="h-2 w-full" /> <ProgressBar color={module} label="" percentage={(exerciseIndex * 100) / totalExercises} className="h-2 w-full" />
</div> </div>
{showGrid && <MCQuestionGrid exam={exam as LevelExam} showSolutions={showSolutions} runOnClick={runOnClick} />} {showGrid && <MCQuestionGrid exam={exam as LevelExam} showSolutions={showSolutions} runOnClick={runOnClick} preview={preview}/>}
</div> </div>
</div> </div>
</> </>

View File

@@ -1,5 +1,4 @@
import {Session} from "@/hooks/useSessions"; import {Session} from "@/hooks/useSessions";
import useExamStore from "@/stores/examStore";
import {sortByModuleName} from "@/utils/moduleUtils"; import {sortByModuleName} from "@/utils/moduleUtils";
import axios from "axios"; import axios from "axios";
import clsx from "clsx"; import clsx from "clsx";

View File

@@ -11,10 +11,10 @@ import { uuidv4 } from "@firebase/util";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { uniqBy } from "lodash"; import { uniqBy } from "lodash";
import { sortByModule } from "@/utils/moduleUtils"; import { sortByModule } from "@/utils/moduleUtils";
import { convertToUserSolutions } from "@/utils/stats";
import { getExamById } from "@/utils/exams"; import { getExamById } from "@/utils/exams";
import { Exam, UserSolution } from "@/interfaces/exam"; import { Exam, UserSolution } from "@/interfaces/exam";
import ModuleBadge from "../ModuleBadge"; import ModuleBadge from "../ModuleBadge";
import useExamStore from "@/stores/exam";
const formatTimestamp = (timestamp: string | number) => { const formatTimestamp = (timestamp: string | number) => {
const time = typeof timestamp === "string" ? parseInt(timestamp) : timestamp; const time = typeof timestamp === "string" ? parseInt(timestamp) : timestamp;
@@ -81,12 +81,6 @@ interface StatsGridItemProps {
selectedTrainingExams?: string[]; selectedTrainingExams?: string[];
maxTrainingExams?: number; maxTrainingExams?: number;
setSelectedTrainingExams?: React.Dispatch<React.SetStateAction<string[]>>; setSelectedTrainingExams?: React.Dispatch<React.SetStateAction<string[]>>;
setExams: (exams: Exam[]) => void;
setShowSolutions: (show: boolean) => void;
setUserSolutions: (solutions: UserSolution[]) => void;
setSelectedModules: (modules: Module[]) => void;
setInactivity: (inactivity: number) => void;
setTimeSpent: (time: number) => void;
renderPdfIcon: (session: string, color: string, textColor: string) => React.ReactNode; renderPdfIcon: (session: string, color: string, textColor: string) => React.ReactNode;
} }
@@ -100,12 +94,6 @@ const StatsGridItem: React.FC<StatsGridItemProps> = ({
selectedTrainingExams, selectedTrainingExams,
gradingSystem, gradingSystem,
setSelectedTrainingExams, setSelectedTrainingExams,
setExams,
setShowSolutions,
setUserSolutions,
setSelectedModules,
setInactivity,
setTimeSpent,
renderPdfIcon, renderPdfIcon,
width = undefined, width = undefined,
height = undefined, height = undefined,
@@ -113,6 +101,8 @@ const StatsGridItem: React.FC<StatsGridItemProps> = ({
maxTrainingExams = undefined, maxTrainingExams = undefined,
}) => { }) => {
const router = useRouter(); const router = useRouter();
const dispatch = useExamStore((s) => s.dispatch);
const correct = stats.reduce((accumulator, current) => accumulator + current.score.correct, 0); const correct = stats.reduce((accumulator, current) => accumulator + current.score.correct, 0);
const total = stats.reduce((accumulator, current) => accumulator + current.score.total, 0); const total = stats.reduce((accumulator, current) => accumulator + current.score.total, 0);
const aggregatedScores = aggregateScoresByModule(stats).filter((x) => x.total > 0); const aggregatedScores = aggregateScoresByModule(stats).filter((x) => x.total > 0);
@@ -171,17 +161,18 @@ const StatsGridItem: React.FC<StatsGridItemProps> = ({
Promise.all(examPromises).then((exams) => { Promise.all(examPromises).then((exams) => {
if (exams.every((x) => !!x)) { if (exams.every((x) => !!x)) {
if (!!timeSpent) setTimeSpent(timeSpent); dispatch({
if (!!inactivity) setInactivity(inactivity); type: 'INIT_SOLUTIONS', payload: {
setUserSolutions(convertToUserSolutions(stats)); exams: exams.map((x) => x!).sort(sortByModule),
setShowSolutions(true); modules: exams
setExams(exams.map((x) => x!).sort(sortByModule)); .map((x) => x!)
setSelectedModules( .sort(sortByModule)
exams .map((x) => x!.module),
.map((x) => x!) stats,
.sort(sortByModule) timeSpent,
.map((x) => x!.module), inactivity
); }
});
router.push("/exam"); router.push("/exam");
} }
}); });

View File

@@ -1,4 +1,4 @@
import useExamStore from "@/stores/examStore"; import useExamStore from "@/stores/exam";
import {useEffect, useState} from "react"; import {useEffect, useState} from "react";
import {motion} from "framer-motion"; import {motion} from "framer-motion";
import TimerEndedModal from "../TimerEndedModal"; import TimerEndedModal from "../TimerEndedModal";
@@ -16,10 +16,7 @@ const Timer: React.FC<Props> = ({minTimer, disableTimer, standalone = false}) =>
const [showModal, setShowModal] = useState(false); const [showModal, setShowModal] = useState(false);
const [warningMode, setWarningMode] = useState(false); const [warningMode, setWarningMode] = useState(false);
const setHasExamEnded = useExamStore((state) => state.setHasExamEnded); const setTimeIsUp = useExamStore((state) => state.setTimeIsUp);
const {timeSpent} = useExamStore((state) => state);
useEffect(() => setTimer((prev) => prev - timeSpent), [timeSpent]);
useEffect(() => { useEffect(() => {
if (!disableTimer) { if (!disableTimer) {
@@ -44,7 +41,7 @@ const Timer: React.FC<Props> = ({minTimer, disableTimer, standalone = false}) =>
<TimerEndedModal <TimerEndedModal
isOpen={showModal} isOpen={showModal}
onClose={() => { onClose={() => {
setHasExamEnded(true); setTimeIsUp(true);
setShowModal(false); setShowModal(false);
}} }}
/> />

View File

@@ -6,10 +6,10 @@ import Writing from "@/exams/Writing";
import { usePersistentStorage } from "@/hooks/usePersistentStorage"; import { usePersistentStorage } from "@/hooks/usePersistentStorage";
import { LevelExam, ListeningExam, ReadingExam, SpeakingExam, WritingExam } from "@/interfaces/exam"; import { LevelExam, ListeningExam, ReadingExam, SpeakingExam, WritingExam } from "@/interfaces/exam";
import { User } from "@/interfaces/user"; import { User } from "@/interfaces/user";
import { usePersistentExamStore } from "@/stores/examStore"; import { usePersistentExamStore } from "@/stores/exam";
import clsx from "clsx"; import clsx from "clsx";
// todo: perms // TODO: perms
const Popout: React.FC<{ user: User }> = ({ user }) => { const Popout: React.FC<{ user: User }> = ({ user }) => {
const state = usePersistentExamStore((state) => state); const state = usePersistentExamStore((state) => state);
@@ -18,35 +18,19 @@ const Popout: React.FC<{ user: User }> = ({ user }) => {
<div className={`relative flex w-full min-h-screen p-4 shadow-md items-center rounded-2xl ${state.bgColor}`}> <div className={`relative flex w-full min-h-screen p-4 shadow-md items-center rounded-2xl ${state.bgColor}`}>
<div className={clsx("relative flex p-20 justify-center flex-1")}> <div className={clsx("relative flex p-20 justify-center flex-1")}>
{state.exam?.module == "level" && state.exam.parts && state.partIndex >= 0 && {state.exam?.module == "level" && state.exam.parts && state.partIndex >= 0 &&
<Level exam={state.exam as LevelExam} onFinish={() => { <Level exam={state.exam as LevelExam} preview={true} />
state.setPartIndex(0);
state.setExerciseIndex(0);
state.setQuestionIndex(0);
}} preview={true} />
} }
{state.exam?.module == "writing" && state.exam.exercises && state.partIndex >= 0 && {state.exam?.module == "writing" && state.exam.exercises && state.partIndex >= 0 &&
<Writing exam={state.exam as WritingExam} onFinish={() => { <Writing exam={state.exam as WritingExam} preview={true} />
state.setExerciseIndex(0);
}} preview={true} />
} }
{state.exam?.module == "reading" && state.exam.parts.length > 0 && {state.exam?.module == "reading" && state.exam.parts.length > 0 &&
<Reading exam={state.exam as ReadingExam} onFinish={() => { <Reading exam={state.exam as ReadingExam} preview={true} />
state.setPartIndex(0);
state.setExerciseIndex(-1);
state.setQuestionIndex(0);
}} preview={true} />
} }
{state.exam?.module == "listening" && state.exam.parts.length > 0 && {state.exam?.module == "listening" && state.exam.parts.length > 0 &&
<Listening exam={state.exam as ListeningExam} onFinish={() => { <Listening exam={state.exam as ListeningExam} preview={true} />
state.setPartIndex(0);
state.setExerciseIndex(-1);
state.setQuestionIndex(0);
}} preview={true} />
} }
{state.exam?.module == "speaking" && state.exam.exercises.length > 0 && {state.exam?.module == "speaking" && state.exam.exercises.length > 0 &&
<Speaking exam={state.exam as SpeakingExam} onFinish={() => { <Speaking exam={state.exam as SpeakingExam} preview={true} />
state.setExerciseIndex(-1);
}} preview={true} />
} }
</div> </div>
</div> </div>

View File

@@ -2,48 +2,12 @@ import { FillBlanksExercise, FillBlanksMCOption, ShuffleMap } from "@/interfaces
import clsx from "clsx"; import clsx from "clsx";
import reactStringReplace from "react-string-replace"; import reactStringReplace from "react-string-replace";
import { CommonProps } from "."; import { CommonProps } from ".";
import { Fragment } from "react"; import useExamStore from "@/stores/exam";
import Button from "../Low/Button";
import useExamStore from "@/stores/examStore";
import { typeCheckWordsMC } from "@/utils/type.check"; import { typeCheckWordsMC } from "@/utils/type.check";
export default function FillBlanksSolutions({ id, type, prompt, solutions, words, text, onNext, onBack, disableProgressButtons = false }: FillBlanksExercise & CommonProps) { const FillBlanksSolutions: React.FC<FillBlanksExercise & CommonProps> = ({ id, solutions, words, text, headerButtons, footerButtons}) => {
const storeUserSolutions = useExamStore((state) => state.userSolutions); const {userSolutions, shuffles} = useExamStore();
const { questionIndex, setQuestionIndex, partIndex, exam } = useExamStore((state) => state); const correctUserSolutions = userSolutions.find((solution) => solution.exercise === id)?.solutions;
const correctUserSolutions = storeUserSolutions.find((solution) => solution.exercise === id)?.solutions;
const shuffles = useExamStore((state) => state.shuffles);
const calculateScore = () => {
const total = text.match(/({{\d+}})/g)?.length || 0;
const correct = correctUserSolutions!.filter((x) => {
const solution = solutions.find((y) => x.id.toString() === y.id.toString())?.solution;
if (!solution) return false;
const option = words.find((w) => {
if (typeof w === "string") {
return w.toLowerCase() === x.solution.toLowerCase();
} else if ("letter" in w) {
return w.letter.toLowerCase() === x.solution.toLowerCase();
} else {
return w.id.toString() === x.id.toString();
}
});
if (!option) return false;
if (typeof option === "string") {
return solution.toLowerCase() === option.toLowerCase();
} else if ("letter" in option) {
return solution.toLowerCase() === option.word.toLowerCase();
} else if ("options" in option) {
return option.options[solution as keyof typeof option.options] == x.solution;
}
return false;
}).length;
const missing = total - correctUserSolutions!.filter((x) => solutions.find((y) => x.id.toString() === y.id.toString())).length;
return { total, correct, missing };
};
const renderLines = (line: string) => { const renderLines = (line: string) => {
return ( return (
@@ -149,31 +113,10 @@ export default function FillBlanksSolutions({ id, type, prompt, solutions, words
); );
}; };
const progressButtons = () => (
<div className="flex justify-between w-full gap-8">
<Button
color="purple"
variant="outline"
onClick={() => onBack({ exercise: id, solutions: correctUserSolutions!, score: calculateScore(), type })}
className="max-w-[200px] w-full"
disabled={exam && typeof partIndex !== "undefined" && exam.module === "level" && questionIndex === 0 && partIndex === 0}>
Back
</Button>
<Button
color="purple"
onClick={() => onNext({ exercise: id, solutions: correctUserSolutions!, score: calculateScore(), type })}
className="max-w-[200px] self-end w-full">
Next
</Button>
</div>
)
return ( return (
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
{!disableProgressButtons && progressButtons()} {headerButtons}
<div className={clsx("flex flex-col gap-4 mt-4 h-full w-full", (!headerButtons || !footerButtons) && "mb-20")}>
<div className={clsx("flex flex-col gap-4 mt-4 h-full w-full", !disableProgressButtons && "mb-20")}>
<span className="bg-mti-gray-smoke rounded-xl px-5 py-6"> <span className="bg-mti-gray-smoke rounded-xl px-5 py-6">
{correctUserSolutions && {correctUserSolutions &&
text.split("\\n").map((line, index) => ( text.split("\\n").map((line, index) => (
@@ -197,9 +140,10 @@ export default function FillBlanksSolutions({ id, type, prompt, solutions, words
Wrong Wrong
</div> </div>
</div> </div>
{footerButtons}
</div> </div>
{!disableProgressButtons && progressButtons()}
</div> </div>
); );
} }
export default FillBlanksSolutions;

View File

@@ -14,14 +14,11 @@ import ReactDiffViewer, {DiffMethod} from "react-diff-viewer";
const Waveform = dynamic(() => import("../Waveform"), {ssr: false}); const Waveform = dynamic(() => import("../Waveform"), {ssr: false});
export default function InteractiveSpeaking({ export default function InteractiveSpeaking({
id,
type,
title, title,
text,
prompts, prompts,
userSolutions, userSolutions,
onNext, headerButtons,
onBack, footerButtons,
}: InteractiveSpeakingExercise & CommonProps) { }: InteractiveSpeakingExercise & CommonProps) {
const [solutionsURL, setSolutionsURL] = useState<string[]>([]); const [solutionsURL, setSolutionsURL] = useState<string[]>([]);
const [diffNumber, setDiffNumber] = useState(0); const [diffNumber, setDiffNumber] = useState(0);
@@ -56,40 +53,7 @@ export default function InteractiveSpeaking({
return ( return (
<div className="flex flex-col gap-4 mt-4 w-full"> <div className="flex flex-col gap-4 mt-4 w-full">
<div className="flex justify-between w-full gap-8"> {headerButtons}
<Button
color="purple"
variant="outline"
onClick={() =>
onBack({
exercise: id,
solutions: userSolutions,
score: {total: 100, missing: 0, correct: speakingReverseMarking[userSolutions[0]!.evaluation!.overall] || 0},
type,
})
}
className="max-w-[200px] self-end w-full">
Back
</Button>
<Button
color="purple"
onClick={() =>
onNext({
exercise: id,
solutions: userSolutions,
score: {
total: 100,
missing: 0,
correct: userSolutions[0]?.evaluation ? speakingReverseMarking[userSolutions[0]!.evaluation!.overall] || 0 : 0,
},
type,
})
}
className="max-w-[200px] self-end w-full">
Next
</Button>
</div>
<Modal title={`Correction (Prompt ${diffNumber})`} isOpen={diffNumber !== 0} onClose={() => setDiffNumber(0)}> <Modal title={`Correction (Prompt ${diffNumber})`} isOpen={diffNumber !== 0} onClose={() => setDiffNumber(0)}>
<> <>
{userSolutions && {userSolutions &&
@@ -291,40 +255,7 @@ export default function InteractiveSpeaking({
)} )}
</div> </div>
</div> </div>
{footerButtons}
<div className="self-end flex justify-between w-full gap-8 absolute bottom-8 left-0 px-8">
<Button
color="purple"
variant="outline"
onClick={() =>
onBack({
exercise: id,
solutions: userSolutions,
score: {total: 100, missing: 0, correct: speakingReverseMarking[userSolutions[0]!.evaluation!.overall] || 0},
type,
})
}
className="max-w-[200px] self-end w-full">
Back
</Button>
<Button
color="purple"
onClick={() =>
onNext({
exercise: id,
solutions: userSolutions,
score: {
total: 100,
missing: 0,
correct: userSolutions[0]?.evaluation ? speakingReverseMarking[userSolutions[0]!.evaluation!.overall] || 0 : 0,
},
type,
})
}
className="max-w-[200px] self-end w-full">
Next
</Button>
</div>
</div> </div>
); );
} }

View File

@@ -1,14 +1,7 @@
import { MatchSentenceExerciseSentence, MatchSentencesExercise } from "@/interfaces/exam"; import { MatchSentenceExerciseSentence, MatchSentencesExercise } from "@/interfaces/exam";
import clsx from "clsx"; import clsx from "clsx";
import LineTo from "react-lineto";
import { CommonProps } from "."; import { CommonProps } from ".";
import { errorButtonStyle, infoButtonStyle } from "@/constants/buttonStyles";
import { mdiArrowLeft, mdiArrowRight } from "@mdi/js";
import Icon from "@mdi/react";
import { Fragment } from "react"; import { Fragment } from "react";
import Button from "../Low/Button";
import Xarrow from "react-xarrows";
import useExamStore from "@/stores/examStore";
function QuestionSolutionArea({ function QuestionSolutionArea({
question, question,
@@ -22,7 +15,7 @@ function QuestionSolutionArea({
<div className="flex items-center gap-3 cursor-pointer col-span-2"> <div className="flex items-center gap-3 cursor-pointer col-span-2">
<button <button
className={clsx( className={clsx(
"text-white w-8 h-8 rounded-full z-10", "text-white p-2 rounded-full z-10",
!userSolution !userSolution
? "bg-mti-gray-davy" ? "bg-mti-gray-davy"
: userSolution.option.toString() === question.solution.toString() : userSolution.option.toString() === question.solution.toString()
@@ -55,51 +48,16 @@ function QuestionSolutionArea({
export default function MatchSentencesSolutions({ export default function MatchSentencesSolutions({
id, id,
type, type,
options,
prompt, prompt,
sentences, sentences,
userSolutions, userSolutions,
onNext, headerButtons,
onBack, footerButtons,
disableProgressButtons = false
}: MatchSentencesExercise & CommonProps) { }: MatchSentencesExercise & CommonProps) {
const { questionIndex, setQuestionIndex, partIndex, exam } = useExamStore((state) => state);
const calculateScore = () => {
const total = sentences.length;
const correct = userSolutions.filter(
(x) => sentences.find((y) => y.id.toString() === x.question.toString())?.solution === x.option || false,
).length;
const missing = total - userSolutions.filter((x) => sentences.find((y) => y.id.toString() === x.question.toString())).length;
return { total, correct, missing };
};
const progressButtons = () => (
<div className="flex justify-between w-full gap-8">
<Button
color="purple"
variant="outline"
onClick={() => onBack({ exercise: id, solutions: userSolutions, score: calculateScore(), type })}
className="max-w-[200px] w-full"
disabled={exam && typeof partIndex !== "undefined" && exam.module === "level" && questionIndex === 0 && partIndex === 0}>
Back
</Button>
<Button
color="purple"
onClick={() => onNext({ exercise: id, solutions: userSolutions, score: calculateScore(), type })}
className="max-w-[200px] self-end w-full">
Next
</Button>
</div>
)
return ( return (
<div className="flex flex-col gap-4 mt-4"> <div className="flex flex-col gap-4 mt-4">
{!disableProgressButtons && progressButtons()} {headerButtons}
<div className={clsx("flex flex-col gap-4 mt-4 h-full w-full", (!headerButtons || !footerButtons) && "mb-20")}>
<div className={clsx("flex flex-col gap-4 mt-4 h-full w-full", !disableProgressButtons && "mb-20")}>
<span className="text-sm w-full leading-6"> <span className="text-sm w-full leading-6">
{prompt.split("\\n").map((line, index) => ( {prompt.split("\\n").map((line, index) => (
<Fragment key={index}> <Fragment key={index}>
@@ -131,9 +89,8 @@ export default function MatchSentencesSolutions({
<div className="w-4 h-4 rounded-full bg-mti-rose" /> Wrong <div className="w-4 h-4 rounded-full bg-mti-rose" /> Wrong
</div> </div>
</div> </div>
{footerButtons}
</div> </div>
{!disableProgressButtons && progressButtons()}
</div> </div>
); );
} }

View File

@@ -1,6 +1,6 @@
/* eslint-disable @next/next/no-img-element */ /* eslint-disable @next/next/no-img-element */
import { MultipleChoiceExercise, MultipleChoiceQuestion, ShuffleMap } from "@/interfaces/exam"; import { MultipleChoiceExercise, MultipleChoiceQuestion, ShuffleMap } from "@/interfaces/exam";
import useExamStore from "@/stores/examStore"; import useExamStore from "@/stores/exam";
import clsx from "clsx"; import clsx from "clsx";
import reactStringReplace from "react-string-replace"; import reactStringReplace from "react-string-replace";
import { CommonProps } from "."; import { CommonProps } from ".";
@@ -89,7 +89,7 @@ function Question({
); );
} }
export default function MultipleChoice({ id, type, prompt, questions, userSolutions, onNext, onBack, disableProgressButtons = false }: MultipleChoiceExercise & CommonProps) { export default function MultipleChoice({ id, type, prompt, questions, userSolutions, headerButtons, footerButtons}: MultipleChoiceExercise & CommonProps) {
const { questionIndex, setQuestionIndex, partIndex, exam } = useExamStore((state) => state); const { questionIndex, setQuestionIndex, partIndex, exam } = useExamStore((state) => state);
const stats = useExamStore((state) => state.userSolutions); const stats = useExamStore((state) => state.userSolutions);
@@ -110,39 +110,6 @@ export default function MultipleChoice({ id, type, prompt, questions, userSoluti
return { total, correct, missing }; return { total, correct, missing };
}; };
const next = () => {
if (questionIndex + 1 >= questions.length - 1) {
onNext({ exercise: id, solutions: userSolutions, score: calculateScore(), type });
} else {
setQuestionIndex(questionIndex + 2);
}
};
const back = () => {
if (questionIndex === 0) {
onBack({ exercise: id, solutions: userSolutions, score: calculateScore(), type });
} else {
setQuestionIndex(questionIndex - 2);
}
};
const progressButtons = () => (
<div className="flex justify-between w-full gap-8">
<Button
color="purple"
variant="outline"
onClick={back}
className="max-w-[200px] w-full"
disabled={exam && typeof partIndex !== "undefined" && exam.module === "level" && questionIndex === 0 && partIndex === 0}>
Back
</Button>
<Button color="purple" onClick={next} className="max-w-[200px] self-end w-full">
Next
</Button>
</div>
)
const renderAllQuestions = () => const renderAllQuestions = () =>
questions.map(question => ( questions.map(question => (
<div <div
@@ -178,10 +145,10 @@ export default function MultipleChoice({ id, type, prompt, questions, userSoluti
return ( return (
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
{!disableProgressButtons && progressButtons()} {headerButtons}
<div className={clsx("flex flex-col gap-4 mt-4", !disableProgressButtons && "mb-20")}> <div className={clsx("flex flex-col gap-4 mt-4", (!headerButtons || !footerButtons) && "mb-20")}>
{disableProgressButtons ? renderAllQuestions() : renderTwoQuestions()} {(!headerButtons || !footerButtons) ? renderAllQuestions() : renderTwoQuestions()}
<div className="flex gap-4 items-center"> <div className="flex gap-4 items-center">
<div className="flex gap-2 items-center"> <div className="flex gap-2 items-center">
@@ -197,9 +164,8 @@ export default function MultipleChoice({ id, type, prompt, questions, userSoluti
Wrong Wrong
</div> </div>
</div> </div>
{footerButtons}
</div> </div>
{!disableProgressButtons && progressButtons()}
</div> </div>
); );
} }

View File

@@ -14,7 +14,7 @@ import ReactDiffViewer, {DiffMethod} from "react-diff-viewer";
const Waveform = dynamic(() => import("../Waveform"), {ssr: false}); const Waveform = dynamic(() => import("../Waveform"), {ssr: false});
export default function Speaking({id, type, title, video_url, text, prompts, userSolutions, onNext, onBack}: SpeakingExercise & CommonProps) { export default function Speaking({id, type, title, video_url, text, prompts, userSolutions, headerButtons, footerButtons}: SpeakingExercise & CommonProps) {
const [solutionURL, setSolutionURL] = useState<string>(); const [solutionURL, setSolutionURL] = useState<string>();
const [showDiff, setShowDiff] = useState(false); const [showDiff, setShowDiff] = useState(false);
@@ -45,40 +45,7 @@ export default function Speaking({id, type, title, video_url, text, prompts, use
return ( return (
<div className="flex flex-col gap-4 mt-4 w-full"> <div className="flex flex-col gap-4 mt-4 w-full">
<div className="flex justify-between w-full gap-8"> {headerButtons}
<Button
color="purple"
variant="outline"
onClick={() =>
onBack({
exercise: id,
solutions: userSolutions,
score: {total: 100, missing: 0, correct: speakingReverseMarking[userSolutions[0]!.evaluation!.overall] || 0},
type,
})
}
className="max-w-[200px] self-end w-full">
Back
</Button>
<Button
color="purple"
onClick={() =>
onNext({
exercise: id,
solutions: userSolutions,
score: {
total: 100,
missing: 0,
correct: userSolutions[0]?.evaluation ? speakingReverseMarking[userSolutions[0]!.evaluation!.overall] || 0 : 0,
},
type,
})
}
className="max-w-[200px] self-end w-full">
Next
</Button>
</div>
<Modal title="Correction" isOpen={showDiff} onClose={() => setShowDiff(false)}> <Modal title="Correction" isOpen={showDiff} onClose={() => setShowDiff(false)}>
<> <>
{userSolutions && {userSolutions &&
@@ -275,40 +242,7 @@ export default function Speaking({id, type, title, video_url, text, prompts, use
)} )}
</div> </div>
</div> </div>
{footerButtons}
<div className="self-end flex justify-between w-full gap-8 absolute bottom-8 left-0 px-8">
<Button
color="purple"
variant="outline"
onClick={() =>
onBack({
exercise: id,
solutions: userSolutions,
score: {total: 100, missing: 0, correct: speakingReverseMarking[userSolutions[0]!.evaluation!.overall] || 0},
type,
})
}
className="max-w-[200px] self-end w-full">
Back
</Button>
<Button
color="purple"
onClick={() =>
onNext({
exercise: id,
solutions: userSolutions,
score: {
total: 100,
missing: 0,
correct: userSolutions[0]?.evaluation ? speakingReverseMarking[userSolutions[0]!.evaluation!.overall] || 0 : 0,
},
type,
})
}
className="max-w-[200px] self-end w-full">
Next
</Button>
</div>
</div> </div>
); );
} }

View File

@@ -4,23 +4,10 @@ import reactStringReplace from "react-string-replace";
import { CommonProps } from "."; import { CommonProps } from ".";
import { Fragment } from "react"; import { Fragment } from "react";
import Button from "../Low/Button"; import Button from "../Low/Button";
import useExamStore from "@/stores/examStore";
type Solution = "true" | "false" | "not_given"; type Solution = "true" | "false" | "not_given";
export default function TrueFalseSolution({ prompt, type, id, questions, userSolutions, onNext, onBack, disableProgressButtons = false }: TrueFalseExercise & CommonProps) { export default function TrueFalseSolution({ prompt, type, id, questions, userSolutions, headerButtons, footerButtons }: TrueFalseExercise & CommonProps) {
const { questionIndex, setQuestionIndex, partIndex, exam } = useExamStore((state) => state);
const calculateScore = () => {
const total = questions.length || 0;
const correct = userSolutions.filter(
(x) => questions.find((y) => x.id.toString() === y.id.toString())?.solution === x.solution.toLowerCase() || false,
).length;
const missing = total - userSolutions.filter((x) => questions.find((y) => x.id.toString() === y.id.toString())).length;
return { total, correct, missing };
};
const getButtonColor = (buttonSolution: Solution, solution: Solution, userSolution: Solution | undefined) => { const getButtonColor = (buttonSolution: Solution, solution: Solution, userSolution: Solution | undefined) => {
if (buttonSolution !== userSolution && buttonSolution !== solution) return "purple"; if (buttonSolution !== userSolution && buttonSolution !== solution) return "purple";
@@ -39,31 +26,11 @@ export default function TrueFalseSolution({ prompt, type, id, questions, userSol
return "gray"; return "gray";
}; };
const progressButtons = () => (
<div className="flex justify-between w-full gap-8">
<Button
color="purple"
variant="outline"
onClick={() => onBack({ exercise: id, solutions: userSolutions, score: calculateScore(), type })}
className="max-w-[200px] w-full"
disabled={exam && typeof partIndex !== "undefined" && exam.module === "level" && questionIndex === 0 && partIndex === 0}>
Back
</Button>
<Button
color="purple"
onClick={() => onNext({ exercise: id, solutions: userSolutions, score: calculateScore(), type })}
className="max-w-[200px] self-end w-full">
Next
</Button>
</div>
)
return ( return (
<div className="flex flex-col gap-4 mt-4"> <div className="flex flex-col gap-4 mt-4">
{!disableProgressButtons && progressButtons()} {headerButtons}
<div className={clsx("flex flex-col gap-4 mt-4 h-full w-full", !disableProgressButtons && "mb-20")}> <div className={clsx("flex flex-col gap-4 mt-4 h-full w-full", (!headerButtons || !footerButtons) && "mb-20")}>
<span className="text-sm w-full leading-6"> <span className="text-sm w-full leading-6">
{prompt.split("\\n").map((line, index) => ( {prompt.split("\\n").map((line, index) => (
<Fragment key={index}> <Fragment key={index}>
@@ -139,9 +106,8 @@ export default function TrueFalseSolution({ prompt, type, id, questions, userSol
Wrong Wrong
</div> </div>
</div> </div>
{footerButtons}
</div> </div>
{!disableProgressButtons && progressButtons()}
</div> </div>
); );
} }

View File

@@ -6,9 +6,6 @@ import clsx from "clsx";
import { Fragment, useEffect, useState } from "react"; import { Fragment, useEffect, useState } from "react";
import reactStringReplace from "react-string-replace"; import reactStringReplace from "react-string-replace";
import { CommonProps } from "."; import { CommonProps } from ".";
import { toast } from "react-toastify";
import Button from "../Low/Button";
import useExamStore from "@/stores/examStore";
function Blank({ function Blank({
id, id,
@@ -50,11 +47,11 @@ function Blank({
{userSolution && !isUserSolutionCorrect() && ( {userSolution && !isUserSolutionCorrect() && (
<div <div
className="py-2 px-3 rounded-2xl w-fit focus:outline-none my-2 bg-mti-rose-ultralight text-mti-rose-light" className="py-2 px-3 rounded-2xl w-fit focus:outline-none my-2 bg-mti-rose-ultralight text-mti-rose-light"
contentEditable={disabled}> >
{userSolution} {userSolution}
</div> </div>
)} )}
<div className={clsx("py-2 px-3 rounded-2xl w-fit focus:outline-none my-2", getSolutionStyling())} contentEditable={disabled}> <div className={clsx("py-2 px-3 rounded-2xl w-fit focus:outline-none my-2", getSolutionStyling())} >
{!solutions ? userInput : solutions.join(" / ")} {!solutions ? userInput : solutions.join(" / ")}
</div> </div>
</span> </span>
@@ -62,33 +59,14 @@ function Blank({
} }
export default function WriteBlanksSolutions({ export default function WriteBlanksSolutions({
id,
type,
prompt, prompt,
maxWords, maxWords,
solutions, solutions,
userSolutions, userSolutions,
text, text,
onNext, headerButtons,
onBack, footerButtons,
disableProgressButtons = false
}: WriteBlanksExercise & CommonProps) { }: WriteBlanksExercise & CommonProps) {
const { questionIndex, setQuestionIndex, partIndex, exam } = useExamStore((state) => state);
const calculateScore = () => {
const total = text.match(/({{\d+}})/g)?.length || 0;
const correct = userSolutions.filter(
(x) =>
solutions
.find((y) => x.id.toString() === y.id.toString())
?.solution.map((y) => y.toLowerCase().trim())
.includes(x.solution.toLowerCase().trim()) || false,
).length;
const missing = total - userSolutions.filter((x) => solutions.find((y) => x.id.toString() === y.id.toString())).length;
return { total, correct, missing };
};
const renderLines = (line: string) => { const renderLines = (line: string) => {
return ( return (
<span className="text-base leading-5"> <span className="text-base leading-5">
@@ -105,31 +83,11 @@ export default function WriteBlanksSolutions({
); );
}; };
const progressButtons = () => (
<div className="flex justify-between w-full gap-8">
<Button
color="purple"
variant="outline"
onClick={() => onBack({ exercise: id, solutions: userSolutions, score: calculateScore(), type })}
className="max-w-[200px] w-full"
disabled={exam && typeof partIndex !== "undefined" && exam.module === "level" && questionIndex === 0 && partIndex === 0}>
Back
</Button>
<Button
color="purple"
onClick={() => onNext({ exercise: id, solutions: userSolutions, score: calculateScore(), type })}
className="max-w-[200px] self-end w-full">
Next
</Button>
</div>
)
return ( return (
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
{!disableProgressButtons && progressButtons()} {headerButtons}
<div className={clsx("flex flex-col gap-4 mt-4 h-full w-full", !disableProgressButtons && "mb-20")}> <div className={clsx("flex flex-col gap-4 mt-4 h-full w-full", (!headerButtons || !footerButtons) && "mb-20")}>
<span className="text-sm w-full leading-6"> <span className="text-sm w-full leading-6">
{prompt.split("\\n").map((line, index) => ( {prompt.split("\\n").map((line, index) => (
<Fragment key={index}> <Fragment key={index}>
@@ -161,9 +119,8 @@ export default function WriteBlanksSolutions({
Wrong Wrong
</div> </div>
</div> </div>
{footerButtons}
</div> </div>
{!disableProgressButtons && progressButtons()}
</div> </div>
); );
} }

View File

@@ -10,7 +10,7 @@ import ReactDiffViewer, {DiffMethod} from "react-diff-viewer";
import useUser from "@/hooks/useUser"; import useUser from "@/hooks/useUser";
import AIDetection from "../AIDetection"; import AIDetection from "../AIDetection";
export default function Writing({id, type, prompt, attachment, userSolutions, onNext, onBack}: WritingExercise & CommonProps) { export default function Writing({id, type, prompt, attachment, userSolutions, headerButtons, footerButtons}: WritingExercise & CommonProps) {
const [isModalOpen, setIsModalOpen] = useState(false); const [isModalOpen, setIsModalOpen] = useState(false);
const [showDiff, setShowDiff] = useState(false); const [showDiff, setShowDiff] = useState(false);
@@ -31,40 +31,7 @@ export default function Writing({id, type, prompt, attachment, userSolutions, on
return ( return (
<div className="flex flex-col gap-4 mt-4"> <div className="flex flex-col gap-4 mt-4">
<div className="self-end flex justify-between w-full gap-8 absolute bottom-8 left-0 px-8"> {headerButtons}
<Button
color="purple"
variant="outline"
onClick={() =>
onBack({
exercise: id,
solutions: userSolutions,
score: {total: 100, missing: 0, correct: writingReverseMarking[userSolutions[0]!.evaluation!.overall] || 0},
type,
})
}
className="max-w-[200px] self-end w-full">
Back
</Button>
<Button
color="purple"
onClick={() =>
onNext({
exercise: id,
solutions: userSolutions,
score: {
total: 100,
missing: 0,
correct: userSolutions[0]?.evaluation ? writingReverseMarking[userSolutions[0]!.evaluation!.overall] || 0 : 0,
},
type,
})
}
className="max-w-[200px] self-end w-full">
Next
</Button>
</div>
{attachment && ( {attachment && (
<Transition show={isModalOpen} as={Fragment}> <Transition show={isModalOpen} as={Fragment}>
<Dialog onClose={() => setIsModalOpen(false)} className="relative z-50"> <Dialog onClose={() => setIsModalOpen(false)} className="relative z-50">
@@ -286,40 +253,7 @@ export default function Writing({id, type, prompt, attachment, userSolutions, on
)} )}
</div> </div>
</div> </div>
{footerButtons}
<div className="self-end flex justify-between w-full gap-8 absolute bottom-8 left-0 px-8">
<Button
color="purple"
variant="outline"
onClick={() =>
onBack({
exercise: id,
solutions: userSolutions,
score: {total: 100, missing: 0, correct: writingReverseMarking[userSolutions[0]!.evaluation!.overall] || 0},
type,
})
}
className="max-w-[200px] self-end w-full">
Back
</Button>
<Button
color="purple"
onClick={() =>
onNext({
exercise: id,
solutions: userSolutions,
score: {
total: 100,
missing: 0,
correct: userSolutions[0]?.evaluation ? writingReverseMarking[userSolutions[0]!.evaluation!.overall] || 0 : 0,
},
type,
})
}
className="max-w-[200px] self-end w-full">
Next
</Button>
</div>
</div> </div>
); );
} }

View File

@@ -22,29 +22,36 @@ import Writing from "./Writing";
const MatchSentences = dynamic(() => import("@/components/Solutions/MatchSentences"), { ssr: false }); const MatchSentences = dynamic(() => import("@/components/Solutions/MatchSentences"), { ssr: false });
export interface CommonProps { export interface CommonProps {
onNext: (userSolutions: UserSolution) => void; headerButtons?: React.ReactNode,
onBack: (userSolutions: UserSolution) => void; footerButtons?: React.ReactNode,
disableProgressButtons?: boolean,
} }
export const renderSolution = (exercise: Exercise, onNext: () => void, onBack: () => void, updateIndex?: (internalIndex: number) => void, export const renderSolution = (
disableProgressButtons?: boolean) => { exercise: Exercise,
headerButtons?: React.ReactNode,
footerButtons?: React.ReactNode,
) => {
const sharedProps = {
key: exercise.id,
headerButtons,
footerButtons,
}
switch (exercise.type) { switch (exercise.type) {
case "fillBlanks": case "fillBlanks":
return <FillBlanks disableProgressButtons={disableProgressButtons} key={exercise.id} {...(exercise as FillBlanksExercise)} onNext={onNext} onBack={onBack} />; return <FillBlanks {...(exercise as FillBlanksExercise)} {...sharedProps}/>;
case "trueFalse": case "trueFalse":
return <TrueFalseSolution disableProgressButtons={disableProgressButtons} key={exercise.id} {...(exercise as TrueFalseExercise)} onNext={onNext} onBack={onBack} />; return <TrueFalseSolution {...(exercise as TrueFalseExercise)} {...sharedProps}/>;
case "matchSentences": case "matchSentences":
return <MatchSentences disableProgressButtons={disableProgressButtons} key={exercise.id} {...(exercise as MatchSentencesExercise)} onNext={onNext} onBack={onBack} />; return <MatchSentences {...(exercise as MatchSentencesExercise)} {...sharedProps}/>;
case "multipleChoice": case "multipleChoice":
return <MultipleChoice disableProgressButtons={disableProgressButtons} key={exercise.id} {...(exercise as MultipleChoiceExercise)} onNext={onNext} onBack={onBack} />; return <MultipleChoice {...(exercise as MultipleChoiceExercise)} {...sharedProps}/>;
case "writeBlanks": case "writeBlanks":
return <WriteBlanks disableProgressButtons={disableProgressButtons} key={exercise.id} {...(exercise as WriteBlanksExercise)} onNext={onNext} onBack={onBack} />; return <WriteBlanks {...(exercise as WriteBlanksExercise)} {...sharedProps}/>;
case "writing": case "writing":
return <Writing key={exercise.id} {...(exercise as WritingExercise)} onNext={onNext} onBack={onBack} />; return <Writing {...(exercise as WritingExercise)} {...sharedProps}/>;
case "speaking": case "speaking":
return <Speaking key={exercise.id} {...(exercise as SpeakingExercise)} onNext={onNext} onBack={onBack} />; return <Speaking {...(exercise as SpeakingExercise)} {...sharedProps}/>;
case "interactiveSpeaking": case "interactiveSpeaking":
return <InteractiveSpeaking key={exercise.id} {...(exercise as InteractiveSpeakingExercise)} onNext={onNext} onBack={onBack} />; return <InteractiveSpeaking {...(exercise as InteractiveSpeakingExercise)} {...sharedProps}/>;
} }
}; };

View File

@@ -2,23 +2,22 @@ import Button from "@/components/Low/Button";
import ProgressBar from "@/components/Low/ProgressBar"; import ProgressBar from "@/components/Low/ProgressBar";
import Modal from "@/components/Modal"; import Modal from "@/components/Modal";
import useUsers from "@/hooks/useUsers"; import useUsers from "@/hooks/useUsers";
import {Module} from "@/interfaces"; import { Module } from "@/interfaces";
import {Assignment} from "@/interfaces/results"; import { Assignment } from "@/interfaces/results";
import {Stat, User} from "@/interfaces/user"; import { Stat, User } from "@/interfaces/user";
import useExamStore from "@/stores/examStore"; import useExamStore from "@/stores/exam";
import {getExamById} from "@/utils/exams"; import { getExamById } from "@/utils/exams";
import {sortByModule} from "@/utils/moduleUtils"; import { sortByModule } from "@/utils/moduleUtils";
import {calculateBandScore} from "@/utils/score"; import { calculateBandScore } from "@/utils/score";
import {convertToUserSolutions} from "@/utils/stats"; import { getUserName } from "@/utils/users";
import {getUserName} from "@/utils/users";
import axios from "axios"; import axios from "axios";
import clsx from "clsx"; import clsx from "clsx";
import {capitalize, uniqBy} from "lodash"; import { capitalize, uniqBy } from "lodash";
import moment from "moment"; import moment from "moment";
import {useRouter} from "next/router"; import { useRouter } from "next/router";
import {BsBook, BsClipboard, BsHeadphones, BsMegaphone, BsPen} from "react-icons/bs"; import { BsBook, BsClipboard, BsHeadphones, BsMegaphone, BsPen } from "react-icons/bs";
import {toast} from "react-toastify"; import { toast } from "react-toastify";
import {futureAssignmentFilter} from "@/utils/assignments"; import { futureAssignmentFilter } from "@/utils/assignments";
interface Props { interface Props {
isOpen: boolean; isOpen: boolean;
@@ -27,13 +26,10 @@ interface Props {
onClose: () => void; onClose: () => void;
} }
export default function AssignmentView({isOpen, users, assignment, onClose}: Props) { export default function AssignmentView({ isOpen, users, assignment, onClose }: Props) {
const router = useRouter(); const router = useRouter();
const setExams = useExamStore((state) => state.setExams); const dispatch = useExamStore((s) => s.dispatch);
const setShowSolutions = useExamStore((state) => state.setShowSolutions);
const setUserSolutions = useExamStore((state) => state.setUserSolutions);
const setSelectedModules = useExamStore((state) => state.setSelectedModules);
const deleteAssignment = async () => { const deleteAssignment = async () => {
if (!confirm("Are you sure you want to delete this assignment?")) return; if (!confirm("Are you sure you want to delete this assignment?")) return;
@@ -80,9 +76,9 @@ export default function AssignmentView({isOpen, users, assignment, onClose}: Pro
return resultModuleBandScores.length === 0 ? -1 : resultModuleBandScores.reduce((acc, curr) => acc + curr, 0) / assignment.results.length; return resultModuleBandScores.length === 0 ? -1 : resultModuleBandScores.reduce((acc, curr) => acc + curr, 0) / assignment.results.length;
}; };
const aggregateScoresByModule = (stats: Stat[]): {module: Module; total: number; missing: number; correct: number}[] => { const aggregateScoresByModule = (stats: Stat[]): { module: Module; total: number; missing: number; correct: number }[] => {
const scores: { const scores: {
[key in Module]: {total: number; missing: number; correct: number}; [key in Module]: { total: number; missing: number; correct: number };
} = { } = {
reading: { reading: {
total: 0, total: 0,
@@ -121,7 +117,7 @@ export default function AssignmentView({isOpen, users, assignment, onClose}: Pro
return Object.keys(scores) return Object.keys(scores)
.filter((x) => scores[x as Module].total > 0) .filter((x) => scores[x as Module].total > 0)
.map((x) => ({module: x as Module, ...scores[x as Module]})); .map((x) => ({ module: x as Module, ...scores[x as Module] }));
}; };
const customContent = (stats: Stat[], user: string, focus: "academic" | "general") => { const customContent = (stats: Stat[], user: string, focus: "academic" | "general") => {
@@ -141,15 +137,16 @@ export default function AssignmentView({isOpen, users, assignment, onClose}: Pro
Promise.all(examPromises).then((exams) => { Promise.all(examPromises).then((exams) => {
if (exams.every((x) => !!x)) { if (exams.every((x) => !!x)) {
setUserSolutions(convertToUserSolutions(stats)); dispatch({
setShowSolutions(true); type: 'INIT_SOLUTIONS', payload: {
setExams(exams.map((x) => x!).sort(sortByModule)); exams: exams.map((x) => x!).sort(sortByModule),
setSelectedModules( modules: exams
exams .map((x) => x!)
.map((x) => x!) .sort(sortByModule)
.sort(sortByModule) .map((x) => x!.module),
.map((x) => x!.module), stats
); }
});
router.push("/exam"); router.push("/exam");
} }
}); });
@@ -180,7 +177,7 @@ export default function AssignmentView({isOpen, users, assignment, onClose}: Pro
<div className="flex w-full flex-col gap-1"> <div className="flex w-full flex-col gap-1">
<div className="-md:mt-2 grid w-full grid-cols-4 place-items-start gap-2"> <div className="-md:mt-2 grid w-full grid-cols-4 place-items-start gap-2">
{aggregatedLevels.map(({module, level}) => ( {aggregatedLevels.map(({ module, level }) => (
<div <div
key={module} key={module}
className={clsx( className={clsx(
@@ -282,7 +279,7 @@ export default function AssignmentView({isOpen, users, assignment, onClose}: Pro
<span className="text-xl font-bold">Average Scores</span> <span className="text-xl font-bold">Average Scores</span>
<div className="-md:mt-2 flex w-full items-center gap-4"> <div className="-md:mt-2 flex w-full items-center gap-4">
{assignment && {assignment &&
uniqBy(assignment.exams, (x) => x.module).map(({module}) => ( uniqBy(assignment.exams, (x) => x.module).map(({ module }) => (
<div <div
data-tip={capitalize(module)} data-tip={capitalize(module)}
key={module} key={module}

View File

@@ -6,28 +6,28 @@ import useAssignments from "@/hooks/useAssignments";
import useGradingSystem from "@/hooks/useGrading"; import useGradingSystem from "@/hooks/useGrading";
import useInvites from "@/hooks/useInvites"; import useInvites from "@/hooks/useInvites";
import useFilterRecordsByUser from "@/hooks/useFilterRecordsByUser"; import useFilterRecordsByUser from "@/hooks/useFilterRecordsByUser";
import useUsers, { userHashStudent, userHashTeacher, userHashCorporate} from "@/hooks/useUsers"; import useUsers, { userHashStudent, userHashTeacher, userHashCorporate } from "@/hooks/useUsers";
import {Invite} from "@/interfaces/invite"; import { Invite } from "@/interfaces/invite";
import {Assignment} from "@/interfaces/results"; import { Assignment } from "@/interfaces/results";
import {CorporateUser, MasterCorporateUser, Stat, User} from "@/interfaces/user"; import { CorporateUser, MasterCorporateUser, Stat, User } from "@/interfaces/user";
import useExamStore from "@/stores/examStore"; import useExamStore from "@/stores/exam";
import {getExamById} from "@/utils/exams"; import { getExamById } from "@/utils/exams";
import {getUserCorporate} from "@/utils/groups"; import { getUserCorporate } from "@/utils/groups";
import {countExamModules, countFullExams, MODULE_ARRAY, sortByModule, sortByModuleName} from "@/utils/moduleUtils"; import { countExamModules, countFullExams, MODULE_ARRAY, sortByModule, sortByModuleName } from "@/utils/moduleUtils";
import {getGradingLabel, getLevelLabel, getLevelScore} from "@/utils/score"; import { getGradingLabel, getLevelLabel, getLevelScore } from "@/utils/score";
import {averageScore, groupBySession} from "@/utils/stats"; import { averageScore, groupBySession } from "@/utils/stats";
import {CreateOrderActions, CreateOrderData, OnApproveActions, OnApproveData, OrderResponseBody} from "@paypal/paypal-js"; import { CreateOrderActions, CreateOrderData, OnApproveActions, OnApproveData, OrderResponseBody } from "@paypal/paypal-js";
import {PayPalButtons} from "@paypal/react-paypal-js"; import { PayPalButtons } from "@paypal/react-paypal-js";
import axios from "axios"; import axios from "axios";
import clsx from "clsx"; import clsx from "clsx";
import {capitalize} from "lodash"; import { capitalize } from "lodash";
import moment from "moment"; import moment from "moment";
import Link from "next/link"; import Link from "next/link";
import {useRouter} from "next/router"; import { useRouter } from "next/router";
import {useEffect, useMemo, useState} from "react"; import { useEffect, useMemo, useState } from "react";
import {BsArrowRepeat, BsBook, BsClipboard, BsFileEarmarkText, BsHeadphones, BsMegaphone, BsPen, BsPencil, BsStar} from "react-icons/bs"; import { BsArrowRepeat, BsBook, BsClipboard, BsFileEarmarkText, BsHeadphones, BsMegaphone, BsPen, BsPencil, BsStar } from "react-icons/bs";
import {toast} from "react-toastify"; import { toast } from "react-toastify";
import {activeAssignmentFilter} from "@/utils/assignments"; import { activeAssignmentFilter } from "@/utils/assignments";
import ModuleBadge from "@/components/ModuleBadge"; import ModuleBadge from "@/components/ModuleBadge";
import useSessions from "@/hooks/useSessions"; import useSessions from "@/hooks/useSessions";
@@ -36,41 +36,36 @@ interface Props {
linkedCorporate?: CorporateUser | MasterCorporateUser; linkedCorporate?: CorporateUser | MasterCorporateUser;
} }
export default function StudentDashboard({user, linkedCorporate}: Props) { export default function StudentDashboard({ user, linkedCorporate }: Props) {
const {gradingSystem} = useGradingSystem(); const { gradingSystem } = useGradingSystem();
const {sessions} = useSessions(user.id); const { sessions } = useSessions(user.id);
const {data: stats} = useFilterRecordsByUser<Stat[]>(user.id, !user?.id); const { data: stats } = useFilterRecordsByUser<Stat[]>(user.id, !user?.id);
const {assignments, isLoading: isAssignmentsLoading, reload: reloadAssignments} = useAssignments({assignees: user?.id}); const { assignments, isLoading: isAssignmentsLoading, reload: reloadAssignments } = useAssignments({ assignees: user?.id });
const {invites, isLoading: isInvitesLoading, reload: reloadInvites} = useInvites({to: user.id}); const { invites, isLoading: isInvitesLoading, reload: reloadInvites } = useInvites({ to: user.id });
const {users: teachers} = useUsers(userHashTeacher); const { users: teachers } = useUsers(userHashTeacher);
const {users: corporates} = useUsers(userHashCorporate); const { users: corporates } = useUsers(userHashCorporate);
const users = useMemo(() => [...teachers, ...corporates], [teachers, corporates]); const users = useMemo(() => [...teachers, ...corporates], [teachers, corporates]);
const router = useRouter(); const router = useRouter();
const setExams = useExamStore((state) => state.setExams); const dispatch = useExamStore((state) => state.dispatch);
const setShowSolutions = useExamStore((state) => state.setShowSolutions);
const setUserSolutions = useExamStore((state) => state.setUserSolutions);
const setSelectedModules = useExamStore((state) => state.setSelectedModules);
const setAssignment = useExamStore((state) => state.setAssignment);
const startAssignment = (assignment: Assignment) => { const startAssignment = (assignment: Assignment) => {
const examPromises = assignment.exams.filter((e) => e.assignee === user.id).map((e) => getExamById(e.module, e.id)); const examPromises = assignment.exams.filter((e) => e.assignee === user.id).map((e) => getExamById(e.module, e.id));
Promise.all(examPromises).then((exams) => { Promise.all(examPromises).then((exams) => {
if (exams.every((x) => !!x)) { if (exams.every((x) => !!x)) {
setUserSolutions([]); dispatch({
setShowSolutions(false); type: "INIT_EXAM", payload: {
setExams(exams.map((x) => x!).sort(sortByModule)); exams: exams.map((x) => x!).sort(sortByModule),
setSelectedModules( modules: exams
exams .map((x) => x!)
.map((x) => x!) .sort(sortByModule)
.sort(sortByModule) .map((x) => x!.module),
.map((x) => x!.module), assignment
); }
setAssignment(assignment); })
router.push("/exam"); router.push("/exam");
} }
}); });

View File

@@ -3,12 +3,11 @@ import ModuleTitle from "@/components/Medium/ModuleTitle";
import { moduleResultText } from "@/constants/ielts"; import { moduleResultText } from "@/constants/ielts";
import { Module } from "@/interfaces"; import { Module } from "@/interfaces";
import { User } from "@/interfaces/user"; import { User } from "@/interfaces/user";
import useExamStore from "@/stores/examStore"; import useExamStore from "@/stores/exam";
import { calculateBandScore, getGradingLabel } from "@/utils/score"; import { calculateBandScore, getGradingLabel } from "@/utils/score";
import clsx from "clsx"; import clsx from "clsx";
import Link from "next/link";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { Fragment, useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { import {
BsArrowCounterclockwise, BsArrowCounterclockwise,
BsBan, BsBan,
@@ -56,9 +55,9 @@ export default function Finish({ user, scores, modules, information, solutions,
const [selectedModule, setSelectedModule] = useState(modules[0]); const [selectedModule, setSelectedModule] = useState(modules[0]);
const [selectedScore, setSelectedScore] = useState<Score>(scores.find((x) => x.module === modules[0])!); const [selectedScore, setSelectedScore] = useState<Score>(scores.find((x) => x.module === modules[0])!);
const [isExtraInformationOpen, setIsExtraInformationOpen] = useState(false); const [isExtraInformationOpen, setIsExtraInformationOpen] = useState(false);
const {selectedModules, exams, dispatch} = useExamStore((s) => s);
const aiUsage = Math.round(ai_usage(solutions) * 100); const aiUsage = Math.round(ai_usage(solutions) * 100);
const exams = useExamStore((state) => state.exams);
const { gradingSystem } = useGradingSystem(); const { gradingSystem } = useGradingSystem();
const router = useRouter() const router = useRouter()
@@ -112,6 +111,11 @@ export default function Finish({ user, scores, modules, information, solutions,
return <span className="text-3xl font-bold">{level}</span>; return <span className="text-3xl font-bold">{level}</span>;
}; };
const handlePlayAgain = () => {
dispatch({type: "INIT_EXAM", payload: {exams, modules: selectedModules}})
router.push(destination || "/exam")
}
return ( return (
<> <>
<Modal title="Extra Information" isOpen={isExtraInformationOpen} onClose={() => setIsExtraInformationOpen(false)}> <Modal title="Extra Information" isOpen={isExtraInformationOpen} onClose={() => setIsExtraInformationOpen(false)}>
@@ -135,6 +139,7 @@ export default function Finish({ user, scores, modules, information, solutions,
totalExercises={getTotalExercises()} totalExercises={getTotalExercises()}
exerciseIndex={getTotalExercises()} exerciseIndex={getTotalExercises()}
minTimer={exams.find((x) => x.module === selectedModule)!.minTimer} minTimer={exams.find((x) => x.module === selectedModule)!.minTimer}
preview={false}
disableTimer disableTimer
/> />
<div className="flex gap-4 self-start w-full"> <div className="flex gap-4 self-start w-full">
@@ -289,7 +294,7 @@ export default function Finish({ user, scores, modules, information, solutions,
<div className="flex gap-8"> <div className="flex gap-8">
<div className="flex w-fit cursor-pointer flex-col items-center gap-1"> <div className="flex w-fit cursor-pointer flex-col items-center gap-1">
<button <button
onClick={() => router.push(destination || "/exam")} onClick={handlePlayAgain}
disabled={!!assignment} disabled={!!assignment}
className="bg-mti-purple-light hover:bg-mti-purple flex h-11 w-11 items-center justify-center rounded-full transition duration-300 ease-in-out"> className="bg-mti-purple-light hover:bg-mti-purple flex h-11 w-11 items-center justify-center rounded-full transition duration-300 ease-in-out">
<BsArrowCounterclockwise className="h-7 w-7 text-white" /> <BsArrowCounterclockwise className="h-7 w-7 text-white" />

View File

@@ -5,11 +5,10 @@ import ModuleTitle from "@/components/Medium/ModuleTitle";
import { renderSolution } from "@/components/Solutions"; import { renderSolution } from "@/components/Solutions";
import { Module } from "@/interfaces"; import { Module } from "@/interfaces";
import { Exercise, FillBlanksMCOption, LevelExam, MultipleChoiceExercise, UserSolution } from "@/interfaces/exam"; import { Exercise, FillBlanksMCOption, LevelExam, MultipleChoiceExercise, UserSolution } from "@/interfaces/exam";
import useExamStore from "@/stores/examStore"; import useExamStore, { usePersistentExamStore } from "@/stores/exam";
import { usePersistentExamStore } from "@/stores/examStore";
import { countExercises } from "@/utils/moduleUtils"; import { countExercises } from "@/utils/moduleUtils";
import clsx from "clsx"; import clsx from "clsx";
import { use, useEffect, useMemo, useState } from "react"; import { use, useCallback, useEffect, useMemo, useRef, useState } from "react";
import TextComponent from "./TextComponent"; import TextComponent from "./TextComponent";
import PartDivider from "../Navigation/SectionDivider"; import PartDivider from "../Navigation/SectionDivider";
import Timer from "@/components/Medium/Timer"; import Timer from "@/components/Medium/Timer";
@@ -19,39 +18,44 @@ import Modal from "@/components/Modal";
import { typeCheckWordsMC } from "@/utils/type.check"; import { typeCheckWordsMC } from "@/utils/type.check";
import SectionNavbar from "../Navigation/SectionNavbar"; import SectionNavbar from "../Navigation/SectionNavbar";
import AudioPlayer from "@/components/Low/AudioPlayer"; import AudioPlayer from "@/components/Low/AudioPlayer";
import { ExamProps } from "../types";
import {answeredEveryQuestionInPart} from "../utils/answeredEveryQuestion";
import useExamTimer from "@/hooks/useExamTimer";
import ProgressButtons from "../components/ProgressButtons";
interface Props {
exam: LevelExam;
showSolutions?: boolean;
onFinish: (userSolutions: UserSolution[]) => void;
preview?: boolean;
partDividers?: boolean;
}
export default function Level({ exam, showSolutions = false, onFinish, preview = false }: Props) { const Level: React.FC<ExamProps<LevelExam>> = ({ exam, showSolutions = false, preview = false }) => {
const levelBgColor = "bg-ielts-level-light"; const levelBgColor = "bg-ielts-level-light";
const updateTimers = useExamTimer(exam.module, preview || showSolutions);
const userSolutionRef = useRef<(() => UserSolution) | null>(null);
const [solutionWasUpdated, setSolutionWasUpdated] = useState(false);
const examState = useExamStore((state) => state); const examState = useExamStore((state) => state);
const persistentExamState = usePersistentExamStore((state) => state); const persistentExamState = usePersistentExamStore((state) => state);
const { const {
userSolutions, userSolutions,
hasExamEnded,
partIndex, partIndex,
exerciseIndex, exerciseIndex,
questionIndex, questionIndex,
shuffles, shuffles,
currentSolution, setTimeIsUp,
setBgColor, setBgColor,
setUserSolutions, setUserSolutions,
setHasExamEnded,
setPartIndex, setPartIndex,
setExerciseIndex, setExerciseIndex,
setQuestionIndex, setQuestionIndex,
setShuffles, setShuffles,
setCurrentSolution flags,
timeSpentCurrentModule,
dispatch,
} = !preview ? examState : persistentExamState; } = !preview ? examState : persistentExamState;
const { finalizeModule, timeIsUp } = flags;
const timer = useRef(exam.minTimer - timeSpentCurrentModule / 60);
// In case client want to switch back // In case client want to switch back
const textRenderDisabled = true; const textRenderDisabled = true;
@@ -61,8 +65,6 @@ export default function Level({ exam, showSolutions = false, onFinish, preview =
const [continueAnyways, setContinueAnyways] = useState(false); const [continueAnyways, setContinueAnyways] = useState(false);
const [textRender, setTextRender] = useState(false); const [textRender, setTextRender] = useState(false);
const [changedPrompt, setChangedPrompt] = useState(false); const [changedPrompt, setChangedPrompt] = useState(false);
const [nextExerciseCalled, setNextExerciseCalled] = useState(false);
const [currentSolutionSet, setCurrentSolutionSet] = useState(false);
const [seenParts, setSeenParts] = useState<Set<number>>(new Set(showSolutions ? exam.parts.map((_, index) => index) : [0])); const [seenParts, setSeenParts] = useState<Set<number>>(new Set(showSolutions ? exam.parts.map((_, index) => index) : [0]));
@@ -72,8 +74,6 @@ export default function Level({ exam, showSolutions = false, onFinish, preview =
type: "blankQuestions", type: "blankQuestions",
onClose: function (x: boolean | undefined) { if (x) { setShowQuestionsModal(false); nextExercise(); } else { setShowQuestionsModal(false) } } onClose: function (x: boolean | undefined) { if (x) { setShowQuestionsModal(false); nextExercise(); } else { setShowQuestionsModal(false) } }
}); });
const [currentExercise, setCurrentExercise] = useState<Exercise | undefined>(undefined);
const [showPartDivider, setShowPartDivider] = useState<boolean>(typeof exam.parts[0].intro === "string" && !showSolutions); const [showPartDivider, setShowPartDivider] = useState<boolean>(typeof exam.parts[0].intro === "string" && !showSolutions);
const [startNow, setStartNow] = useState<boolean>(!showSolutions); const [startNow, setStartNow] = useState<boolean>(!showSolutions);
@@ -86,12 +86,10 @@ export default function Level({ exam, showSolutions = false, onFinish, preview =
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [exerciseIndex]); }, [exerciseIndex]);
useEffect(() => { const registerSolution = useCallback((updateSolution: () => UserSolution) => {
if (currentExercise === undefined && partIndex === 0 && exerciseIndex === 0) { userSolutionRef.current = updateSolution;
setCurrentExercise(exam.parts[0].exercises[0]); setSolutionWasUpdated(true);
} }, []);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [currentExercise, partIndex, exerciseIndex]);
const scrollToTop = () => Array.from(document.getElementsByTagName("body")).forEach((body) => body.scrollTo(0, 0)); const scrollToTop = () => Array.from(document.getElementsByTagName("body")).forEach((body) => body.scrollTo(0, 0));
@@ -99,23 +97,6 @@ export default function Level({ exam, showSolutions = false, onFinish, preview =
const [contextWordLines, setContextWordLines] = useState<number[] | undefined>(undefined); const [contextWordLines, setContextWordLines] = useState<number[] | undefined>(undefined);
const [totalLines, setTotalLines] = useState<number>(0); const [totalLines, setTotalLines] = useState<number>(0);
const [showSolutionsSave, setShowSolutionsSave] = useState(showSolutions ? userSolutions.filter((x) => x.module === "level") : undefined)
useEffect(() => {
if (typeof currentSolution !== "undefined") {
setUserSolutions([...userSolutions.filter((x) => x.exercise !== currentSolution.exercise), { ...currentSolution, module: "level" as Module, exam: exam.id, shuffleMaps: exam.shuffle ? [...shuffles.find((x) => x.exerciseID == currentExercise?.id)?.shuffles!] : [] }]);
setCurrentSolutionSet(true);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [currentSolution, exam.id, exam.shuffle, shuffles, currentExercise])
useEffect(() => {
if (typeof currentSolution !== "undefined") {
setCurrentSolution(undefined);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [currentSolution]);
useEffect(() => { useEffect(() => {
if (showSolutions) { if (showSolutions) {
const solutionShuffles = userSolutions.map(solution => ({ const solutionShuffles = userSolutions.map(solution => ({
@@ -127,42 +108,53 @@ export default function Level({ exam, showSolutions = false, onFinish, preview =
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, []); }, []);
const getExercise = () => {
let exercise = exam.parts[partIndex]?.exercises[exerciseIndex]; const currentExercise = useMemo<Exercise>(() => {
let exercise = exam.parts[partIndex].exercises[exerciseIndex];
exercise = { exercise = {
...exercise, ...exercise,
userSolutions: userSolutions.find((x) => x.exercise == exercise.id)?.solutions || [], userSolutions: userSolutions.find((x) => x.exercise === exercise.id)?.solutions || [],
}; };
exercise = shuffleExamExercise(exam.shuffle, exercise, showSolutions, userSolutions, shuffles, setShuffles); exercise = shuffleExamExercise(exam.shuffle, exercise, showSolutions, userSolutions, shuffles, setShuffles);
return exercise; return exercise;
}; // eslint-disable-next-line react-hooks/exhaustive-deps
}, [partIndex, exerciseIndex]);
useEffect(() => { useEffect(() => {
setCurrentExercise(getExercise()); if (solutionWasUpdated && userSolutionRef.current) {
const solution = userSolutionRef.current();
setUserSolutions([...userSolutions.filter((x) => x.exercise !== solution.exercise), { ...solution, module: "level" as Module, exam: exam.id, shuffleMaps: exam.shuffle ? [...shuffles.find((x) => x.exerciseID == currentExercise?.id)?.shuffles!] : [] }]);
setSolutionWasUpdated(false);
}
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [partIndex, exerciseIndex, questionIndex]); }, [solutionWasUpdated]);
const next = () => { useEffect(() => {
setNextExerciseCalled(true); if (finalizeModule || timeIsUp) {
} updateTimers();
if (timeIsUp) setTimeIsUp(false);
dispatch({ type: "FINALIZE_MODULE", payload: { updateTimers: false } })
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [finalizeModule, timeIsUp])
const nextExercise = () => { const nextExercise = () => {
scrollToTop(); scrollToTop();
if (exerciseIndex + 1 < exam.parts[partIndex].exercises.length && !hasExamEnded) { if (exerciseIndex + 1 < exam.parts[partIndex].exercises.length) {
setExerciseIndex(exerciseIndex + 1); setExerciseIndex(exerciseIndex + 1);
setCurrentSolutionSet(false);
return; return;
} }
if (partIndex + 1 === exam.parts.length && !hasExamEnded && !showQuestionsModal && !showSolutions && !continueAnyways) { if (partIndex + 1 === exam.parts.length && !showQuestionsModal && !showSolutions && !continueAnyways) {
modalKwargs(); modalKwargs();
setShowQuestionsModal(true); setShowQuestionsModal(true);
return; return;
} }
if (partIndex + 1 < exam.parts.length && !hasExamEnded) { if (partIndex + 1 < exam.parts.length) {
if (!answeredEveryQuestion(partIndex) && !continueAnyways && !showSolutions && !seenParts.has(partIndex + 1)) { if (!answeredEveryQuestionInPart(exam, partIndex, userSolutions) && !continueAnyways && !showSolutions && !seenParts.has(partIndex + 1)) {
modalKwargs(); modalKwargs();
setShowQuestionsModal(true); setShowQuestionsModal(true);
return; return;
@@ -181,7 +173,6 @@ export default function Level({ exam, showSolutions = false, onFinish, preview =
setPartIndex(partIndex + 1); setPartIndex(partIndex + 1);
setExerciseIndex(0); setExerciseIndex(0);
setQuestionIndex(0); setQuestionIndex(0);
setCurrentSolutionSet(false);
return; return;
} }
@@ -190,24 +181,14 @@ export default function Level({ exam, showSolutions = false, onFinish, preview =
setShowQuestionsModal(true); setShowQuestionsModal(true);
} }
setHasExamEnded(false); if (!showSolutions) {
setCurrentSolutionSet(false); dispatch({ type: "FINALIZE_MODULE", payload: { updateTimers: true } })
if (typeof showSolutionsSave !== "undefined") { } else {
onFinish(showSolutionsSave); dispatch({ type: "FINALIZE_MODULE_SOLUTIONS"})
} else { }
onFinish(userSolutions);
}
} }
useEffect(() => { const previousExercise = () => {
if (nextExerciseCalled && currentSolutionSet) {
nextExercise();
setNextExerciseCalled(false);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [nextExerciseCalled, currentSolutionSet])
const previousExercise = (solution?: UserSolution) => {
scrollToTop(); scrollToTop();
if (exam.parts[partIndex].context && questionIndex === 0 && !textRender && !textRenderDisabled) { if (exam.parts[partIndex].context && questionIndex === 0 && !textRender && !textRenderDisabled) {
@@ -353,25 +334,6 @@ export default function Level({ exam, showSolutions = false, onFinish, preview =
} }
} }
const answeredEveryQuestion = (partIndex: number) => {
return exam.parts[partIndex].exercises.every((exercise) => {
const userSolution = userSolutions.find(x => x.exercise === exercise.id);
switch (exercise.type) {
case 'multipleChoice':
return userSolution?.solutions.length === exercise.questions.length;
case 'fillBlanks':
return userSolution?.solutions.length === exercise.words.length;
case 'writeBlanks':
return userSolution?.solutions.length === exercise.solutions.length;
case 'matchSentences':
return userSolution?.solutions.length === exercise.sentences.length;
case 'trueFalse':
return userSolution?.solutions.length === exercise.questions.length;
}
return false;
});
}
useEffect(() => { useEffect(() => {
const regex = /.*?['"](.*?)['"] in line (\d+)\?$/; const regex = /.*?['"](.*?)['"] in line (\d+)\?$/;
@@ -408,8 +370,7 @@ export default function Level({ exam, showSolutions = false, onFinish, preview =
useEffect(() => { useEffect(() => {
if ( if (
exerciseIndex !== -1 && currentExercise && currentExercise && currentExercise.type === "multipleChoice" &&
currentExercise.type === "multipleChoice" &&
exam.parts[partIndex].context && contextWordLines exam.parts[partIndex].context && contextWordLines
) { ) {
if (contextWordLines.length > 0) { if (contextWordLines.length > 0) {
@@ -446,7 +407,7 @@ export default function Level({ exam, showSolutions = false, onFinish, preview =
if (partIndex === exam.parts.length - 1) { if (partIndex === exam.parts.length - 1) {
kwargs.type = "submit" kwargs.type = "submit"
kwargs.unanswered = !exam.parts.every((_, partIndex) => answeredEveryQuestion(partIndex)); kwargs.unanswered = !exam.parts.every((_, partIndex) => answeredEveryQuestionInPart(exam, partIndex, userSolutions));
kwargs.onClose = function (x: boolean | undefined) { if (x) { setShowSubmissionModal(true); setShowQuestionsModal(false); } else { setShowQuestionsModal(false) } }; kwargs.onClose = function (x: boolean | undefined) { if (x) { setShowSubmissionModal(true); setShowQuestionsModal(false); } else { setShowQuestionsModal(false) } };
} }
setQuestionModalKwargs(kwargs); setQuestionModalKwargs(kwargs);
@@ -462,6 +423,7 @@ export default function Level({ exam, showSolutions = false, onFinish, preview =
runOnClick: setQuestionIndex runOnClick: setQuestionIndex
} }
const progressButtons = <ProgressButtons handlePrevious={previousExercise} handleNext={nextExercise} />;
const memoizedRender = useMemo(() => { const memoizedRender = useMemo(() => {
setChangedPrompt(false); setChangedPrompt(false);
@@ -473,8 +435,8 @@ export default function Level({ exam, showSolutions = false, onFinish, preview =
{exam.parts[partIndex]?.context && renderText()} {exam.parts[partIndex]?.context && renderText()}
{exam.parts[partIndex]?.audio && renderAudioPlayer()} {exam.parts[partIndex]?.audio && renderAudioPlayer()}
{(showSolutions) ? {(showSolutions) ?
currentExercise && renderSolution(currentExercise, nextExercise, previousExercise) : currentExercise && renderSolution(currentExercise, progressButtons, progressButtons) :
currentExercise && renderExercise(currentExercise, exam.id, next, previousExercise) currentExercise && renderExercise(currentExercise, exam.id, registerSolution, preview, progressButtons, progressButtons)
} }
</> </>
} }
@@ -520,34 +482,24 @@ export default function Level({ exam, showSolutions = false, onFinish, preview =
onNext={() => { setShowPartDivider(false); setStartNow(false); setBgColor("bg-white"); setSeenParts(prev => new Set(prev).add(partIndex)); }} onNext={() => { setShowPartDivider(false); setStartNow(false); setBgColor("bg-white"); setSeenParts(prev => new Set(prev).add(partIndex)); }}
/> : ( /> : (
<> <>
{exam.parts[0].intro && ( <SectionNavbar
<SectionNavbar module="level"
module="level" sectionLabel="Part"
sections={exam.parts} seenParts={seenParts}
sectionLabel="Part" setShowPartDivider={setShowPartDivider}
sectionIndex={partIndex} setSeenParts={setSeenParts}
setSectionIndex={setPartIndex} preview={preview}
onClick={ />
(index: number) => {
setExerciseIndex(0);
setQuestionIndex(0);
if (!seenParts.has(index)) {
setShowPartDivider(true);
setBgColor(levelBgColor);
setSeenParts(prev => new Set(prev).add(index));
}
}
} />
)}
<ModuleTitle <ModuleTitle
examLabel={exam.label} examLabel={exam.label}
partLabel={partLabel()} partLabel={partLabel()}
minTimer={exam.minTimer} minTimer={timer.current}
exerciseIndex={calculateExerciseIndex()} exerciseIndex={calculateExerciseIndex()}
module="level" module="level"
totalExercises={countExercises(exam.parts.flatMap((x) => x.exercises))} totalExercises={countExercises(exam.parts.flatMap((x) => x.exercises))}
disableTimer={showSolutions} disableTimer={showSolutions}
showTimer={false} showTimer={false}
preview={preview}
{...mcNavKwargs} {...mcNavKwargs}
/> />
<div <div
@@ -563,3 +515,5 @@ export default function Level({ exam, showSolutions = false, onFinish, preview =
</> </>
); );
} }
export default Level;

View File

@@ -1,287 +1,127 @@
import { ListeningExam, MultipleChoiceExercise, Script, UserSolution } from "@/interfaces/exam"; import { Exercise, ListeningExam, MultipleChoiceExercise, Script, UserSolution } from "@/interfaces/exam";
import { Fragment, useEffect, useState } from "react"; import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { renderExercise } from "@/components/Exercises"; import { renderExercise } from "@/components/Exercises";
import { renderSolution } from "@/components/Solutions"; import { renderSolution } from "@/components/Solutions";
import ModuleTitle from "@/components/Medium/ModuleTitle"; import ModuleTitle from "@/components/Medium/ModuleTitle";
import AudioPlayer from "@/components/Low/AudioPlayer"; import AudioPlayer from "@/components/Low/AudioPlayer";
import Button from "@/components/Low/Button"; import Button from "@/components/Low/Button";
import BlankQuestionsModal from "@/components/QuestionsModal"; import BlankQuestionsModal from "@/components/QuestionsModal";
import useExamStore, { usePersistentExamStore } from "@/stores/examStore"; import useExamStore, { usePersistentExamStore } from "@/stores/exam";
import PartDivider from "./Navigation/SectionDivider"; import PartDivider from "./Navigation/SectionDivider";
import { Dialog, Transition } from "@headlessui/react"; import { Dialog, Transition } from "@headlessui/react";
import { capitalize } from "lodash"; import { capitalize } from "lodash";
import { mapBy } from "@/utils"; import { mapBy } from "@/utils";
import ScriptModal from "./components/ScriptModal";
import { ExamProps } from "./types";
import useExamTimer from "@/hooks/useExamTimer";
import useExamNavigation from "./Navigation/useExamNavigation";
import RenderAudioInstructionsPlayer from "./components/RenderAudioInstructionsPlayer";
import RenderAudioPlayer from "./components/RenderAudioPlayer";
import SectionNavbar from "./Navigation/SectionNavbar";
import ProgressButtons from "./components/ProgressButtons";
import { countExercises } from "@/utils/moduleUtils";
import { calculateExerciseIndex } from "./utils/calculateExerciseIndex";
interface Props {
exam: ListeningExam;
showSolutions?: boolean;
preview?: boolean;
onFinish: (userSolutions: UserSolution[]) => void;
}
function ScriptModal({ isOpen, script, onClose }: { isOpen: boolean; script: Script; onClose: () => void }) { const Listening: React.FC<ExamProps<ListeningExam>> = ({ exam, showSolutions = false, preview = false }) => {
return ( const updateTimers = useExamTimer(exam.module, preview || showSolutions);
<Transition appear show={isOpen} as={Fragment}> const userSolutionRef = useRef<(() => UserSolution) | null>(null);
<Dialog as="div" className="relative z-10" onClose={onClose}> const [solutionWasUpdated, setSolutionWasUpdated] = useState(false);
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="ease-in duration-200"
leaveFrom="opacity-100"
leaveTo="opacity-0">
<div className="fixed inset-0 bg-black bg-opacity-25" />
</Transition.Child>
<div className="fixed inset-0 overflow-y-auto">
<div className="flex min-h-full items-center justify-center p-4 text-center">
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0 scale-95"
enterTo="opacity-100 scale-100"
leave="ease-in duration-200"
leaveFrom="opacity-100 scale-100"
leaveTo="opacity-0 scale-95">
<Dialog.Panel className="w-full relative max-w-4xl transform rounded-2xl bg-white p-6 text-left align-middle shadow-xl transition-all">
<div className="mt-2 overflow-auto mb-28">
<p className="text-sm">
{typeof script === "string" && script.split("\\n").map((line, index) => (
<Fragment key={index}>
{line}
<br />
</Fragment>
))}
{typeof script === "object" && script.map((line, index) => (
<span key={index}>
<b>{line.name} ({capitalize(line.gender)})</b>: {line.text}
<br />
<br />
</span>
))}
</p>
</div>
<div className="absolute bottom-8 right-8 max-w-[200px] self-end w-full">
<Button color="purple" variant="outline" className="max-w-[200px] self-end w-full" onClick={onClose}>
Close
</Button>
</div>
</Dialog.Panel>
</Transition.Child>
</div>
</div>
</Dialog>
</Transition>
);
}
const INSTRUCTIONS_AUDIO_SRC =
"https://firebasestorage.googleapis.com/v0/b/storied-phalanx-349916.appspot.com/o/generic_listening_intro_v2.mp3?alt=media&token=16769f5f-1e9b-4a72-86a9-45a6f0fa9f82";
export default function Listening({ exam, showSolutions = false, preview = false, onFinish }: Props) {
const listeningBgColor = "bg-ielts-listening-light";
const [showTextModal, setShowTextModal] = useState(false); const [showTextModal, setShowTextModal] = useState(false);
const [timesListened, setTimesListened] = useState(0); const [timesListened, setTimesListened] = useState(0);
const [showBlankModal, setShowBlankModal] = useState(false); const [showBlankModal, setShowBlankModal] = useState(false);
const [multipleChoicesDone, setMultipleChoicesDone] = useState<{ id: string; amount: number }[]>([]);
const [seenParts, setSeenParts] = useState<Set<number>>(new Set(showSolutions ? exam.parts.map((_, index) => index) : []));
const [showPartDivider, setShowPartDivider] = useState<boolean>(typeof exam.parts[0].intro === "string" && exam.parts[0].intro !== "");
const examState = useExamStore((state) => state); const examState = useExamStore((state) => state);
const persistentExamState = usePersistentExamStore((state) => state); const persistentExamState = usePersistentExamStore((state) => state);
const { const {
hasExamEnded, exerciseIndex, partIndex, assignment,
userSolutions, userSolutions, flags, timeSpentCurrentModule,
exerciseIndex, questionIndex,
partIndex, setBgColor, setUserSolutions, setTimeIsUp,
questionIndex: storeQuestionIndex, dispatch
setBgColor,
setUserSolutions,
setHasExamEnded,
setExerciseIndex,
setPartIndex,
setQuestionIndex: setStoreQuestionIndex
} = !preview ? examState : persistentExamState; } = !preview ? examState : persistentExamState;
const scrollToTop = () => Array.from(document.getElementsByTagName("body")).forEach((body) => body.scrollTo(0, 0)); const { finalizeModule, timeIsUp } = flags;
const timer = useRef(exam.minTimer - timeSpentCurrentModule / 60);
const [isFirstTimeRender, setIsFirstTimeRender] = useState(partIndex === 0 && exerciseIndex == 0 && !showSolutions);
const {
nextExercise, previousExercise,
showPartDivider, setShowPartDivider,
seenParts, setSeenParts
} = useExamNavigation({ exam, module: "listening", showBlankModal, setShowBlankModal, showSolutions, preview, disableBetweenParts: true });
useEffect(() => { useEffect(() => {
if (!showSolutions && exam.parts[partIndex]?.intro !== undefined && exam.parts[partIndex]?.intro !== "" && !seenParts.has(exerciseIndex)) { if (finalizeModule || timeIsUp) {
setShowPartDivider(true); updateTimers();
setBgColor(listeningBgColor); if (timeIsUp) setTimeIsUp(false);
dispatch({ type: "FINALIZE_MODULE", payload: { updateTimers: false } })
} }
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [partIndex]); }, [finalizeModule, timeIsUp])
useEffect(() => {
if (showSolutions) return setExerciseIndex(-1);
}, [setExerciseIndex, showSolutions]);
useEffect(() => { const registerSolution = useCallback((updateSolution: () => UserSolution) => {
if (partIndex === -1 && exam.variant === "partial") { userSolutionRef.current = updateSolution;
setPartIndex(0); setSolutionWasUpdated(true);
}
}, [partIndex, exam, setPartIndex]);
useEffect(() => {
const previousParts = exam.parts.filter((_, index) => index < partIndex);
let previousMultipleChoice = previousParts.flatMap((x) => x.exercises).filter((x) => x.type === "multipleChoice") as MultipleChoiceExercise[];
if (partIndex > -1 && exerciseIndex > -1) {
const previousPartExercises = exam.parts[partIndex].exercises.filter((_, index) => index < exerciseIndex);
const partMultipleChoice = previousPartExercises.filter((x) => x.type === "multipleChoice") as MultipleChoiceExercise[];
previousMultipleChoice = [...previousMultipleChoice, ...partMultipleChoice];
}
setMultipleChoicesDone(previousMultipleChoice.map((x) => ({ id: x.id, amount: x.questions.length - 1 })));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []); }, []);
useEffect(() => { useEffect(() => {
if (hasExamEnded && exerciseIndex === -1) { if (solutionWasUpdated && userSolutionRef.current) {
setExerciseIndex(exerciseIndex + 1); const solution = userSolutionRef.current();
setUserSolutions([...userSolutions.filter((x) => x.exercise !== solution.exercise), { ...solution, module: "listening", exam: exam.id }]);
setSolutionWasUpdated(false);
} }
}, [hasExamEnded, exerciseIndex, setExerciseIndex]); // eslint-disable-next-line react-hooks/exhaustive-deps
}, [solutionWasUpdated])
const currentExercise = useMemo<Exercise>(() => {
const exercise = exam.parts[partIndex].exercises[exerciseIndex];
return {
...exercise,
userSolutions: userSolutions.find((x) => x.exercise === exercise.id)?.solutions || [],
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [partIndex, exerciseIndex]);
const confirmFinishModule = (keepGoing?: boolean) => { const confirmFinishModule = (keepGoing?: boolean) => {
if (!keepGoing) { if (!keepGoing) {
setShowBlankModal(false); setShowBlankModal(false);
return; return;
} else {
nextExercise(true);
setShowBlankModal(false);
} }
onFinish(userSolutions);
}; };
const nextExercise = (solution?: UserSolution) => { const memoizedExerciseIndex = useMemo(() =>
if (solution) calculateExerciseIndex(exam, partIndex, exerciseIndex, questionIndex)
setUserSolutions([
...userSolutions.filter((x) => x.exercise !== solution.exercise),
{ ...solution, module: "listening", exam: exam.id }
]);
};
const previousExercise = (solution?: UserSolution) => { }; // eslint-disable-next-line react-hooks/exhaustive-deps
, [partIndex, exerciseIndex, questionIndex]
const nextPart = () => {
scrollToTop()
if (partIndex + 1 < exam.parts.length && !hasExamEnded) {
setPartIndex(partIndex + 1);
setExerciseIndex(0);
return;
}
if (!showSolutions && !hasExamEnded) {
const exercises = partIndex < exam.parts.length ? exam.parts[partIndex].exercises : []
const exerciseIDs = mapBy(exercises, 'id')
const hasMissing = userSolutions.filter(x => exerciseIDs.includes(x.exercise)).map(x => x.score.missing).some(x => x > 0)
if (hasMissing) return setShowBlankModal(true);
}
setHasExamEnded(false);
onFinish(userSolutions);
}
const renderPartExercises = () => {
const exercises = partIndex > -1 ? exam.parts[partIndex].exercises : []
const formattedExercises = exercises.map(exercise => ({
...exercise,
userSolutions: userSolutions.find((x) => x.exercise === exercise.id)?.solutions || [],
}))
return (
<div className="flex flex-col gap-4">
{formattedExercises.map(e => showSolutions
? renderSolution(e, nextExercise, previousExercise, undefined, true)
: renderExercise(e, exam.id, nextExercise, previousExercise, undefined, true))}
</div>
)
}
const renderAudioInstructionsPlayer = () => (
<div className="flex flex-col gap-8 w-full bg-mti-gray-seasalt rounded-xl py-8 px-16">
<div className="flex flex-col w-full gap-2">
<h4 className="text-xl font-semibold">Please listen to the instructions audio attentively.</h4>
</div>
<div className="rounded-xl flex flex-col gap-4 items-center w-full h-fit">
<AudioPlayer key={partIndex} src={INSTRUCTIONS_AUDIO_SRC} color="listening" />
</div>
</div>
); );
const renderAudioPlayer = () => ( const handlePartDividerClick = () => {
<div className="flex flex-col gap-8 w-full bg-mti-gray-seasalt rounded-xl py-8 px-16"> setShowPartDivider(false);
{exam?.parts[partIndex]?.audio?.source ? ( setBgColor("bg-white");
<> setSeenParts((prev) => new Set(prev).add(partIndex));
<div className="w-full items-start flex justify-between"> if (isFirstTimeRender) setIsFirstTimeRender(false);
<div className="flex flex-col w-full gap-2"> }
<h4 className="text-xl font-semibold">Please listen to the following audio attentively.</h4>
<span className="text-base">
{(() => {
const audioRepeatTimes = exam?.parts[partIndex]?.audio?.repeatableTimes;
return audioRepeatTimes && audioRepeatTimes > 0
? `You will only be allowed to listen to the audio ${audioRepeatTimes - timesListened} time(s).`
: "You may listen to the audio as many times as you would like.";
})()}
</span>
</div>
{partIndex > -1 && !examState.assignment && !!exam.parts[partIndex].script && (
<Button
onClick={() => setShowTextModal(true)}
variant="outline"
color="gray"
className="w-full max-w-[200px]"
>
View Transcript
</Button>
)}
</div>
<div className="rounded-xl flex flex-col gap-4 items-center w-full h-fit">
<AudioPlayer
key={partIndex}
src={exam?.parts[partIndex]?.audio?.source ?? ''}
color="listening"
onEnd={() => setTimesListened((prev) => prev + 1)}
disabled={exam?.parts[partIndex]?.audio?.repeatableTimes != null &&
timesListened === exam.parts[partIndex]?.audio?.repeatableTimes}
disablePause
/>
</div>
</>
) : (
<span>This section will be displayed the audio once it has been generated.</span>
)}
</div> useEffect(() => {
); setTimesListened(0);
}, [partIndex])
const progressButtons = () => ( const progressButtons = useMemo(() =>
<div className="flex justify-between w-full gap-8"> // Do not remove the ()=> in handle next
<Button <ProgressButtons handlePrevious={previousExercise} handleNext={() => nextExercise()} />
color="purple" , [nextExercise, previousExercise]);
variant="outline"
onClick={previousExercise}
className="max-w-[200px] w-full">
Back
</Button>
<Button
color="purple"
onClick={nextPart}
className="max-w-[200px] self-end w-full">
Next
</Button>
</div>
)
return ( return (
<> <>
@@ -292,73 +132,64 @@ export default function Listening({ exam, showSolutions = false, preview = false
defaultTitle="Listening exam" defaultTitle="Listening exam"
section={exam.parts[partIndex]} section={exam.parts[partIndex]}
sectionIndex={partIndex} sectionIndex={partIndex}
onNext={() => { setShowPartDivider(false); setBgColor("bg-white"); setSeenParts((prev) => new Set(prev).add(exerciseIndex)) }} onNext={handlePartDividerClick}
/> : ( /> : (
<> <>
<BlankQuestionsModal isOpen={showBlankModal} onClose={confirmFinishModule} /> <BlankQuestionsModal isOpen={showBlankModal} onClose={confirmFinishModule} />
{partIndex > -1 && exam.parts[partIndex].script && {!isFirstTimeRender && exam.parts[partIndex].script &&
<ScriptModal script={exam.parts[partIndex].script!} isOpen={showTextModal} onClose={() => setShowTextModal(false)} /> <ScriptModal script={exam.parts[partIndex].script!} isOpen={showTextModal} onClose={() => setShowTextModal(false)} />
} }
<div className="flex flex-col h-full w-full gap-8 justify-between"> <div className="flex flex-col h-full w-full gap-8 justify-between">
<ModuleTitle {exam.parts.length > 1 && <SectionNavbar
exerciseIndex={partIndex + 1}
minTimer={exam.minTimer}
module="listening" module="listening"
totalExercises={exam.parts.length} sectionLabel="Section"
seenParts={seenParts}
setShowPartDivider={setShowPartDivider}
setSeenParts={setSeenParts}
preview={preview}
/>
}
<ModuleTitle
minTimer={timer.current}
module="listening"
exerciseIndex={memoizedExerciseIndex}
totalExercises={countExercises(exam.parts.flatMap((x) => x.exercises))}
disableTimer={showSolutions || preview} disableTimer={showSolutions || preview}
indexLabel="Part" indexLabel="Exercise"
preview={preview}
/> />
{/* Audio Player for the Instructions */} {/* Audio Player for the Instructions */}
{partIndex === -1 && renderAudioInstructionsPlayer()} {isFirstTimeRender && <RenderAudioInstructionsPlayer />}
{/* Part's audio player */} {/* Part's audio player */}
{partIndex > -1 && renderAudioPlayer()} {!isFirstTimeRender &&
<RenderAudioPlayer
audioSource={exam?.parts[partIndex]?.audio?.source}
repeatableTimes={exam?.parts[partIndex]?.audio?.repeatableTimes}
script={exam?.parts[partIndex]?.script}
assignment={assignment}
timesListened={timesListened}
setShowTextModal={setShowTextModal}
setTimesListened={setTimesListened}
/>}
{/* Exercise renderer */} {/* Exercise renderer */}
{!isFirstTimeRender && !showPartDivider && !showSolutions && renderExercise(currentExercise, exam.id, registerSolution, preview, progressButtons, progressButtons)}
{exerciseIndex > -1 && partIndex > -1 && ( {showSolutions && renderSolution(currentExercise, progressButtons, progressButtons)}
<>
{progressButtons()}
{renderPartExercises()}
{progressButtons()}
</>
)}
</div> </div>
{exerciseIndex === -1 && partIndex > -1 && exam.variant !== "partial" && ( {((isFirstTimeRender) && !showPartDivider && !showSolutions) &&
<div className="self-end flex justify-between w-full gap-8 absolute bottom-8 left-0 px-8"> <ProgressButtons
<Button hidePrevious={partIndex == 0 && isFirstTimeRender}
color="purple" nextLabel={isFirstTimeRender ? "Start now" : "Next Page"}
variant="outline" handlePrevious={previousExercise}
onClick={() => { handleNext={() => isFirstTimeRender ? setIsFirstTimeRender(false) : nextExercise()} />
if (partIndex === 0) return setPartIndex(-1); }
setExerciseIndex(exam.parts[partIndex - 1].exercises.length - 1);
setPartIndex(partIndex - 1);
}}
className="max-w-[200px] w-full">
Back
</Button>
<Button color="purple" onClick={() => setExerciseIndex(0)} className="max-w-[200px] self-end w-full">
Next
</Button>
</div>
)}
{partIndex === -1 && exam.variant !== "partial" && (
<Button color="purple" onClick={() => setPartIndex(0)} className="max-w-[200px] self-end w-full justify-self-end">
Start now
</Button>
)}
{exerciseIndex === -1 && partIndex === 0 && exam.variant === "partial" && (
<Button color="purple" onClick={() => setExerciseIndex(0)} className="max-w-[200px] self-end w-full justify-self-end">
Start now
</Button>
)}
</>) </>)
} }
</> </>
); );
} }
export default Listening;

View File

@@ -1,26 +1,64 @@
import { Module } from "@/interfaces"; import { Module } from "@/interfaces";
import { LevelPart, ListeningPart, ReadingPart, SpeakingExercise, WritingExercise } from "@/interfaces/exam"; import { ExerciseOnlyExam, LevelPart, ListeningPart, PartExam, ReadingPart, SpeakingExercise, WritingExercise } from "@/interfaces/exam";
import useExamStore, { usePersistentExamStore } from "@/stores/exam";
import { Tab, TabGroup, TabList } from "@headlessui/react"; import { Tab, TabGroup, TabList } from "@headlessui/react";
import clsx from "clsx"; import clsx from "clsx";
import React from "react"; import React from "react";
import hasDivider from "../utils/hasDivider";
interface Props { interface Props {
module: Module; module: Module;
sections: LevelPart[] | ReadingPart[] | ListeningPart[] | WritingExercise[] | SpeakingExercise[];
sectionIndex: number;
sectionLabel: string; sectionLabel: string;
setSectionIndex: (index: number) => void; seenParts: Set<number>;
onClick: (index: number) => void; setShowPartDivider: React.Dispatch<React.SetStateAction<boolean>>;
setSeenParts: React.Dispatch<React.SetStateAction<Set<number>>>;
preview: boolean;
setIsBetweenParts?: React.Dispatch<React.SetStateAction<boolean>>;
} }
const SectionNavbar: React.FC<Props> = ({module, sections, sectionIndex, sectionLabel, setSectionIndex, onClick}) => { const SectionNavbar: React.FC<Props> = ({ module, sectionLabel, seenParts, setSeenParts, setShowPartDivider, setIsBetweenParts, preview }) => {
const isPartExam = ["reading", "listening", "level"].includes(module);
const examState = useExamStore((state) => state);
const persistentExamState = usePersistentExamStore((state) => state);
const {
exam,
partIndex, setPartIndex,
exerciseIndex, setExerciseIndex,
setQuestionIndex,
setBgColor
} = !preview ? examState : persistentExamState;
const sections = isPartExam ? (exam as PartExam).parts : (exam as ExerciseOnlyExam).exercises;
const sectionIndex = isPartExam ? partIndex : exerciseIndex;
const handleSectionChange = (index: number) => {
const changeSection = isPartExam ? setPartIndex : setExerciseIndex;
changeSection(index);
}
const handleClick = (index: number) => {
setExerciseIndex(0);
setQuestionIndex(0);
if (!seenParts.has(index)) {
if (hasDivider(exam!, index)) {
setShowPartDivider(true);
setBgColor(`bg-ielts-${module}-light`);
} else if(setIsBetweenParts) {
setIsBetweenParts(true);
}
setSeenParts(prev => new Set(prev).add(index));
}
}
return ( return (
<div className="w-full"> <div className="w-full">
<TabGroup className="w-[90%]" selectedIndex={sectionIndex} onChange={setSectionIndex}> <TabGroup className="w-[90%]" selectedIndex={sectionIndex} onChange={handleSectionChange}>
<TabList className={`flex space-x-1 rounded-xl bg-ielts-${module}/20 p-1`}> <TabList className={`flex space-x-1 rounded-xl bg-ielts-${module}/20 p-1`}>
{sections.map((_, index) => {sections.map((_, index) =>
<Tab key={index} onClick={() => onClick(index)} <Tab key={index} onClick={() => handleClick(index)}
className={({ selected }) => className={({ selected }) =>
clsx( clsx(
`w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-ielts-${module}/80`, `w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-ielts-${module}/80`,

View 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;

View File

@@ -1,162 +1,66 @@
import { MultipleChoiceExercise, ReadingExam, ReadingPart, UserSolution } from "@/interfaces/exam"; import { Exercise, ReadingExam, UserSolution } from "@/interfaces/exam";
import { Fragment, useEffect, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import clsx from "clsx"; import clsx from "clsx";
import { convertCamelCaseToReadable } from "@/utils/string"; import { convertCamelCaseToReadable } from "@/utils/string";
import { Dialog, Transition } from "@headlessui/react";
import { renderExercise } from "@/components/Exercises"; import { renderExercise } from "@/components/Exercises";
import { renderSolution } from "@/components/Solutions"; import { renderSolution } from "@/components/Solutions";
import { BsChevronDown, BsChevronUp } from "react-icons/bs";
import ModuleTitle from "@/components/Medium/ModuleTitle"; import ModuleTitle from "@/components/Medium/ModuleTitle";
import Button from "@/components/Low/Button";
import BlankQuestionsModal from "@/components/QuestionsModal"; import BlankQuestionsModal from "@/components/QuestionsModal";
import useExamStore, { usePersistentExamStore } from "@/stores/examStore";
import { countExercises } from "@/utils/moduleUtils"; import { countExercises } from "@/utils/moduleUtils";
import PartDivider from "./Navigation/SectionDivider"; import PartDivider from "./Navigation/SectionDivider";
import ReadingPassage from "./components/ReadingPassage";
//import ReadingPassageModal from "./components/ReadingPassageModal";
import {calculateExerciseIndex} from "./utils/calculateExerciseIndex";
import useExamNavigation from "./Navigation/useExamNavigation";
import { ExamProps } from "./types";
import useExamStore, { usePersistentExamStore } from "@/stores/exam";
import useExamTimer from "@/hooks/useExamTimer";
import SectionNavbar from "./Navigation/SectionNavbar";
import ProgressButtons from "./components/ProgressButtons";
interface Props { const Reading: React.FC<ExamProps<ReadingExam>> = ({ exam, showSolutions = false, preview = false }) => {
exam: ReadingExam; const updateTimers = useExamTimer(exam.module, preview || showSolutions);
showSolutions?: boolean; const userSolutionRef = useRef<(() => UserSolution) | null>(null);
preview?: boolean; const [solutionWasUpdated, setSolutionWasUpdated] = useState(false);
onFinish: (userSolutions: UserSolution[]) => void;
}
const numberToLetter = (number: number) => (number + 9).toString(36).toUpperCase();
function TextModal({ isOpen, title, content, onClose }: { isOpen: boolean; title: string; content: string; onClose: () => void }) {
return (
<Transition appear show={isOpen} as={Fragment}>
<Dialog as="div" className="relative z-10" onClose={onClose}>
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="ease-in duration-200"
leaveFrom="opacity-100"
leaveTo="opacity-0">
<div className="fixed inset-0 bg-black bg-opacity-25" />
</Transition.Child>
<div className="fixed inset-0 overflow-y-auto">
<div className="flex min-h-full items-center justify-center p-4 text-center">
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0 scale-95"
enterTo="opacity-100 scale-100"
leave="ease-in duration-200"
leaveFrom="opacity-100 scale-100"
leaveTo="opacity-0 scale-95">
<Dialog.Panel className="w-full relative max-w-4xl transform rounded-2xl bg-white p-6 text-left align-middle shadow-xl transition-all">
<Dialog.Title as="h3" className="text-lg font-medium leading-6 text-gray-900">
{title}
</Dialog.Title>
<div className="mt-2 overflow-auto mb-28">
<p className="text-sm">
{content.split("\\n").map((line, index) => (
<Fragment key={index}>
{line}
<br />
</Fragment>
))}
</p>
</div>
<div className="absolute bottom-8 right-8 max-w-[200px] self-end w-full">
<Button color="purple" variant="outline" className="max-w-[200px] self-end w-full" onClick={onClose}>
Close
</Button>
</div>
</Dialog.Panel>
</Transition.Child>
</div>
</div>
</Dialog>
</Transition>
);
}
function TextComponent({ part, exerciseType }: { part: ReadingPart; exerciseType: string }) {
return (
<div className="flex flex-col gap-2 w-full">
<h3 className="text-xl font-semibold">{part.text.title}</h3>
<div className="border border-mti-gray-dim w-full rounded-full opacity-10" />
{part.text.content
.split(/\n|(\\n)/g)
.filter((x) => x && x.length > 0 && x !== "\\n")
.map((line, index) => (
<Fragment key={index}>
{exerciseType === "matchSentences" && (
<div className="flex gap-3 border border-transparent hover:border-mti-purple-light rounded-lg transition ease-in-out duration-300 p-2 px-3 cursor-pointer">
<span className="font-bold text-mti-purple-dark">{numberToLetter(index + 1)}</span>
<p>{line}</p>
</div>
)}
{exerciseType !== "matchSentences" && <p key={index}>{line}</p>}
</Fragment>
))}
</div>
);
}
export default function Reading({ exam, showSolutions = false, preview = false, onFinish }: Props) {
const readingBgColor = "bg-ielts-reading-light";
const [showTextModal, setShowTextModal] = useState(false);
const [showBlankModal, setShowBlankModal] = useState(false); const [showBlankModal, setShowBlankModal] = useState(false);
const [multipleChoicesDone, setMultipleChoicesDone] = useState<{ id: string; amount: number }[]>([]); //const [showTextModal, setShowTextModal] = useState(false);
const [isTextMinimized, setIsTextMinimzed] = useState(false); const [isTextMinimized, setIsTextMinimzed] = useState(false);
const [exerciseType, setExerciseType] = useState("");
const [seenParts, setSeenParts] = useState<Set<number>>(new Set(showSolutions ? exam.parts.map((_, index) => index) : []));
const [showPartDivider, setShowPartDivider] = useState<boolean>(typeof exam.parts[0].intro === "string" && exam.parts[0].intro !== "");
const examState = useExamStore((state) => state); const examState = useExamStore((state) => state);
const persistentExamState = usePersistentExamStore((state) => state); const persistentExamState = usePersistentExamStore((state) => state);
const { const {
hasExamEnded, exerciseIndex, partIndex, questionIndex,
userSolutions, userSolutions, flags, timeSpentCurrentModule,
exerciseIndex, setBgColor, setUserSolutions, setTimeIsUp,
partIndex, dispatch
questionIndex: storeQuestionIndex,
setBgColor,
setUserSolutions,
setHasExamEnded,
setExerciseIndex,
setPartIndex,
setQuestionIndex: setStoreQuestionIndex
} = !preview ? examState : persistentExamState; } = !preview ? examState : persistentExamState;
const scrollToTop = () => Array.from(document.getElementsByTagName("body")).forEach((body) => body.scrollTo(0, 0)); const timer = useRef(exam.minTimer - timeSpentCurrentModule / 60);
const { finalizeModule, timeIsUp } = flags;
const [isFirstTimeRender, setIsFirstTimeRender] = useState(partIndex === 0 && exerciseIndex == 0 && !showSolutions);
const {
nextExercise, previousExercise,
showPartDivider, setShowPartDivider,
seenParts, setSeenParts,
isBetweenParts, setIsBetweenParts
} = useExamNavigation({ exam, module: "reading", showBlankModal, setShowBlankModal, showSolutions, preview, disableBetweenParts: showSolutions });
useEffect(() => { useEffect(() => {
if (!showSolutions && exam.parts[partIndex]?.intro !== undefined && exam.parts[partIndex]?.intro !== "" && !seenParts.has(exerciseIndex)) { if (finalizeModule || timeIsUp) {
setShowPartDivider(true); updateTimers();
setBgColor(readingBgColor);
if (timeIsUp) {
setTimeIsUp(false);
}
dispatch({ type: "FINALIZE_MODULE", payload: { updateTimers: false } })
} }
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [partIndex]); }, [finalizeModule, timeIsUp])
useEffect(() => {
if (showSolutions) setExerciseIndex(-1);
}, [setExerciseIndex, showSolutions]);
useEffect(() => {
const previousParts = exam.parts.filter((_, index) => index < partIndex);
let previousMultipleChoice = previousParts.flatMap((x) => x.exercises).filter((x) => x.type === "multipleChoice") as MultipleChoiceExercise[];
if (partIndex > -1 && exerciseIndex > -1) {
const previousPartExercises = exam.parts[partIndex].exercises.filter((_, index) => index < exerciseIndex);
const partMultipleChoice = previousPartExercises.filter((x) => x.type === "multipleChoice") as MultipleChoiceExercise[];
previousMultipleChoice = [...previousMultipleChoice, ...partMultipleChoice];
}
setMultipleChoicesDone(previousMultipleChoice.map((x) => ({ id: x.id, amount: x.questions.length - 1 })));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => { useEffect(() => {
const listener = (e: KeyboardEvent) => { const listener = (e: KeyboardEvent) => {
@@ -164,144 +68,70 @@ export default function Reading({ exam, showSolutions = false, preview = false,
e.preventDefault(); e.preventDefault();
} }
}; };
document.addEventListener("keydown", listener); document.addEventListener("keydown", listener);
return () => { return () => {
document.removeEventListener("keydown", listener); document.removeEventListener("keydown", listener);
}; };
}, []); }, []);
const registerSolution = useCallback((updateSolution: () => UserSolution) => {
userSolutionRef.current = updateSolution;
setSolutionWasUpdated(true);
}, []);
useEffect(() => { useEffect(() => {
if (hasExamEnded && exerciseIndex === -1) { if (solutionWasUpdated && userSolutionRef.current) {
setExerciseIndex(exerciseIndex + 1); const solution = userSolutionRef.current();
}
}, [hasExamEnded, exerciseIndex, setExerciseIndex]);
const confirmFinishModule = (keepGoing?: boolean) => {
if (!keepGoing) {
setShowBlankModal(false);
return;
}
onFinish(userSolutions);
};
const nextExercise = (solution?: UserSolution) => {
scrollToTop();
if (solution) {
setUserSolutions([...userSolutions.filter((x) => x.exercise !== solution.exercise), { ...solution, module: "reading", exam: exam.id }]); setUserSolutions([...userSolutions.filter((x) => x.exercise !== solution.exercise), { ...solution, module: "reading", exam: exam.id }]);
setSolutionWasUpdated(false);
} }
if (storeQuestionIndex > 0) { // eslint-disable-next-line react-hooks/exhaustive-deps
const exercise = getExercise(); }, [solutionWasUpdated])
setExerciseType(exercise.type);
setMultipleChoicesDone((prev) => [...prev.filter((x) => x.id !== exercise.id), { id: exercise.id, amount: storeQuestionIndex }]);
}
setStoreQuestionIndex(0);
if (exerciseIndex + 1 < exam.parts[partIndex].exercises.length && !hasExamEnded) { const currentExercise = useMemo<Exercise>(() => {
setExerciseIndex(exerciseIndex + 1);
return;
}
if (partIndex + 1 < exam.parts.length && !hasExamEnded) {
setPartIndex(partIndex + 1);
setExerciseIndex(showSolutions ? 0 : -1);
return;
}
if (
solution &&
![...userSolutions.filter((x) => x.exercise !== solution?.exercise).map((x) => x.score.missing), solution?.score.missing].every(
(x) => x === 0,
) &&
!showSolutions &&
!preview &&
!hasExamEnded
) {
setShowBlankModal(true);
return;
}
setHasExamEnded(false);
if (solution) {
onFinish([...userSolutions.filter((x) => x.exercise !== solution.exercise), { ...solution, module: "reading", exam: exam.id }]);
} else {
onFinish(userSolutions);
}
};
const previousExercise = (solution?: UserSolution) => {
scrollToTop();
if (solution) {
setUserSolutions([...userSolutions.filter((x) => x.exercise !== solution.exercise), { ...solution, module: "reading", exam: exam.id }]);
}
setStoreQuestionIndex(0);
setExerciseIndex(exerciseIndex - 1);
};
const getExercise = () => {
const exercise = exam.parts[partIndex].exercises[exerciseIndex]; const exercise = exam.parts[partIndex].exercises[exerciseIndex];
return { return {
...exercise, ...exercise,
userSolutions: userSolutions.find((x) => x.exercise === exercise.id)?.solutions || [], userSolutions: userSolutions.find((x) => x.exercise === exercise.id)?.solutions || [],
}; };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [partIndex, exerciseIndex]);
const confirmFinishModule = (keepGoing?: boolean) => {
if (!keepGoing) {
setShowBlankModal(false);
return;
} else {
nextExercise(true);
setShowBlankModal(false);
}
}; };
const memoizedExerciseIndex = useMemo(() =>
calculateExerciseIndex(exam, partIndex, exerciseIndex, questionIndex)
// eslint-disable-next-line react-hooks/exhaustive-deps
, [partIndex, exerciseIndex, questionIndex]
);
const handlePartDividerClick = () => {
setShowPartDivider(false);
setBgColor("bg-white");
setSeenParts((prev) => new Set(prev).add(partIndex));
if (isFirstTimeRender) setIsFirstTimeRender(false);
}
useEffect(() => { useEffect(() => {
if (partIndex > -1 && exerciseIndex > -1) { if (partIndex !== 0 && !showSolutions) {
const exercise = getExercise(); setIsBetweenParts(true);
setExerciseType(exercise.type);
setMultipleChoicesDone((prev) => prev.filter((x) => x.id !== exercise.id));
} }
// eslint-disable-next-line react-hooks/exhaustive-deps }, [partIndex, setIsBetweenParts, showSolutions])
}, [exerciseIndex, partIndex]);
const calculateExerciseIndex = () => {
if (partIndex === -1) return 0;
if (partIndex === 0)
return (
(exerciseIndex === -1 ? 0 : exerciseIndex + 1) + storeQuestionIndex + multipleChoicesDone.reduce((acc, curr) => acc + curr.amount, 0)
);
const exercisesPerPart = exam.parts.map((x) => x.exercises.length); const progressButtons = useMemo(() =>
const exercisesDone = exercisesPerPart.filter((_, index) => index < partIndex).reduce((acc, curr) => curr + acc, 0); // Do not remove the ()=> in handle next
return ( <ProgressButtons handlePrevious={previousExercise} handleNext={() => nextExercise()} />
exercisesDone + , [nextExercise, previousExercise]);
(exerciseIndex === -1 ? 0 : exerciseIndex + 1) +
storeQuestionIndex +
multipleChoicesDone.reduce((acc, curr) => acc + curr.amount, 0)
);
};
const renderText = () => (
<div className={clsx("flex flex-col gap-6 w-full bg-mti-gray-seasalt rounded-xl mt-4 relative", isTextMinimized ? "p-2 px-8" : "py-8 px-16")}>
<button
data-tip={isTextMinimized ? "Maximise" : "Minimize"}
className={clsx("absolute right-8 tooltip", isTextMinimized ? "top-1/2 -translate-y-1/2" : "top-8")}
onClick={() => setIsTextMinimzed((prev) => !prev)}>
{isTextMinimized ? (
<BsChevronDown className="text-mti-purple-dark text-lg" />
) : (
<BsChevronUp className="text-mti-purple-dark text-lg" />
)}
</button>
{!isTextMinimized && (
<>
<div className="flex flex-col w-full gap-2">
<h4 className="text-xl font-semibold">
Please read the following excerpt attentively, you will then be asked questions about the text you&apos;ve read.
</h4>
<span className="text-base">You will be allowed to read the text while doing the exercises</span>
</div>
<TextComponent part={exam.parts[partIndex]} exerciseType={exerciseType} />
</>
)}
{isTextMinimized && <span className="font-semibold">Reading Passage</span>}
</div>
);
return ( return (
<> <>
@@ -313,42 +143,47 @@ export default function Reading({ exam, showSolutions = false, preview = false,
defaultTitle="Reading exam" defaultTitle="Reading exam"
section={exam.parts[partIndex]} section={exam.parts[partIndex]}
sectionIndex={partIndex} sectionIndex={partIndex}
onNext={() => { setShowPartDivider(false); setBgColor("bg-white"); setSeenParts((prev) => new Set(prev).add(exerciseIndex)) }} onNext={() => handlePartDividerClick()}
/> />
</div> : ( </div> : (
<> <>
<div className="flex flex-col h-full w-full gap-8"> <div className="flex flex-col h-full w-full gap-8">
<BlankQuestionsModal isOpen={showBlankModal} onClose={confirmFinishModule} /> <BlankQuestionsModal isOpen={showBlankModal} onClose={confirmFinishModule} />
{partIndex > -1 && <TextModal {...exam.parts[partIndex].text} isOpen={showTextModal} onClose={() => setShowTextModal(false)} />} {/*<ReadingPassageModal text={exam.parts[partIndex].text} isOpen={showTextModal} onClose={() => setShowTextModal(false)} />*/}
{exam.parts.length > 1 && <SectionNavbar
module="reading"
sectionLabel="Part"
seenParts={seenParts}
setShowPartDivider={setShowPartDivider}
setSeenParts={setSeenParts}
setIsBetweenParts={setIsBetweenParts}
preview={preview}
/>}
<ModuleTitle <ModuleTitle
minTimer={exam.minTimer} minTimer={timer.current}
exerciseIndex={calculateExerciseIndex()} exerciseIndex={memoizedExerciseIndex}
module="reading" module="reading"
totalExercises={countExercises(exam.parts.flatMap((x) => x.exercises))} totalExercises={countExercises(exam.parts.flatMap((x) => x.exercises))}
disableTimer={showSolutions || preview} disableTimer={showSolutions || preview}
label={exerciseIndex === -1 ? undefined : convertCamelCaseToReadable(exam.parts[partIndex].exercises[exerciseIndex].type)} label={convertCamelCaseToReadable(exam.parts[partIndex].exercises[exerciseIndex].type)}
preview={preview}
/> />
<div <div
className={clsx( className={clsx(
"mb-20 w-full", "mb-20 w-full",
exerciseIndex > -1 && !isTextMinimized && "grid grid-cols-2 gap-4", ((isFirstTimeRender || isBetweenParts) && !showSolutions) ? "flex flex-col gap-2" : "grid grid-cols-2 gap-4",
exerciseIndex > -1 && isTextMinimized && "flex flex-col gap-2",
)}> )}>
{partIndex > -1 && renderText()} <ReadingPassage
exam={exam}
{exerciseIndex > -1 && partIndex={partIndex}
partIndex > -1 && exerciseType={currentExercise.type}
exerciseIndex < exam.parts[partIndex].exercises.length && isTextMinimized={isTextMinimized}
!showSolutions && setIsTextMinimized={setIsTextMinimzed}
renderExercise(getExercise(), exam.id, nextExercise, previousExercise, undefined, preview)} />
{!isFirstTimeRender && !showPartDivider && !showSolutions && !isBetweenParts && renderExercise(currentExercise, exam.id, registerSolution, preview, progressButtons, progressButtons)}
{exerciseIndex > -1 && {showSolutions && renderSolution(currentExercise, progressButtons, progressButtons)}
partIndex > -1 &&
exerciseIndex < exam.parts[partIndex].exercises.length &&
showSolutions &&
renderSolution(exam.parts[partIndex].exercises[exerciseIndex], nextExercise, previousExercise)}
</div> </div>
{exerciseIndex > -1 && partIndex > -1 && exerciseIndex < exam.parts[partIndex].exercises.length && ( {/*exerciseIndex > -1 && partIndex > -1 && exerciseIndex < exam.parts[partIndex].exercises.length && (
<Button <Button
color="purple" color="purple"
variant="outline" variant="outline"
@@ -356,33 +191,19 @@ export default function Reading({ exam, showSolutions = false, preview = false,
className="max-w-[200px] self-end w-full absolute bottom-[31px] right-64"> className="max-w-[200px] self-end w-full absolute bottom-[31px] right-64">
Read text Read text
</Button> </Button>
)} )*/}
</div> </div>
{exerciseIndex === -1 && partIndex > 0 && ( {((isFirstTimeRender || isBetweenParts) && !showPartDivider && !showSolutions) &&
<div className="self-end flex justify-between w-full gap-8 absolute bottom-8 left-0 px-8"> <ProgressButtons
<Button hidePrevious={partIndex == 0 && isBetweenParts || isFirstTimeRender}
color="purple" nextLabel={isFirstTimeRender ? "Start now" : "Next Page"}
variant="outline" handlePrevious={previousExercise}
onClick={() => { handleNext={() => isFirstTimeRender ? setIsFirstTimeRender(false) : nextExercise()} />
setExerciseIndex(exam.parts[partIndex - 1].exercises.length - 1); }
setPartIndex(partIndex - 1);
}}
className="max-w-[200px] w-full">
Back
</Button>
<Button color="purple" onClick={() => nextExercise()} className="max-w-[200px] self-end w-full">
Next
</Button>
</div>
)}
{exerciseIndex === -1 && partIndex === 0 && (
<Button color="purple" onClick={() => nextExercise()} className="max-w-[200px] self-end w-full">
Start now
</Button>
)}
</> </>
)} )}
</> </>
); );
} }
export default Reading;

View File

@@ -15,7 +15,7 @@ import ProfileSummary from "@/components/ProfileSummary";
import {ShuffleMap, Shuffles, Variant} from "@/interfaces/exam"; import {ShuffleMap, Shuffles, Variant} from "@/interfaces/exam";
import useSessions, {Session} from "@/hooks/useSessions"; import useSessions, {Session} from "@/hooks/useSessions";
import SessionCard from "@/components/Medium/SessionCard"; import SessionCard from "@/components/Medium/SessionCard";
import useExamStore from "@/stores/examStore"; import useExamStore from "@/stores/exam";
import moment from "moment"; import moment from "moment";
interface Props { interface Props {
@@ -32,7 +32,7 @@ export default function Selection({user, page, onStart}: Props) {
const {data: stats} = useFilterRecordsByUser<Stat[]>(user?.id); const {data: stats} = useFilterRecordsByUser<Stat[]>(user?.id);
const {sessions, isLoading, reload} = useSessions(user.id); const {sessions, isLoading, reload} = useSessions(user.id);
const state = useExamStore((state) => state); const dispatch = useExamStore((state) => state.dispatch);
const toggleModule = (module: Module) => { const toggleModule = (module: Module) => {
const modules = selectedModules.filter((x) => x !== module); const modules = selectedModules.filter((x) => x !== module);
@@ -44,19 +44,7 @@ export default function Selection({user, page, onStart}: Props) {
) )
const loadSession = async (session: Session) => { const loadSession = async (session: Session) => {
state.setShuffles(session.userSolutions.map((x) => ({exerciseID: x.exercise, shuffles: x.shuffleMaps ? x.shuffleMaps : []}))); dispatch({type: "SET_SESSION", payload: { session }})
state.setSelectedModules(session.selectedModules);
state.setExam(session.exam);
state.setExams(session.exams);
state.setSessionId(session.sessionId);
state.setAssignment(session.assignment);
state.setExerciseIndex(session.exerciseIndex);
state.setPartIndex(session.partIndex);
state.setModuleIndex(session.moduleIndex);
state.setTimeSpent(session.timeSpent);
state.setUserSolutions(session.userSolutions);
state.setShowSolutions(false);
state.setQuestionIndex(session.questionIndex);
}; };
return ( return (

View File

@@ -1,112 +1,104 @@
import { renderExercise } from "@/components/Exercises"; import { renderExercise } from "@/components/Exercises";
import ModuleTitle from "@/components/Medium/ModuleTitle"; import ModuleTitle from "@/components/Medium/ModuleTitle";
import { renderSolution } from "@/components/Solutions"; import { renderSolution } from "@/components/Solutions";
import { infoButtonStyle } from "@/constants/buttonStyles"; import { UserSolution, SpeakingExam, SpeakingExercise, InteractiveSpeakingExercise, Exercise } from "@/interfaces/exam";
import { UserSolution, SpeakingExam, SpeakingExercise, InteractiveSpeakingExercise } from "@/interfaces/exam"; import useExamStore, { usePersistentExamStore } from "@/stores/exam";
import useExamStore, { usePersistentExamStore } from "@/stores/examStore";
import { defaultUserSolutions } from "@/utils/exams";
import { countExercises } from "@/utils/moduleUtils"; import { countExercises } from "@/utils/moduleUtils";
import { convertCamelCaseToReadable } from "@/utils/string"; import { convertCamelCaseToReadable } from "@/utils/string";
import { mdiArrowRight } from "@mdi/js"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import Icon from "@mdi/react";
import clsx from "clsx";
import { Fragment, useEffect, useState } from "react";
import { toast } from "react-toastify";
import PartDivider from "./Navigation/SectionDivider"; import PartDivider from "./Navigation/SectionDivider";
import { ExamProps } from "./types";
import useExamTimer from "@/hooks/useExamTimer";
import useExamNavigation from "./Navigation/useExamNavigation";
import ProgressButtons from "./components/ProgressButtons";
import { calculateExerciseIndexSpeaking } from "./utils/calculateExerciseIndex";
interface Props {
exam: SpeakingExam;
showSolutions?: boolean;
onFinish: (userSolutions: UserSolution[]) => void;
preview?: boolean;
}
export default function Speaking({ exam, showSolutions = false, onFinish, preview = false }: Props) { const Speaking: React.FC<ExamProps<SpeakingExam>> = ({ exam, showSolutions = false, preview = false }) => {
const [speakingPromptsDone, setSpeakingPromptsDone] = useState<{ id: string; amount: number }[]>([]); const updateTimers = useExamTimer(exam.module, preview);
const userSolutionRef = useRef<(() => UserSolution) | null>(null);
const speakingBgColor = "bg-ielts-speaking-light"; const [solutionWasUpdated, setSolutionWasUpdated] = useState(false);
const examState = useExamStore((state) => state); const examState = useExamStore((state) => state);
const persistentExamState = usePersistentExamStore((state) => state); const persistentExamState = usePersistentExamStore((state) => state);
const { const {
userSolutions, exerciseIndex, userSolutions, flags,
questionIndex, timeSpentCurrentModule, questionIndex,
exerciseIndex, setBgColor, setUserSolutions, setTimeIsUp,
hasExamEnded, dispatch,
setBgColor,
setUserSolutions,
setHasExamEnded,
setQuestionIndex,
setExerciseIndex,
} = !preview ? examState : persistentExamState; } = !preview ? examState : persistentExamState;
const [seenParts, setSeenParts] = useState<Set<number>>(new Set(showSolutions ? exam.exercises.map((_, index) => index) : [])); const { finalizeModule, timeIsUp } = flags;
const [showPartDivider, setShowPartDivider] = useState<boolean>(typeof exam.exercises[0].intro === "string" && exam.exercises[0].intro !== "");
const timer = useRef(exam.minTimer - timeSpentCurrentModule / 60);
const {
nextExercise, previousExercise,
showPartDivider, setShowPartDivider,
setSeenParts,
} = useExamNavigation({ exam, module: "speaking", showSolutions, preview, disableBetweenParts: true });
useEffect(() => { useEffect(() => {
if (!showSolutions && exam.exercises[exerciseIndex]?.intro !== undefined && exam.exercises[exerciseIndex]?.intro !== "" && !seenParts.has(exerciseIndex)) { if (finalizeModule || timeIsUp) {
setShowPartDivider(true); updateTimers();
setBgColor(speakingBgColor);
if (timeIsUp) {
setTimeIsUp(false);
}
dispatch({ type: "FINALIZE_MODULE", payload: { updateTimers: false } })
} }
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [exerciseIndex]); }, [finalizeModule, timeIsUp])
const registerSolution = useCallback((updateSolution: () => UserSolution) => {
userSolutionRef.current = updateSolution;
setSolutionWasUpdated(true);
}, []);
useEffect(() => { useEffect(() => {
if (hasExamEnded && exerciseIndex === -1) { if (solutionWasUpdated && userSolutionRef.current) {
setExerciseIndex(exerciseIndex + 1); const solution = userSolutionRef.current();
}
}, [hasExamEnded, exerciseIndex, setExerciseIndex]);
const scrollToTop = () => Array.from(document.getElementsByTagName("body")).forEach((body) => body.scrollTo(0, 0));
const nextExercise = (solution?: UserSolution) => {
scrollToTop();
if (solution) {
setUserSolutions([...userSolutions.filter((x) => x.exercise !== solution.exercise), { ...solution, module: "speaking", exam: exam.id }]); setUserSolutions([...userSolutions.filter((x) => x.exercise !== solution.exercise), { ...solution, module: "speaking", exam: exam.id }]);
setSolutionWasUpdated(false);
} }
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [solutionWasUpdated])
if (questionIndex > 0) {
const exercise = getExercise();
setSpeakingPromptsDone((prev) => [...prev.filter((x) => x.id !== exercise.id), { id: exercise.id, amount: questionIndex }]);
}
setQuestionIndex(0);
if (exerciseIndex + 1 < exam.exercises.length) { const currentExercise = useMemo<Exercise>(() => {
setExerciseIndex(exerciseIndex + 1);
return;
}
if (exerciseIndex >= exam.exercises.length) return;
setHasExamEnded(false);
if (solution) {
onFinish([...userSolutions.filter((x) => x.exercise !== solution.exercise), { ...solution, module: "speaking", exam: exam.id }]);
} else {
onFinish(userSolutions);
}
};
const previousExercise = (solution?: UserSolution) => {
scrollToTop();
if (solution) {
setUserSolutions([...userSolutions.filter((x) => x.exercise !== solution.exercise), { ...solution, module: "speaking", exam: exam.id }]);
}
if (exerciseIndex > 0) {
setExerciseIndex(exerciseIndex - 1);
}
};
const getExercise = () => {
const exercise = exam.exercises[exerciseIndex]; const exercise = exam.exercises[exerciseIndex];
return { return {
...exercise, ...exercise,
variant: exerciseIndex < 2 && exercise.type === "interactiveSpeaking" ? "initial" : undefined, variant: exerciseIndex < 2 && exercise.type === "interactiveSpeaking" ? "initial" : undefined,
userSolutions: userSolutions.find((x) => x.exercise === exercise.id)?.solutions || [], userSolutions: userSolutions.find((x) => x.exercise === exercise.id)?.solutions || [],
} as SpeakingExercise | InteractiveSpeakingExercise; } as SpeakingExercise | InteractiveSpeakingExercise;
}; // eslint-disable-next-line react-hooks/exhaustive-deps
}, [exerciseIndex]);
const progressButtons = useMemo(() =>
// Do not remove the ()=> in handle next
<ProgressButtons handlePrevious={previousExercise} handleNext={() => nextExercise()} />
, [nextExercise, previousExercise]);
const handlePartDividerClick = () => {
setShowPartDivider(false);
setBgColor("bg-white");
setSeenParts((prev) => new Set(prev).add(exerciseIndex));
}
const memoizedExerciseIndex = useMemo(() => {
const bruh = calculateExerciseIndexSpeaking(exam, exerciseIndex, questionIndex)
console.log(bruh);
return bruh;
}
// eslint-disable-next-line react-hooks/exhaustive-deps
, [exerciseIndex, questionIndex]
);
return ( return (
<> <>
@@ -117,27 +109,24 @@ export default function Speaking({ exam, showSolutions = false, onFinish, previe
defaultTitle="Speaking exam" defaultTitle="Speaking exam"
section={exam.exercises[exerciseIndex]} section={exam.exercises[exerciseIndex]}
sectionIndex={exerciseIndex} sectionIndex={exerciseIndex}
onNext={() => { setShowPartDivider(false); setBgColor("bg-white"); setSeenParts((prev) => new Set(prev).add(exerciseIndex)) }} onNext={handlePartDividerClick}
/> : ( /> : (
<div className="flex flex-col h-full w-full gap-8 items-center"> <div className="flex flex-col h-full w-full gap-8 items-center">
<ModuleTitle <ModuleTitle
label={convertCamelCaseToReadable(exam.exercises[exerciseIndex].type)} label={convertCamelCaseToReadable(exam.exercises[exerciseIndex].type)}
minTimer={exam.minTimer} minTimer={timer.current}
exerciseIndex={exerciseIndex + 1 + questionIndex + speakingPromptsDone.reduce((acc, curr) => acc + curr.amount, 0)} exerciseIndex={memoizedExerciseIndex}
module="speaking" module="speaking"
totalExercises={countExercises(exam.exercises)} totalExercises={countExercises(exam.exercises)}
disableTimer={showSolutions || preview} disableTimer={showSolutions || preview}
preview={preview}
/> />
{exerciseIndex > -1 && {!showPartDivider && !showSolutions && renderExercise(currentExercise, exam.id, registerSolution, preview, progressButtons, progressButtons)}
exerciseIndex < exam.exercises.length && {showSolutions && renderSolution(currentExercise, progressButtons, progressButtons)}
!showSolutions &&
renderExercise(getExercise(), exam.id, nextExercise, previousExercise, undefined, preview)}
{exerciseIndex > -1 &&
exerciseIndex < exam.exercises.length &&
showSolutions &&
renderSolution(exam.exercises[exerciseIndex], nextExercise, previousExercise)}
</div> </div>
)} )}
</> </>
); );
} }
export default Speaking;

View File

@@ -1,95 +1,88 @@
import { renderExercise } from "@/components/Exercises"; import { renderExercise } from "@/components/Exercises";
import ModuleTitle from "@/components/Medium/ModuleTitle"; import ModuleTitle from "@/components/Medium/ModuleTitle";
import { renderSolution } from "@/components/Solutions"; import { renderSolution } from "@/components/Solutions";
import { UserSolution, WritingExam } from "@/interfaces/exam"; import { Exercise, UserSolution, WritingExam } from "@/interfaces/exam";
import useExamStore, { usePersistentExamStore } from "@/stores/examStore"; import useExamStore, { usePersistentExamStore } from "@/stores/exam";
import { countExercises } from "@/utils/moduleUtils"; import { countExercises } from "@/utils/moduleUtils";
import PartDivider from "./Navigation/SectionDivider"; import PartDivider from "./Navigation/SectionDivider";
import { useEffect, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { ExamProps } from "./types";
import useExamTimer from "@/hooks/useExamTimer";
import useExamNavigation from "./Navigation/useExamNavigation";
import SectionNavbar from "./Navigation/SectionNavbar";
import ProgressButtons from "./components/ProgressButtons";
interface Props { const Writing: React.FC<ExamProps<WritingExam>> = ({ exam, showSolutions = false, preview = false }) => {
exam: WritingExam; const updateTimers = useExamTimer(exam.module, preview);
showSolutions?: boolean; const userSolutionRef = useRef<(() => UserSolution) | null>(null);
preview?: boolean; const [solutionWasUpdated, setSolutionWasUpdated] = useState(false);
onFinish: (userSolutions: UserSolution[]) => void;
}
export default function Writing({ exam, showSolutions = false, preview = false, onFinish }: Props) {
const writingBgColor = "bg-ielts-writing-light";
const examState = useExamStore((state) => state); const examState = useExamStore((state) => state);
const persistentExamState = usePersistentExamStore((state) => state); const persistentExamState = usePersistentExamStore((state) => state);
const { const {
userSolutions, exerciseIndex, flags, setBgColor,
exerciseIndex, setUserSolutions, setTimeIsUp, userSolutions,
hasExamEnded, dispatch, navigation, timeSpentCurrentModule
setBgColor,
setUserSolutions,
setHasExamEnded,
setExerciseIndex,
} = !preview ? examState : persistentExamState; } = !preview ? examState : persistentExamState;
const [seenParts, setSeenParts] = useState<Set<number>>(new Set(showSolutions ? exam.exercises.map((_, index) => index) : [])); const timer = useRef(exam.minTimer - timeSpentCurrentModule / 60);
const [showPartDivider, setShowPartDivider] = useState<boolean>(typeof exam.exercises[0].intro === "string" && exam.exercises[0].intro !== "");
const { finalizeModule, timeIsUp } = flags;
const { nextDisabled } = navigation;
useEffect(() => { useEffect(() => {
if (hasExamEnded && exerciseIndex === -1) { if (finalizeModule || timeIsUp) {
setExerciseIndex(exerciseIndex + 1); updateTimers();
}
}, [hasExamEnded, exerciseIndex, setExerciseIndex]);
const scrollToTop = () => Array.from(document.getElementsByTagName("body")).forEach((body) => body.scrollTo(0, 0)); if (timeIsUp) {
setTimeIsUp(false);
useEffect(() => { }
if (!showSolutions && exam.exercises[exerciseIndex]?.intro !== undefined && exam.exercises[exerciseIndex]?.intro !== "" && !seenParts.has(exerciseIndex)) { dispatch({ type: "FINALIZE_MODULE", payload: { updateTimers: false } })
setShowPartDivider(true);
setBgColor(writingBgColor);
} }
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [exerciseIndex]); }, [finalizeModule, timeIsUp])
const {
nextExercise, previousExercise,
showPartDivider, setShowPartDivider,
seenParts, setSeenParts,
} = useExamNavigation({ exam, module: "writing", showSolutions, preview });
const registerSolution = useCallback((updateSolution: () => UserSolution) => {
userSolutionRef.current = updateSolution;
setSolutionWasUpdated(true);
}, []);
const nextExercise = (solution?: UserSolution) => { useEffect(() => {
scrollToTop(); if (solutionWasUpdated && userSolutionRef.current) {
if (solution) { const solution = userSolutionRef.current();
setUserSolutions([...userSolutions.filter((x) => x.exercise !== solution.exercise), { ...solution, module: "writing", exam: exam.id }]); setUserSolutions([...userSolutions.filter((x) => x.exercise !== solution.exercise), { ...solution, module: "writing", exam: exam.id }]);
setSolutionWasUpdated(false);
} }
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [solutionWasUpdated])
if (exerciseIndex + 1 < exam.exercises.length) {
setExerciseIndex(exerciseIndex + 1);
return;
}
if (exerciseIndex >= exam.exercises.length) return; const currentExercise = useMemo<Exercise>(() => {
setHasExamEnded(false);
if (solution) {
onFinish([...userSolutions.filter((x) => x.exercise !== solution.exercise), { ...solution, module: "writing", exam: exam.id }]);
} else {
onFinish(userSolutions);
}
};
const previousExercise = (solution?: UserSolution) => {
scrollToTop();
if (solution) {
setUserSolutions([...userSolutions.filter((x) => x.exercise !== solution.exercise), { ...solution, module: "writing", exam: exam.id }]);
}
if (exerciseIndex > 0) {
setExerciseIndex(exerciseIndex - 1);
}
};
const getExercise = () => {
const exercise = exam.exercises[exerciseIndex]; const exercise = exam.exercises[exerciseIndex];
return { return {
...exercise, ...exercise,
userSolutions: userSolutions.find((x) => x.exercise === exercise.id)?.solutions || [], userSolutions: userSolutions.find((x) => x.exercise === exercise.id)?.solutions || [],
}; };
}; // eslint-disable-next-line react-hooks/exhaustive-deps
}, [exerciseIndex]);
const handlePartDividerClick = () => {
setShowPartDivider(false);
setBgColor("bg-white");
setSeenParts((prev) => new Set(prev).add(exerciseIndex));
}
const progressButtons = useMemo(() =>
// Do not remove the ()=> in handle next
<ProgressButtons handlePrevious={previousExercise} handleNext={() => nextExercise()} nextDisabled={nextDisabled}/>
, [nextExercise, previousExercise, nextDisabled]);
return ( return (
<> <>
@@ -100,27 +93,32 @@ export default function Writing({ exam, showSolutions = false, preview = false,
defaultTitle="Writing exam" defaultTitle="Writing exam"
section={exam.exercises[exerciseIndex]} section={exam.exercises[exerciseIndex]}
sectionIndex={exerciseIndex} sectionIndex={exerciseIndex}
onNext={() => { setShowPartDivider(false); setBgColor("bg-white"); setSeenParts((prev) => new Set(prev).add(exerciseIndex))}} onNext={handlePartDividerClick}
/> : ( /> : (
<div className="flex flex-col h-full w-full gap-8 items-center"> <div className="flex flex-col h-full w-full gap-8 items-center">
{exam.exercises.length > 1 && <SectionNavbar
module="writing"
sectionLabel="Part"
seenParts={seenParts}
setShowPartDivider={setShowPartDivider}
setSeenParts={setSeenParts}
preview={preview}
/>}
<ModuleTitle <ModuleTitle
minTimer={exam.minTimer} minTimer={timer.current}
exerciseIndex={exerciseIndex + 1} exerciseIndex={exerciseIndex + 1}
module="writing" module="writing"
totalExercises={countExercises(exam.exercises)} totalExercises={countExercises(exam.exercises)}
disableTimer={showSolutions || preview} disableTimer={showSolutions || preview}
preview={preview}
/> />
{exerciseIndex > -1 && {!showPartDivider && !showSolutions && renderExercise(currentExercise, exam.id, registerSolution, preview, progressButtons, progressButtons)}
exerciseIndex < exam.exercises.length && {showSolutions && renderSolution(currentExercise, progressButtons, progressButtons)}
!showSolutions &&
renderExercise(getExercise(), exam.id, nextExercise, previousExercise, preview)}
{exerciseIndex > -1 &&
exerciseIndex < exam.exercises.length &&
showSolutions &&
renderSolution(exam.exercises[exerciseIndex], nextExercise, previousExercise)}
</div> </div>
) )
} }
</> </>
); );
} }
export default Writing;

View 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;

View 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&apos;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;

View 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;

View 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;

View 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;

View 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
View File

@@ -0,0 +1,7 @@
import { Exam } from "@/interfaces/exam";
export type ExamProps<T extends Exam> = {
exam: T;
showSolutions?: boolean;
preview?: boolean;
};

View 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
};

View 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
};

View 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;

View 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
View 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;

View File

@@ -1,6 +1,4 @@
import {Exam} from "@/interfaces/exam";
import {Permission, PermissionType} from "@/interfaces/permissions"; import {Permission, PermissionType} from "@/interfaces/permissions";
import {ExamState} from "@/stores/examStore";
import Axios from "axios"; import Axios from "axios";
import {setupCache} from "axios-cache-interceptor"; import {setupCache} from "axios-cache-interceptor";
import {useEffect, useState} from "react"; import {useEffect, useState} from "react";

View File

@@ -1,7 +1,5 @@
import {Exam} from "@/interfaces/exam"; import {ExamState} from "@/stores/exam/types";
import {ExamState} from "@/stores/examStore";
import axios from "axios"; import axios from "axios";
import {setupCache} from "axios-cache-interceptor";
import {useEffect, useState} from "react"; import {useEffect, useState} from "react";
export type Session = ExamState & {user: string; id: string; date: string}; export type Session = ExamState & {user: string; id: string; date: string};

View File

@@ -349,3 +349,5 @@ export interface Shuffles {
} }
export type ModuleExam = LevelExam | ReadingExam | ListeningExam | WritingExam | SpeakingExam; export type ModuleExam = LevelExam | ReadingExam | ListeningExam | WritingExam | SpeakingExam;
export type PartExam = ReadingExam | ListeningExam | LevelExam;
export type ExerciseOnlyExam = SpeakingExam | WritingExam;

View File

@@ -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>
);
}

View File

@@ -1,7 +1,7 @@
import Button from "@/components/Low/Button"; import Button from "@/components/Low/Button";
import Input from "@/components/Low/Input"; import Input from "@/components/Low/Input";
import {Module} from "@/interfaces"; import {Module} from "@/interfaces";
import useExamStore from "@/stores/examStore"; import useExamStore from "@/stores/exam";
import {getExamById} from "@/utils/exams"; import {getExamById} from "@/utils/exams";
import {MODULE_ARRAY} from "@/utils/moduleUtils"; import {MODULE_ARRAY} from "@/utils/moduleUtils";
import {RadioGroup} from "@headlessui/react"; import {RadioGroup} from "@headlessui/react";
@@ -16,8 +16,7 @@ export default function ExamLoader() {
const [examId, setExamId] = useState<string>(); const [examId, setExamId] = useState<string>();
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const setExams = useExamStore((state) => state.setExams); const dispatch = useExamStore((store) => store.dispatch);
const setSelectedModules = useExamStore((state) => state.setSelectedModules);
const router = useRouter(); const router = useRouter();
@@ -35,9 +34,7 @@ export default function ExamLoader() {
setIsLoading(false); setIsLoading(false);
return; return;
} }
dispatch({type: 'INIT_EXAM', payload: {exams: [exam], modules: [selectedModule]}})
setExams([exam]);
setSelectedModules([selectedModule]);
router.push("/exam"); router.push("/exam");
} }

View File

@@ -5,7 +5,7 @@ import useUsers from "@/hooks/useUsers";
import {Module} from "@/interfaces"; import {Module} from "@/interfaces";
import {Exam} from "@/interfaces/exam"; import {Exam} from "@/interfaces/exam";
import {Type, User} from "@/interfaces/user"; import {Type, User} from "@/interfaces/user";
import useExamStore from "@/stores/examStore"; import useExamStore from "@/stores/exam";
import {getExamById} from "@/utils/exams"; import {getExamById} from "@/utils/exams";
import {countExercises} from "@/utils/moduleUtils"; import {countExercises} from "@/utils/moduleUtils";
import {createColumnHelper, flexRender, getCoreRowModel, useReactTable} from "@tanstack/react-table"; import {createColumnHelper, flexRender, getCoreRowModel, useReactTable} from "@tanstack/react-table";
@@ -92,8 +92,7 @@ export default function ExamList({user, entities}: {user: User; entities: Entity
const {rows: filteredRows, renderSearch} = useListSearch<Exam>(searchFields, parsedExams); const {rows: filteredRows, renderSearch} = useListSearch<Exam>(searchFields, parsedExams);
const setExams = useExamStore((state) => state.setExams); const dispatch = useExamStore((state) => state.dispatch);
const setSelectedModules = useExamStore((state) => state.setSelectedModules);
const router = useRouter(); const router = useRouter();
@@ -106,9 +105,7 @@ export default function ExamList({user, entities}: {user: User; entities: Entity
return; return;
} }
dispatch({type: "INIT_EXAM", payload: {exams: [exam], modules: [module]}})
setExams([exam]);
setSelectedModules([module]);
router.push("/exercises"); router.push("/exercises");
}; };

View File

@@ -1,23 +1,14 @@
import Input from "@/components/Low/Input"; import Input from "@/components/Low/Input";
import Modal from "@/components/Modal"; import Modal from "@/components/Modal";
import {PERMISSIONS} from "@/constants/userPermissions";
import useExams from "@/hooks/useExams";
import usePackages from "@/hooks/usePackages"; import usePackages from "@/hooks/usePackages";
import useUsers from "@/hooks/useUsers";
import {Module} from "@/interfaces"; import {Module} from "@/interfaces";
import {Exam} from "@/interfaces/exam";
import {Package} from "@/interfaces/paypal"; import {Package} from "@/interfaces/paypal";
import {Type, User} from "@/interfaces/user"; import {User} from "@/interfaces/user";
import useExamStore from "@/stores/examStore";
import {getExamById} from "@/utils/exams";
import {countExercises} from "@/utils/moduleUtils";
import {createColumnHelper, flexRender, getCoreRowModel, useReactTable} from "@tanstack/react-table"; import {createColumnHelper, flexRender, getCoreRowModel, useReactTable} from "@tanstack/react-table";
import axios from "axios"; import axios from "axios";
import clsx from "clsx";
import {capitalize} from "lodash"; import {capitalize} from "lodash";
import {useRouter} from "next/router";
import {useState} from "react"; import {useState} from "react";
import {BsCheck, BsPencil, BsTrash, BsUpload} from "react-icons/bs"; import {BsPencil, BsTrash} from "react-icons/bs";
import {toast} from "react-toastify"; import {toast} from "react-toastify";
import Select from "react-select"; import Select from "react-select";
import {CURRENCIES} from "@/resources/paypal"; import {CURRENCIES} from "@/resources/paypal";

View File

@@ -1,6 +1,6 @@
/* eslint-disable @next/next/no-img-element */ /* eslint-disable @next/next/no-img-element */
import { Module } from "@/interfaces"; import { Module } from "@/interfaces";
import { useEffect, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import AbandonPopup from "@/components/AbandonPopup"; import AbandonPopup from "@/components/AbandonPopup";
import Layout from "@/components/High/Layout"; import Layout from "@/components/High/Layout";
@@ -11,9 +11,8 @@ import Reading from "@/exams/Reading";
import Selection from "@/exams/Selection"; import Selection from "@/exams/Selection";
import Speaking from "@/exams/Speaking"; import Speaking from "@/exams/Speaking";
import Writing from "@/exams/Writing"; import Writing from "@/exams/Writing";
import { Exam, LevelExam, UserSolution, Variant } from "@/interfaces/exam"; import { Exam, ExerciseOnlyExam, LevelExam, PartExam, UserSolution, Variant } from "@/interfaces/exam";
import { Stat, User } from "@/interfaces/user"; import { Stat, User } from "@/interfaces/user";
import useExamStore from "@/stores/examStore";
import { evaluateSpeakingAnswer, evaluateWritingAnswer } from "@/utils/evaluation"; import { evaluateSpeakingAnswer, evaluateWritingAnswer } from "@/utils/evaluation";
import { defaultExamUserSolutions, getExam } from "@/utils/exams"; import { defaultExamUserSolutions, getExam } from "@/utils/exams";
import axios from "axios"; import axios from "axios";
@@ -21,6 +20,8 @@ import { useRouter } from "next/router";
import { toast, ToastContainer } from "react-toastify"; import { toast, ToastContainer } from "react-toastify";
import { v4 as uuidv4 } from "uuid"; import { v4 as uuidv4 } from "uuid";
import ShortUniqueId from "short-unique-id"; import ShortUniqueId from "short-unique-id";
import { ExamProps } from "@/exams/types";
import useExamStore from "@/stores/exam";
interface Props { interface Props {
page: "exams" | "exercises"; page: "exams" | "exercises";
@@ -29,198 +30,64 @@ interface Props {
hideSidebar?: boolean hideSidebar?: boolean
} }
export default function ExamPage({ page, user, destination = "/exam", hideSidebar = false }: Props) { const ExamPage: React.FC<Props> = ({ page, user, destination = "/exam", hideSidebar = false }) => {
const router = useRouter();
const [variant, setVariant] = useState<Variant>("full"); const [variant, setVariant] = useState<Variant>("full");
const [avoidRepeated, setAvoidRepeated] = useState(false); const [avoidRepeated, setAvoidRepeated] = useState(false);
const [hasBeenUploaded, setHasBeenUploaded] = useState(false); const [hasBeenUploaded, setHasBeenUploaded] = useState(false);
const [showAbandonPopup, setShowAbandonPopup] = useState(false); const [showAbandonPopup, setShowAbandonPopup] = useState(false);
const [isEvaluationLoading, setIsEvaluationLoading] = useState(false); const [isEvaluationLoading, setIsEvaluationLoading] = useState(false);
const [statsAwaitingEvaluation, setStatsAwaitingEvaluation] = useState<string[]>([]); const [statsAwaitingEvaluation, setStatsAwaitingEvaluation] = useState<string[]>([]);
const [inactivityTimer, setInactivityTimer] = useState(0);
const [totalInactivity, setTotalInactivity] = useState(0);
const [timeSpent, setTimeSpent] = useState(0);
const resetStore = useExamStore((state) => state.reset); const {
const assignment = useExamStore((state) => state.assignment); exam, setExam,
const initialTimeSpent = useExamStore((state) => state.timeSpent); exams,
sessionId, setSessionId, setPartIndex,
moduleIndex, setModuleIndex,
setQuestionIndex, setExerciseIndex,
userSolutions, setUserSolutions,
showSolutions, setShowSolutions,
selectedModules, setSelectedModules,
setUser,
inactivity,
timeSpent,
assignment,
bgColor,
flags,
dispatch,
reset: resetStore,
saveStats,
saveSession,
setFlags,
setShuffles
} = useExamStore();
const { exam, setExam } = useExamStore((state) => state); const { finalizeModule, finalizeExam } = flags;
const { exams, setExams } = useExamStore((state) => state);
const { sessionId, setSessionId } = useExamStore((state) => state);
const { partIndex, setPartIndex } = useExamStore((state) => state);
const { moduleIndex, setModuleIndex } = useExamStore((state) => state);
const { questionIndex, setQuestionIndex } = useExamStore((state) => state);
const { exerciseIndex, setExerciseIndex } = useExamStore((state) => state);
const { userSolutions, setUserSolutions } = useExamStore((state) => state);
const { showSolutions, setShowSolutions } = useExamStore((state) => state);
const { selectedModules, setSelectedModules } = useExamStore((state) => state);
const { inactivity, setInactivity } = useExamStore((state) => state);
const { bgColor, setBgColor } = useExamStore((state) => state);
const setShuffleMaps = useExamStore((state) => state.setShuffles);
const router = useRouter(); const [isFetchingExams, setIsFetchingExams] = useState(false);
const [isExamLoaded, setIsExamLoaded] = useState(moduleIndex < selectedModules.length);
// eslint-disable-next-line react-hooks/exhaustive-deps
const resetInactivityTimer = () => {
setInactivityTimer((prev) => {
if (moduleIndex >= selectedModules.length || moduleIndex === -1) return 0;
if (prev >= 120) setTotalInactivity((totalPrev) => totalPrev + prev);
return 0;
});
};
const reset = () => {
resetStore();
setVariant("full");
setAvoidRepeated(false);
setHasBeenUploaded(false);
setShowAbandonPopup(false);
setIsEvaluationLoading(false);
setStatsAwaitingEvaluation([]);
setTimeSpent(0);
setInactivity(0);
document.removeEventListener("keydown", resetInactivityTimer);
document.removeEventListener("mousemove", resetInactivityTimer);
document.removeEventListener("mousedown", resetInactivityTimer);
};
useEffect(() => { useEffect(() => {
if (moduleIndex >= selectedModules.length || moduleIndex === -1 || showSolutions) { setIsExamLoaded(moduleIndex < selectedModules.length);
document.removeEventListener("keydown", resetInactivityTimer); }, [showSolutions, moduleIndex, selectedModules]);
document.removeEventListener("mousemove", resetInactivityTimer);
document.removeEventListener("mousedown", resetInactivityTimer);
}
}, [moduleIndex, resetInactivityTimer, selectedModules.length, showSolutions]);
// eslint-disable-next-line react-hooks/exhaustive-deps
const saveSession = async () => {
console.log("Saving your session...");
await axios.post("/api/sessions", {
id: sessionId,
sessionId,
date: new Date().toISOString(),
userSolutions,
moduleIndex,
selectedModules,
assignment,
timeSpent,
inactivity: totalInactivity,
exams,
exam,
partIndex,
exerciseIndex,
questionIndex,
user: user?.id,
});
};
useEffect(() => setTimeSpent(initialTimeSpent), [initialTimeSpent]);
useEffect(() => setTotalInactivity(inactivity), [inactivity]);
useEffect(() => { useEffect(() => {
if (userSolutions.length === 0 && exams.length > 0) { if (!showSolutions && sessionId.length === 0 && user?.id) {
const defaultSolutions = exams.map(defaultExamUserSolutions).flat();
setUserSolutions(defaultSolutions);
}
}, [exams, setUserSolutions, userSolutions]);
useEffect(() => {
if (
sessionId.length > 0 &&
userSolutions.length > 0 &&
selectedModules.length > 0 &&
exams.length > 0 &&
!!exam &&
timeSpent > 0 &&
!showSolutions &&
moduleIndex < selectedModules.length &&
selectedModules[moduleIndex] !== "speaking"
)
saveSession();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [assignment, exam, exams, moduleIndex, selectedModules, sessionId, userSolutions, user, exerciseIndex, partIndex, questionIndex]);
useEffect(() => {
if (
timeSpent % 20 === 0 &&
timeSpent > 0 &&
moduleIndex < selectedModules.length &&
selectedModules[moduleIndex] !== "speaking" &&
!showSolutions
)
saveSession();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [timeSpent]);
useEffect(() => {
if (selectedModules.length > 0 && sessionId.length === 0) {
const shortUID = new ShortUniqueId(); const shortUID = new ShortUniqueId();
setUser(user.id);
setSessionId(shortUID.randomUUID(8)); setSessionId(shortUID.randomUUID(8));
} }
}, [setSessionId, selectedModules, sessionId]); }, [setSessionId, isExamLoaded, sessionId, showSolutions, setUser, user?.id]);
useEffect(() => { useEffect(() => {
if (user?.type === "developer") console.log(exam); if (user?.type === "developer") console.log(exam);
}, [exam, user]); }, [exam, user]);
useEffect(() => {
if (selectedModules.length > 0 && timeSpent === 0 && !showSolutions) {
const timerInterval = setInterval(() => {
setTimeSpent((prev) => prev + 1);
}, 1000);
return () => {
clearInterval(timerInterval);
};
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedModules.length]);
useEffect(() => {
if (selectedModules.length > 0 && !showSolutions && inactivityTimer === 0) {
const inactivityInterval = setInterval(() => {
setInactivityTimer((prev) => prev + 1);
}, 1000);
return () => {
clearInterval(inactivityInterval);
};
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedModules.length]);
useEffect(() => {
document.addEventListener("keydown", resetInactivityTimer);
document.addEventListener("mousemove", resetInactivityTimer);
document.addEventListener("mousedown", resetInactivityTimer);
return () => {
document.removeEventListener("keydown", resetInactivityTimer);
document.removeEventListener("mousemove", resetInactivityTimer);
document.removeEventListener("mousedown", resetInactivityTimer);
};
});
useEffect(() => {
if (showSolutions) setModuleIndex(-1);
}, [setModuleIndex, showSolutions]);
useEffect(() => {
if (selectedModules.length > 0 && exams.length > 0 && moduleIndex < selectedModules.length) {
const nextExam = exams[moduleIndex];
if (partIndex === -1 && nextExam?.module !== "listening") setPartIndex(0);
if (exerciseIndex === -1 && !["reading", "listening"].includes(nextExam?.module)) setExerciseIndex(0);
setExam(nextExam ? updateExamWithUserSolutions(nextExam) : undefined);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedModules, moduleIndex, exams]);
useEffect(() => { useEffect(() => {
(async () => { (async () => {
if (selectedModules.length > 0 && exams.length === 0) { if (selectedModules.length > 0 && exams.length === 0) {
setIsFetchingExams(true);
const examPromises = selectedModules.map((module) => const examPromises = selectedModules.map((module) =>
getExam( getExam(
module, module,
@@ -230,8 +97,9 @@ export default function ExamPage({ page, user, destination = "/exam", hideSideba
), ),
); );
Promise.all(examPromises).then((values) => { Promise.all(examPromises).then((values) => {
setIsFetchingExams(false);
if (values.every((x) => !!x)) { if (values.every((x) => !!x)) {
setExams(values.map((x) => x!)); dispatch({ type: 'INIT_EXAM', payload: { exams: values.map((x) => x!), modules: selectedModules } })
} else { } else {
toast.error("Something went wrong, please try again"); toast.error("Something went wrong, please try again");
setTimeout(router.reload, 500); setTimeout(router.reload, 500);
@@ -240,100 +108,61 @@ export default function ExamPage({ page, user, destination = "/exam", hideSideba
} }
})(); })();
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedModules, setExams, exams]); }, [selectedModules, exams]);
useEffect(() => {
if (selectedModules.length > 0 && exams.length !== 0 && moduleIndex >= selectedModules.length && !hasBeenUploaded && !showSolutions) {
const newStats: Stat[] = userSolutions.map((solution) => ({
...solution,
id: solution.id || uuidv4(),
timeSpent,
inactivity: totalInactivity,
session: sessionId,
exam: solution.exam!,
module: solution.module!,
user: user?.id || "",
date: new Date().getTime(),
isDisabled: solution.isDisabled,
shuffleMaps: solution.shuffleMaps,
...(assignment ? { assignment: assignment.id } : {}),
isPractice: solution.isPractice
}));
axios const reset = () => {
.post<{ ok: boolean }>("/api/stats", newStats) resetStore();
.then((response) => setHasBeenUploaded(response.data.ok)) setVariant("full");
.catch(() => setHasBeenUploaded(false)); setAvoidRepeated(false);
} setHasBeenUploaded(false);
// eslint-disable-next-line react-hooks/exhaustive-deps setShowAbandonPopup(false);
}, [selectedModules, moduleIndex, hasBeenUploaded]); setIsEvaluationLoading(false);
setStatsAwaitingEvaluation([]);
useEffect(() => {
setIsEvaluationLoading(statsAwaitingEvaluation.length !== 0);
}, [statsAwaitingEvaluation]);
useEffect(() => {
if (statsAwaitingEvaluation.length > 0) checkIfStatsHaveBeenEvaluated(statsAwaitingEvaluation);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [statsAwaitingEvaluation]);
useEffect(() => {
if (exam && exam.module === "level" && !showSolutions) setBgColor("bg-ielts-level-light");
}, [exam, showSolutions, setBgColor]);
const checkIfStatsHaveBeenEvaluated = (ids: string[]) => {
setTimeout(async () => {
try {
const awaitedStats = await Promise.all(ids.map(async (id) => (await axios.get<Stat>(`/api/stats/${id}`)).data));
const solutionsEvaluated = awaitedStats.every((stat) => stat.solutions.every((x) => x.evaluation !== null));
if (solutionsEvaluated) {
const statsUserSolutions: UserSolution[] = awaitedStats.map((stat) => ({
id: stat.id,
exercise: stat.exercise,
score: stat.score,
solutions: stat.solutions,
type: stat.type,
exam: stat.exam,
module: stat.module,
}));
const updatedUserSolutions = userSolutions.map((x) => {
const respectiveSolution = statsUserSolutions.find((y) => y.exercise === x.exercise);
return respectiveSolution ? respectiveSolution : x;
});
setUserSolutions(updatedUserSolutions);
return setStatsAwaitingEvaluation((prev) => prev.filter((x) => !ids.includes(x)));
}
return checkIfStatsHaveBeenEvaluated(ids);
} catch {
return checkIfStatsHaveBeenEvaluated(ids);
}
}, 5 * 1000);
}; };
const updateExamWithUserSolutions = (exam: Exam): Exam => { useEffect(() => {
if (exam.module === "reading" || exam.module === "listening" || exam.module === "level") { if (finalizeModule && !showSolutions) {
const parts = exam.parts.map((p) => /*if (exam && (exam.module === "writing" || exam.module === "speaking") && userSolutions.length > 0 && !showSolutions) {
Object.assign(p, { setIsEvaluationLoading(true);
exercises: p.exercises.map((x) => (async () => {
Object.assign(x, { const responses: UserSolution[] = (
userSolutions: userSolutions.find((y) => x.id === y.exercise)?.solutions, await Promise.all(
}), exam.exercises.map(async (exercise, index) => {
), const evaluationID = uuidv4();
}), if (exercise.type === "writing")
); return await evaluateWritingAnswer(exercise, index + 1, userSolutions.find((x) => x.exercise === exercise.id)!, evaluationID);
return Object.assign(exam, { parts });
}
const exercises = exam.exercises.map((x) => if (exercise.type === "interactiveSpeaking" || exercise.type === "speaking")
Object.assign(x, { return await evaluateSpeakingAnswer(
userSolutions: userSolutions.find((y) => x.id === y.exercise)?.solutions, exercise,
}), userSolutions.find((x) => x.exercise === exercise.id)!,
); evaluationID,
return Object.assign(exam, { exercises }); index + 1,
}; );
}),
)
).filter((x) => !!x) as UserSolution[];
})();
}*/
}
}, [exam, finalizeModule, showSolutions, userSolutions]);
/*useEffect(() => {
// poll backend and setIsEvaluationLoading to false
}, []);*/
useEffect(() => {
if (finalizeExam && !isEvaluationLoading) {
(async () => {
axios.get("/api/stats/update");
await saveStats();
setModuleIndex(-1);
setFlags({ finalizeExam: false });
})();
}
}, [finalizeExam, saveStats, setFlags, setModuleIndex, isEvaluationLoading]);
const onFinish = async (solutions: UserSolution[]) => { const onFinish = async (solutions: UserSolution[]) => {
const solutionIds = solutions.map((x) => x.exercise); const solutionIds = solutions.map((x) => x.exercise);
@@ -375,8 +204,8 @@ export default function ExamPage({ page, user, destination = "/exam", hideSideba
setUserSolutions([...userSolutions.filter((x) => !solutionIds.includes(x.exercise)), ...newSolutions]); setUserSolutions([...userSolutions.filter((x) => !solutionIds.includes(x.exercise)), ...newSolutions]);
setModuleIndex(moduleIndex + 1); setModuleIndex(moduleIndex + 1);
setPartIndex(-1); setPartIndex(0);
setExerciseIndex(-1); setExerciseIndex(0);
setQuestionIndex(0); setQuestionIndex(0);
}; };
@@ -432,82 +261,19 @@ export default function ExamPage({ page, user, destination = "/exam", hideSideba
.map((x) => ({ module: x as Module, ...scores[x as Module] })); .map((x) => ({ module: x as Module, ...scores[x as Module] }));
}; };
const renderScreen = () => { const ModuleExamMap: Record<Module, React.ComponentType<ExamProps<Exam>>> = {
if (selectedModules.length === 0) { "reading": Reading as React.ComponentType<ExamProps<Exam>>,
return ( "listening": Listening as React.ComponentType<ExamProps<Exam>>,
<Selection "writing": Writing as React.ComponentType<ExamProps<Exam>>,
page={page} "speaking": Speaking as React.ComponentType<ExamProps<Exam>>,
user={user!} "level": Level as React.ComponentType<ExamProps<Exam>>,
onStart={(modules: Module[], avoid: boolean, variant: Variant) => { }
setModuleIndex(0);
setAvoidRepeated(avoid);
setSelectedModules(modules);
setVariant(variant);
}}
/>
);
}
if (moduleIndex >= selectedModules.length || moduleIndex === -1) { const CurrentExam = exam?.module ? ModuleExamMap[exam.module] : undefined;
return (
<Finish
isLoading={isEvaluationLoading}
user={user!}
modules={selectedModules}
solutions={userSolutions}
assignment={assignment}
information={{
timeSpent,
inactivity: totalInactivity,
}}
destination={destination}
onViewResults={(index?: number) => {
if (exams[0].module === "level") {
const levelExam = exams[0] as LevelExam;
const allExercises = levelExam.parts.flatMap((part) => part.exercises);
const exerciseOrderMap = new Map(allExercises.map((ex, index) => [ex.id, index]));
const orderedSolutions = userSolutions.slice().sort((a, b) => {
const indexA = exerciseOrderMap.get(a.exercise) ?? Infinity;
const indexB = exerciseOrderMap.get(b.exercise) ?? Infinity;
return indexA - indexB;
});
setUserSolutions(orderedSolutions);
} else {
setUserSolutions(userSolutions);
}
setShuffleMaps([]);
setShowSolutions(true);
setModuleIndex(index || 0);
setExerciseIndex(["reading", "listening"].includes(exams[0].module) ? -1 : 0);
setPartIndex(exams[0].module === "listening" ? -1 : 0);
setExam(exams[0]);
}}
scores={aggregateScoresByModule()}
/>
);
}
if (exam && exam.module === "reading") { const onAbandon = async () => {
return <Reading exam={exam} onFinish={onFinish} showSolutions={showSolutions} />; await saveSession();
} reset();
if (exam && exam.module === "listening") {
return <Listening exam={exam} onFinish={onFinish} showSolutions={showSolutions} />;
}
if (exam && exam.module === "writing") {
return <Writing exam={exam} onFinish={onFinish} showSolutions={showSolutions} />;
}
if (exam && exam.module === "speaking") {
return <Speaking exam={exam} onFinish={onFinish} showSolutions={showSolutions} />;
}
if (exam && exam.module === "level") {
return <Level exam={exam} onFinish={onFinish} showSolutions={showSolutions} />;
}
return <>Loading...</>;
}; };
return ( return (
@@ -522,18 +288,79 @@ export default function ExamPage({ page, user, destination = "/exam", hideSideba
focusMode={selectedModules.length !== 0 && !showSolutions && moduleIndex < selectedModules.length} focusMode={selectedModules.length !== 0 && !showSolutions && moduleIndex < selectedModules.length}
onFocusLayerMouseEnter={() => setShowAbandonPopup(true)}> onFocusLayerMouseEnter={() => setShowAbandonPopup(true)}>
<> <>
{renderScreen()} {/* Modules weren't yet set by an INIT_EXAM or INIT_SOLUTIONS dispatch, show Selection component*/}
{!showSolutions && moduleIndex < selectedModules.length && ( {selectedModules.length === 0 && <Selection
<AbandonPopup page={page}
isOpen={showAbandonPopup} user={user!}
abandonPopupTitle="Leave Exercise" onStart={(modules: Module[], avoid: boolean, variant: Variant) => {
abandonPopupDescription="Are you sure you want to leave the exercise? Your progress will be saved and this exam can be resumed on the Dashboard." setModuleIndex(0);
abandonConfirmButtonText="Confirm" setAvoidRepeated(avoid);
onAbandon={() => { setSelectedModules(modules);
reset(); setVariant(variant);
}}
/>}
{isFetchingExams && (
<div className="flex flex-grow flex-col items-center justify-center animate-pulse">
<span className={`loading loading-infinity w-32 bg-ielts-${selectedModules[0]}`} />
<span className={`font-bold text-2xl text-ielts-${selectedModules[0]}`}>Loading Exam ...</span>
</div>
)}
{(moduleIndex === -1 && selectedModules.length !== 0) &&
<Finish
isLoading={isEvaluationLoading}
user={user!}
modules={selectedModules}
solutions={userSolutions}
assignment={assignment}
information={{
timeSpent,
inactivity,
}} }}
onCancel={() => setShowAbandonPopup(false)} destination={destination}
/> onViewResults={(index?: number) => {
if (exams[0].module === "level") {
const levelExam = exams[0] as LevelExam;
const allExercises = levelExam.parts.flatMap((part) => part.exercises);
const exerciseOrderMap = new Map(allExercises.map((ex, index) => [ex.id, index]));
const orderedSolutions = userSolutions.slice().sort((a, b) => {
const indexA = exerciseOrderMap.get(a.exercise) ?? Infinity;
const indexB = exerciseOrderMap.get(b.exercise) ?? Infinity;
return indexA - indexB;
});
setUserSolutions(orderedSolutions);
} else {
setUserSolutions(userSolutions);
}
setShuffles([]);
if (index === undefined) {
setFlags({ reviewAll: true });
setModuleIndex(0);
setExam(exams[0]);
} else {
setModuleIndex(index);
setExam(exams[index]);
}
setShowSolutions(true);
setQuestionIndex(0);
setExerciseIndex(0);
setPartIndex(0);
}}
scores={aggregateScoresByModule()}
/>}
{/* Exam is on going, display it and the abandon modal */}
{isExamLoaded && moduleIndex !== -1 && (
<>
{exam && CurrentExam && <CurrentExam exam={exam} showSolutions={showSolutions} />}
{!showSolutions && <AbandonPopup
isOpen={showAbandonPopup}
abandonPopupTitle="Leave Exercise"
abandonPopupDescription="Are you sure you want to leave the exercise? Your progress will be saved and this exam can be resumed on the Dashboard."
abandonConfirmButtonText="Confirm"
onAbandon={onAbandon}
onCancel={() => setShowAbandonPopup(false)}
/>
}
</>
)} )}
</> </>
</Layout> </Layout>
@@ -541,3 +368,5 @@ export default function ExamPage({ page, user, destination = "/exam", hideSideba
</> </>
); );
} }
export default ExamPage;

View File

@@ -8,12 +8,11 @@ import "primeicons/primeicons.css";
import "react-datepicker/dist/react-datepicker.css"; import "react-datepicker/dist/react-datepicker.css";
import {useRouter} from "next/router"; import {useRouter} from "next/router";
import {useEffect} from "react"; import {useEffect} from "react";
import useExamStore from "@/stores/examStore"; import useExamStore from "@/stores/exam";
import usePreferencesStore from "@/stores/preferencesStore"; import usePreferencesStore from "@/stores/preferencesStore";
import axios from "axios";
export default function App({Component, pageProps}: AppProps) { export default function App({Component, pageProps}: AppProps) {
const {reset} = useExamStore((state) => state); const {reset} = useExamStore();
const setIsSidebarMinimized = usePreferencesStore((state) => state.setSidebarMinimized); const setIsSidebarMinimized = usePreferencesStore((state) => state.setSidebarMinimized);
const router = useRouter(); const router = useRouter();

View File

@@ -90,7 +90,7 @@ async function evaluate(body: {answers: object[]}, variant?: "initial" | "final"
}, },
}); });
if (typeof backendRequest.data === "string") return evaluate(body); if (backendRequest.status !== 200) return evaluate(body);
return backendRequest; return backendRequest;
} }

View File

@@ -7,11 +7,9 @@ import formidable from "formidable-serverless";
import {getDownloadURL, ref, uploadBytes} from "firebase/storage"; import {getDownloadURL, ref, uploadBytes} from "firebase/storage";
import fs from "fs"; import fs from "fs";
import {storage} from "@/firebase"; import {storage} from "@/firebase";
import client from "@/lib/mongodb";
import {Stat} from "@/interfaces/user"; import {Stat} from "@/interfaces/user";
import {speakingReverseMarking} from "@/utils/score";
const db = client.db(process.env.MONGODB_DB);
export default withIronSessionApiRoute(handler, sessionOptions); export default withIronSessionApiRoute(handler, sessionOptions);
function delay(ms: number) { function delay(ms: number) {
@@ -30,54 +28,29 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
const audioFile = files.audio; const audioFile = files.audio;
const audioFileRef = ref(storage, `speaking_recordings/${fields.id}.wav`); const audioFileRef = ref(storage, `speaking_recordings/${fields.id}.wav`);
const task = parseInt(fields.task.toString());
const binary = fs.readFileSync((audioFile as any).path).buffer; const binary = fs.readFileSync((audioFile as any).path).buffer;
const snapshot = await uploadBytes(audioFileRef, binary); const snapshot = await uploadBytes(audioFileRef, binary);
const url = await getDownloadURL(snapshot.ref); const url = await getDownloadURL(snapshot.ref);
const path = snapshot.metadata.fullPath; const path = snapshot.metadata.fullPath;
res.status(200).json(null);
console.log("🌱 - Still processing");
const backendRequest = await evaluate({answer: path, question: fields.question}, task);
console.log("🌱 - Process complete");
const correspondingStat = await getCorrespondingStat(fields.id, 1); /*const solutions = correspondingStat.solutions.map((x) => ({
const solutions = correspondingStat.solutions.map((x) => ({
...x, ...x,
evaluation: backendRequest.data, evaluation: backendRequest.data,
solution: url, solution: url,
})); }));*/
await db.collection("stats").updateOne( await axios.post(`${process.env.BACKEND_URL}/grade/speaking/2`, {answer: path, question: fields.question}, {
{ id: fields.id }, headers: {
{ Authorization: `Bearer ${process.env.BACKEND_JWT}`,
id: fields.id,
solutions,
score: {
correct: speakingReverseMarking[backendRequest.data.overall || 0] || 0,
total: 100,
missing: 0,
},
isDisabled: false,
}, },
{upsert: true}, });
);
console.log("🌱 - Updated the DB");
}); });
} }
async function getCorrespondingStat(id: string, index: number): Promise<Stat> {
console.log(`🌱 - Try number ${index} - ${id}`);
const correspondingStat = await db.collection("stats").findOne<Stat>({ id: id });
if (correspondingStat) return correspondingStat;
await delay(3 * 10000);
return getCorrespondingStat(id, index + 1);
}
async function evaluate(body: {answer: string; question: string}, task: number): Promise<AxiosResponse> { async function evaluate(body: {answer: string; question: string}, task: number): Promise<AxiosResponse> {
const backendRequest = await axios.post(`${process.env.BACKEND_URL}/grade/speaking/2`, body, { const backendRequest = await axios.post(`${process.env.BACKEND_URL}/grade/speaking/2`, body, {
headers: { headers: {
@@ -85,7 +58,7 @@ async function evaluate(body: {answer: string; question: string}, task: number):
}, },
}); });
if (typeof backendRequest.data === "string") return evaluate(body, task); if (backendRequest.status !== 200) return evaluate(body, task);
return backendRequest; return backendRequest;
} }

View File

@@ -1,11 +1,8 @@
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction // Next.js API route support: https://nextjs.org/docs/api-routes/introduction
import type { NextApiRequest, NextApiResponse } from "next"; import type { NextApiRequest, NextApiResponse } from "next";
import client from "@/lib/mongodb";
import { withIronSessionApiRoute } from "iron-session/next"; import { withIronSessionApiRoute } from "iron-session/next";
import { sessionOptions } from "@/lib/session"; import { sessionOptions } from "@/lib/session";
import axios, { AxiosResponse } from "axios"; import axios from "axios";
import { Stat } from "@/interfaces/user";
import { writingReverseMarking } from "@/utils/score";
interface Body { interface Body {
question: string; question: string;
@@ -14,67 +11,22 @@ interface Body {
id: string; id: string;
} }
function delay(ms: number) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
const db = client.db(process.env.MONGODB_DB);
export default withIronSessionApiRoute(handler, sessionOptions); export default withIronSessionApiRoute(handler, sessionOptions);
async function handler(req: NextApiRequest, res: NextApiResponse) { async function handler(req: NextApiRequest, res: NextApiResponse) {
if (!req.session.user) { if (!req.session.user) {
res.status(401).json({ ok: false }); res.status(401).json({ ok: false });
return; return;
} }
res.status(200).json(null); const body = req.body as Body;
console.log("🌱 - Still processing");
const backendRequest = await evaluate(req.body as Body);
console.log("🌱 - Process complete");
const correspondingStat = await getCorrespondingStat(req.body.id, 1);
const solutions = correspondingStat.solutions.map((x) => ({ ...x, evaluation: backendRequest.data }));
await db.collection("stats").updateOne(
{ id: (req.body as Body).id },
{
$set: {
id: (req.body as Body).id,
solutions,
score: {
correct: writingReverseMarking[backendRequest.data.overall],
total: 100,
missing: 0,
},
isDisabled: false,
}
},
{ upsert: true },
);
console.log("🌱 - Updated the DB");
}
async function getCorrespondingStat(id: string, index: number): Promise<Stat> {
console.log(`🌱 - Try number ${index} - ${id}`);
const correspondingStat = await db.collection("stats").findOne<Stat>({ id: id });
if (correspondingStat) return correspondingStat;
await delay(3 * 10000);
return getCorrespondingStat(id, index + 1);
}
async function evaluate(body: Body): Promise<AxiosResponse> {
const taskNumber = body.task.toString() !== "1" && body.task.toString() !== "2" ? "1" : body.task.toString(); const taskNumber = body.task.toString() !== "1" && body.task.toString() !== "2" ? "1" : body.task.toString();
const backendRequest = await axios.post(`${process.env.BACKEND_URL}/grade/writing/${taskNumber}`, body as Body, { await axios.post(`${process.env.BACKEND_URL}/grade/writing/${taskNumber}`, body, {
headers: { headers: {
Authorization: `Bearer ${process.env.BACKEND_JWT}`, Authorization: `Bearer ${process.env.BACKEND_JWT}`,
}, },
}); });
res.status(200);
if (typeof backendRequest.data === "string") return evaluate(body);
return backendRequest;
} }

View File

@@ -2,32 +2,31 @@ import Button from "@/components/Low/Button";
import ProgressBar from "@/components/Low/ProgressBar"; import ProgressBar from "@/components/Low/ProgressBar";
import Modal from "@/components/Modal"; import Modal from "@/components/Modal";
import useUsers from "@/hooks/useUsers"; import useUsers from "@/hooks/useUsers";
import {Module} from "@/interfaces"; import { Module } from "@/interfaces";
import {Assignment} from "@/interfaces/results"; import { Assignment } from "@/interfaces/results";
import {Group, Stat, User} from "@/interfaces/user"; import { Group, Stat, User } from "@/interfaces/user";
import useExamStore from "@/stores/examStore"; import useExamStore from "@/stores/exam";
import {getExamById} from "@/utils/exams"; import { getExamById } from "@/utils/exams";
import {sortByModule} from "@/utils/moduleUtils"; import { sortByModule } from "@/utils/moduleUtils";
import {calculateBandScore} from "@/utils/score"; import { calculateBandScore } from "@/utils/score";
import {convertToUserSolutions} from "@/utils/stats"; import { convertToUserSolutions } from "@/utils/stats";
import {getUserName} from "@/utils/users"; import { getUserName } from "@/utils/users";
import axios from "axios"; import axios from "axios";
import clsx from "clsx"; import clsx from "clsx";
import {capitalize, uniqBy} from "lodash"; import { capitalize, uniqBy } from "lodash";
import moment from "moment"; import moment from "moment";
import {useRouter} from "next/router"; import { useRouter } from "next/router";
import {BsBook, BsBuilding, BsChevronLeft, BsClipboard, BsHeadphones, BsMegaphone, BsPen} from "react-icons/bs"; import { BsBook, BsBuilding, BsChevronLeft, BsClipboard, BsHeadphones, BsMegaphone, BsPen } from "react-icons/bs";
import {toast} from "react-toastify"; import { toast } from "react-toastify";
import {futureAssignmentFilter} from "@/utils/assignments"; import { futureAssignmentFilter } from "@/utils/assignments";
import {withIronSessionSsr} from "iron-session/next"; import { withIronSessionSsr } from "iron-session/next";
import {checkAccess, doesEntityAllow} from "@/utils/permissions"; import { checkAccess, doesEntityAllow } from "@/utils/permissions";
import {mapBy, redirect, serialize} from "@/utils"; import { mapBy, redirect, serialize } from "@/utils";
import {getAssignment} from "@/utils/assignments.be"; import { getAssignment } from "@/utils/assignments.be";
import {getEntitiesUsers, getEntityUsers, getUsers} from "@/utils/users.be"; import { getEntityUsers, getUsers } from "@/utils/users.be";
import {getEntitiesWithRoles, getEntityWithRoles} from "@/utils/entities.be"; import { getEntityWithRoles } from "@/utils/entities.be";
import {getGroups, getGroupsByEntities, getGroupsByEntity} from "@/utils/groups.be"; import { sessionOptions } from "@/lib/session";
import {sessionOptions} from "@/lib/session"; import { EntityWithRoles } from "@/interfaces/entity";
import {EntityWithRoles} from "@/interfaces/entity";
import Head from "next/head"; import Head from "next/head";
import Layout from "@/components/High/Layout"; import Layout from "@/components/High/Layout";
import Separator from "@/components/Low/Separator"; import Separator from "@/components/Low/Separator";
@@ -35,7 +34,7 @@ import Link from "next/link";
import { requestUser } from "@/utils/api"; import { requestUser } from "@/utils/api";
import { useEntityPermission } from "@/hooks/useEntityPermissions"; import { useEntityPermission } from "@/hooks/useEntityPermissions";
export const getServerSideProps = withIronSessionSsr(async ({req, res, params}) => { export const getServerSideProps = withIronSessionSsr(async ({ req, res, params }) => {
const user = await requestUser(req, res) const user = await requestUser(req, res)
if (!user) return redirect("/login") if (!user) return redirect("/login")
@@ -44,22 +43,22 @@ export const getServerSideProps = withIronSessionSsr(async ({req, res, params})
res.setHeader("Cache-Control", "public, s-maxage=10, stale-while-revalidate=59"); res.setHeader("Cache-Control", "public, s-maxage=10, stale-while-revalidate=59");
const {id} = params as {id: string}; const { id } = params as { id: string };
const assignment = await getAssignment(id); const assignment = await getAssignment(id);
if (!assignment) return redirect("/assignments") if (!assignment) return redirect("/assignments")
const entity = await getEntityWithRoles(assignment.entity || "") const entity = await getEntityWithRoles(assignment.entity || "")
if (!entity){ if (!entity) {
const users = await getUsers() const users = await getUsers()
return {props: serialize({user, users, assignment})}; return { props: serialize({ user, users, assignment }) };
} }
if (!doesEntityAllow(user, entity, 'view_assignments')) return redirect("/assignments") if (!doesEntityAllow(user, entity, 'view_assignments')) return redirect("/assignments")
const users = await (checkAccess(user, ["developer", "admin"]) ? getUsers() : getEntityUsers(entity.id)); const users = await (checkAccess(user, ["developer", "admin"]) ? getUsers() : getEntityUsers(entity.id));
return {props: serialize({user, users, entity, assignment})}; return { props: serialize({ user, users, entity, assignment }) };
}, sessionOptions); }, sessionOptions);
interface Props { interface Props {
@@ -69,17 +68,14 @@ interface Props {
entity?: EntityWithRoles entity?: EntityWithRoles
} }
export default function AssignmentView({user, users, entity, assignment}: Props) { export default function AssignmentView({ user, users, entity, assignment }: Props) {
const canDeleteAssignment = useEntityPermission(user, entity, 'delete_assignment') const canDeleteAssignment = useEntityPermission(user, entity, 'delete_assignment')
const canStartAssignment = useEntityPermission(user, entity, 'start_assignment') const canStartAssignment = useEntityPermission(user, entity, 'start_assignment')
const setExams = useExamStore((state) => state.setExams);
const setShowSolutions = useExamStore((state) => state.setShowSolutions);
const setUserSolutions = useExamStore((state) => state.setUserSolutions);
const setSelectedModules = useExamStore((state) => state.setSelectedModules);
const router = useRouter(); const router = useRouter();
const dispatch = useExamStore((state) => state.dispatch);
const deleteAssignment = async () => { const deleteAssignment = async () => {
if (!canDeleteAssignment) return if (!canDeleteAssignment) return
if (!confirm("Are you sure you want to delete this assignment?")) return; if (!confirm("Are you sure you want to delete this assignment?")) return;
@@ -128,9 +124,9 @@ export default function AssignmentView({user, users, entity, assignment}: Props)
return resultModuleBandScores.length === 0 ? -1 : resultModuleBandScores.reduce((acc, curr) => acc + curr, 0) / assignment.results.length; return resultModuleBandScores.length === 0 ? -1 : resultModuleBandScores.reduce((acc, curr) => acc + curr, 0) / assignment.results.length;
}; };
const aggregateScoresByModule = (stats: Stat[]): {module: Module; total: number; missing: number; correct: number}[] => { const aggregateScoresByModule = (stats: Stat[]): { module: Module; total: number; missing: number; correct: number }[] => {
const scores: { const scores: {
[key in Module]: {total: number; missing: number; correct: number}; [key in Module]: { total: number; missing: number; correct: number };
} = { } = {
reading: { reading: {
total: 0, total: 0,
@@ -169,7 +165,7 @@ export default function AssignmentView({user, users, entity, assignment}: Props)
return Object.keys(scores) return Object.keys(scores)
.filter((x) => scores[x as Module].total > 0) .filter((x) => scores[x as Module].total > 0)
.map((x) => ({module: x as Module, ...scores[x as Module]})); .map((x) => ({ module: x as Module, ...scores[x as Module] }));
}; };
const customContent = (stats: Stat[], user: string, focus: "academic" | "general") => { const customContent = (stats: Stat[], user: string, focus: "academic" | "general") => {
@@ -189,15 +185,16 @@ export default function AssignmentView({user, users, entity, assignment}: Props)
Promise.all(examPromises).then((exams) => { Promise.all(examPromises).then((exams) => {
if (exams.every((x) => !!x)) { if (exams.every((x) => !!x)) {
setUserSolutions(convertToUserSolutions(stats)); dispatch({
setShowSolutions(true); type: 'INIT_SOLUTIONS', payload: {
setExams(exams.map((x) => x!).sort(sortByModule)); exams: exams.map((x) => x!).sort(sortByModule),
setSelectedModules( modules: exams
exams .map((x) => x!)
.map((x) => x!) .sort(sortByModule)
.sort(sortByModule) .map((x) => x!.module),
.map((x) => x!.module), stats,
); }
});
router.push("/exam"); router.push("/exam");
} }
}); });
@@ -228,7 +225,7 @@ export default function AssignmentView({user, users, entity, assignment}: Props)
<div className="flex w-full flex-col gap-1"> <div className="flex w-full flex-col gap-1">
<div className="-md:mt-2 grid w-full grid-cols-4 place-items-start gap-2"> <div className="-md:mt-2 grid w-full grid-cols-4 place-items-start gap-2">
{aggregatedLevels.map(({module, level}) => ( {aggregatedLevels.map(({ module, level }) => (
<div <div
key={module} key={module}
className={clsx( className={clsx(
@@ -306,7 +303,7 @@ export default function AssignmentView({user, users, entity, assignment}: Props)
if (!confirm(`Are you sure you want to remove ${inactiveAssignees.length} assignees?`)) return if (!confirm(`Are you sure you want to remove ${inactiveAssignees.length} assignees?`)) return
axios axios
.patch(`/api/assignments/${assignment.id}`, {assignees: activeAssignees}) .patch(`/api/assignments/${assignment.id}`, { assignees: activeAssignees })
.then(() => { .then(() => {
toast.success(`The assignment "${assignment.name}" has been updated successfully!`); toast.success(`The assignment "${assignment.name}" has been updated successfully!`);
router.replace(router.asPath); router.replace(router.asPath);
@@ -388,7 +385,7 @@ export default function AssignmentView({user, users, entity, assignment}: Props)
<span className="text-xl font-bold">Average Scores</span> <span className="text-xl font-bold">Average Scores</span>
<div className="-md:mt-2 flex w-full items-center gap-4"> <div className="-md:mt-2 flex w-full items-center gap-4">
{assignment && {assignment &&
uniqBy(assignment.exams, (x) => x.module).map(({module}) => ( uniqBy(assignment.exams, (x) => x.module).map(({ module }) => (
<div <div
data-tip={capitalize(module)} data-tip={capitalize(module)}
key={module} key={module}

View File

@@ -13,7 +13,7 @@ import { InviteWithEntity } from "@/interfaces/invite";
import {Assignment} from "@/interfaces/results"; import {Assignment} from "@/interfaces/results";
import {Stat, User} from "@/interfaces/user"; import {Stat, User} from "@/interfaces/user";
import {sessionOptions} from "@/lib/session"; import {sessionOptions} from "@/lib/session";
import useExamStore from "@/stores/examStore"; import useExamStore from "@/stores/exam";
import {findBy, mapBy, redirect, serialize} from "@/utils"; import {findBy, mapBy, redirect, serialize} from "@/utils";
import { requestUser } from "@/utils/api"; import { requestUser } from "@/utils/api";
import {activeAssignmentFilter} from "@/utils/assignments"; import {activeAssignmentFilter} from "@/utils/assignments";
@@ -81,11 +81,7 @@ export const getServerSideProps = withIronSessionSsr(async ({req, res}) => {
export default function Dashboard({user, entities, assignments, stats, invites, grading, sessions, exams}: Props) { export default function Dashboard({user, entities, assignments, stats, invites, grading, sessions, exams}: Props) {
const router = useRouter(); const router = useRouter();
const setExams = useExamStore((state) => state.setExams); const dispatch = useExamStore((state) => state.dispatch);
const setShowSolutions = useExamStore((state) => state.setShowSolutions);
const setUserSolutions = useExamStore((state) => state.setUserSolutions);
const setSelectedModules = useExamStore((state) => state.setSelectedModules);
const setAssignment = useExamStore((state) => state.setAssignment);
const startAssignment = (assignment: Assignment) => { const startAssignment = (assignment: Assignment) => {
const assignmentExams = exams.filter(e => { const assignmentExams = exams.filter(e => {
@@ -94,11 +90,11 @@ export default function Dashboard({user, entities, assignments, stats, invites,
}) })
if (assignmentExams.every((x) => !!x)) { if (assignmentExams.every((x) => !!x)) {
setUserSolutions([]); dispatch({type: "INIT_EXAM", payload: {
setShowSolutions(false); exams: assignmentExams.sort(sortByModule),
setExams(assignmentExams.sort(sortByModule)); modules: mapBy(assignmentExams.sort(sortByModule), 'module'),
setSelectedModules(mapBy(assignmentExams.sort(sortByModule), 'module')); assignment
setAssignment(assignment); }})
router.push("/exam"); router.push("/exam");
} }

View File

@@ -1,4 +1,5 @@
/* eslint-disable @next/next/no-img-element */ /* eslint-disable @next/next/no-img-element */
//import "@/utils/wdyr";
import { withIronSessionSsr } from "iron-session/next"; import { withIronSessionSsr } from "iron-session/next";
import { sessionOptions } from "@/lib/session"; import { sessionOptions } from "@/lib/session";
@@ -6,20 +7,19 @@ import { shouldRedirectHome } from "@/utils/navigation.disabled";
import ExamPage from "./(exam)/ExamPage"; import ExamPage from "./(exam)/ExamPage";
import Head from "next/head"; import Head from "next/head";
import { User } from "@/interfaces/user"; import { User } from "@/interfaces/user";
import { filterBy, findBy, redirect, serialize } from "@/utils"; import { filterBy, redirect, serialize } from "@/utils";
import { requestUser } from "@/utils/api"; import { requestUser } from "@/utils/api";
import { getAssignment, getAssignments, getAssignmentsByAssignee } from "@/utils/assignments.be"; import { getAssignment } from "@/utils/assignments.be";
import { Assignment } from "@/interfaces/results"; import { Assignment } from "@/interfaces/results";
import useExamStore from "@/stores/examStore"; import useExamStore from "@/stores/exam";
import { useEffect } from "react"; import { useEffect } from "react";
import { Exam } from "@/interfaces/exam"; import { Exam } from "@/interfaces/exam";
import { getExamsByIds } from "@/utils/exams.be"; import { getExamsByIds } from "@/utils/exams.be";
import { sortByModule } from "@/utils/moduleUtils"; import { sortByModule } from "@/utils/moduleUtils";
import { uniqBy } from "lodash"; import { uniqBy } from "lodash";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { getSessionByAssignment, getSessionsByUser } from "@/utils/sessions.be"; import { getSessionByAssignment } from "@/utils/sessions.be";
import { Session } from "@/hooks/useSessions"; import { Session } from "@/hooks/useSessions";
import moment from "moment";
import { activeAssignmentFilter } from "@/utils/assignments"; import { activeAssignmentFilter } from "@/utils/assignments";
export const getServerSideProps = withIronSessionSsr(async ({ req, res, query }) => { export const getServerSideProps = withIronSessionSsr(async ({ req, res, query }) => {
@@ -63,25 +63,23 @@ interface Props {
destinationURL?: string destinationURL?: string
} }
export default function Page({ user, assignment, exams = [], destinationURL = "/exam", session }: Props) { const Page: React.FC<Props> = ({ user, assignment, exams = [], destinationURL = "/exam", session }) => {
const router = useRouter() const router = useRouter()
const { assignment: storeAssignment, dispatch } = useExamStore();
const state = useExamStore((state) => state)
useEffect(() => { useEffect(() => {
if (assignment && exams.length > 0 && !state.assignment && !session) { if (assignment && exams.length > 0 && !storeAssignment && !session) {
if (!activeAssignmentFilter(assignment)) return if (!activeAssignmentFilter(assignment)) return
dispatch({
state.setUserSolutions([]); type: "INIT_EXAM", payload: {
state.setShowSolutions(false); exams: exams.sort(sortByModule),
state.setAssignment(assignment); modules: exams
state.setExams(exams.sort(sortByModule)); .map((x) => x!)
state.setSelectedModules( .sort(sortByModule)
exams .map((x) => x!.module),
.map((x) => x!) assignment
.sort(sortByModule) }
.map((x) => x!.module), });
);
router.replace(router.asPath) router.replace(router.asPath)
} }
@@ -89,21 +87,8 @@ export default function Page({ user, assignment, exams = [], destinationURL = "/
}, [assignment, exams, session]) }, [assignment, exams, session])
useEffect(() => { useEffect(() => {
if (assignment && exams.length > 0 && !state.assignment && !!session) { if (assignment && exams.length > 0 && !storeAssignment && !!session) {
state.setShuffles(session.userSolutions.map((x) => ({ exerciseID: x.exercise, shuffles: x.shuffleMaps ? x.shuffleMaps : [] }))); dispatch({ type: "SET_SESSION", payload: { session } })
state.setSelectedModules(session.selectedModules);
state.setExam(session.exam);
state.setExams(session.exams);
state.setSessionId(session.sessionId);
state.setAssignment(session.assignment);
state.setExerciseIndex(session.exerciseIndex);
state.setPartIndex(session.partIndex);
state.setModuleIndex(session.moduleIndex);
state.setTimeSpent(session.timeSpent);
state.setUserSolutions(session.userSolutions);
state.setShowSolutions(false);
state.setQuestionIndex(session.questionIndex);
router.replace(router.asPath) router.replace(router.asPath)
} }
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
@@ -120,7 +105,10 @@ export default function Page({ user, assignment, exams = [], destinationURL = "/
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" href="/favicon.ico" /> <link rel="icon" href="/favicon.ico" />
</Head> </Head>
<ExamPage page="exams" destination={destinationURL} user={user} hideSidebar={!!assignment || !!state.assignment} /> <ExamPage page="exams" destination={destinationURL} user={user} hideSidebar={!!assignment || !!storeAssignment} />
</> </>
); );
} }
//Page.whyDidYouRender = true;
export default Page;

View File

@@ -1,34 +1,34 @@
/* eslint-disable @next/next/no-img-element */ /* eslint-disable @next/next/no-img-element */
import {withIronSessionSsr} from "iron-session/next"; import { withIronSessionSsr } from "iron-session/next";
import {sessionOptions} from "@/lib/session"; import { sessionOptions } from "@/lib/session";
import {shouldRedirectHome} from "@/utils/navigation.disabled"; import { shouldRedirectHome } from "@/utils/navigation.disabled";
import ExamPage from "./(exam)/ExamPage"; import ExamPage from "./(exam)/ExamPage";
import Head from "next/head"; import Head from "next/head";
import {User} from "@/interfaces/user"; import { User } from "@/interfaces/user";
import { filterBy, findBy, redirect, serialize } from "@/utils"; import { filterBy, redirect, serialize } from "@/utils";
import { requestUser } from "@/utils/api"; import { requestUser } from "@/utils/api";
import { getAssignment, getAssignments, getAssignmentsByAssignee } from "@/utils/assignments.be"; import { getAssignment } from "@/utils/assignments.be";
import { Assignment } from "@/interfaces/results"; import { Assignment } from "@/interfaces/results";
import useExamStore from "@/stores/examStore"; import useExamStore from "@/stores/exam";
import { useEffect } from "react"; import { useEffect } from "react";
import { Exam } from "@/interfaces/exam"; import { Exam } from "@/interfaces/exam";
import { getExamsByIds } from "@/utils/exams.be"; import { getExamsByIds } from "@/utils/exams.be";
import { sortByModule } from "@/utils/moduleUtils"; import { sortByModule } from "@/utils/moduleUtils";
import { uniqBy } from "lodash"; import { uniqBy } from "lodash";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { getSessionByAssignment, getSessionsByUser } from "@/utils/sessions.be"; import { getSessionByAssignment } from "@/utils/sessions.be";
import { Session } from "@/hooks/useSessions"; import { Session } from "@/hooks/useSessions";
import moment from "moment"; import moment from "moment";
export const getServerSideProps = withIronSessionSsr(async ({req, res, query}) => { export const getServerSideProps = withIronSessionSsr(async ({ req, res, query }) => {
const user = await requestUser(req, res) const user = await requestUser(req, res)
const destination = Buffer.from(req.url || "/").toString("base64") const destination = Buffer.from(req.url || "/").toString("base64")
if (!user) return redirect(`/login?destination=${destination}`) if (!user) return redirect(`/login?destination=${destination}`)
if (shouldRedirectHome(user)) return redirect("/") if (shouldRedirectHome(user)) return redirect("/")
const {assignment: assignmentID} = query as {assignment?: string} const { assignment: assignmentID } = query as { assignment?: string }
if (assignmentID) { if (assignmentID) {
const assignment = await getAssignment(assignmentID) const assignment = await getAssignment(assignmentID)
@@ -47,12 +47,12 @@ export const getServerSideProps = withIronSessionSsr(async ({req, res, query}) =
return redirect("/exam") return redirect("/exam")
return { return {
props: serialize({user, assignment, exams, session}) props: serialize({ user, assignment, exams, session })
} }
} }
return { return {
props: serialize({user}), props: serialize({ user }),
}; };
}, sessionOptions); }, sessionOptions);
@@ -63,48 +63,36 @@ interface Props {
session?: Session session?: Session
} }
export default function Page({user, assignment, exams = [], session}: Props) { export default function Page({ user, assignment, exams = [], session }: Props) {
const router = useRouter() const router = useRouter()
const state = useExamStore((state) => state) const { assignment: storeAssignment, dispatch } = useExamStore()
useEffect(() => { useEffect(() => {
if (assignment && exams.length > 0 && !state.assignment && !session) { if (assignment && exams.length > 0 && !storeAssignment && !session) {
state.setUserSolutions([]); dispatch({
state.setShowSolutions(false); type: "INIT_EXAM", payload: {
state.setAssignment(assignment); exams: exams.sort(sortByModule),
state.setExams(exams.sort(sortByModule)); modules: exams
state.setSelectedModules( .map((x) => x!)
exams .sort(sortByModule)
.map((x) => x!) .map((x) => x!.module),
.sort(sortByModule) assignment
.map((x) => x!.module), }
); })
router.replace(router.asPath) router.replace(router.asPath)
} }
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [assignment, exams, session]) }, [assignment, exams, session])
useEffect(() => { useEffect(() => {
if (assignment && exams.length > 0 && !state.assignment && !!session) { if (assignment && exams.length > 0 && !storeAssignment && !!session) {
state.setShuffles(session.userSolutions.map((x) => ({exerciseID: x.exercise, shuffles: x.shuffleMaps ? x.shuffleMaps : []}))); dispatch({ type: "SET_SESSION", payload: { session } });
state.setSelectedModules(session.selectedModules);
state.setExam(session.exam);
state.setExams(session.exams);
state.setSessionId(session.sessionId);
state.setAssignment(session.assignment);
state.setExerciseIndex(session.exerciseIndex);
state.setPartIndex(session.partIndex);
state.setModuleIndex(session.moduleIndex);
state.setTimeSpent(session.timeSpent);
state.setUserSolutions(session.userSolutions);
state.setShowSolutions(false);
state.setQuestionIndex(session.questionIndex);
router.replace(router.asPath) router.replace(router.asPath)
} }
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [assignment, exams, session]) }, [assignment, exams, session])
return ( return (

View File

@@ -12,7 +12,7 @@ import { InviteWithEntity } from "@/interfaces/invite";
import { Assignment } from "@/interfaces/results"; import { Assignment } from "@/interfaces/results";
import { Stat, User } from "@/interfaces/user"; import { Stat, User } from "@/interfaces/user";
import { sessionOptions } from "@/lib/session"; import { sessionOptions } from "@/lib/session";
import useExamStore from "@/stores/examStore"; import useExamStore from "@/stores/exam";
import { filterBy, findBy, mapBy, redirect, serialize } from "@/utils"; import { filterBy, findBy, mapBy, redirect, serialize } from "@/utils";
import { requestUser } from "@/utils/api"; import { requestUser } from "@/utils/api";
import { activeAssignmentFilter, futureAssignmentFilter } from "@/utils/assignments"; import { activeAssignmentFilter, futureAssignmentFilter } from "@/utils/assignments";
@@ -75,7 +75,9 @@ export default function OfficialExam({ user, entities, assignments, sessions, ex
const [isLoading, setIsLoading] = useState(false) const [isLoading, setIsLoading] = useState(false)
const router = useRouter(); const router = useRouter();
const state = useExamStore((state) => state);
const dispatch = useExamStore((state) => state.dispatch);
const reload = () => { const reload = () => {
setIsLoading(true) setIsLoading(true)
router.replace(router.asPath) router.replace(router.asPath)
@@ -89,31 +91,19 @@ export default function OfficialExam({ user, entities, assignments, sessions, ex
}) })
if (assignmentExams.every((x) => !!x)) { if (assignmentExams.every((x) => !!x)) {
state.setUserSolutions([]); dispatch({
state.setShowSolutions(false); type: "INIT_EXAM", payload: {
state.setExams(assignmentExams.sort(sortByModule)); exams: assignmentExams.sort(sortByModule),
state.setSelectedModules(mapBy(assignmentExams.sort(sortByModule), 'module')); modules: mapBy(assignmentExams.sort(sortByModule), 'module'),
state.setAssignment(assignment); assignment
}
})
router.push(`/exam?assignment=${assignment.id}&destination=${destination}`); router.push(`/exam?assignment=${assignment.id}&destination=${destination}`);
} }
}; };
const loadSession = async (session: Session) => { const loadSession = async (session: Session) => {
state.setShuffles(session.userSolutions.map((x) => ({ exerciseID: x.exercise, shuffles: x.shuffleMaps ? x.shuffleMaps : [] }))); dispatch({type: "SET_SESSION", payload: {session}});
state.setSelectedModules(session.selectedModules);
state.setExam(session.exam);
state.setExams(session.exams);
state.setSessionId(session.sessionId);
state.setAssignment(session.assignment);
state.setExerciseIndex(session.exerciseIndex);
state.setPartIndex(session.partIndex);
state.setModuleIndex(session.moduleIndex);
state.setTimeSpent(session.timeSpent);
state.setUserSolutions(session.userSolutions);
state.setShowSolutions(false);
state.setQuestionIndex(session.questionIndex);
router.push(`/exam?assignment=${session.assignment?.id}&destination=${destination}`); router.push(`/exam?assignment=${session.assignment?.id}&destination=${destination}`);
}; };

View File

@@ -7,7 +7,6 @@ import { useEffect, useMemo, useState } from "react";
import useFilterRecordsByUser from "@/hooks/useFilterRecordsByUser"; import useFilterRecordsByUser from "@/hooks/useFilterRecordsByUser";
import { groupByDate } from "@/utils/stats"; import { groupByDate } from "@/utils/stats";
import moment from "moment"; import moment from "moment";
import useExamStore from "@/stores/examStore";
import { ToastContainer } from "react-toastify"; import { ToastContainer } from "react-toastify";
import Layout from "@/components/High/Layout"; import Layout from "@/components/High/Layout";
import clsx from "clsx"; import clsx from "clsx";
@@ -75,12 +74,6 @@ export default function History({ user, users, assignments, entities }: Props) {
const { data: stats, isLoading: isStatsLoading } = useFilterRecordsByUser<Stat[]>(statsUserId || user?.id); const { data: stats, isLoading: isStatsLoading } = useFilterRecordsByUser<Stat[]>(statsUserId || user?.id);
const { gradingSystem } = useGradingSystem(); const { gradingSystem } = useGradingSystem();
const setExams = useExamStore((state) => state.setExams);
const setShowSolutions = useExamStore((state) => state.setShowSolutions);
const setUserSolutions = useExamStore((state) => state.setUserSolutions);
const setSelectedModules = useExamStore((state) => state.setSelectedModules);
const setInactivity = useExamStore((state) => state.setInactivity);
const setTimeSpent = useExamStore((state) => state.setTimeSpent);
const renderPdfIcon = usePDFDownload("stats"); const renderPdfIcon = usePDFDownload("stats");
const [selectedTrainingExams, setSelectedTrainingExams] = useState<string[]>([]); const [selectedTrainingExams, setSelectedTrainingExams] = useState<string[]>([]);
@@ -174,13 +167,7 @@ export default function History({ user, users, assignments, entities }: Props) {
selectedTrainingExams={selectedTrainingExams} selectedTrainingExams={selectedTrainingExams}
setSelectedTrainingExams={setSelectedTrainingExams} setSelectedTrainingExams={setSelectedTrainingExams}
maxTrainingExams={MAX_TRAINING_EXAMS} maxTrainingExams={MAX_TRAINING_EXAMS}
setExams={setExams}
gradingSystem={gradingSystem?.steps} gradingSystem={gradingSystem?.steps}
setShowSolutions={setShowSolutions}
setUserSolutions={setUserSolutions}
setSelectedModules={setSelectedModules}
setInactivity={setInactivity}
setTimeSpent={setTimeSpent}
renderPdfIcon={renderPdfIcon} renderPdfIcon={renderPdfIcon}
/> />
); );

View File

@@ -11,7 +11,6 @@ import clsx from "clsx";
import Lists from "./(admin)/Lists"; import Lists from "./(admin)/Lists";
import BatchCodeGenerator from "./(admin)/BatchCodeGenerator"; import BatchCodeGenerator from "./(admin)/BatchCodeGenerator";
import { shouldRedirectHome } from "@/utils/navigation.disabled"; import { shouldRedirectHome } from "@/utils/navigation.disabled";
import ExamGenerator from "./(admin)/ExamGenerator";
import BatchCreateUser from "./(admin)/Lists/BatchCreateUser"; import BatchCreateUser from "./(admin)/Lists/BatchCreateUser";
import { checkAccess, getTypesOfUser } from "@/utils/permissions"; import { checkAccess, getTypesOfUser } from "@/utils/permissions";
import usePermissions from "@/hooks/usePermissions"; import usePermissions from "@/hooks/usePermissions";

View File

@@ -20,7 +20,7 @@ import {shouldRedirectHome} from "@/utils/navigation.disabled";
import {sessionOptions} from "@/lib/session"; import {sessionOptions} from "@/lib/session";
import qs from "qs"; import qs from "qs";
import StatsGridItem from "@/components/Medium/StatGridItem"; import StatsGridItem from "@/components/Medium/StatGridItem";
import useExamStore from "@/stores/examStore"; import useExamStore from "@/stores/exam";
import {usePDFDownload} from "@/hooks/usePDFDownload"; import {usePDFDownload} from "@/hooks/usePDFDownload";
import useAssignments from "@/hooks/useAssignments"; import useAssignments from "@/hooks/useAssignments";
import useUsers from "@/hooks/useUsers"; import useUsers from "@/hooks/useUsers";
@@ -29,7 +29,6 @@ import InfiniteCarousel from "@/components/InfiniteCarousel";
import {LuExternalLink} from "react-icons/lu"; import {LuExternalLink} from "react-icons/lu";
import {uniqBy} from "lodash"; import {uniqBy} from "lodash";
import {getExamById} from "@/utils/exams"; import {getExamById} from "@/utils/exams";
import {convertToUserSolutions} from "@/utils/stats";
import {sortByModule} from "@/utils/moduleUtils"; import {sortByModule} from "@/utils/moduleUtils";
import { requestUser } from "@/utils/api"; import { requestUser } from "@/utils/api";
import { redirect, serialize } from "@/utils"; import { redirect, serialize } from "@/utils";
@@ -46,15 +45,10 @@ export const getServerSideProps = withIronSessionSsr(async ({req, res}) => {
}, sessionOptions); }, sessionOptions);
const TrainingContent: React.FC<{user: User}> = ({user}) => { const TrainingContent: React.FC<{user: User}> = ({user}) => {
// Record stuff
const setExams = useExamStore((state) => state.setExams);
const setShowSolutions = useExamStore((state) => state.setShowSolutions);
const setUserSolutions = useExamStore((state) => state.setUserSolutions);
const setSelectedModules = useExamStore((state) => state.setSelectedModules);
const setInactivity = useExamStore((state) => state.setInactivity);
const setTimeSpent = useExamStore((state) => state.setTimeSpent);
const renderPdfIcon = usePDFDownload("stats"); const renderPdfIcon = usePDFDownload("stats");
const dispatch = useExamStore((s) => s.dispatch);
const [trainingContent, setTrainingContent] = useState<ITrainingContent | null>(null); const [trainingContent, setTrainingContent] = useState<ITrainingContent | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [trainingTips, setTrainingTips] = useState<ITrainingTip[]>([]); const [trainingTips, setTrainingTips] = useState<ITrainingTip[]>([]);
@@ -125,17 +119,18 @@ const TrainingContent: React.FC<{user: User}> = ({user}) => {
Promise.all(examPromises).then((exams) => { Promise.all(examPromises).then((exams) => {
if (exams.every((x) => !!x)) { if (exams.every((x) => !!x)) {
if (!!timeSpent) setTimeSpent(timeSpent); dispatch({
if (!!inactivity) setInactivity(inactivity); type: 'INIT_SOLUTIONS', payload: {
setUserSolutions(convertToUserSolutions(stats)); exams: exams.map((x) => x!).sort(sortByModule),
setShowSolutions(true); modules: exams
setExams(exams.map((x) => x!).sort(sortByModule)); .map((x) => x!)
setSelectedModules( .sort(sortByModule)
exams .map((x) => x!.module),
.map((x) => x!) stats,
.sort(sortByModule) timeSpent,
.map((x) => x!.module), inactivity
); }
});
router.push("/exam"); router.push("/exam");
} }
}); });
@@ -185,12 +180,6 @@ const TrainingContent: React.FC<{user: User}> = ({user}) => {
user={user} user={user}
assignments={assignments} assignments={assignments}
users={users} users={users}
setExams={setExams}
setShowSolutions={setShowSolutions}
setUserSolutions={setUserSolutions}
setSelectedModules={setSelectedModules}
setInactivity={setInactivity}
setTimeSpent={setTimeSpent}
renderPdfIcon={renderPdfIcon} renderPdfIcon={renderPdfIcon}
/> />
))} ))}

180
src/stores/exam/index.ts Normal file
View 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;

View 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 {};
}
};

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