Implemented the Writing exercise's solution display
This commit is contained in:
@@ -8,8 +8,10 @@ import {CommonProps} from ".";
|
|||||||
import {Fragment, useEffect, useState} from "react";
|
import {Fragment, useEffect, useState} from "react";
|
||||||
import {toast} from "react-toastify";
|
import {toast} from "react-toastify";
|
||||||
import Button from "../Low/Button";
|
import Button from "../Low/Button";
|
||||||
|
import {Dialog, Transition} from "@headlessui/react";
|
||||||
|
|
||||||
export default function Writing({id, prompt, info, type, wordCounter, attachment, userSolutions, onNext, onBack}: WritingExercise & CommonProps) {
|
export default function Writing({id, prompt, info, type, wordCounter, attachment, userSolutions, onNext, onBack}: WritingExercise & CommonProps) {
|
||||||
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||||
const [inputText, setInputText] = useState(userSolutions.length === 1 ? userSolutions[0].solution : "");
|
const [inputText, setInputText] = useState(userSolutions.length === 1 ? userSolutions[0].solution : "");
|
||||||
const [isSubmitEnabled, setIsSubmitEnabled] = useState(false);
|
const [isSubmitEnabled, setIsSubmitEnabled] = useState(false);
|
||||||
|
|
||||||
@@ -28,33 +30,73 @@ export default function Writing({id, prompt, info, type, wordCounter, attachment
|
|||||||
}, [inputText, wordCounter]);
|
}, [inputText, wordCounter]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full w-full gap-9">
|
<>
|
||||||
<div className="flex flex-col w-full gap-7 bg-mti-gray-smoke rounded-xl py-8 pb-12 px-16">
|
{attachment && (
|
||||||
<span>{info}</span>
|
<Transition show={isModalOpen} as={Fragment}>
|
||||||
<span className="font-semibold">
|
<Dialog onClose={() => setIsModalOpen(false)} className="relative z-50">
|
||||||
{prompt.split("\\n").map((line, index) => (
|
<Transition.Child
|
||||||
<Fragment key={index}>
|
as={Fragment}
|
||||||
<p>{line}</p>
|
enter="ease-out duration-300"
|
||||||
<br />
|
enterFrom="opacity-0"
|
||||||
</Fragment>
|
enterTo="opacity-100"
|
||||||
))}
|
leave="ease-in duration-200"
|
||||||
</span>
|
leaveFrom="opacity-100"
|
||||||
{attachment && <img src={attachment} alt="Exercise attachment" className="max-w-md self-center" />}
|
leaveTo="opacity-0">
|
||||||
|
<div className="fixed inset-0 bg-black/30" />
|
||||||
|
</Transition.Child>
|
||||||
|
|
||||||
|
<Transition.Child
|
||||||
|
as={Fragment}
|
||||||
|
enter="ease-out duration-300"
|
||||||
|
enterFrom="opacity-0 scale-95"
|
||||||
|
enterTo="opacity-100 scale-100"
|
||||||
|
leave="ease-in duration-200"
|
||||||
|
leaveFrom="opacity-100 scale-100"
|
||||||
|
leaveTo="opacity-0 scale-95">
|
||||||
|
<div className="fixed inset-0 flex items-center justify-center p-4">
|
||||||
|
<Dialog.Panel 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>
|
||||||
|
</div>
|
||||||
|
</Transition.Child>
|
||||||
|
</Dialog>
|
||||||
|
</Transition>
|
||||||
|
)}
|
||||||
|
<div className="flex flex-col h-full w-full gap-9 mb-20">
|
||||||
|
<div className="flex flex-col w-full gap-7 bg-mti-gray-smoke rounded-xl py-8 pb-12 px-16">
|
||||||
|
<span>{info}</span>
|
||||||
|
<span className="font-semibold">
|
||||||
|
{prompt.split("\\n").map((line, index) => (
|
||||||
|
<Fragment key={index}>
|
||||||
|
<p>{line}</p>
|
||||||
|
<br />
|
||||||
|
</Fragment>
|
||||||
|
))}
|
||||||
|
</span>
|
||||||
|
{attachment && (
|
||||||
|
<img
|
||||||
|
onClick={() => setIsModalOpen(true)}
|
||||||
|
src={attachment.url}
|
||||||
|
alt={attachment.description}
|
||||||
|
className="max-w-md self-center rounded-xl cursor-pointer"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="w-full h-full flex flex-col gap-4">
|
||||||
|
<span>
|
||||||
|
You should write {wordCounter.type === "min" ? "at least" : "at most"} {wordCounter.limit} words.
|
||||||
|
</span>
|
||||||
|
<textarea
|
||||||
|
className="w-full h-full min-h-[148px] cursor-text px-7 py-8 input border-2 border-mti-gray-platinum bg-white rounded-3xl"
|
||||||
|
onChange={(e) => setInputText(e.target.value)}
|
||||||
|
value={inputText}
|
||||||
|
placeholder="Write your text here..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="w-full h-full flex flex-col gap-4">
|
<div className="self-end flex justify-between w-full gap-8 absolute bottom-8 left-0 px-8">
|
||||||
<span>
|
|
||||||
You should write {wordCounter.type === "min" ? "at least" : "at most"} {wordCounter.limit} words.
|
|
||||||
</span>
|
|
||||||
<textarea
|
|
||||||
className="w-full h-full min-h-[148px] cursor-text px-7 py-8 input border-2 border-mti-gray-platinum bg-white rounded-3xl"
|
|
||||||
onChange={(e) => setInputText(e.target.value)}
|
|
||||||
value={inputText}
|
|
||||||
placeholder="Write your text here..."
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="self-end flex justify-between w-full gap-8">
|
|
||||||
<Button
|
<Button
|
||||||
color="green"
|
color="green"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
@@ -70,6 +112,6 @@ export default function Writing({id, prompt, info, type, wordCounter, attachment
|
|||||||
Next
|
Next
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
105
src/components/Solutions/Writing.tsx
Normal file
105
src/components/Solutions/Writing.tsx
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
/* eslint-disable @next/next/no-img-element */
|
||||||
|
import {errorButtonStyle, infoButtonStyle} from "@/constants/buttonStyles";
|
||||||
|
import {WritingExercise} from "@/interfaces/exam";
|
||||||
|
import {mdiArrowLeft, mdiArrowRight} from "@mdi/js";
|
||||||
|
import Icon from "@mdi/react";
|
||||||
|
import clsx from "clsx";
|
||||||
|
import {CommonProps} from ".";
|
||||||
|
import {Fragment, useEffect, useState} from "react";
|
||||||
|
import {toast} from "react-toastify";
|
||||||
|
import Button from "../Low/Button";
|
||||||
|
import {Dialog, Transition} from "@headlessui/react";
|
||||||
|
|
||||||
|
export default function Writing({id, prompt, info, evaluation, attachment, userSolutions, onNext, onBack}: WritingExercise & CommonProps) {
|
||||||
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{attachment && (
|
||||||
|
<Transition show={isModalOpen} as={Fragment}>
|
||||||
|
<Dialog onClose={() => setIsModalOpen(false)} className="relative z-50">
|
||||||
|
<Transition.Child
|
||||||
|
as={Fragment}
|
||||||
|
enter="ease-out duration-300"
|
||||||
|
enterFrom="opacity-0"
|
||||||
|
enterTo="opacity-100"
|
||||||
|
leave="ease-in duration-200"
|
||||||
|
leaveFrom="opacity-100"
|
||||||
|
leaveTo="opacity-0">
|
||||||
|
<div className="fixed inset-0 bg-black/30" />
|
||||||
|
</Transition.Child>
|
||||||
|
|
||||||
|
<Transition.Child
|
||||||
|
as={Fragment}
|
||||||
|
enter="ease-out duration-300"
|
||||||
|
enterFrom="opacity-0 scale-95"
|
||||||
|
enterTo="opacity-100 scale-100"
|
||||||
|
leave="ease-in duration-200"
|
||||||
|
leaveFrom="opacity-100 scale-100"
|
||||||
|
leaveTo="opacity-0 scale-95">
|
||||||
|
<div className="fixed inset-0 flex items-center justify-center p-4">
|
||||||
|
<Dialog.Panel 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>
|
||||||
|
</div>
|
||||||
|
</Transition.Child>
|
||||||
|
</Dialog>
|
||||||
|
</Transition>
|
||||||
|
)}
|
||||||
|
<div className="flex flex-col h-full w-full gap-8 mb-20">
|
||||||
|
<div className="flex w-full gap-7 bg-mti-gray-smoke rounded-xl py-8 pb-12 px-16">
|
||||||
|
<span className="font-semibold">
|
||||||
|
{prompt.split("\\n").map((line, index) => (
|
||||||
|
<Fragment key={index}>
|
||||||
|
<p>{line}</p>
|
||||||
|
<br />
|
||||||
|
</Fragment>
|
||||||
|
))}
|
||||||
|
</span>
|
||||||
|
{attachment && (
|
||||||
|
<img
|
||||||
|
onClick={() => setIsModalOpen(true)}
|
||||||
|
src={attachment.url}
|
||||||
|
alt={attachment.description}
|
||||||
|
className="max-w-[200px] self-center rounded-xl cursor-pointer"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="w-full h-full flex flex-col gap-8">
|
||||||
|
{userSolutions && (
|
||||||
|
<div className="flex flex-col gap-4 w-full">
|
||||||
|
<span>Your answer:</span>
|
||||||
|
<textarea
|
||||||
|
className="w-full h-full min-h-[320px] cursor-text px-7 py-8 input border-2 border-mti-gray-platinum bg-white rounded-3xl"
|
||||||
|
contentEditable={false}
|
||||||
|
readOnly
|
||||||
|
value={userSolutions[0] as any}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex flex-col gap-4 w-full">
|
||||||
|
<div className="flex gap-4 px-1">
|
||||||
|
{Object.keys(evaluation!.task_response).map((key) => (
|
||||||
|
<div className="bg-ielts-writing text-ielts-writing-light rounded-xl px-4 py-2" key={key}>
|
||||||
|
{key}: Level {evaluation!.task_response[key]}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="w-full h-full min-h-fit cursor-text px-7 py-8 bg-mti-gray-smoke rounded-3xl">{evaluation!.comment}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="self-end flex justify-between w-full gap-8 absolute bottom-8 left-0 px-8">
|
||||||
|
<Button color="green" variant="outline" onClick={onBack} className="max-w-[200px] w-full">
|
||||||
|
Back
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button color="green" onClick={() => onNext()} className="max-w-[200px] self-end w-full">
|
||||||
|
Next
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,8 +1,9 @@
|
|||||||
import {Exercise, FillBlanksExercise, MatchSentencesExercise, MultipleChoiceExercise, WriteBlanksExercise} from "@/interfaces/exam";
|
import {Exercise, FillBlanksExercise, MatchSentencesExercise, MultipleChoiceExercise, WriteBlanksExercise, WritingExercise} 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";
|
||||||
import WriteBlanks from "./WriteBlanks";
|
import WriteBlanks from "./WriteBlanks";
|
||||||
|
import Writing from "./Writing";
|
||||||
|
|
||||||
const MatchSentences = dynamic(() => import("@/components/Solutions/MatchSentences"), {ssr: false});
|
const MatchSentences = dynamic(() => import("@/components/Solutions/MatchSentences"), {ssr: false});
|
||||||
|
|
||||||
@@ -21,5 +22,7 @@ export const renderSolution = (exercise: Exercise, onNext: () => void, onBack: (
|
|||||||
return <MultipleChoice {...(exercise as MultipleChoiceExercise)} onNext={onNext} onBack={onBack} />;
|
return <MultipleChoice {...(exercise as MultipleChoiceExercise)} onNext={onNext} onBack={onBack} />;
|
||||||
case "writeBlanks":
|
case "writeBlanks":
|
||||||
return <WriteBlanks {...(exercise as WriteBlanksExercise)} onNext={onNext} onBack={onBack} />;
|
return <WriteBlanks {...(exercise as WriteBlanksExercise)} onNext={onNext} onBack={onBack} />;
|
||||||
|
case "writing":
|
||||||
|
return <Writing {...(exercise as WritingExercise)} onNext={onNext} onBack={onBack} />;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -22,10 +22,11 @@ interface Props {
|
|||||||
user: User;
|
user: User;
|
||||||
modules: Module[];
|
modules: Module[];
|
||||||
scores: Score[];
|
scores: Score[];
|
||||||
|
isLoading: boolean;
|
||||||
onViewResults: () => void;
|
onViewResults: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Finish({user, scores, modules, onViewResults}: Props) {
|
export default function Finish({user, scores, modules, isLoading, onViewResults}: Props) {
|
||||||
const [selectedModule, setSelectedModule] = useState(modules[0]);
|
const [selectedModule, setSelectedModule] = useState(modules[0]);
|
||||||
const [selectedScore, setSelectedScore] = useState<Score>(scores.find((x) => x.module === modules[0])!);
|
const [selectedScore, setSelectedScore] = useState<Score>(scores.find((x) => x.module === modules[0])!);
|
||||||
|
|
||||||
@@ -108,82 +109,94 @@ export default function Finish({user, scores, modules, onViewResults}: Props) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="w-full flex gap-9 mt-32 items-center justify-between">
|
{isLoading && (
|
||||||
<span className="max-w-3xl">
|
<div className="w-fit h-fit absolute top-1/2 -translate-y-1/2 left-1/2 -translate-x-1/2 animate-pulse flex flex-col gap-12 items-center">
|
||||||
{levelText(calculateBandScore(selectedScore.correct, selectedScore.total, selectedModule, user.focus))}
|
<span className={clsx("loading loading-infinity w-32", moduleColors[selectedModule].progress)} />
|
||||||
</span>
|
<span className={clsx("font-bold text-2xl", moduleColors[selectedModule].progress)}>Evaluating your answers...</span>
|
||||||
<div className="flex gap-9 px-16">
|
</div>
|
||||||
<div
|
)}
|
||||||
className={clsx("radial-progress overflow-hidden", moduleColors[selectedModule].progress)}
|
{!isLoading && (
|
||||||
style={{"--value": (selectedScore.correct / selectedScore.total) * 100, "--thickness": "12px", "--size": "13rem"} as any}>
|
<div className="w-full flex gap-9 mt-32 items-center justify-between">
|
||||||
|
<span className="max-w-3xl">
|
||||||
|
{levelText(calculateBandScore(selectedScore.correct, selectedScore.total, selectedModule, user.focus))}
|
||||||
|
</span>
|
||||||
|
<div className="flex gap-9 px-16">
|
||||||
<div
|
<div
|
||||||
className={clsx(
|
className={clsx("radial-progress overflow-hidden", moduleColors[selectedModule].progress)}
|
||||||
"w-48 h-48 rounded-full flex flex-col items-center justify-center",
|
style={
|
||||||
moduleColors[selectedModule].inner,
|
{"--value": (selectedScore.correct / selectedScore.total) * 100, "--thickness": "12px", "--size": "13rem"} as any
|
||||||
)}>
|
}>
|
||||||
<span className="text-xl">Level</span>
|
<div
|
||||||
<span className="text-3xl font-bold">
|
className={clsx(
|
||||||
{calculateBandScore(selectedScore.correct, selectedScore.total, selectedModule, user.focus)}
|
"w-48 h-48 rounded-full flex flex-col items-center justify-center",
|
||||||
</span>
|
moduleColors[selectedModule].inner,
|
||||||
</div>
|
)}>
|
||||||
</div>
|
<span className="text-xl">Level</span>
|
||||||
<div className="flex flex-col gap-5">
|
<span className="text-3xl font-bold">
|
||||||
<div className="flex gap-2">
|
{calculateBandScore(selectedScore.correct, selectedScore.total, selectedModule, user.focus)}
|
||||||
<div className="w-3 h-3 bg-mti-blue-light rounded-full mt-1" />
|
|
||||||
<div className="flex flex-col">
|
|
||||||
<span className="text-mti-blue-light">
|
|
||||||
{(((selectedScore.total - selectedScore.missing) / selectedScore.total) * 100).toFixed(0)}%
|
|
||||||
</span>
|
</span>
|
||||||
<span className="text-lg">Completion</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
<div className="flex flex-col gap-5">
|
||||||
<div className="w-3 h-3 bg-mti-green-light rounded-full mt-1" />
|
<div className="flex gap-2">
|
||||||
<div className="flex flex-col">
|
<div className="w-3 h-3 bg-mti-blue-light rounded-full mt-1" />
|
||||||
<span className="text-mti-green-light">{selectedScore.total.toString().padStart(2, "0")}</span>
|
<div className="flex flex-col">
|
||||||
<span className="text-lg">Correct</span>
|
<span className="text-mti-blue-light">
|
||||||
|
{(((selectedScore.total - selectedScore.missing) / selectedScore.total) * 100).toFixed(0)}%
|
||||||
|
</span>
|
||||||
|
<span className="text-lg">Completion</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div className="flex gap-2">
|
||||||
<div className="flex gap-2">
|
<div className="w-3 h-3 bg-mti-green-light rounded-full mt-1" />
|
||||||
<div className="w-3 h-3 bg-mti-orange-light rounded-full mt-1" />
|
<div className="flex flex-col">
|
||||||
<div className="flex flex-col">
|
<span className="text-mti-green-light">{selectedScore.total.toString().padStart(2, "0")}</span>
|
||||||
<span className="text-mti-orange-light">
|
<span className="text-lg">Correct</span>
|
||||||
{(selectedScore.total - selectedScore.correct).toString().padStart(2, "0")}
|
</div>
|
||||||
</span>
|
</div>
|
||||||
<span className="text-lg">Wrong</span>
|
<div className="flex gap-2">
|
||||||
|
<div className="w-3 h-3 bg-mti-orange-light rounded-full mt-1" />
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="text-mti-orange-light">
|
||||||
|
{(selectedScore.total - selectedScore.correct).toString().padStart(2, "0")}
|
||||||
|
</span>
|
||||||
|
<span className="text-lg">Wrong</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="self-end flex justify-between w-full gap-8 absolute bottom-8 left-0 px-8">
|
{!isLoading && (
|
||||||
<div className="flex gap-8">
|
<div className="self-end flex justify-between w-full gap-8 absolute bottom-8 left-0 px-8">
|
||||||
<div className="w-fit flex flex-col items-center gap-1 cursor-pointer">
|
<div className="flex gap-8">
|
||||||
<button
|
<div className="w-fit flex flex-col items-center gap-1 cursor-pointer">
|
||||||
onClick={() => window.location.reload()}
|
<button
|
||||||
className="w-11 h-11 rounded-full bg-mti-green-light hover:bg-mti-green flex items-center justify-center transition duration-300 ease-in-out">
|
onClick={() => window.location.reload()}
|
||||||
<BsArrowCounterclockwise className="text-white w-7 h-7" />
|
className="w-11 h-11 rounded-full bg-mti-green-light hover:bg-mti-green flex items-center justify-center transition duration-300 ease-in-out">
|
||||||
</button>
|
<BsArrowCounterclockwise className="text-white w-7 h-7" />
|
||||||
<span>Play Again</span>
|
</button>
|
||||||
|
<span>Play Again</span>
|
||||||
|
</div>
|
||||||
|
<div className="w-fit flex flex-col items-center gap-1 cursor-pointer">
|
||||||
|
<button
|
||||||
|
onClick={onViewResults}
|
||||||
|
className="w-11 h-11 rounded-full bg-mti-green-light hover:bg-mti-green flex items-center justify-center transition duration-300 ease-in-out">
|
||||||
|
<BsEyeFill className="text-white w-7 h-7" />
|
||||||
|
</button>
|
||||||
|
<span>Review Answers</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="w-fit flex flex-col items-center gap-1 cursor-pointer">
|
|
||||||
<button
|
|
||||||
onClick={onViewResults}
|
|
||||||
className="w-11 h-11 rounded-full bg-mti-green-light hover:bg-mti-green flex items-center justify-center transition duration-300 ease-in-out">
|
|
||||||
<BsEyeFill className="text-white w-7 h-7" />
|
|
||||||
</button>
|
|
||||||
<span>Review Answers</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Link href="/" className="max-w-[200px] w-full self-end">
|
<Link href="/" className="max-w-[200px] w-full self-end">
|
||||||
<Button color="green" className="max-w-[200px] self-end w-full">
|
<Button color="green" className="max-w-[200px] self-end w-full">
|
||||||
Dashboard
|
Dashboard
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -69,13 +69,23 @@ export type Exercise =
|
|||||||
| WritingExercise
|
| WritingExercise
|
||||||
| SpeakingExercise;
|
| SpeakingExercise;
|
||||||
|
|
||||||
|
export interface WritingEvaluation {
|
||||||
|
comment: string;
|
||||||
|
overall: number;
|
||||||
|
task_response: {[key: string]: number};
|
||||||
|
}
|
||||||
|
|
||||||
export interface WritingExercise {
|
export interface WritingExercise {
|
||||||
id: string;
|
id: string;
|
||||||
type: "writing";
|
type: "writing";
|
||||||
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
|
||||||
attachment?: string; //* The url for an image to work as an attachment to show the user
|
attachment?: {
|
||||||
|
url: string;
|
||||||
|
description: string;
|
||||||
|
}; //* The url for an image to work as an attachment to show the user
|
||||||
|
evaluation?: WritingEvaluation;
|
||||||
userSolutions: {
|
userSolutions: {
|
||||||
id: string;
|
id: string;
|
||||||
solution: string;
|
solution: string;
|
||||||
|
|||||||
36
src/pages/api/exam/[module]/evaluate.ts
Normal file
36
src/pages/api/exam/[module]/evaluate.ts
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
|
||||||
|
import type {NextApiRequest, NextApiResponse} from "next";
|
||||||
|
import {getFirestore, doc, getDoc} from "firebase/firestore";
|
||||||
|
import {withIronSessionApiRoute} from "iron-session/next";
|
||||||
|
import {sessionOptions} from "@/lib/session";
|
||||||
|
import axios from "axios";
|
||||||
|
|
||||||
|
interface Body {
|
||||||
|
question: string;
|
||||||
|
answer: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default withIronSessionApiRoute(handler, sessionOptions);
|
||||||
|
|
||||||
|
async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||||
|
if (!req.session.user) {
|
||||||
|
res.status(401).json({ok: false});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const {module} = req.query as {module: string};
|
||||||
|
|
||||||
|
if (module === "writing") {
|
||||||
|
const backendRequest = await axios.post(`${process.env.BACKEND_URL}/writing_task2`, req.body as Body, {
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${process.env.BACKEND_JWT}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
res.status(backendRequest.status).json(backendRequest.data);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.status(404).json({ok: false});
|
||||||
|
return;
|
||||||
|
}
|
||||||
@@ -6,7 +6,7 @@ import {Module} from "@/interfaces";
|
|||||||
|
|
||||||
import Selection from "@/exams/Selection";
|
import Selection from "@/exams/Selection";
|
||||||
import Reading from "@/exams/Reading";
|
import Reading from "@/exams/Reading";
|
||||||
import {Exam, ListeningExam, ReadingExam, SpeakingExam, UserSolution, WritingExam} from "@/interfaces/exam";
|
import {Exam, ListeningExam, ReadingExam, SpeakingExam, UserSolution, WritingExam, WritingExercise} 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, toast} from "react-toastify";
|
import {ToastContainer, toast} from "react-toastify";
|
||||||
@@ -21,6 +21,7 @@ import useUser from "@/hooks/useUser";
|
|||||||
import useExamStore from "@/stores/examStore";
|
import useExamStore from "@/stores/examStore";
|
||||||
import Sidebar from "@/components/Sidebar";
|
import Sidebar from "@/components/Sidebar";
|
||||||
import Layout from "@/components/High/Layout";
|
import Layout from "@/components/High/Layout";
|
||||||
|
import {sortByModule} from "@/utils/moduleUtils";
|
||||||
|
|
||||||
export const getServerSideProps = withIronSessionSsr(({req, res}) => {
|
export const getServerSideProps = withIronSessionSsr(({req, res}) => {
|
||||||
const user = req.session.user;
|
const user = req.session.user;
|
||||||
@@ -46,7 +47,7 @@ export default function Page() {
|
|||||||
const [moduleIndex, setModuleIndex] = useState(0);
|
const [moduleIndex, setModuleIndex] = useState(0);
|
||||||
const [sessionId, setSessionId] = useState("");
|
const [sessionId, setSessionId] = useState("");
|
||||||
const [exam, setExam] = useState<Exam>();
|
const [exam, setExam] = useState<Exam>();
|
||||||
const [timer, setTimer] = useState(-1);
|
const [isEvaluationLoading, setIsEvaluationLoading] = useState(false);
|
||||||
|
|
||||||
const [exams, setExams] = useExamStore((state) => [state.exams, state.setExams]);
|
const [exams, setExams] = useExamStore((state) => [state.exams, state.setExams]);
|
||||||
const [userSolutions, setUserSolutions] = useExamStore((state) => [state.userSolutions, state.setUserSolutions]);
|
const [userSolutions, setUserSolutions] = useExamStore((state) => [state.userSolutions, state.setUserSolutions]);
|
||||||
@@ -101,17 +102,6 @@ export default function Page() {
|
|||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [selectedModules, moduleIndex, hasBeenUploaded]);
|
}, [selectedModules, moduleIndex, hasBeenUploaded]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (exam) {
|
|
||||||
setTimer(exam.minTimer * 60);
|
|
||||||
const timerInterval = setInterval(() => setTimer((prev) => (prev && prev > 0 ? prev - 1 : 0)), 1000);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
clearInterval(timerInterval);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}, [exam]);
|
|
||||||
|
|
||||||
const getExam = async (module: Module): Promise<Exam | undefined> => {
|
const getExam = async (module: Module): Promise<Exam | undefined> => {
|
||||||
const examRequest = await axios<Exam[]>(`/api/exam/${module}`);
|
const examRequest = await axios<Exam[]>(`/api/exam/${module}`);
|
||||||
if (examRequest.status !== 200) {
|
if (examRequest.status !== 200) {
|
||||||
@@ -133,6 +123,24 @@ export default function Page() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const evaluateWritingAnswer = async (examId: string, exerciseId: string, answer: string) => {
|
||||||
|
const writingExam = exams.find((x) => x.id === examId)!;
|
||||||
|
const exercise = writingExam.exercises.find((x) => x.id === exerciseId)! as WritingExercise;
|
||||||
|
|
||||||
|
const response = await axios.post("/api/exam/writing/evaluate", {
|
||||||
|
question: `${exercise.prompt} ${exercise.attachment ? exercise.attachment.description : ""}`.replaceAll("\n", ""),
|
||||||
|
answer: answer.trim().replaceAll("\n", " "),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.status === 200) {
|
||||||
|
writingExam.exercises = [
|
||||||
|
...writingExam.exercises.filter((x) => x.id !== exerciseId),
|
||||||
|
Object.assign(exercise, {evaluation: response.data}),
|
||||||
|
];
|
||||||
|
setExams([...exams.filter((x) => x.id !== examId), writingExam].sort(sortByModule));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const updateExamWithUserSolutions = (exam: Exam): Exam => {
|
const updateExamWithUserSolutions = (exam: Exam): Exam => {
|
||||||
const exercises = exam.exercises.map((x) =>
|
const exercises = exam.exercises.map((x) =>
|
||||||
Object.assign(x, !x.userSolutions ? {userSolutions: userSolutions.find((y) => x.id === y.exercise)?.solutions} : x.userSolutions),
|
Object.assign(x, !x.userSolutions ? {userSolutions: userSolutions.find((y) => x.id === y.exercise)?.solutions} : x.userSolutions),
|
||||||
@@ -144,6 +152,17 @@ export default function Page() {
|
|||||||
const onFinish = (solutions: UserSolution[]) => {
|
const onFinish = (solutions: UserSolution[]) => {
|
||||||
const solutionIds = solutions.map((x) => x.exercise);
|
const solutionIds = solutions.map((x) => x.exercise);
|
||||||
|
|
||||||
|
if (exam && exam.module === "writing" && solutions.length > 0 && !showSolutions) {
|
||||||
|
setIsEvaluationLoading(true);
|
||||||
|
Promise.all(
|
||||||
|
exam.exercises.map((exercise) =>
|
||||||
|
evaluateWritingAnswer(exam.id, exercise.id, solutions.find((x) => x.exercise === exercise.id)!.solutions[0]),
|
||||||
|
),
|
||||||
|
).finally(() => {
|
||||||
|
setIsEvaluationLoading(false);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
setUserSolutions([...userSolutions.filter((x) => !solutionIds.includes(x.exercise)), ...solutions]);
|
setUserSolutions([...userSolutions.filter((x) => !solutionIds.includes(x.exercise)), ...solutions]);
|
||||||
setModuleIndex((prev) => prev + 1);
|
setModuleIndex((prev) => prev + 1);
|
||||||
};
|
};
|
||||||
@@ -193,6 +212,7 @@ export default function Page() {
|
|||||||
if (moduleIndex >= selectedModules.length) {
|
if (moduleIndex >= selectedModules.length) {
|
||||||
return (
|
return (
|
||||||
<Finish
|
<Finish
|
||||||
|
isLoading={isEvaluationLoading}
|
||||||
user={user!}
|
user={user!}
|
||||||
modules={selectedModules}
|
modules={selectedModules}
|
||||||
onViewResults={() => {
|
onViewResults={() => {
|
||||||
@@ -213,11 +233,6 @@ export default function Page() {
|
|||||||
return <Listening exam={exam} onFinish={onFinish} showSolutions={showSolutions} />;
|
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} onFinish={onFinish} showSolutions={showSolutions} />;
|
return <Writing exam={exam} onFinish={onFinish} showSolutions={showSolutions} />;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user