Finished implementing a Solutions version for each exercise

This commit is contained in:
Tiago Ribeiro
2023-04-11 21:35:44 +01:00
parent 45a5cb0f5c
commit 49c515b02a
20 changed files with 610 additions and 333 deletions

View File

@@ -15,8 +15,6 @@ interface WordsPopoutProps {
onAnswer: (answer: string) => void; onAnswer: (answer: string) => void;
} }
type UserSolution = {id: string; solution: string};
function WordsPopout({words, isOpen, onCancel, onAnswer}: WordsPopoutProps) { function WordsPopout({words, isOpen, onCancel, onAnswer}: WordsPopoutProps) {
return ( return (
<Transition appear show={isOpen} as={Fragment}> <Transition appear show={isOpen} as={Fragment}>
@@ -72,10 +70,17 @@ function WordsPopout({words, isOpen, onCancel, onAnswer}: WordsPopoutProps) {
); );
} }
export default function FillBlanks({allowRepetition, prompt, solutions, text, words, onNext, onBack}: FillBlanksExercise & CommonProps) { export default function FillBlanks({id, allowRepetition, prompt, solutions, text, words, onNext, onBack}: FillBlanksExercise & CommonProps) {
const [userSolutions, setUserSolutions] = useState<{id: string; solution: string}[]>([]); const [userSolutions, setUserSolutions] = useState<{id: string; solution: string}[]>([]);
const [currentBlankId, setCurrentBlankId] = useState<string>(); const [currentBlankId, setCurrentBlankId] = useState<string>();
const calculateScore = () => {
const total = text.match(/({{\d+}})/g)?.length || 0;
const correct = userSolutions.filter((x) => solutions.find((y) => x.id === y.id)?.solution === x.solution.toLowerCase() || false).length;
return {total, correct};
};
const renderLines = (line: string) => { const renderLines = (line: string) => {
return ( return (
<span> <span>
@@ -123,7 +128,9 @@ export default function FillBlanks({allowRepetition, prompt, solutions, text, wo
</div> </div>
Back Back
</button> </button>
<button className={clsx("btn btn-wide gap-4 relative text-white", infoButtonStyle)} onClick={onNext}> <button
className={clsx("btn btn-wide gap-4 relative text-white", infoButtonStyle)}
onClick={() => onNext({id, solutions: userSolutions, score: calculateScore()})}>
Next Next
<div className="absolute right-4"> <div className="absolute right-4">
<Icon path={mdiArrowRight} color="white" size={1} /> <Icon path={mdiArrowRight} color="white" size={1} />
@@ -133,59 +140,3 @@ export default function FillBlanks({allowRepetition, prompt, solutions, text, wo
</> </>
); );
} }
export function FillBlanksSolutions({
allowRepetition,
prompt,
solutions,
text,
words,
userSolutions,
}: FillBlanksExercise & {userSolutions: UserSolution[]}) {
const renderLines = (line: string) => {
return (
<span>
{reactStringReplace(line, /({{\d}})/g, (match) => {
const id = match.replaceAll(/[\{\}]/g, "");
const userSolution = userSolutions.find((x) => x.id === id);
const solution = solutions.find((x) => x.id === id)!;
if (!userSolution) {
return (
<>
<button className={clsx("border-2 rounded-xl px-4 text-gray-500 border-gray-500")}>{solution.solution}</button>
</>
);
}
if (userSolution.solution === solution.solution) {
return <button className={clsx("border-2 rounded-xl px-4 text-green-500 border-green-500")}>{solution.solution}</button>;
}
if (userSolution.solution !== solution.solution) {
return (
<>
<button className={clsx("border-2 rounded-xl px-4 text-red-500 border-red-500 mr-1")}>{userSolution.solution}</button>
<button className={clsx("border-2 rounded-xl px-4 text-blue-400 border-blue-400")}>{solution.solution}</button>
</>
);
}
})}
</span>
);
};
return (
<div className="flex flex-col">
<span className="text-lg font-medium text-center px-48">{prompt}</span>
<span>
{text.split("\n").map((line) => (
<>
{renderLines(line)}
<br />
</>
))}
</span>
</div>
);
}

View File

@@ -7,12 +7,17 @@ import {useState} from "react";
import LineTo from "react-lineto"; import LineTo from "react-lineto";
import {CommonProps} from "."; import {CommonProps} from ".";
const AVAILABLE_COLORS = ["#63526a", "#f7651d", "#278f04", "#ef4487", "#ca68c0", "#f5fe9b", "#b3ab01", "#af963a", "#9a85f1", "#1b1750"]; export default function MatchSentences({id, options, prompt, sentences, onNext, onBack}: MatchSentencesExercise & CommonProps) {
export default function MatchSentences({allowRepetition, options, prompt, sentences, onNext, onBack}: MatchSentencesExercise & CommonProps) {
const [selectedQuestion, setSelectedQuestion] = useState<string>(); const [selectedQuestion, setSelectedQuestion] = useState<string>();
const [userSolutions, setUserSolutions] = useState<{question: string; option: string}[]>([]); const [userSolutions, setUserSolutions] = useState<{question: string; option: string}[]>([]);
const calculateScore = () => {
const total = sentences.length;
const correct = userSolutions.filter((x) => sentences.find((y) => y.id === x.question)?.solution === x.option || false).length;
return {total, correct};
};
const selectOption = (option: string) => { const selectOption = (option: string) => {
if (!selectedQuestion) return; if (!selectedQuestion) return;
setUserSolutions((prev) => [...prev.filter((x) => x.question !== selectedQuestion), {question: selectedQuestion, option}]); setUserSolutions((prev) => [...prev.filter((x) => x.question !== selectedQuestion), {question: selectedQuestion, option}]);
@@ -87,7 +92,9 @@ export default function MatchSentences({allowRepetition, options, prompt, senten
</div> </div>
Back Back
</button> </button>
<button className={clsx("btn btn-wide gap-4 relative text-white", infoButtonStyle)} onClick={onNext}> <button
className={clsx("btn btn-wide gap-4 relative text-white", infoButtonStyle)}
onClick={() => onNext({id, solutions: userSolutions, score: calculateScore()})}>
Next Next
<div className="absolute right-4"> <div className="absolute right-4">
<Icon path={mdiArrowRight} color="white" size={1} /> <Icon path={mdiArrowRight} color="white" size={1} />
@@ -97,106 +104,3 @@ export default function MatchSentences({allowRepetition, options, prompt, senten
</> </>
); );
} }
export function MatchSentencesSolutions({allowRepetition, options, prompt, sentences, userSolutions}: MatchSentencesExercise) {
const getSentenceColor = (id: string) => {
return sentences.find((x) => x.id === id)?.color || "";
};
return (
<div className="flex flex-col items-center gap-8">
<span className="text-lg font-medium text-center px-48">{prompt}</span>
<div className="grid grid-cols-2 gap-16 place-items-center">
<div className="flex flex-col gap-1">
{sentences.map(({sentence, id, color}) => (
<div key={`question_${id}`} className="flex items-center justify-end gap-2 cursor-pointer">
<span>
<span className="font-semibold">{id}.</span> {sentence}{" "}
</span>
<div
style={{borderColor: color, backgroundColor: "transparent"}}
className={clsx("border-2 border-blue-500 w-4 h-4 rounded-full", id)}
/>
</div>
))}
</div>
<div className="flex flex-col gap-1">
{options.map(({sentence, id}) => (
<div key={`answer_${id}`} className={clsx("flex items-center justify-start gap-2 cursor-pointer")}>
<div
style={
userSolutions.find((x) => x.option === id)
? {
border: `2px solid ${getSentenceColor(userSolutions.find((x) => x.option === id)!.question)}`,
}
: {}
}
className={clsx("border-2 border-green-500 bg-transparent w-4 h-4 rounded-full", id)}
/>
<span>
<span className="font-semibold">{id}.</span> {sentence}{" "}
</span>
</div>
))}
</div>
{userSolutions.map((solution, index) => (
<div key={`solution_${index}`} className="absolute">
<LineTo
className="rounded-full"
from={solution.question}
to={solution.option}
borderColor={sentences.find((x) => x.id === solution.question)!.color}
borderWidth={5}
/>
</div>
))}
</div>
<div className="grid grid-cols-2 gap-16 place-items-center">
<div className="flex flex-col gap-1">
{sentences.map(({sentence, id, color}) => (
<div key={`question_${id}`} className="flex items-center justify-end gap-2 cursor-pointer">
<span>
<span className="font-semibold">{id}.</span> {sentence}{" "}
</span>
<div
style={{borderColor: color, backgroundColor: "transparent"}}
className={clsx("border-2 border-blue-500 w-4 h-4 rounded-full", id)}
/>
</div>
))}
</div>
<div className="flex flex-col gap-1">
{options.map(({sentence, id}) => (
<div key={`answer_${id}`} className={clsx("flex items-center justify-start gap-2 cursor-pointer")}>
<div
style={
userSolutions.find((x) => x.option === id)
? {
border: `2px solid ${getSentenceColor(userSolutions.find((x) => x.option === id)!.question)}`,
}
: {}
}
className={clsx("border-2 border-green-500 bg-transparent w-4 h-4 rounded-full", id)}
/>
<span>
<span className="font-semibold">{id}.</span> {sentence}{" "}
</span>
</div>
))}
</div>
{sentences.map(({id, solution}, index) => (
<div key={`solution_${index}`} className="absolute">
<LineTo
className="rounded-full"
from={id}
to={solution}
borderColor={sentences.find((x) => x.id === id)!.color}
borderWidth={5}
/>
</div>
))}
</div>
</div>
);
}

View File

@@ -83,7 +83,7 @@ function Question({
); );
} }
export default function MultipleChoice({prompt, questions, onNext, onBack}: MultipleChoiceExercise & CommonProps) { export default function MultipleChoice({id, prompt, questions, onNext, onBack}: MultipleChoiceExercise & CommonProps) {
const [userSolutions, setUserSolutions] = useState<{question: string; option: string}[]>([]); const [userSolutions, setUserSolutions] = useState<{question: string; option: string}[]>([]);
const [questionIndex, setQuestionIndex] = useState(0); const [questionIndex, setQuestionIndex] = useState(0);
@@ -92,9 +92,16 @@ export default function MultipleChoice({prompt, questions, onNext, onBack}: Mult
setUserSolutions((prev) => [...prev.filter((x) => x.question !== question.id), {option, question: question.id}]); setUserSolutions((prev) => [...prev.filter((x) => x.question !== question.id), {option, question: question.id}]);
}; };
const calculateScore = () => {
const total = questions.length;
const correct = userSolutions.filter((x) => questions.find((y) => y.id === x.question)?.solution === x.option || false).length;
return {total, correct};
};
const next = () => { const next = () => {
if (questionIndex === questions.length - 1) { if (questionIndex === questions.length - 1) {
onNext(); onNext({id, solutions: userSolutions, score: calculateScore()});
} else { } else {
setQuestionIndex((prev) => prev + 1); setQuestionIndex((prev) => prev + 1);
} }
@@ -137,52 +144,3 @@ export default function MultipleChoice({prompt, questions, onNext, onBack}: Mult
</> </>
); );
} }
export function MultipleChoiceSolutions({prompt, questions, userSolutions, onNext, onBack}: MultipleChoiceExercise & CommonProps) {
const [questionIndex, setQuestionIndex] = useState(0);
const next = () => {
if (questionIndex === questions.length - 1) {
onNext();
} else {
setQuestionIndex((prev) => prev + 1);
}
};
const back = () => {
if (questionIndex === 0) {
onBack();
} else {
setQuestionIndex((prev) => prev - 1);
}
};
return (
<>
<div className="flex flex-col items-center gap-8">
<span className="text-lg font-medium text-center px-48">{prompt}</span>
{questionIndex < questions.length && (
<Question
{...questions[questionIndex]}
userSolution={userSolutions.find((x) => questions[questionIndex].id === x.question)?.option}
showSolution
/>
)}
</div>
<div className="self-end flex gap-8">
<button className={clsx("btn btn-wide gap-4 relative text-white", errorButtonStyle)} onClick={back}>
<div className="absolute left-4">
<Icon path={mdiArrowLeft} color="white" size={1} />
</div>
Back
</button>
<button className={clsx("btn btn-wide gap-4 relative text-white", infoButtonStyle)} onClick={next}>
Next
<div className="absolute right-4">
<Icon path={mdiArrowRight} color="white" size={1} />
</div>
</button>
</div>
</>
);
}

View File

@@ -11,51 +11,53 @@ import {toast} from "react-toastify";
function Blank({ function Blank({
id, id,
maxWords, maxWords,
solutions, showSolutions = false,
userSolution,
disabled = false,
setUserSolution, setUserSolution,
}: { }: {
id: string; id: string;
solutions?: string[]; solutions?: string[];
userSolution?: string; userSolution?: string;
maxWords: number; maxWords: number;
disabled?: boolean; showSolutions?: boolean;
setUserSolution?: (solution: string) => void; setUserSolution?: (solution: string) => void;
}) { }) {
const [userInput, setUserInput] = useState(userSolution || ""); const [userInput, setUserInput] = useState("");
useEffect(() => { useEffect(() => {
const words = userInput.split(" ").filter((x) => x !== ""); const words = userInput.split(" ").filter((x) => x !== "");
if (words.length >= maxWords) { if (words.length >= maxWords) {
toast.warning(`You have reached your word limit of ${maxWords} words!`, {toastId: "word-limit"}); toast.warning(`You have reached your word limit of ${maxWords} words!`, {toastId: "word-limit"});
setUserInput(words.join(" ").trim()); setUserInput(words.join(" ").trim());
if (setUserSolution) setUserSolution(words.join(" ").trim());
} }
}, [maxWords, userInput, setUserSolution]); }, [maxWords, userInput]);
const getSolutionStyling = () => {
if (solutions && userSolution) {
if (solutions.map((x) => x.trim().toLowerCase()).includes(userSolution.trim().toLowerCase())) return "text-green-500 border-green-500";
}
return "text-red-500 border-red-500";
};
return ( return (
<input <input
className={clsx("input border rounded-xl px-2 py-1 bg-white", !solutions && "text-blue-400 border-blue-400", getSolutionStyling())} className={clsx("input border rounded-xl px-2 py-1 bg-white text-blue-400 border-blue-400")}
placeholder={id} placeholder={id}
onChange={(e) => setUserInput(e.target.value)} onChange={(e) => setUserInput(e.target.value)}
value={!solutions ? userInput : solutions.join(" / ")} value={userInput}
contentEditable={disabled} contentEditable={showSolutions}
/> />
); );
} }
export default function WriteBlanks({prompt, maxWords, solutions, text, onNext, onBack}: WriteBlanksExercise & CommonProps) { export default function WriteBlanks({id, prompt, maxWords, solutions, text, onNext, onBack}: WriteBlanksExercise & CommonProps) {
const [userSolutions, setUserSolutions] = useState<{id: string; solution: string}[]>([]); const [userSolutions, setUserSolutions] = useState<{id: string; solution: string}[]>([]);
const calculateScore = () => {
const total = text.match(/({{\d+}})/g)?.length || 0;
const correct = userSolutions.filter(
(x) =>
solutions
.find((y) => x.id === y.id)
?.solution.map((y) => y.toLowerCase())
.includes(x.solution.toLowerCase()) || false,
).length;
return {total, correct};
};
const renderLines = (line: string) => { const renderLines = (line: string) => {
return ( return (
<span> <span>
@@ -63,6 +65,7 @@ export default function WriteBlanks({prompt, maxWords, solutions, text, onNext,
const id = match.replaceAll(/[\{\}]/g, ""); const id = match.replaceAll(/[\{\}]/g, "");
const userSolution = userSolutions.find((x) => x.id === id); const userSolution = userSolutions.find((x) => x.id === id);
const setUserSolution = (solution: string) => { const setUserSolution = (solution: string) => {
console.log({solution});
setUserSolutions((prev) => [...prev.filter((x) => x.id !== id), {id, solution}]); setUserSolutions((prev) => [...prev.filter((x) => x.id !== id), {id, solution}]);
}; };
@@ -93,54 +96,9 @@ export default function WriteBlanks({prompt, maxWords, solutions, text, onNext,
</div> </div>
Back Back
</button> </button>
<button className={clsx("btn btn-wide gap-4 relative text-white", infoButtonStyle)} onClick={onNext}> <button
Next className={clsx("btn btn-wide gap-4 relative text-white", infoButtonStyle)}
<div className="absolute right-4"> onClick={() => onNext({id, solutions: userSolutions, score: calculateScore()})}>
<Icon path={mdiArrowRight} color="white" size={1} />
</div>
</button>
</div>
</>
);
}
export function WriteBlanksSolutions({prompt, maxWords, solutions, userSolutions, text, onNext, onBack}: WriteBlanksExercise & CommonProps) {
const renderLines = (line: string) => {
return (
<span>
{reactStringReplace(line, /({{\d+}})/g, (match) => {
const id = match.replaceAll(/[\{\}]/g, "");
const userSolution = userSolutions.find((x) => x.id === id);
const solution = solutions.find((x) => x.id === id)!;
return <Blank userSolution={userSolution?.solution} maxWords={maxWords} id={id} solutions={solution.solution} disabled />;
})}
</span>
);
};
return (
<>
<div className="flex flex-col">
<span className="text-lg font-medium text-center px-48">{prompt}</span>
<span>
{text.split("\n").map((line) => (
<>
{renderLines(line)}
<br />
</>
))}
</span>
</div>
<div className="self-end flex gap-8">
<button className={clsx("btn btn-wide gap-4 relative text-white", errorButtonStyle)} onClick={onBack}>
<div className="absolute left-4">
<Icon path={mdiArrowLeft} color="white" size={1} />
</div>
Back
</button>
<button className={clsx("btn btn-wide gap-4 relative text-white", infoButtonStyle)} onClick={onNext}>
Next Next
<div className="absolute right-4"> <div className="absolute right-4">
<Icon path={mdiArrowRight} color="white" size={1} /> <Icon path={mdiArrowRight} color="white" size={1} />

View File

@@ -1,4 +1,4 @@
import {Exercise, FillBlanksExercise, MatchSentencesExercise, MultipleChoiceExercise, WriteBlanksExercise} from "@/interfaces/exam"; import {Exercise, FillBlanksExercise, MatchSentencesExercise, MultipleChoiceExercise, UserSolution, WriteBlanksExercise} from "@/interfaces/exam";
import dynamic from "next/dynamic"; import dynamic from "next/dynamic";
import FillBlanks from "./FillBlanks"; import FillBlanks from "./FillBlanks";
import MultipleChoice from "./MultipleChoice"; import MultipleChoice from "./MultipleChoice";
@@ -7,11 +7,11 @@ import WriteBlanks from "./WriteBlanks";
const MatchSentences = dynamic(() => import("@/components/Exercises/MatchSentences"), {ssr: false}); const MatchSentences = dynamic(() => import("@/components/Exercises/MatchSentences"), {ssr: false});
export interface CommonProps { export interface CommonProps {
onNext: () => void; onNext: (userSolutions: UserSolution) => void;
onBack: () => void; onBack: () => void;
} }
export const renderExercise = (exercise: Exercise, onNext: () => void, onBack: () => void) => { export const renderExercise = (exercise: Exercise, onNext: (userSolutions: UserSolution) => void, onBack: () => void) => {
switch (exercise.type) { switch (exercise.type) {
case "fillBlanks": case "fillBlanks":
return <FillBlanks {...(exercise as FillBlanksExercise)} onNext={onNext} onBack={onBack} />; return <FillBlanks {...(exercise as FillBlanksExercise)} onNext={onNext} onBack={onBack} />;

View File

@@ -0,0 +1,73 @@
import {errorButtonStyle, infoButtonStyle} from "@/constants/buttonStyles";
import {FillBlanksExercise} from "@/interfaces/exam";
import {mdiArrowLeft, mdiArrowRight} from "@mdi/js";
import Icon from "@mdi/react";
import clsx from "clsx";
import reactStringReplace from "react-string-replace";
import {CommonProps} from ".";
export default function FillBlanksSolutions({prompt, solutions, text, userSolutions, onNext, onBack}: FillBlanksExercise & CommonProps) {
const renderLines = (line: string) => {
return (
<span>
{reactStringReplace(line, /({{\d}})/g, (match) => {
const id = match.replaceAll(/[\{\}]/g, "");
const userSolution = userSolutions.find((x) => x.id === id);
const solution = solutions.find((x) => x.id === id)!;
if (!userSolution) {
return (
<>
<button className={clsx("border-2 rounded-xl px-4 text-gray-500 border-gray-500")}>{solution.solution}</button>
</>
);
}
if (userSolution.solution === solution.solution) {
return <button className={clsx("border-2 rounded-xl px-4 text-green-500 border-green-500")}>{solution.solution}</button>;
}
if (userSolution.solution !== solution.solution) {
return (
<>
<button className={clsx("border-2 rounded-xl px-4 text-red-500 border-red-500 mr-1")}>{userSolution.solution}</button>
<button className={clsx("border-2 rounded-xl px-4 text-blue-400 border-blue-400")}>{solution.solution}</button>
</>
);
}
})}
</span>
);
};
return (
<>
<div className="flex flex-col">
<span className="text-lg font-medium text-center px-48">{prompt}</span>
<span>
{text.split("\n").map((line) => (
<>
{renderLines(line)}
<br />
</>
))}
</span>
</div>
<div className="self-end flex gap-8">
<button className={clsx("btn btn-wide gap-4 relative text-white", errorButtonStyle)} onClick={onBack}>
<div className="absolute left-4">
<Icon path={mdiArrowLeft} color="white" size={1} />
</div>
Back
</button>
<button className={clsx("btn btn-wide gap-4 relative text-white", infoButtonStyle)} onClick={() => onNext()}>
Next
<div className="absolute right-4">
<Icon path={mdiArrowRight} color="white" size={1} />
</div>
</button>
</div>
</>
);
}

View File

@@ -0,0 +1,73 @@
import {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";
export default function MatchSentencesSolutions({options, prompt, sentences, userSolutions, onNext, onBack}: MatchSentencesExercise & CommonProps) {
return (
<>
<div className="flex flex-col items-center gap-8">
<span className="text-lg font-medium text-center px-48">{prompt}</span>
<div className="grid grid-cols-2 gap-16 place-items-center">
<div className="flex flex-col gap-1">
{sentences.map(({sentence, id, color, solution}) => (
<div
key={`question_${id}`}
className={clsx(
"flex items-center justify-end gap-2 cursor-pointer",
userSolutions.find((x) => x.question === id)?.option === solution ? "text-green-500" : "text-red-500",
)}>
<span>
<span className="font-semibold">{id}.</span> {sentence}{" "}
</span>
<div style={{borderColor: color}} className={clsx("border-2 border-blue-500 w-4 h-4 rounded-full", id)} />
</div>
))}
</div>
<div className="flex flex-col gap-1">
{options.map(({sentence, id}) => (
<div key={`answer_${id}`} className={clsx("flex items-center justify-start gap-2 cursor-pointer")}>
<div
style={
sentences.find((x) => x.solution === id)
? {
border: `2px solid ${sentences.find((x) => x.solution === id)!.color}`,
}
: {}
}
className={clsx("border-2 border-green-500 bg-transparent w-4 h-4 rounded-full", id)}
/>
<span>
<span className="font-semibold">{id}.</span> {sentence}{" "}
</span>
</div>
))}
</div>
{sentences.map((sentence, index) => (
<div key={`solution_${index}`} className="absolute">
<LineTo className="rounded-full" from={sentence.id} to={sentence.solution} borderColor={sentence.color} borderWidth={5} />
</div>
))}
</div>
</div>
<div className="self-end flex gap-8">
<button className={clsx("btn btn-wide gap-4 relative text-white", errorButtonStyle)} onClick={onBack}>
<div className="absolute left-4">
<Icon path={mdiArrowLeft} color="white" size={1} />
</div>
Back
</button>
<button className={clsx("btn btn-wide gap-4 relative text-white", infoButtonStyle)} onClick={() => onNext()}>
Next
<div className="absolute right-4">
<Icon path={mdiArrowRight} color="white" size={1} />
</div>
</button>
</div>
</>
);
}

View File

@@ -0,0 +1,133 @@
/* eslint-disable @next/next/no-img-element */
import {errorButtonStyle, infoButtonStyle} from "@/constants/buttonStyles";
import {MultipleChoiceExercise, MultipleChoiceQuestion} from "@/interfaces/exam";
import {mdiArrowLeft, mdiArrowRight, mdiCheck, mdiClose} from "@mdi/js";
import Icon from "@mdi/react";
import clsx from "clsx";
import {useState} from "react";
import {CommonProps} from ".";
function Question({
variant,
prompt,
solution,
options,
userSolution,
onSelectOption,
showSolution = false,
}: MultipleChoiceQuestion & {userSolution: string | undefined; onSelectOption?: (option: string) => void; showSolution?: boolean}) {
const optionColor = (option: string) => {
if (!showSolution) {
return userSolution === option ? "border-blue-400" : "";
}
if (option === solution) {
return "border-green-500 text-green-500";
}
return userSolution === option ? "border-red-500 text-red-500" : "";
};
const optionBadge = (option: string) => {
if (option === userSolution) {
if (solution === option) {
return (
<div className="badge badge-lg bg-green-500 border-green-500 absolute -top-2 -right-4">
<div className="tooltip" data-tip="You have correctly answered!">
<Icon path={mdiCheck} color="white" size={0.8} />
</div>
</div>
);
}
return (
<div className="badge badge-lg bg-red-500 border-red-500 absolute -top-2 -right-4">
<div className="tooltip" data-tip="You have wrongly answered!">
<Icon path={mdiClose} color="white" size={0.8} />
</div>
</div>
);
}
};
return (
<div className="flex flex-col items-center gap-4">
<span>{prompt}</span>
<div className="grid grid-cols-4 gap-4 place-items-center">
{variant === "image" &&
options.map((option) => (
<div
key={option.id}
onClick={() => (onSelectOption ? onSelectOption(option.id) : null)}
className={clsx(
"flex flex-col items-center border-2 p-4 rounded-xl gap-4 cursor-pointer bg-white relative",
optionColor(option.id),
)}>
{showSolution && optionBadge(option.id)}
<img src={option.src!} alt={`Option ${option.id}`} />
<span>{option.id}</span>
</div>
))}
{variant === "text" &&
options.map((option) => (
<div
key={option.id}
onClick={() => (onSelectOption ? onSelectOption(option.id) : null)}
className={clsx("flex border-2 p-4 rounded-xl gap-2 cursor-pointer bg-white", optionColor(option.id))}>
<span className="font-bold">{option.id}.</span>
<span>{option.text}</span>
</div>
))}
</div>
</div>
);
}
export default function MultipleChoice({prompt, questions, userSolutions, onNext, onBack}: MultipleChoiceExercise & CommonProps) {
const [questionIndex, setQuestionIndex] = useState(0);
const next = () => {
if (questionIndex === questions.length - 1) {
onNext();
} else {
setQuestionIndex((prev) => prev + 1);
}
};
const back = () => {
if (questionIndex === 0) {
onBack();
} else {
setQuestionIndex((prev) => prev - 1);
}
};
return (
<>
<div className="flex flex-col items-center gap-8">
<span className="text-lg font-medium text-center px-48">{prompt}</span>
{questionIndex < questions.length && (
<Question
{...questions[questionIndex]}
userSolution={userSolutions.find((x) => questions[questionIndex].id === x.question)?.option}
showSolution
/>
)}
</div>
<div className="self-end flex gap-8">
<button className={clsx("btn btn-wide gap-4 relative text-white", errorButtonStyle)} onClick={back}>
<div className="absolute left-4">
<Icon path={mdiArrowLeft} color="white" size={1} />
</div>
Back
</button>
<button className={clsx("btn btn-wide gap-4 relative text-white", infoButtonStyle)} onClick={next}>
Next
<div className="absolute right-4">
<Icon path={mdiArrowRight} color="white" size={1} />
</div>
</button>
</div>
</>
);
}

View File

@@ -0,0 +1,110 @@
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 {useEffect, useState} from "react";
import reactStringReplace from "react-string-replace";
import {CommonProps} from ".";
import {toast} from "react-toastify";
function Blank({
id,
maxWords,
solutions,
userSolution,
disabled = false,
setUserSolution,
}: {
id: string;
solutions?: string[];
userSolution?: string;
maxWords: number;
disabled?: boolean;
setUserSolution?: (solution: string) => void;
}) {
const [userInput, setUserInput] = useState(userSolution || "");
useEffect(() => {
const words = userInput.split(" ").filter((x) => x !== "");
if (words.length >= maxWords) {
toast.warning(`You have reached your word limit of ${maxWords} words!`, {toastId: "word-limit"});
setUserInput(words.join(" ").trim());
if (setUserSolution) setUserSolution(words.join(" ").trim());
}
}, [maxWords, userInput, setUserSolution]);
const getSolutionStyling = () => {
if (solutions && userSolution) {
if (solutions.map((x) => x.trim().toLowerCase()).includes(userSolution.trim().toLowerCase())) return "text-green-500 border-green-500";
}
return "text-red-500 border-red-500";
};
return (
<input
className={clsx("input border rounded-xl px-2 py-1 bg-white", !solutions && "text-blue-400 border-blue-400", getSolutionStyling())}
placeholder={id}
onChange={(e) => setUserInput(e.target.value)}
value={!solutions ? userInput : solutions.join(" / ")}
contentEditable={disabled}
/>
);
}
export default function WriteBlanksSolutions({
id,
prompt,
maxWords,
solutions,
userSolutions,
text,
onNext,
onBack,
}: WriteBlanksExercise & CommonProps) {
const renderLines = (line: string) => {
return (
<span>
{reactStringReplace(line, /({{\d+}})/g, (match) => {
const id = match.replaceAll(/[\{\}]/g, "");
const userSolution = userSolutions.find((x) => x.id === id);
const solution = solutions.find((x) => x.id === id)!;
return <Blank userSolution={userSolution?.solution} maxWords={maxWords} id={id} solutions={solution.solution} disabled />;
})}
</span>
);
};
return (
<>
<div className="flex flex-col">
<span className="text-lg font-medium text-center px-48">{prompt}</span>
<span>
{text.split("\n").map((line) => (
<>
{renderLines(line)}
<br />
</>
))}
</span>
</div>
<div className="self-end flex gap-8">
<button className={clsx("btn btn-wide gap-4 relative text-white", errorButtonStyle)} onClick={onBack}>
<div className="absolute left-4">
<Icon path={mdiArrowLeft} color="white" size={1} />
</div>
Back
</button>
<button className={clsx("btn btn-wide gap-4 relative text-white", infoButtonStyle)} onClick={() => onNext()}>
Next
<div className="absolute right-4">
<Icon path={mdiArrowRight} color="white" size={1} />
</div>
</button>
</div>
</>
);
}

View File

@@ -0,0 +1,25 @@
import {Exercise, FillBlanksExercise, MatchSentencesExercise, MultipleChoiceExercise, WriteBlanksExercise} from "@/interfaces/exam";
import dynamic from "next/dynamic";
import FillBlanks from "./FillBlanks";
import MultipleChoice from "./MultipleChoice";
import WriteBlanks from "./WriteBlanks";
const MatchSentences = dynamic(() => import("@/components/Solutions/MatchSentences"), {ssr: false});
export interface CommonProps {
onNext: () => void;
onBack: () => void;
}
export const renderSolution = (exercise: Exercise, onNext: () => void, onBack: () => void) => {
switch (exercise.type) {
case "fillBlanks":
return <FillBlanks {...(exercise as FillBlanksExercise)} onNext={onNext} onBack={onBack} />;
case "matchSentences":
return <MatchSentences {...(exercise as MatchSentencesExercise)} onNext={onNext} onBack={onBack} />;
case "multipleChoice":
return <MultipleChoice {...(exercise as MultipleChoiceExercise)} onNext={onNext} onBack={onBack} />;
case "writeBlanks":
return <WriteBlanks {...(exercise as WriteBlanksExercise)} onNext={onNext} onBack={onBack} />;
}
};

View File

@@ -1,16 +1,16 @@
{ {
"audio": { "audio": {
"title": "", "source": "https://www.sndup.net/hqvf/d",
"source": "",
"transcript": "",
"repeatableTimes": 3 "repeatableTimes": 3
}, },
"module": "listening", "module": "listening",
"minTimer": 5, "minTimer": 5,
"id": "1c70c6a2-8d4c-4dfc-849c-f89786fa38dd",
"exercises": [ "exercises": [
{ {
"type": "multipleChoice", "type": "multipleChoice",
"prompt": "Select the appropriate option", "prompt": "Select the appropriate option",
"id": "57f21739-146d-4d1f-aed9-274f351ca27f",
"questions": [ "questions": [
{ {
"id": "1", "id": "1",
@@ -66,6 +66,7 @@
"type": "writeBlanks", "type": "writeBlanks",
"prompt": "Complete the notes below by writing NO MORE THAN THREE WORDS in the spaces provided.", "prompt": "Complete the notes below by writing NO MORE THAN THREE WORDS in the spaces provided.",
"maxWords": 3, "maxWords": 3,
"id": "81215364-b18e-4717-9a33-a6dfc1816960",
"text": "The Government plans to give ${{14}} to assist the farmers. This money was to be spent on improving Sydneys {{15}} but has now been re-allocated. Australia has experienced its worst drought in over fifty years. Farmers say that the money will not help them because it is {{16}}.", "text": "The Government plans to give ${{14}} to assist the farmers. This money was to be spent on improving Sydneys {{15}} but has now been re-allocated. Australia has experienced its worst drought in over fifty years. Farmers say that the money will not help them because it is {{16}}.",
"solutions": [ "solutions": [
{ {

View File

@@ -5,11 +5,13 @@
}, },
"module": "reading", "module": "reading",
"minTimer": 5, "minTimer": 5,
"id": "ffb738a5-265c-4daa-85c4-3681d3f8b48b",
"exercises": [ "exercises": [
{ {
"type": "fillBlanks", "type": "fillBlanks",
"prompt": "Complete the summary below. Click a blank to select the corresponding word for it.\nThere are more words than spaces so you will not use them all. You may use any of the words more than once.", "prompt": "Complete the summary below. Click a blank to select the corresponding word for it.\nThere are more words than spaces so you will not use them all. You may use any of the words more than once.",
"allowRepetition": true, "allowRepetition": true,
"id": "acf930d1-3615-4b7d-b057-9f6e60b30f74",
"solutions": [ "solutions": [
{ {
"id": "1", "id": "1",
@@ -71,6 +73,7 @@
{ {
"type": "matchSentences", "type": "matchSentences",
"prompt": "Look at the following notes that have been made about the matches described in Reading Passage 1. Decide which type of match (A-H) corresponds with each description and write your answers in boxes 9 15 on your answer sheet.", "prompt": "Look at the following notes that have been made about the matches described in Reading Passage 1. Decide which type of match (A-H) corresponds with each description and write your answers in boxes 9 15 on your answer sheet.",
"id": "404437ff-2bc0-44b5-9e32-40ef4eedb023",
"sentences": [ "sentences": [
{ {
"id": "9", "id": "9",

View File

@@ -8,5 +8,7 @@
"limit": 5 "limit": 5
} }
}, },
"id": "908286eb-2c5f-4d43-8806-0e15379143dd",
"exercises": [],
"minTimer": 5 "minTimer": 5
} }

0
src/exams/Finish.tsx Normal file
View File

View File

@@ -1,20 +1,23 @@
import {ListeningExam} from "@/interfaces/exam"; import {ListeningExam, UserSolution} from "@/interfaces/exam";
import {useEffect, useState} from "react"; import {useEffect, useState} from "react";
import Icon from "@mdi/react"; import Icon from "@mdi/react";
import {mdiArrowRight} from "@mdi/js"; import {mdiArrowRight} from "@mdi/js";
import clsx from "clsx"; import clsx from "clsx";
import {infoButtonStyle} from "@/constants/buttonStyles"; import {infoButtonStyle} from "@/constants/buttonStyles";
import {renderExercise} from "@/components/Exercises"; import {renderExercise} from "@/components/Exercises";
import {renderSolution} from "@/components/Solutions";
interface Props { interface Props {
exam: ListeningExam; exam: ListeningExam;
onFinish: () => void; showSolutions?: boolean;
onFinish: (userSolutions: UserSolution[]) => void;
} }
export default function Listening({exam, onFinish}: Props) { export default function Listening({exam, showSolutions = false, onFinish}: Props) {
const [exerciseIndex, setExerciseIndex] = useState(-1); const [exerciseIndex, setExerciseIndex] = useState(-1);
const [timesListened, setTimesListened] = useState(0); const [timesListened, setTimesListened] = useState(0);
const [timer, setTimer] = useState<number>(); const [timer, setTimer] = useState<number>();
const [userSolutions, setUserSolutions] = useState<UserSolution[]>([]);
useEffect(() => { useEffect(() => {
setTimer(exam.minTimer * 60); setTimer(exam.minTimer * 60);
@@ -25,13 +28,21 @@ export default function Listening({exam, onFinish}: Props) {
}; };
}, [exam.minTimer]); }, [exam.minTimer]);
const nextExercise = () => { const nextExercise = (solution?: UserSolution) => {
if (solution) {
setUserSolutions((prev) => [...prev.filter((x) => x.id !== solution.id), solution]);
}
if (exerciseIndex + 1 < exam.exercises.length) { if (exerciseIndex + 1 < exam.exercises.length) {
setExerciseIndex((prev) => prev + 1); setExerciseIndex((prev) => prev + 1);
return; return;
} }
onFinish(); if (solution) {
onFinish([...userSolutions.filter((x) => x.id !== solution.id), solution].map((x) => ({...x, module: "listening"})));
} else {
onFinish(userSolutions.map((x) => ({...x, module: "listening"})));
}
}; };
const previousExercise = () => { const previousExercise = () => {
@@ -53,13 +64,11 @@ export default function Listening({exam, onFinish}: Props) {
</div> </div>
)} )}
<div className="bg-gray-300 rounded-xl p-4 flex flex-col gap-4 items-center w-full overflow-auto"> <div className="bg-gray-300 rounded-xl p-4 flex flex-col gap-4 items-center w-full overflow-auto">
<span className="text-xl font-semibold">{exam.audio.title}</span>
{exam.audio.repeatableTimes > 0 && ( {exam.audio.repeatableTimes > 0 && (
<>{exam.audio.repeatableTimes <= timesListened && <span>You are no longer allowed to listen to the audio again.</span>}</> <>{exam.audio.repeatableTimes <= timesListened && <span>You are no longer allowed to listen to the audio again.</span>}</>
)} )}
<audio preload="auto" controls autoPlay onPlay={() => setTimesListened((prev) => prev + 1)} onPause={() => alert("PAUSE")}> <audio preload="auto" controls autoPlay onPlay={() => setTimesListened((prev) => prev + 1)}>
<source src="https://www.sndup.net/hqvf/d" type="audio/mpeg" /> <source src={exam.audio.source} type="audio/mpeg" />
<source src="https://www.sndup.net/hqvf/m" type="audio/mpeg" />
Your browser does not support the audio element Your browser does not support the audio element
</audio> </audio>
</div> </div>
@@ -79,9 +88,14 @@ export default function Listening({exam, onFinish}: Props) {
{renderAudioPlayer()} {renderAudioPlayer()}
{exerciseIndex > -1 && {exerciseIndex > -1 &&
exerciseIndex < exam.exercises.length && exerciseIndex < exam.exercises.length &&
!showSolutions &&
renderExercise(exam.exercises[exerciseIndex], nextExercise, previousExercise)} renderExercise(exam.exercises[exerciseIndex], nextExercise, previousExercise)}
{exerciseIndex > -1 &&
exerciseIndex < exam.exercises.length &&
showSolutions &&
renderSolution(exam.exercises[exerciseIndex], nextExercise, previousExercise)}
{exerciseIndex === -1 && ( {exerciseIndex === -1 && (
<button className={clsx("btn btn-wide gap-4 relative text-white self-end", infoButtonStyle)} onClick={nextExercise}> <button className={clsx("btn btn-wide gap-4 relative text-white self-end", infoButtonStyle)} onClick={() => nextExercise()}>
Next Next
<div className="absolute right-4"> <div className="absolute right-4">
<Icon path={mdiArrowRight} color="white" size={1} /> <Icon path={mdiArrowRight} color="white" size={1} />

View File

@@ -1,4 +1,4 @@
import {ReadingExam} from "@/interfaces/exam"; import {ReadingExam, UserSolution} from "@/interfaces/exam";
import {Fragment, useEffect, useState} from "react"; import {Fragment, useEffect, useState} from "react";
import Icon from "@mdi/react"; import Icon from "@mdi/react";
import {mdiArrowRight, mdiNotebook} from "@mdi/js"; import {mdiArrowRight, mdiNotebook} from "@mdi/js";
@@ -6,10 +6,12 @@ import clsx from "clsx";
import {infoButtonStyle} from "@/constants/buttonStyles"; import {infoButtonStyle} from "@/constants/buttonStyles";
import {Dialog, Transition} from "@headlessui/react"; import {Dialog, Transition} from "@headlessui/react";
import {renderExercise} from "@/components/Exercises"; import {renderExercise} from "@/components/Exercises";
import {renderSolution} from "@/components/Solutions";
interface Props { interface Props {
exam: ReadingExam; exam: ReadingExam;
onFinish: () => void; showSolutions?: boolean;
onFinish: (userSolutions: UserSolution[]) => void;
} }
function TextModal({isOpen, title, content, onClose}: {isOpen: boolean; title: string; content: string; onClose: () => void}) { function TextModal({isOpen, title, content, onClose}: {isOpen: boolean; title: string; content: string; onClose: () => void}) {
@@ -69,10 +71,11 @@ function TextModal({isOpen, title, content, onClose}: {isOpen: boolean; title: s
); );
} }
export default function Reading({exam, onFinish}: Props) { export default function Reading({exam, showSolutions = false, onFinish}: Props) {
const [exerciseIndex, setExerciseIndex] = useState(-1); const [exerciseIndex, setExerciseIndex] = useState(-1);
const [showTextModal, setShowTextModal] = useState(false); const [showTextModal, setShowTextModal] = useState(false);
const [timer, setTimer] = useState<number>(); const [timer, setTimer] = useState<number>();
const [userSolutions, setUserSolutions] = useState<UserSolution[]>([]);
useEffect(() => { useEffect(() => {
setTimer(exam.minTimer * 60); setTimer(exam.minTimer * 60);
@@ -83,13 +86,21 @@ export default function Reading({exam, onFinish}: Props) {
}; };
}, [exam.minTimer]); }, [exam.minTimer]);
const nextExercise = () => { const nextExercise = (solution?: UserSolution) => {
if (solution) {
setUserSolutions((prev) => [...prev.filter((x) => x.id !== solution.id), solution]);
}
if (exerciseIndex + 1 < exam.exercises.length) { if (exerciseIndex + 1 < exam.exercises.length) {
setExerciseIndex((prev) => prev + 1); setExerciseIndex((prev) => prev + 1);
return; return;
} }
onFinish(); if (solution) {
onFinish([...userSolutions.filter((x) => x.id !== solution.id), solution].map((x) => ({...x, module: "reading"})));
} else {
onFinish(userSolutions.map((x) => ({...x, module: "reading"})));
}
}; };
const previousExercise = () => { const previousExercise = () => {
@@ -132,7 +143,12 @@ export default function Reading({exam, onFinish}: Props) {
{exerciseIndex === -1 && renderText()} {exerciseIndex === -1 && renderText()}
{exerciseIndex > -1 && {exerciseIndex > -1 &&
exerciseIndex < exam.exercises.length && exerciseIndex < exam.exercises.length &&
!showSolutions &&
renderExercise(exam.exercises[exerciseIndex], nextExercise, previousExercise)} renderExercise(exam.exercises[exerciseIndex], nextExercise, previousExercise)}
{exerciseIndex > -1 &&
exerciseIndex < exam.exercises.length &&
showSolutions &&
renderSolution(exam.exercises[exerciseIndex], nextExercise, previousExercise)}
<div className={clsx("flex gap-8", exerciseIndex > -1 ? "w-full justify-between" : "self-end")}> <div className={clsx("flex gap-8", exerciseIndex > -1 ? "w-full justify-between" : "self-end")}>
{exerciseIndex > -1 && ( {exerciseIndex > -1 && (
<button <button
@@ -148,7 +164,7 @@ export default function Reading({exam, onFinish}: Props) {
</button> </button>
)} )}
{exerciseIndex === -1 && ( {exerciseIndex === -1 && (
<button className={clsx("btn btn-wide gap-4 relative text-white self-end", infoButtonStyle)} onClick={nextExercise}> <button className={clsx("btn btn-wide gap-4 relative text-white self-end", infoButtonStyle)} onClick={() => nextExercise()}>
Next Next
<div className="absolute right-4"> <div className="absolute right-4">
<Icon path={mdiArrowRight} color="white" size={1} /> <Icon path={mdiArrowRight} color="white" size={1} />

View File

@@ -1,14 +1,16 @@
import {infoButtonStyle} from "@/constants/buttonStyles"; import {infoButtonStyle} from "@/constants/buttonStyles";
import {WritingExam} from "@/interfaces/exam"; import {UserSolution, WritingExam} from "@/interfaces/exam";
import clsx from "clsx"; import clsx from "clsx";
import {Fragment, useEffect, useState} from "react"; import {Fragment, useEffect, useState} from "react";
import {toast} from "react-toastify"; import {toast} from "react-toastify";
interface Props { interface Props {
exam: WritingExam; exam: WritingExam;
showSolutions?: boolean;
onFinish: (userSolutions: UserSolution[]) => void;
} }
export default function Writing({exam}: Props) { export default function Writing({exam, showSolutions = false, onFinish}: Props) {
const [inputText, setInputText] = useState(""); const [inputText, setInputText] = useState("");
const [isSubmitEnabled, setIsSubmitEnabled] = useState(false); const [isSubmitEnabled, setIsSubmitEnabled] = useState(false);
const [timer, setTimer] = useState<number>(); const [timer, setTimer] = useState<number>();
@@ -68,13 +70,19 @@ export default function Writing({exam}: Props) {
<div className="w-1/2 flex justify-end"> <div className="w-1/2 flex justify-end">
{!isSubmitEnabled && ( {!isSubmitEnabled && (
<div className="tooltip" data-tip={`You have not yet reached your minimum word count of ${exam.text.wordCounter.limit} words!`}> <div className="tooltip" data-tip={`You have not yet reached your minimum word count of ${exam.text.wordCounter.limit} words!`}>
<button className={clsx("btn btn-wide gap-4 relative text-white", infoButtonStyle)} disabled={!isSubmitEnabled}> <button
className={clsx("btn btn-wide gap-4 relative text-white", infoButtonStyle)}
disabled={!isSubmitEnabled}
onClick={() => onFinish([{id: exam.id, solutions: [inputText.trim()], score: {correct: 0, total: 1}}])}>
Next Next
</button> </button>
</div> </div>
)} )}
{isSubmitEnabled && ( {isSubmitEnabled && (
<button className={clsx("btn btn-wide gap-4 relative text-white", infoButtonStyle)} disabled={!isSubmitEnabled}> <button
className={clsx("btn btn-wide gap-4 relative text-white", infoButtonStyle)}
disabled={!isSubmitEnabled}
onClick={() => onFinish([{id: exam.id, solutions: [inputText.trim()], score: {correct: 0, total: 1}}])}>
Next Next
</button> </button>
)} )}

View File

@@ -1,3 +1,5 @@
import {Module} from ".";
export type Exam = ReadingExam | ListeningExam | WritingExam; export type Exam = ReadingExam | ListeningExam | WritingExam;
export interface ReadingExam { export interface ReadingExam {
@@ -5,6 +7,7 @@ export interface ReadingExam {
title: string; title: string;
content: string; content: string;
}; };
id: string;
exercises: Exercise[]; exercises: Exercise[];
module: "reading"; module: "reading";
minTimer: number; minTimer: number;
@@ -12,24 +15,35 @@ export interface ReadingExam {
export interface ListeningExam { export interface ListeningExam {
audio: { audio: {
title: string;
source: string; source: string;
transcript: string;
repeatableTimes: number; // *The amount of times the user is allowed to repeat the audio, 0 for unlimited repeatableTimes: number; // *The amount of times the user is allowed to repeat the audio, 0 for unlimited
}; };
id: string;
exercises: Exercise[]; exercises: Exercise[];
module: "listening"; module: "listening";
minTimer: number; minTimer: number;
} }
export interface UserSolution {
solutions: any[];
module?: Module;
score: {
correct: number;
total: number;
};
id: string;
}
export interface WritingExam { export interface WritingExam {
module: "writing"; module: "writing";
id: string;
text: { text: {
info: string; //* The information about the task, like the amount of time they should spend on it info: string; //* The information about the task, like the amount of time they should spend on it
prompt: string; //* The context given to the user containing what they should write about prompt: string; //* The context given to the user containing what they should write about
wordCounter: WordCounter; //* The minimum or maximum amount of words that should be written wordCounter: WordCounter; //* The minimum or maximum amount of words that should be written
}; };
minTimer: number; minTimer: number;
exercises: Exercise[];
} }
interface WordCounter { interface WordCounter {
@@ -42,6 +56,7 @@ export type Exercise = FillBlanksExercise | MatchSentencesExercise | MultipleCho
export interface FillBlanksExercise { export interface FillBlanksExercise {
prompt: string; // *EXAMPLE: "Complete the summary below. Click a blank to select the corresponding word for it." prompt: string; // *EXAMPLE: "Complete the summary below. Click a blank to select the corresponding word for it."
type: "fillBlanks"; type: "fillBlanks";
id: string;
words: string[]; // *EXAMPLE: ["preserve", "unaware"] words: string[]; // *EXAMPLE: ["preserve", "unaware"]
text: string; // *EXAMPLE: "They tried to {{1}} burning" text: string; // *EXAMPLE: "They tried to {{1}} burning"
allowRepetition: boolean; allowRepetition: boolean;
@@ -49,12 +64,17 @@ export interface FillBlanksExercise {
id: string; // *EXAMPLE: "1" id: string; // *EXAMPLE: "1"
solution: string; // *EXAMPLE: "preserve" solution: string; // *EXAMPLE: "preserve"
}[]; }[];
userSolutions: {
id: string; // *EXAMPLE: "1"
solution: string; // *EXAMPLE: "preserve"
}[];
} }
export interface WriteBlanksExercise { export interface WriteBlanksExercise {
prompt: string; // *EXAMPLE: "Complete the notes below by writing NO MORE THAN THREE WORDS in the spaces provided." prompt: string; // *EXAMPLE: "Complete the notes below by writing NO MORE THAN THREE WORDS in the spaces provided."
maxWords: number; // *EXAMPLE: 3 - The maximum amount of words allowed per blank, 0 for unlimited maxWords: number; // *EXAMPLE: 3 - The maximum amount of words allowed per blank, 0 for unlimited
type: "writeBlanks"; type: "writeBlanks";
id: string;
text: string; // *EXAMPLE: "The Government plans to give ${{14}}" text: string; // *EXAMPLE: "The Government plans to give ${{14}}"
solutions: { solutions: {
id: string; // *EXAMPLE: "14" id: string; // *EXAMPLE: "14"
@@ -68,6 +88,7 @@ export interface WriteBlanksExercise {
export interface MatchSentencesExercise { export interface MatchSentencesExercise {
type: "matchSentences"; type: "matchSentences";
id: string;
prompt: string; prompt: string;
userSolutions: {question: string; option: string}[]; userSolutions: {question: string; option: string}[];
sentences: { sentences: {
@@ -85,6 +106,7 @@ export interface MatchSentencesExercise {
export interface MultipleChoiceExercise { export interface MultipleChoiceExercise {
type: "multipleChoice"; type: "multipleChoice";
id: string;
prompt: string; // *EXAMPLE: "Select the appropriate option." prompt: string; // *EXAMPLE: "Select the appropriate option."
questions: MultipleChoiceQuestion[]; questions: MultipleChoiceQuestion[];
userSolutions: {question: string; option: string}[]; userSolutions: {question: string; option: string}[];

View File

@@ -12,20 +12,25 @@ import JSON_WRITING from "@/demo/writing.json";
import Selection from "@/exams/Selection"; import Selection from "@/exams/Selection";
import Reading from "@/exams/Reading"; import Reading from "@/exams/Reading";
import {Exam, ListeningExam, ReadingExam, WritingExam} from "@/interfaces/exam"; import {Exam, ListeningExam, ReadingExam, UserSolution, WritingExam} from "@/interfaces/exam";
import Listening from "@/exams/Listening"; import Listening from "@/exams/Listening";
import Writing from "@/exams/Writing"; import Writing from "@/exams/Writing";
import {ToastContainer} from "react-toastify"; import {ToastContainer} from "react-toastify";
import Link from "next/link";
export default function Home() { export default function Home() {
const [selectedModules, setSelectedModules] = useState<Module[]>([]); const [selectedModules, setSelectedModules] = useState<Module[]>([]);
const [moduleIndex, setModuleIndex] = useState(0); const [moduleIndex, setModuleIndex] = useState(0);
const [exam, setExam] = useState<Exam>(); const [exam, setExam] = useState<Exam>();
const [userSolutions, setUserSolutions] = useState<UserSolution[]>([]);
const [showSolutions, setShowSolutions] = useState(false);
useEffect(() => { useEffect(() => {
if (selectedModules.length > 0 && moduleIndex < selectedModules.length) { if (selectedModules.length > 0 && moduleIndex < selectedModules.length) {
setExam(getExam(selectedModules[moduleIndex])); const nextExam = getExam(selectedModules[moduleIndex]);
setExam(nextExam ? updateExamWithUserSolutions(nextExam) : undefined);
} }
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedModules, moduleIndex]); }, [selectedModules, moduleIndex]);
const getExam = (module: Module): Exam | undefined => { const getExam = (module: Module): Exam | undefined => {
@@ -41,25 +46,59 @@ export default function Home() {
return undefined; return undefined;
}; };
const updateExamWithUserSolutions = (exam: Exam): Exam => {
const exercises = exam.exercises.map((x) => Object.assign(x, {userSolutions: userSolutions.find((y) => x.id === y.id)?.solutions}));
return Object.assign(exam, exercises);
};
const onFinish = (solutions: UserSolution[]) => {
const solutionIds = solutions.map((x) => x.id);
console.log({solutions});
setUserSolutions((prev) => [...prev.filter((x) => !solutionIds.includes(x.id)), ...solutions]);
setModuleIndex((prev) => prev + 1);
};
const renderScreen = () => { const renderScreen = () => {
if (selectedModules.length === 0) { if (selectedModules.length === 0) {
return <Selection user={JSON_USER} onStart={setSelectedModules} />; return <Selection user={JSON_USER} onStart={setSelectedModules} />;
} }
if (moduleIndex >= selectedModules.length) { if (moduleIndex >= selectedModules.length) {
return <>Finished!</>; return (
<>
Finished!{" "}
<button
className="btn btn-wide"
onClick={() => {
setShowSolutions(true);
setModuleIndex(0);
}}>
View Solutions
</button>
<Link href="/">
<button className="btn btn-wide">Go Home</button>
</Link>
</>
);
} }
if (exam && exam.module === "reading") { if (exam && exam.module === "reading") {
return <Reading exam={exam} onFinish={() => setModuleIndex((prev) => prev + 1)} />; return <Reading exam={exam} onFinish={onFinish} showSolutions={showSolutions} />;
} }
if (exam && exam.module === "listening") { if (exam && exam.module === "listening") {
return <Listening exam={exam} onFinish={() => setModuleIndex((prev) => prev + 1)} />; return <Listening exam={exam} onFinish={onFinish} showSolutions={showSolutions} />;
}
if (exam && exam.module === "writing" && showSolutions) {
setModuleIndex((prev) => prev + 1);
return <></>;
} }
if (exam && exam.module === "writing") { if (exam && exam.module === "writing") {
return <Writing exam={exam} />; return <Writing exam={exam} onFinish={onFinish} showSolutions={showSolutions} />;
} }
return <>Loading...</>; return <>Loading...</>;

View File

@@ -1,19 +1,6 @@
import {Module} from "@/interfaces"; import {Module} from "@/interfaces";
import {create} from "zustand"; import {create} from "zustand";
const useExamStore = create((set) => ({ const useExamStore = create((set) => ({}));
reading: undefined,
listening: undefined,
speaking: undefined,
writing: undefined,
updateModule: (module: Module, id: string) => set(() => ({[module]: id})),
clearExam: () =>
set(() => ({
reading: undefined,
listening: undefined,
speaking: undefined,
writing: undefined,
})),
}));
export default useExamStore; export default useExamStore;