From 2b38f9df9bdcff1365eaff827ee6657225aaadb5 Mon Sep 17 00:00:00 2001 From: Tiago Ribeiro Date: Fri, 24 Mar 2023 18:09:05 +0000 Subject: [PATCH] Created the first exercise of the listening demo --- src/components/Exercises/FillBlanks.tsx | 80 ++++++++++++- src/components/Exercises/MatchSentences.tsx | 125 ++++++++++++-------- src/components/Exercises/MultipleChoice.tsx | 107 +++++++++++++++++ src/components/Exercises/index.tsx | 22 ++++ src/demo/listening.json | 64 ++++++++++ src/interfaces/exam.ts | 34 +++++- src/pages/exam/listening/[id].tsx | 90 ++++++++++++++ src/pages/exam/reading/[id].tsx | 35 ++---- 8 files changed, 473 insertions(+), 84 deletions(-) create mode 100644 src/components/Exercises/MultipleChoice.tsx create mode 100644 src/components/Exercises/index.tsx create mode 100644 src/demo/listening.json create mode 100644 src/pages/exam/listening/[id].tsx diff --git a/src/components/Exercises/FillBlanks.tsx b/src/components/Exercises/FillBlanks.tsx index 11d81c4f..8ae5e073 100644 --- a/src/components/Exercises/FillBlanks.tsx +++ b/src/components/Exercises/FillBlanks.tsx @@ -1,9 +1,12 @@ import {errorButtonStyle, infoButtonStyle} from "@/constants/buttonStyles"; import {FillBlanksExercise} from "@/interfaces/exam"; import {Dialog, Transition} from "@headlessui/react"; +import { mdiArrowLeft, mdiArrowRight } from "@mdi/js"; +import Icon from "@mdi/react"; import clsx from "clsx"; import {Fragment, useState} from "react"; import reactStringReplace from "react-string-replace"; +import { CommonProps } from "."; interface WordsPopoutProps { words: {word: string; isDisabled: boolean}[]; @@ -12,6 +15,8 @@ interface WordsPopoutProps { onAnswer: (answer: string) => void; } +type UserSolution = {id: string; solution: string}; + function WordsPopout({words, isOpen, onCancel, onAnswer}: WordsPopoutProps) { return ( @@ -67,7 +72,7 @@ function WordsPopout({words, isOpen, onCancel, onAnswer}: WordsPopoutProps) { ); } -export default function FillBlanks({allowRepetition, prompt, solutions, text, words}: FillBlanksExercise) { +export default function FillBlanks({allowRepetition, prompt, solutions, text, words, onNext, onBack}: FillBlanksExercise & CommonProps) { const [userSolutions, setUserSolutions] = useState<{id: string; solution: string}[]>([]); const [currentBlankId, setCurrentBlankId] = useState(); @@ -89,6 +94,7 @@ export default function FillBlanks({allowRepetition, prompt, solutions, text, wo }; return ( + <>
({word, isDisabled: allowRepetition ? false : userSolutions.map((x) => x.solution).includes(word)}))} @@ -109,5 +115,77 @@ 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 ( + + {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 ( + <> + + + ); + } + + if (userSolution.solution === solution.solution) { + return ; + } + + if (userSolution.solution !== solution.solution) { + return ( + <> + + + + ); + } + })} + + ); + }; + + return ( +
+ {prompt} + + {text.split("\n").map((line) => ( + <> + {renderLines(line)} +
+ + ))} +
+
); } diff --git a/src/components/Exercises/MatchSentences.tsx b/src/components/Exercises/MatchSentences.tsx index 1fc4cf29..2f46234f 100644 --- a/src/components/Exercises/MatchSentences.tsx +++ b/src/components/Exercises/MatchSentences.tsx @@ -1,11 +1,15 @@ +import {errorButtonStyle, infoButtonStyle} from "@/constants/buttonStyles"; import {MatchSentencesExercise} from "@/interfaces/exam"; +import {mdiArrowLeft, mdiArrowRight} from "@mdi/js"; +import Icon from "@mdi/react"; import clsx from "clsx"; import {useState} from "react"; import LineTo from "react-lineto"; +import {CommonProps} from "."; const AVAILABLE_COLORS = ["#63526a", "#f7651d", "#278f04", "#ef4487", "#ca68c0", "#f5fe9b", "#b3ab01", "#af963a", "#9a85f1", "#1b1750"]; -export default function MatchSentences({allowRepetition, options, prompt, sentences}: MatchSentencesExercise) { +export default function MatchSentences({allowRepetition, options, prompt, sentences, onNext, onBack}: MatchSentencesExercise & CommonProps) { const [selectedQuestion, setSelectedQuestion] = useState(); const [userSolutions, setUserSolutions] = useState<{question: string; option: string}[]>([]); @@ -20,60 +24,77 @@ export default function MatchSentences({allowRepetition, options, prompt, senten }; return ( -
- {prompt} -
-
- {sentences.map(({sentence, id, color}) => ( -
setSelectedQuestion((prev) => (prev === id ? undefined : id))}> - - {id}. {sentence}{" "} - + <> +
+ {prompt} +
+
+ {sentences.map(({sentence, id, color}) => (
-
- ))} -
-
- {options.map(({sentence, id}) => ( -
selectOption(id)}> -
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)} - /> - - {id}. {sentence}{" "} - -
- ))} -
- {userSolutions.map((solution, index) => ( -
- x.id === solution.question)!.color} - borderWidth={5} - /> + key={`question_${id}`} + className="flex items-center justify-end gap-2 cursor-pointer" + onClick={() => setSelectedQuestion((prev) => (prev === id ? undefined : id))}> + + {id}. {sentence}{" "} + +
+
+ ))}
- ))} +
+ {options.map(({sentence, id}) => ( +
selectOption(id)}> +
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)} + /> + + {id}. {sentence}{" "} + +
+ ))} +
+ {userSolutions.map((solution, index) => ( +
+ x.id === solution.question)!.color} + borderWidth={5} + /> +
+ ))} +
-
+ +
+ + +
+ ); } diff --git a/src/components/Exercises/MultipleChoice.tsx b/src/components/Exercises/MultipleChoice.tsx new file mode 100644 index 00000000..65a9a60c --- /dev/null +++ b/src/components/Exercises/MultipleChoice.tsx @@ -0,0 +1,107 @@ +/* eslint-disable @next/next/no-img-element */ +import {errorButtonStyle, infoButtonStyle} from "@/constants/buttonStyles"; +import {MultipleChoiceExercise, MultipleChoiceQuestion} from "@/interfaces/exam"; +import {mdiArrowLeft, mdiArrowRight} from "@mdi/js"; +import Icon from "@mdi/react"; +import clsx from "clsx"; +import {useState} from "react"; +import {CommonProps} from "."; + +function Question({ + variant, + id, + prompt, + solution, + options, + userSolution, + onSelectOption, +}: MultipleChoiceQuestion & {userSolution: string | undefined; onSelectOption: (option: string) => void}) { + return ( +
+ {prompt} +
+ {variant === "image" && + options.map((option) => ( +
onSelectOption(option.id)} + className={clsx( + "flex flex-col items-center border-2 p-4 rounded-xl gap-4 cursor-pointer bg-white", + userSolution === option.id && "border-blue-400", + )}> + {`Option + {option.id} +
+ ))} + {variant === "text" && + options.map((option) => ( +
onSelectOption(option.id)} + className={clsx( + "flex border-2 p-4 rounded-xl gap-2 cursor-pointer bg-white", + userSolution === option.id && "border-blue-400", + )}> + {option.id}. + {option.text} +
+ ))} +
+
+ ); +} + +export default function MultipleChoice({prompt, questions, onNext, onBack}: MultipleChoiceExercise & CommonProps) { + const [userSolutions, setUserSolutions] = useState<{question: string; option: string}[]>([]); + const [questionIndex, setQuestionIndex] = useState(0); + + const onSelectOption = (option: string) => { + const question = questions[questionIndex]; + setUserSolutions((prev) => [...prev.filter((x) => x.question !== question.id), {option, question: question.id}]); + }; + + const next = () => { + if (questionIndex === questions.length) { + onNext(); + } else { + setQuestionIndex((prev) => prev + 1); + } + }; + + const back = () => { + if (questionIndex === 0) { + onBack(); + } else { + setQuestionIndex((prev) => prev - 1); + } + }; + + return ( + <> +
+ {prompt} + {questionIndex < questions.length && ( + questions[questionIndex].id === x.question)?.option} + onSelectOption={onSelectOption} + /> + )} +
+
+ + +
+ + ); +} diff --git a/src/components/Exercises/index.tsx b/src/components/Exercises/index.tsx new file mode 100644 index 00000000..3fd98dc7 --- /dev/null +++ b/src/components/Exercises/index.tsx @@ -0,0 +1,22 @@ +import {Exercise, FillBlanksExercise, MatchSentencesExercise, MultipleChoiceExercise} from "@/interfaces/exam"; +import dynamic from "next/dynamic"; +import FillBlanks from "./FillBlanks"; +import MultipleChoice from "./MultipleChoice"; + +const MatchSentences = dynamic(() => import("@/components/Exercises/MatchSentences"), {ssr: false}); + +export interface CommonProps { + onNext: () => void; + onBack: () => void; +} + +export const renderExercise = (exercise: Exercise, onNext: () => void, onBack: () => void) => { + switch (exercise.type) { + case "fillBlanks": + return ; + case "matchSentences": + return ; + case "multipleChoice": + return ; + } +}; diff --git a/src/demo/listening.json b/src/demo/listening.json new file mode 100644 index 00000000..46e9e03b --- /dev/null +++ b/src/demo/listening.json @@ -0,0 +1,64 @@ +{ + "audio": { + "title": "", + "source": "", + "transcript": "", + "repeatableTimes": 3 + }, + "exercises": [ + { + "type": "multipleChoice", + "prompt": "Select the appropriate option", + "questions": [ + { + "id": "1", + "prompt": "What does her briefcase look like?", + "solution": "A", + "variant": "image", + "options": [ + { + "id": "A", + "src": "https://i.imgur.com/sU7SLvF.png" + }, + { + "id": "B", + "src": "https://i.imgur.com/i5RacYK.png" + }, + { + "id": "C", + "src": "https://i.imgur.com/rEbrSqA.png" + }, + { + "id": "D", + "src": "https://i.imgur.com/2lZZ9kM.png" + } + ] + }, + { + "id": "2", + "prompt": "What did she have inside her briefcase?", + "solution": "D", + "variant": "text", + "options": [ + { + "id": "A", + "text": "wallet, pens and novel" + }, + { + "id": "B", + "text": "papers and wallet" + }, + { + "id": "C", + "text": "pens and novel" + }, + { + "id": "D", + "text": "papers, pens and novel" + } + ] + } + ] + } + ] +} \ No newline at end of file diff --git a/src/interfaces/exam.ts b/src/interfaces/exam.ts index de0c645d..97c340d8 100644 --- a/src/interfaces/exam.ts +++ b/src/interfaces/exam.ts @@ -1,5 +1,3 @@ -export type Type = "fillBlanks" | "matchingSentences"; - export interface ReadingExam { text: { title: string; @@ -8,7 +6,17 @@ export interface ReadingExam { exercises: Exercise[]; } -type Exercise = FillBlanksExercise | MatchSentencesExercise; +export interface ListeningExam { + audio: { + title: string; + source: string; + transcript: string; + repeatableTimes: number; // *The amount of times the user is allowed to repeat the audio, 0 for unlimited + }; + exercises: Exercise[]; +} + +export type Exercise = FillBlanksExercise | MatchSentencesExercise | MultipleChoiceExercise; export interface FillBlanksExercise { prompt: string; // *EXAMPLE: "Complete the summary below. Click a blank to select the corresponding word for it." @@ -23,7 +31,7 @@ export interface FillBlanksExercise { } export interface MatchSentencesExercise { - type: string; + type: "matchSentences"; prompt: string; sentences: { id: string; @@ -37,3 +45,21 @@ export interface MatchSentencesExercise { sentence: string; }[]; } + +export interface MultipleChoiceExercise { + type: "multipleChoice"; + prompt: string; // *EXAMPLE: "Select the appropriate option." + questions: MultipleChoiceQuestion[]; +} + +export interface MultipleChoiceQuestion { + variant: "image" | "text"; + id: string; // *EXAMPLE: "1" + prompt: string; // *EXAMPLE: "What does her briefcase look like?" + solution: string; // *EXAMPLE: "A" + options: { + id: string; // *EXAMPLE: "A" + src?: string; // *EXAMPLE: "https://i.imgur.com/rEbrSqA.png" (only used if the variant is "image") + text?: string; // *EXAMPLE: "wallet, pens and novel" (only used if the variant is "text") + }[]; +} diff --git a/src/pages/exam/listening/[id].tsx b/src/pages/exam/listening/[id].tsx new file mode 100644 index 00000000..060a49ea --- /dev/null +++ b/src/pages/exam/listening/[id].tsx @@ -0,0 +1,90 @@ +import Navbar from "@/components/Navbar"; +import {ListeningExam} from "@/interfaces/exam"; +import Head from "next/head"; + +// TODO: Remove this import +import JSON_LISTENING from "@/demo/listening.json"; +import JSON_USER from "@/demo/user.json"; +import {useState} from "react"; +import Icon from "@mdi/react"; +import {mdiArrowRight} from "@mdi/js"; +import clsx from "clsx"; +import {infoButtonStyle} from "@/constants/buttonStyles"; +import {renderExercise} from "@/components/Exercises"; + +interface Props { + exam: ListeningExam; +} + +export const getServerSideProps = () => { + return { + props: { + exam: JSON_LISTENING, + }, + }; +}; + +export default function Listening({exam}: Props) { + const [exerciseIndex, setExerciseIndex] = useState(-1); + const [timesListened, setTimesListened] = useState(0); + + const nextExercise = () => { + setExerciseIndex((prev) => prev + 1); + }; + + const previousExercise = () => { + setExerciseIndex((prev) => prev - 1); + }; + + const renderAudioPlayer = () => ( + <> + {exerciseIndex === -1 && ( +
+ Please listen to the following audio attentively. + {exam.audio.repeatableTimes > 0 ? ( + + You will only be allowed to listen to the audio {exam.audio.repeatableTimes} time(s). + + ) : ( + You may listen to the audio as many times as you would like. + )} +
+ )} +
+ {exam.audio.title} + {exam.audio.repeatableTimes > 0 && ( + <>{exam.audio.repeatableTimes <= timesListened && You are no longer allowed to listen to the audio again.} + )} + AUDIO WILL GO HERE +
+ + ); + + return ( + <> + + Create Next App + + + + +
+ +
+ {renderAudioPlayer()} + {exerciseIndex > -1 && + exerciseIndex < exam.exercises.length && + renderExercise(exam.exercises[exerciseIndex], nextExercise, previousExercise)} + {exerciseIndex === -1 && ( + + )} +
+
+ + ); +} diff --git a/src/pages/exam/reading/[id].tsx b/src/pages/exam/reading/[id].tsx index c61fd1a7..1539d199 100644 --- a/src/pages/exam/reading/[id].tsx +++ b/src/pages/exam/reading/[id].tsx @@ -1,5 +1,5 @@ import Navbar from "@/components/Navbar"; -import {FillBlanksExercise, MatchSentencesExercise, ReadingExam} from "@/interfaces/exam"; +import {ReadingExam} from "@/interfaces/exam"; import Head from "next/head"; // TODO: Remove this import @@ -10,11 +10,8 @@ import Icon from "@mdi/react"; import {mdiArrowLeft, mdiArrowRight, mdiNotebook} from "@mdi/js"; import clsx from "clsx"; import {errorButtonStyle, infoButtonStyle} from "@/constants/buttonStyles"; -import FillBlanks from "@/components/Exercises/FillBlanks"; import {Dialog, Transition} from "@headlessui/react"; -import dynamic from "next/dynamic"; - -const MatchSentences = dynamic(() => import("@/components/Exercises/MatchSentences"), {ssr: false}); +import {renderExercise} from "@/components/Exercises"; interface Props { exam: ReadingExam; @@ -119,16 +116,6 @@ export default function Reading({exam}: Props) { ); - const renderQuestion = () => { - const exercise = exam.exercises[exerciseIndex]; - switch (exercise.type) { - case "fillBlanks": - return ; - case "matchSentences": - return ; - } - }; - return ( <> @@ -142,7 +129,9 @@ export default function Reading({exam}: Props) { setShowTextModal(false)} />
{exerciseIndex === -1 && renderText()} - {exerciseIndex > -1 && exerciseIndex < exam.exercises.length && renderQuestion()} + {exerciseIndex > -1 && + exerciseIndex < exam.exercises.length && + renderExercise(exam.exercises[exerciseIndex], nextExercise, previousExercise)}
-1 ? "w-full justify-between" : "self-end")}> {exerciseIndex > -1 && ( )} -
- {exerciseIndex > -1 && ( - - )} - -
+ )}