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

View File

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

View File

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

View File

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

View File

@@ -24,6 +24,12 @@ const WriteBlanksFill: React.FC<{ exercise: WriteBlanksExercise; sectionId: numb
const [selectedBlankId, setSelectedBlankId] = useState<string | null>(null);
const [editing, setEditing] = useState(false);
const updateLocal = (exercise: WriteBlanksExercise) => {
setLocal(exercise);
setEditingAlert(true, setAlerts);
setEditing(true);
};
const [blanksState, blanksDispatcher] = useReducer(blanksReducer, {
text: exercise.text || "",
blanks: [],
@@ -79,7 +85,7 @@ const WriteBlanksFill: React.FC<{ exercise: WriteBlanksExercise; sectionId: numb
newState.exercises = newState.exercises.map((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 } });
}
});
@@ -143,7 +149,7 @@ const WriteBlanksFill: React.FC<{ exercise: WriteBlanksExercise; sectionId: numb
...prev,
solutions: prev.solutions.filter(s => s.id !== blankId.toString())
}));
blanksDispatcher({ type: "REMOVE_BLANK", payload: blankId });
};
@@ -175,6 +181,8 @@ const WriteBlanksFill: React.FC<{ exercise: WriteBlanksExercise; sectionId: numb
onPractice={handlePractice}
setEditing={setEditing}
isEvaluationEnabled={!local.isPractice}
prompt={local.prompt}
updatePrompt={(prompt: string) => updateLocal({ ...local, prompt })}
>
{!blanksState.textMode && (
<Card>

View File

@@ -19,6 +19,7 @@ import clsx from "clsx";
import { Card, CardContent } from "@/components/ui/card";
import { Blank, DropZone } from "./DragNDrop";
import { getTextSegments, BlankState, BlanksState, BlanksAction, BlankToken } from "./BlanksReducer";
import PromptEdit from "../Shared/PromptEdit";
interface Props {
@@ -30,6 +31,8 @@ interface Props {
editing: boolean;
showBlankBank: boolean;
alerts: AlertItem[];
prompt: string;
updatePrompt: (prompt: string) => void;
setEditing: React.Dispatch<React.SetStateAction<boolean>>;
blanksDispatcher: React.Dispatch<BlanksAction>
onBlankSelect?: (blankId: number | null) => void;
@@ -42,25 +45,27 @@ interface Props {
children: ReactNode;
}
const BlanksEditor: React.FC<Props> = ({
const BlanksEditor: React.FC<Props> = ({
title = "Fill Blanks",
initialText,
description,
state,
editing,
module,
children,
children,
showBlankBank = true,
alerts,
blanksDispatcher,
blanksDispatcher,
onBlankSelect,
onBlankRemove,
onSave,
onDiscard,
onSave,
onDiscard,
onDelete,
onPractice,
isEvaluationEnabled,
setEditing
setEditing,
prompt,
updatePrompt
}) => {
useEffect(() => {
@@ -105,21 +110,21 @@ const BlanksEditor: React.FC<Props> = ({
const handleTextChange = useCallback(
(newText: string) => {
const processedText = newText.replace(/\[(\d+)\]/g, "{{$1}}");
const existingBlankIds = getTextSegments(state.text)
.filter(token => token.type === 'blank')
.map(token => (token as BlankToken).id);
const newBlankIds = getTextSegments(processedText)
.filter(token => token.type === 'blank')
.map(token => (token as BlankToken).id);
const removedBlankIds = existingBlankIds.filter(id => !newBlankIds.includes(id));
removedBlankIds.forEach(id => {
onBlankRemove(id);
});
blanksDispatcher({ type: "SET_TEXT", payload: processedText });
},
[blanksDispatcher, state.text, onBlankRemove]
@@ -171,10 +176,11 @@ const BlanksEditor: React.FC<Props> = ({
isEvaluationEnabled={isEvaluationEnabled}
/>
{alerts.length > 0 && <Alert alerts={alerts} />}
<PromptEdit value={prompt} onChange={(text: string) => updatePrompt(text)} />
<Card>
<CardContent className="p-4 text-white font-semibold flex gap-2">
<button
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`}
>
Add Blank
@@ -203,7 +209,7 @@ const BlanksEditor: React.FC<Props> = ({
{state.textMode ? (
<AutoExpandingTextArea
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"
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 { MdAdd } from "react-icons/md";
import Alert, { AlertItem } from "../../Shared/Alert";
import PromptEdit from "../../Shared/PromptEdit";
const UnderlineMultipleChoice: React.FC<{exercise: MultipleChoiceExercise, sectionId: number}> = ({
@@ -125,7 +126,7 @@ const UnderlineMultipleChoice: React.FC<{exercise: MultipleChoiceExercise, secti
isEvaluationEnabled={!local.isPractice}
/>
{alerts.length > 0 && <Alert className="mb-6" alerts={alerts} />}
<PromptEdit value={local.prompt} onChange={(prompt: string) => updateLocal({...local, prompt})} />
<div className="space-y-4">
<QuestionsList
ids={local.questions.map(q => q.id)}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

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

View File

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

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 {User} from "@/interfaces/user";
import useExamStore from "@/stores/examStore";
import useExamStore from "@/stores/exam";
import axios from "axios";
import {useState} from "react";
import {toast} from "react-toastify";

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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