From 322d7905c3ade720cb1a8daedf9bc24cef46082c Mon Sep 17 00:00:00 2001 From: Carlos-Mesquita Date: Sun, 10 Nov 2024 04:24:23 +0000 Subject: [PATCH] Reverted Level to only utas placement test exercises, Speaking, bug fixes, placeholder --- .../MultipleChoice/Underline/index.tsx | 1 + .../MultipleChoice/Vanilla/index.tsx | 2 + .../Speaking/InteractiveSpeaking.tsx | 95 ++++- .../Exercises/Speaking/Speaking1.tsx | 108 +++++- .../Exercises/Speaking/Speaking2.tsx | 63 ++- .../SectionContext/reading.tsx | 3 +- .../SectionExercises/index.tsx | 2 +- .../SectionExercises/level.tsx | 22 +- .../SettingsEditor/Shared/Generate.ts | 2 + .../SettingsEditor/Shared/GenerateBtn.tsx | 7 +- .../SettingsEditor/Shared/generateVideos.ts | 125 ++++++ .../ExamEditor/SettingsEditor/index.tsx | 1 + .../ExamEditor/SettingsEditor/level.tsx | 117 ++++-- .../ExamEditor/SettingsEditor/listening.tsx | 40 +- .../ExamEditor/SettingsEditor/reading.tsx | 31 +- .../ExamEditor/SettingsEditor/speaking.tsx | 360 ++++++++++++++++-- .../Shared/ExercisePicker/ExerciseWizard.tsx | 43 ++- .../Shared/ExercisePicker/exercises.ts | 37 +- .../ExercisePicker/generatedExercises.ts | 2 +- .../Shared/ExercisePicker/index.tsx | 26 +- .../Shared/ImportExam/WordUploader.tsx | 26 +- src/components/ExamEditor/Shared/Passage.tsx | 65 ++-- src/components/ExamEditor/index.tsx | 4 +- .../Exercises/InteractiveSpeaking.tsx | 9 +- src/components/Exercises/Speaking.tsx | 46 ++- src/components/Low/Input.tsx | 4 +- src/components/Popouts/Exam.tsx | 10 +- src/exams/Level/index.tsx | 13 +- src/exams/Navigation/SectionDivider.tsx | 4 +- src/exams/Speaking.tsx | 111 ++++-- src/interfaces/exam.ts | 4 + src/pages/api/exam/generate/[...module].ts | 2 +- src/pages/api/exam/media/[...module].ts | 53 ++- src/pages/api/exam/media/poll.ts | 26 ++ src/pages/generation.tsx | 5 +- src/stores/examEditor/defaults.ts | 2 +- src/stores/examEditor/reducers/index.ts | 27 +- src/stores/examEditor/reorder/global.ts | 23 +- src/stores/examEditor/types.ts | 9 +- 39 files changed, 1251 insertions(+), 279 deletions(-) create mode 100644 src/components/ExamEditor/SettingsEditor/Shared/generateVideos.ts create mode 100644 src/pages/api/exam/media/poll.ts diff --git a/src/components/ExamEditor/Exercises/MultipleChoice/Underline/index.tsx b/src/components/ExamEditor/Exercises/MultipleChoice/Underline/index.tsx index 0b2114cc..76f02b3f 100644 --- a/src/components/ExamEditor/Exercises/MultipleChoice/Underline/index.tsx +++ b/src/components/ExamEditor/Exercises/MultipleChoice/Underline/index.tsx @@ -88,6 +88,7 @@ const UnderlineMultipleChoice: React.FC<{exercise: MultipleChoiceExercise, secti dispatch({ type: "UPDATE_SECTION_STATE", payload: { sectionId, update: newSection } }); }, onDiscard: () => { + setAlerts([]); setLocal(exercise); setEditing(false); }, diff --git a/src/components/ExamEditor/Exercises/MultipleChoice/Vanilla/index.tsx b/src/components/ExamEditor/Exercises/MultipleChoice/Vanilla/index.tsx index 76e1c396..2f9c9670 100644 --- a/src/components/ExamEditor/Exercises/MultipleChoice/Vanilla/index.tsx +++ b/src/components/ExamEditor/Exercises/MultipleChoice/Vanilla/index.tsx @@ -167,6 +167,8 @@ const MultipleChoice: React.FC = ({ exercise, sectionId, op dispatch({ type: "UPDATE_SECTION_STATE", payload: { sectionId, update: newSection } }); }, onDiscard: () => { + setEditing(false); + setAlerts([]); setLocal(exercise); }, onMode: () => { diff --git a/src/components/ExamEditor/Exercises/Speaking/InteractiveSpeaking.tsx b/src/components/ExamEditor/Exercises/Speaking/InteractiveSpeaking.tsx index 83d948d6..ac86f827 100644 --- a/src/components/ExamEditor/Exercises/Speaking/InteractiveSpeaking.tsx +++ b/src/components/ExamEditor/Exercises/Speaking/InteractiveSpeaking.tsx @@ -10,6 +10,8 @@ import useSectionEdit from "../../Hooks/useSectionEdit"; import useExamEditorStore from "@/stores/examEditor"; import { InteractiveSpeakingExercise } from "@/interfaces/exam"; import { BsFileText } from "react-icons/bs"; +import { FaChevronLeft, FaChevronRight } from "react-icons/fa6"; +import { RiVideoLine } from "react-icons/ri"; interface Props { sectionId: number; @@ -20,6 +22,8 @@ const InteractiveSpeaking: React.FC = ({ sectionId, exercise }) => { const { currentModule, dispatch } = useExamEditorStore(); const [local, setLocal] = useState(exercise); + const [currentVideoIndex, setCurrentVideoIndex] = useState(0); + const { generating, genResult } = useExamEditorStore( (state) => state.modules[currentModule].sections.find((section) => section.sectionId === sectionId)! ); @@ -86,6 +90,42 @@ const InteractiveSpeaking: React.FC = ({ sectionId, exercise }) => { const isUnedited = local.prompts.length === 0; + useEffect(() => { + if (genResult && generating === "media") { + setLocal({ ...local, prompts: genResult[0].prompts }); + dispatch({ type: "UPDATE_SECTION_STATE", payload: { sectionId, update: { ...local, prompts: genResult[0].prompts } } }); + dispatch({ + type: "UPDATE_SECTION_SINGLE_FIELD", + payload: { + sectionId, + module: currentModule, + field: "generating", + value: undefined + } + }); + dispatch({ + type: "UPDATE_SECTION_SINGLE_FIELD", + payload: { + sectionId, + module: currentModule, + field: "genResult", + value: undefined + } + }); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [genResult, generating]); + + const handlePrevVideo = () => { + setCurrentVideoIndex((prev) => (prev > 0 ? prev - 1 : prev)); + }; + + const handleNextVideo = () => { + setCurrentVideoIndex((prev) => + (prev < local.prompts.length - 1 ? prev + 1 : prev) + ); + }; + return ( <>
@@ -100,12 +140,65 @@ const InteractiveSpeaking: React.FC = ({ sectionId, exercise }) => { module="speaking" />
- {generating ? ( + {generating && generating === "context" ? ( ) : ( <> {editing ? ( <> + {local.prompts.every((p) => p.video_url !== "") && ( + + +
+
+
+ +

Videos

+
+
+ + + {currentVideoIndex + 1} / {local.prompts.length} + + +
+
+
+
+ +
+
+
+
+
+ )} + {generating && generating === "media" && + + }
diff --git a/src/components/ExamEditor/Exercises/Speaking/Speaking1.tsx b/src/components/ExamEditor/Exercises/Speaking/Speaking1.tsx index 33ffb90d..9cb8fe93 100644 --- a/src/components/ExamEditor/Exercises/Speaking/Speaking1.tsx +++ b/src/components/ExamEditor/Exercises/Speaking/Speaking1.tsx @@ -8,6 +8,8 @@ import useSectionEdit from "../../Hooks/useSectionEdit"; import useExamEditorStore from "@/stores/examEditor"; import { InteractiveSpeakingExercise } from "@/interfaces/exam"; import { BsFileText } from "react-icons/bs"; +import { RiVideoLine } from 'react-icons/ri'; +import { FaChevronLeft, FaChevronRight } from 'react-icons/fa6'; interface Props { sectionId: number; @@ -25,16 +27,7 @@ const Speaking1: React.FC = ({ sectionId, exercise }) => { return { ...exercise, prompts: defaultPrompts }; }); - const updateAvatarName = (avatarName: string) => { - setLocal(prev => { - const updatedPrompts = [...prev.prompts]; - updatedPrompts[0] = { - ...updatedPrompts[0], - text: updatedPrompts[0].text.replace("{avatar}", avatarName) - }; - return { ...prev, prompts: updatedPrompts }; - }); - }; + const [currentVideoIndex, setCurrentVideoIndex] = useState(0); const { generating, genResult } = useExamEditorStore( (state) => state.modules[currentModule].sections.find((section) => section.sectionId === sectionId)! @@ -87,7 +80,7 @@ const Speaking1: React.FC = ({ sectionId, exercise }) => { } }); } - // eslint-disable-next-line react-hooks/exhaustive-deps + // eslint-disable-next-line react-hooks/exhaustive-deps }, [genResult, generating]); const addPrompt = () => { @@ -116,6 +109,44 @@ const Speaking1: React.FC = ({ sectionId, exercise }) => { const isUnedited = local.prompts.length === 2; + + useEffect(() => { + if (genResult && generating === "media") { + console.log(genResult[0].prompts); + setLocal({ ...local, prompts: genResult[0].prompts }); + dispatch({ type: "UPDATE_SECTION_STATE", payload: { sectionId, update: { ...local, prompts: genResult[0].prompts } } }); + dispatch({ + type: "UPDATE_SECTION_SINGLE_FIELD", + payload: { + sectionId, + module: currentModule, + field: "generating", + value: undefined + } + }); + dispatch({ + type: "UPDATE_SECTION_SINGLE_FIELD", + payload: { + sectionId, + module: currentModule, + field: "genResult", + value: undefined + } + }); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [genResult, generating]); + + const handlePrevVideo = () => { + setCurrentVideoIndex((prev) => (prev > 0 ? prev - 1 : prev)); + }; + + const handleNextVideo = () => { + setCurrentVideoIndex((prev) => + (prev < local.prompts.length - 1 ? prev + 1 : prev) + ); + }; + return ( <>
@@ -130,7 +161,7 @@ const Speaking1: React.FC = ({ sectionId, exercise }) => { module="speaking" />
- {generating ? ( + {generating && generating === "context" ? ( ) : ( <> @@ -224,6 +255,59 @@ const Speaking1: React.FC = ({ sectionId, exercise }) => {

) : (
+ {local.prompts.every((p) => p.video_url !== "") && ( + + +
+
+
+ +

Videos

+
+
+ + + {currentVideoIndex + 1} / {local.prompts.length} + + +
+
+
+
+ +
+
+
+
+
+ )} + {generating && generating === "media" && + + }
diff --git a/src/components/ExamEditor/Exercises/Speaking/Speaking2.tsx b/src/components/ExamEditor/Exercises/Speaking/Speaking2.tsx index 1a5df28f..1b506e5f 100644 --- a/src/components/ExamEditor/Exercises/Speaking/Speaking2.tsx +++ b/src/components/ExamEditor/Exercises/Speaking/Speaking2.tsx @@ -11,12 +11,14 @@ import { BsFileText } from 'react-icons/bs'; import { AiOutlineUnorderedList } from 'react-icons/ai'; import { BiQuestionMark, BiMessageRoundedDetail } from "react-icons/bi"; import GenLoader from "../Shared/GenLoader"; +import { RiVideoLine } from 'react-icons/ri'; interface Props { sectionId: number; exercise: SpeakingExercise; } + const Speaking2: React.FC = ({ sectionId, exercise }) => { const { currentModule, dispatch } = useExamEditorStore(); const [local, setLocal] = useState(exercise); @@ -25,18 +27,12 @@ const Speaking2: React.FC = ({ sectionId, exercise }) => { (state) => state.modules[currentModule].sections.find((section) => section.sectionId === sectionId)! ); - - const updateTopic = (topic: string) => { - setLocal(prev => ({ ...prev, topic: topic })); - dispatch({ type: "UPDATE_SECTION_STATE", payload: { sectionId, update: { topic: topic } } }); - }; - - const { editing, setEditing, handleSave, handleDiscard, modeHandle } = useSectionEdit({ sectionId, mode: "edit", onSave: () => { setEditing(false); + console.log(local); dispatch({ type: "UPDATE_SECTION_STATE", payload: { sectionId: sectionId, update: local } }); }, onDiscard: () => { @@ -69,6 +65,32 @@ const Speaking2: React.FC = ({ sectionId, exercise }) => { // eslint-disable-next-line react-hooks/exhaustive-deps }, [genResult, generating]); + useEffect(() => { + if (genResult && generating === "media") { + setLocal({...local, video_url: genResult[0].video_url}); + dispatch({ type: "UPDATE_SECTION_STATE", payload: { sectionId, update: {...local, video_url: genResult[0].video_url} } }); + dispatch({ + type: "UPDATE_SECTION_SINGLE_FIELD", + payload: { + sectionId, + module: currentModule, + field: "generating", + value: undefined + } + }); + dispatch({ + type: "UPDATE_SECTION_SINGLE_FIELD", + payload: { + sectionId, + module: currentModule, + field: "genResult", + value: undefined + } + }); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [genResult, generating]); + const addPrompt = () => { setLocal(prev => ({ ...prev, @@ -91,9 +113,9 @@ const Speaking2: React.FC = ({ sectionId, exercise }) => { }); }; - const isUnedited = local.text === "" || - (local.title === undefined || local.title === "") || - local.prompts.length === 0; + const isUnedited = local.text === "" || + (local.title === undefined || local.title === "") || + local.prompts.length === 0; const tooltipContent = `
@@ -122,7 +144,7 @@ const Speaking2: React.FC = ({ sectionId, exercise }) => { module="speaking" />
- {generating ? ( + {generating && generating === "context" ? ( ) : ( <> @@ -222,6 +244,25 @@ const Speaking2: React.FC = ({ sectionId, exercise }) => {

) : (
+ {local.video_url && + +
+
+ +

Video

+
+
+ +
+
+
+
+ } + {generating && generating === "media" && + + }
diff --git a/src/components/ExamEditor/SectionRenderer/SectionContext/reading.tsx b/src/components/ExamEditor/SectionRenderer/SectionContext/reading.tsx index 5c5276ac..7c357d7a 100644 --- a/src/components/ExamEditor/SectionRenderer/SectionContext/reading.tsx +++ b/src/components/ExamEditor/SectionRenderer/SectionContext/reading.tsx @@ -41,8 +41,9 @@ const ReadingContext: React.FC<{sectionId: number;}> = ({sectionId}) => { useEffect(()=> { if (genResult !== undefined && generating === "context") { setEditing(true); + console.log(genResult); setTitle(genResult[0].title); - setContent(genResult[0].text) + setContent(genResult[0].text); dispatch({type: "UPDATE_SECTION_SINGLE_FIELD", payload: {sectionId, module: currentModule, field: "genResult", value: undefined}}) } // eslint-disable-next-line react-hooks/exhaustive-deps diff --git a/src/components/ExamEditor/SectionRenderer/SectionExercises/index.tsx b/src/components/ExamEditor/SectionRenderer/SectionExercises/index.tsx index 7f40e22e..b41c4929 100644 --- a/src/components/ExamEditor/SectionRenderer/SectionExercises/index.tsx +++ b/src/components/ExamEditor/SectionRenderer/SectionExercises/index.tsx @@ -131,7 +131,7 @@ const SectionExercises: React.FC<{ sectionId: number; }> = ({ sectionId }) => { collisionDetection={closestCenter} onDragEnd={(e) => dispatch({ type: "REORDER_EXERCISES", payload: { event: e, sectionId } })} > - {(currentModule === "level" && questions.ids?.length === 0) ? ( + {(currentModule === "level" && questions.ids?.length === 0 && generating === undefined) ? ( background(Generated exercises will appear here!) ) : ( expandedSections.includes(sectionId) && diff --git a/src/components/ExamEditor/SectionRenderer/SectionExercises/level.tsx b/src/components/ExamEditor/SectionRenderer/SectionExercises/level.tsx index 8d40da04..120d1ea9 100644 --- a/src/components/ExamEditor/SectionRenderer/SectionExercises/level.tsx +++ b/src/components/ExamEditor/SectionRenderer/SectionExercises/level.tsx @@ -3,6 +3,7 @@ import ExerciseItem, { isExerciseItem } from "./types"; import ExerciseLabel from "../../Shared/ExerciseLabel"; import MultipleChoice from "../../Exercises/MultipleChoice"; import FillBlanksMC from "../../Exercises/Blanks/MultipleChoice"; +import Passage from "../../Shared/Passage"; const getLevelQuestionItems = (exercises: Exercise[], sectionId: number): ExerciseItem[] => { @@ -14,6 +15,19 @@ const getLevelQuestionItems = (exercises: Exercise[], sectionId: number): Exerci let firstWordId, lastWordId; switch (exercise.type) { case "multipleChoice": + let content = ; + const isReadingPassage = exercise.mcVariant && exercise.mcVariant === "passageUtas"; + if (isReadingPassage) { + content = (<> +
+ +
+ + ); + } firstWordId = exercise.questions[0].id; lastWordId = exercise.questions[exercise.questions.length - 1].id; return { @@ -21,7 +35,7 @@ const getLevelQuestionItems = (exercises: Exercise[], sectionId: number): Exerci sectionId, label: ( "{previewLabel(exercise.prompt)}..." @@ -29,7 +43,7 @@ const getLevelQuestionItems = (exercises: Exercise[], sectionId: number): Exerci } /> ), - content: + content }; case "fillBlanks": firstWordId = exercise.solutions[0].id; @@ -39,7 +53,7 @@ const getLevelQuestionItems = (exercises: Exercise[], sectionId: number): Exerci sectionId, label: ( "{previewLabel(exercise.prompt)}..." @@ -54,7 +68,7 @@ const getLevelQuestionItems = (exercises: Exercise[], sectionId: number): Exerci } }).filter(isExerciseItem); - return items; + return items; }; diff --git a/src/components/ExamEditor/SettingsEditor/Shared/Generate.ts b/src/components/ExamEditor/SettingsEditor/Shared/Generate.ts index e1f09bda..0fe09d76 100644 --- a/src/components/ExamEditor/SettingsEditor/Shared/Generate.ts +++ b/src/components/ExamEditor/SettingsEditor/Shared/Generate.ts @@ -42,6 +42,8 @@ export function generate( const url = `/api/exam/generate/${module}/${sectionId}${queryString ? `?${queryString}` : ''}`; + console.log(config.body); + const request = config.method === 'POST' ? axios.post(url, config.body) : axios.get(url); diff --git a/src/components/ExamEditor/SettingsEditor/Shared/GenerateBtn.tsx b/src/components/ExamEditor/SettingsEditor/Shared/GenerateBtn.tsx index 31fab026..cdc3b36e 100644 --- a/src/components/ExamEditor/SettingsEditor/Shared/GenerateBtn.tsx +++ b/src/components/ExamEditor/SettingsEditor/Shared/GenerateBtn.tsx @@ -14,7 +14,12 @@ interface Props { } const GenerateBtn: React.FC = ({module, sectionId, genType, generateFnc, className}) => { - const {generating} = useExamEditorStore((store) => store.modules[module].sections.find((s)=> s.sectionId == sectionId))!; + const section = useExamEditorStore((store) => store.modules[module].sections.find((s)=> s.sectionId == sectionId)); + if (section === undefined) return; + + const {generating} = section; + + const loading = generating && generating === genType; return ( - + ) : ( + + )}
diff --git a/src/components/Exercises/Speaking.tsx b/src/components/Exercises/Speaking.tsx index 12e942d0..7875a626 100644 --- a/src/components/Exercises/Speaking.tsx +++ b/src/components/Exercises/Speaking.tsx @@ -1,20 +1,20 @@ -import {SpeakingExercise} from "@/interfaces/exam"; -import {CommonProps} from "."; -import {Fragment, useEffect, useState} from "react"; -import {BsCheckCircleFill, BsMicFill, BsPauseCircle, BsPlayCircle, BsTrashFill} from "react-icons/bs"; +import { SpeakingExercise } from "@/interfaces/exam"; +import { CommonProps } from "."; +import { Fragment, useEffect, useState } from "react"; +import { BsCheckCircleFill, BsMicFill, BsPauseCircle, BsPlayCircle, BsTrashFill } from "react-icons/bs"; import dynamic from "next/dynamic"; import Button from "../Low/Button"; import useExamStore from "@/stores/examStore"; -import {downloadBlob} from "@/utils/evaluation"; +import { downloadBlob } from "@/utils/evaluation"; import axios from "axios"; import Modal from "../Modal"; -const Waveform = dynamic(() => import("../Waveform"), {ssr: false}); +const Waveform = dynamic(() => import("../Waveform"), { ssr: false }); const ReactMediaRecorder = dynamic(() => import("react-media-recorder").then((mod) => mod.ReactMediaRecorder), { ssr: false, }); -export default function Speaking({id, title, text, video_url, type, prompts, suffix, userSolutions, onNext, onBack}: SpeakingExercise & CommonProps) { +export default function Speaking({ id, title, text, video_url, type, prompts, suffix, userSolutions, onNext, onBack, preview = false }: SpeakingExercise & CommonProps) { const [recordingDuration, setRecordingDuration] = useState(0); const [isRecording, setIsRecording] = useState(false); const [mediaBlob, setMediaBlob] = useState(); @@ -28,7 +28,7 @@ export default function Speaking({id, title, text, video_url, type, prompts, suf const saveToStorage = async () => { if (mediaBlob && mediaBlob.startsWith("blob")) { const blobBuffer = await downloadBlob(mediaBlob); - const audioFile = new File([blobBuffer], "audio.wav", {type: "audio/wav"}); + const audioFile = new File([blobBuffer], "audio.wav", { type: "audio/wav" }); const seed = Math.random().toString().replace("0.", ""); @@ -42,8 +42,8 @@ export default function Speaking({id, title, text, video_url, type, prompts, suf }, }; - const response = await axios.post<{path: string}>("/api/storage/insert", formData, config); - if (audioURL) await axios.post("/api/storage/delete", {path: audioURL}); + const response = await axios.post<{ path: string }>("/api/storage/insert", formData, config); + if (audioURL) await axios.post("/api/storage/delete", { path: audioURL }); return response.data.path; } @@ -52,7 +52,7 @@ export default function Speaking({id, title, text, video_url, type, prompts, suf useEffect(() => { if (userSolutions.length > 0) { - const {solution} = userSolutions[0] as {solution?: string}; + const { solution } = userSolutions[0] as { solution?: string }; if (solution && !mediaBlob) setMediaBlob(solution); if (solution && !solution.startsWith("blob")) setAudioURL(solution); } @@ -79,8 +79,8 @@ export default function Speaking({id, title, text, video_url, type, prompts, suf const next = async () => { onNext({ exercise: id, - solutions: mediaBlob ? [{id, solution: mediaBlob}] : [], - score: {correct: 0, total: 100, missing: 0}, + solutions: mediaBlob ? [{ id, solution: mediaBlob }] : [], + score: { correct: 0, total: 100, missing: 0 }, type, }); }; @@ -88,8 +88,8 @@ export default function Speaking({id, title, text, video_url, type, prompts, suf const back = async () => { onBack({ exercise: id, - solutions: mediaBlob ? [{id, solution: mediaBlob}] : [], - score: {correct: 0, total: 100, missing: 0}, + solutions: mediaBlob ? [{ id, solution: mediaBlob }] : [], + score: { correct: 0, total: 100, missing: 0 }, type, }); }; @@ -115,6 +115,8 @@ export default function Speaking({id, title, text, video_url, type, prompts, suf } }; + console.log(preview); + return (
@@ -189,7 +191,7 @@ export default function Speaking({id, title, text, video_url, type, prompts, suf setMediaBlob(blob)} - render={({status, startRecording, stopRecording, pauseRecording, resumeRecording, clearBlobUrl, mediaBlobUrl}) => ( + render={({ status, startRecording, stopRecording, pauseRecording, resumeRecording, clearBlobUrl, mediaBlobUrl }) => (

Record your answer:

@@ -307,9 +309,15 @@ export default function Speaking({id, title, text, video_url, type, prompts, suf - + {preview ? ( + + ) : ( + + )}
diff --git a/src/components/Low/Input.tsx b/src/components/Low/Input.tsx index ae1b8710..e2c26a27 100644 --- a/src/components/Low/Input.tsx +++ b/src/components/Low/Input.tsx @@ -12,6 +12,7 @@ interface Props { className?: string; disabled?: boolean; max?: number; + min?: number; name: string; onChange: (value: string) => void; } @@ -28,6 +29,7 @@ export default function Input({ className, roundness = "full", disabled = false, + min, onChange, }: Props) { const [showPassword, setShowPassword] = useState(false); @@ -90,7 +92,7 @@ export default function Input({ value={value} max={max} onChange={(e) => onChange(e.target.value)} - min={type === "number" ? 0 : undefined} + min={type === "number" ? (min ?? 0) : undefined} placeholder={placeholder} className={clsx( "px-8 py-6 text-sm font-normal bg-white border border-mti-gray-platinum focus:outline-none", diff --git a/src/components/Popouts/Exam.tsx b/src/components/Popouts/Exam.tsx index 8d8b2d8f..980fcae7 100644 --- a/src/components/Popouts/Exam.tsx +++ b/src/components/Popouts/Exam.tsx @@ -1,9 +1,10 @@ import Level from "@/exams/Level"; import Listening from "@/exams/Listening"; import Reading from "@/exams/Reading"; +import Speaking from "@/exams/Speaking"; import Writing from "@/exams/Writing"; import { usePersistentStorage } from "@/hooks/usePersistentStorage"; -import { LevelExam, ListeningExam, ReadingExam, WritingExam } from "@/interfaces/exam"; +import { LevelExam, ListeningExam, ReadingExam, SpeakingExam, WritingExam } from "@/interfaces/exam"; import { User } from "@/interfaces/user"; import { usePersistentExamStore } from "@/stores/examStore"; import clsx from "clsx"; @@ -21,7 +22,7 @@ const Popout: React.FC<{ user: User }> = ({ user }) => { state.setPartIndex(0); state.setExerciseIndex(0); state.setQuestionIndex(0); - }} showSolutions={true} preview={true} /> + }} preview={true} /> } {state.exam?.module == "writing" && state.exam.exercises && state.partIndex >= 0 && { @@ -42,6 +43,11 @@ const Popout: React.FC<{ user: User }> = ({ user }) => { state.setQuestionIndex(0); }} preview={true} /> } + {state.exam?.module == "speaking" && state.exam.exercises.length > 0 && + { + state.setExerciseIndex(-1); + }} preview={true} /> + }
); diff --git a/src/exams/Level/index.tsx b/src/exams/Level/index.tsx index ef35f4e3..3038b617 100644 --- a/src/exams/Level/index.tsx +++ b/src/exams/Level/index.tsx @@ -75,6 +75,15 @@ export default function Level({ exam, showSolutions = false, onFinish, preview = const [showPartDivider, setShowPartDivider] = useState(typeof exam.parts[0].intro === "string" && !showSolutions); const [startNow, setStartNow] = useState(!showSolutions); + + useEffect(() => { + if (!showSolutions && exam.parts[partIndex]?.intro !== undefined && exam.parts[partIndex]?.intro !== "" && !seenParts.has(exerciseIndex)) { + setShowPartDivider(true); + setBgColor(levelBgColor); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [exerciseIndex]); + useEffect(() => { if (currentExercise === undefined && partIndex === 0 && exerciseIndex === 0) { setCurrentExercise(exam.parts[0].exercises[0]); @@ -462,7 +471,7 @@ export default function Level({ exam, showSolutions = false, onFinish, preview = { !(partIndex === 0 && questionIndex === 0 && (showPartDivider || startNow)) && - + } {(showPartDivider || startNow) ? { setShowPartDivider(false); setStartNow(false); setBgColor("bg-white"); }} /> : ( <> - {exam.parts[0].intro && ( + {exam.parts[0].intro && exam.parts.length !== 1 && ( void; } diff --git a/src/exams/Speaking.tsx b/src/exams/Speaking.tsx index 07c7352f..6b124ae3 100644 --- a/src/exams/Speaking.tsx +++ b/src/exams/Speaking.tsx @@ -1,31 +1,56 @@ -import {renderExercise} from "@/components/Exercises"; +import { renderExercise } from "@/components/Exercises"; import ModuleTitle from "@/components/Medium/ModuleTitle"; -import {renderSolution} from "@/components/Solutions"; -import {infoButtonStyle} from "@/constants/buttonStyles"; -import {UserSolution, SpeakingExam, SpeakingExercise, InteractiveSpeakingExercise} from "@/interfaces/exam"; -import useExamStore from "@/stores/examStore"; -import {defaultUserSolutions} from "@/utils/exams"; -import {countExercises} from "@/utils/moduleUtils"; -import {convertCamelCaseToReadable} from "@/utils/string"; -import {mdiArrowRight} from "@mdi/js"; +import { renderSolution } from "@/components/Solutions"; +import { infoButtonStyle } from "@/constants/buttonStyles"; +import { UserSolution, SpeakingExam, SpeakingExercise, InteractiveSpeakingExercise } from "@/interfaces/exam"; +import useExamStore, { usePersistentExamStore } from "@/stores/examStore"; +import { defaultUserSolutions } from "@/utils/exams"; +import { countExercises } from "@/utils/moduleUtils"; +import { convertCamelCaseToReadable } from "@/utils/string"; +import { mdiArrowRight } from "@mdi/js"; import Icon from "@mdi/react"; import clsx from "clsx"; -import {Fragment, useEffect, useState} from "react"; -import {toast} from "react-toastify"; +import { Fragment, useEffect, useState } from "react"; +import { toast } from "react-toastify"; +import PartDivider from "./Navigation/SectionDivider"; interface Props { exam: SpeakingExam; showSolutions?: boolean; onFinish: (userSolutions: UserSolution[]) => void; + preview?: boolean; } -export default function Speaking({exam, showSolutions = false, onFinish}: Props) { - const [speakingPromptsDone, setSpeakingPromptsDone] = useState<{id: string; amount: number}[]>([]); +export default function Speaking({ exam, showSolutions = false, onFinish, preview = false }: Props) { + const [speakingPromptsDone, setSpeakingPromptsDone] = useState<{ id: string; amount: number }[]>([]); - const {userSolutions, setUserSolutions} = useExamStore((state) => state); - const {questionIndex, setQuestionIndex} = useExamStore((state) => state); - const {hasExamEnded, setHasExamEnded} = useExamStore((state) => state); - const {exerciseIndex, setExerciseIndex} = useExamStore((state) => state); + const speakingBgColor = "bg-ielts-speaking-light"; + + const examState = useExamStore((state) => state); + const persistentExamState = usePersistentExamStore((state) => state); + + const { + userSolutions, + questionIndex, + exerciseIndex, + hasExamEnded, + setBgColor, + setUserSolutions, + setHasExamEnded, + setQuestionIndex, + setExerciseIndex, + } = !preview ? examState : persistentExamState; + + const [seenParts, setSeenParts] = useState>(new Set(showSolutions ? exam.exercises.map((_, index) => index) : [])); + const [showPartDivider, setShowPartDivider] = useState(typeof exam.exercises[0].intro === "string" && exam.exercises[0].intro !== ""); + + useEffect(() => { + if (!showSolutions && exam.exercises[exerciseIndex]?.intro !== undefined && exam.exercises[exerciseIndex]?.intro !== "" && !seenParts.has(exerciseIndex)) { + setShowPartDivider(true); + setBgColor(speakingBgColor); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [exerciseIndex]); useEffect(() => { if (hasExamEnded && exerciseIndex === -1) { @@ -38,12 +63,12 @@ export default function Speaking({exam, showSolutions = false, onFinish}: Props) const nextExercise = (solution?: UserSolution) => { scrollToTop(); if (solution) { - setUserSolutions([...userSolutions.filter((x) => x.exercise !== solution.exercise), {...solution, module: "speaking", exam: exam.id}]); + setUserSolutions([...userSolutions.filter((x) => x.exercise !== solution.exercise), { ...solution, module: "speaking", exam: exam.id }]); } if (questionIndex > 0) { const exercise = getExercise(); - setSpeakingPromptsDone((prev) => [...prev.filter((x) => x.id !== exercise.id), {id: exercise.id, amount: questionIndex}]); + setSpeakingPromptsDone((prev) => [...prev.filter((x) => x.id !== exercise.id), { id: exercise.id, amount: questionIndex }]); } setQuestionIndex(0); @@ -57,7 +82,7 @@ export default function Speaking({exam, showSolutions = false, onFinish}: Props) setHasExamEnded(false); if (solution) { - onFinish([...userSolutions.filter((x) => x.exercise !== solution.exercise), {...solution, module: "speaking", exam: exam.id}]); + onFinish([...userSolutions.filter((x) => x.exercise !== solution.exercise), { ...solution, module: "speaking", exam: exam.id }]); } else { onFinish(userSolutions); } @@ -66,7 +91,7 @@ export default function Speaking({exam, showSolutions = false, onFinish}: Props) const previousExercise = (solution?: UserSolution) => { scrollToTop(); if (solution) { - setUserSolutions([...userSolutions.filter((x) => x.exercise !== solution.exercise), {...solution, module: "speaking", exam: exam.id}]); + setUserSolutions([...userSolutions.filter((x) => x.exercise !== solution.exercise), { ...solution, module: "speaking", exam: exam.id }]); } if (exerciseIndex > 0) { @@ -85,24 +110,34 @@ export default function Speaking({exam, showSolutions = false, onFinish}: Props) return ( <> -
- acc + curr.amount, 0)} + {(showPartDivider) ? + - {exerciseIndex > -1 && - exerciseIndex < exam.exercises.length && - !showSolutions && - renderExercise(getExercise(), exam.id, nextExercise, previousExercise)} - {exerciseIndex > -1 && - exerciseIndex < exam.exercises.length && - showSolutions && - renderSolution(exam.exercises[exerciseIndex], nextExercise, previousExercise)} -
+ sectionLabel="Speaking" + defaultTitle="Speaking exam" + section={exam.exercises[exerciseIndex]} + sectionIndex={exerciseIndex} + onNext={() => { setShowPartDivider(false); setBgColor("bg-white"); setSeenParts((prev) => new Set(prev).add(exerciseIndex)) }} + /> : ( +
+ acc + curr.amount, 0)} + module="speaking" + totalExercises={countExercises(exam.exercises)} + disableTimer={showSolutions || preview} + /> + {exerciseIndex > -1 && + exerciseIndex < exam.exercises.length && + !showSolutions && + renderExercise(getExercise(), exam.id, nextExercise, previousExercise, undefined, preview)} + {exerciseIndex > -1 && + exerciseIndex < exam.exercises.length && + showSolutions && + renderSolution(exam.exercises[exerciseIndex], nextExercise, previousExercise)} +
+ )} ); } diff --git a/src/interfaces/exam.ts b/src/interfaces/exam.ts index a79212ff..cd7001e6 100644 --- a/src/interfaces/exam.ts +++ b/src/interfaces/exam.ts @@ -305,6 +305,10 @@ export interface MultipleChoiceExercise { questions: MultipleChoiceQuestion[]; userSolutions: { question: string; option: string }[]; mcVariant?: string; + passage?: { + title: string; + content: string; + } } export interface MultipleChoiceQuestion { diff --git a/src/pages/api/exam/generate/[...module].ts b/src/pages/api/exam/generate/[...module].ts index 71ca7a68..3ed3825e 100644 --- a/src/pages/api/exam/generate/[...module].ts +++ b/src/pages/api/exam/generate/[...module].ts @@ -33,7 +33,7 @@ async function post(req: NextApiRequest, res: NextApiResponse) { const queryParams = queryToURLSearchParams(req); let endpoint = queryParams.getAll('module').join("/"); if (endpoint.startsWith("level")) { - endpoint = "level" + endpoint = "level/" } const result = await axios.post(`${process.env.BACKEND_URL}/${endpoint}`, diff --git a/src/pages/api/exam/media/[...module].ts b/src/pages/api/exam/media/[...module].ts index 254ae27e..ac3e95a3 100644 --- a/src/pages/api/exam/media/[...module].ts +++ b/src/pages/api/exam/media/[...module].ts @@ -15,26 +15,45 @@ async function handler(req: NextApiRequest, res: NextApiResponse) { async function post(req: NextApiRequest, res: NextApiResponse) { if (!req.session.user) return res.status(401).json({ ok: false }); - const queryParams = queryToURLSearchParams(req); let endpoint = queryParams.getAll('module').join("/"); - const response = await axios.post( - `${process.env.BACKEND_URL}/${endpoint}/media`, - req.body, - { - headers: { - Authorization: `Bearer ${process.env.BACKEND_JWT}`, - 'Accept': 'audio/mpeg' - }, - responseType: 'arraybuffer', - } - ); + if (endpoint === "listening") { + const response = await axios.post( + `${process.env.BACKEND_URL}/${endpoint}/media`, + req.body, + { + headers: { + Authorization: `Bearer ${process.env.BACKEND_JWT}`, + Accept: 'audio/mpeg' + }, + responseType: 'arraybuffer', + } + ); - res.writeHead(200, { - 'Content-Type': 'audio/mpeg', - 'Content-Length': response.data.length - }); + res.writeHead(200, { + 'Content-Type': 'audio/mpeg', + 'Content-Length': response.data.length + }); - res.end(response.data); + res.end(response.data); + return; + } + + if (endpoint === "speaking") { + const response = await axios.post( + `${process.env.BACKEND_URL}/${endpoint}/media`, + req.body, + { + headers: { + Authorization: `Bearer ${process.env.BACKEND_JWT}`, + } + } + ); + + res.status(200).json(response.data); + return; + } + + return res.status(405).json({ "error": "Method not allowed."}); } diff --git a/src/pages/api/exam/media/poll.ts b/src/pages/api/exam/media/poll.ts new file mode 100644 index 00000000..9a98b9d6 --- /dev/null +++ b/src/pages/api/exam/media/poll.ts @@ -0,0 +1,26 @@ +import type { NextApiRequest, NextApiResponse } from "next"; +import { withIronSessionApiRoute } from "iron-session/next"; +import { sessionOptions } from "@/lib/session"; +import axios from "axios"; +export default withIronSessionApiRoute(handler, sessionOptions); + +async function handler(req: NextApiRequest, res: NextApiResponse) { + if (req.method === "GET") return get(req, res); + + return res.status(404).json({ ok: false }); +} + +async function get(req: NextApiRequest, res: NextApiResponse) { + if (!req.session.user) return res.status(401).json({ ok: false }); + + const { videoId } = req.query; + const response = await axios.get( + `${process.env.BACKEND_URL}/speaking/media/${videoId}`, + { + headers: { + Authorization: `Bearer ${process.env.BACKEND_JWT}`, + } + } + ); + return res.status(200).json(response.data); +} diff --git a/src/pages/generation.tsx b/src/pages/generation.tsx index b4d92559..add36a9c 100644 --- a/src/pages/generation.tsx +++ b/src/pages/generation.tsx @@ -44,7 +44,6 @@ export default function Generation({ user }: { user: User; }) { useEffect(() => { const fetchAvatars = async () => { const response = await axios.get("/api/exam/avatars"); - console.log(response.data); updateRoot({ speakingAvatars: response.data }); }; @@ -93,7 +92,7 @@ export default function Generation({ user }: { user: User; }) { }) } }); - + dispatch({type: 'FULL_RESET'}); }; // eslint-disable-next-line react-hooks/exhaustive-deps }, []); @@ -113,7 +112,7 @@ export default function Generation({ user }: { user: User; }) { {user && ( -

Exam Generation

+

Exam Editor

{ case 'speaking': return { ...baseSettings, - isGenerateAudio: false + isGenerateAudioOpen: false } default: return baseSettings; diff --git a/src/stores/examEditor/reducers/index.ts b/src/stores/examEditor/reducers/index.ts index 9a84aef3..a229972a 100644 --- a/src/stores/examEditor/reducers/index.ts +++ b/src/stores/examEditor/reducers/index.ts @@ -1,20 +1,26 @@ +import defaultModuleSettings from "../defaults"; import ExamEditorStore from "../types"; import { MODULE_ACTIONS, ModuleActions, moduleReducer } from "./moduleReducer"; import { SECTION_ACTIONS, SectionActions, sectionReducer } from "./sectionReducer"; +type UpdateRoot = { + type: 'UPDATE_ROOT'; + payload: { + updates: Partial + } +}; +type FullReset = { type: 'FULL_RESET' }; -export type Action = ModuleActions | SectionActions | { type: 'UPDATE_ROOT'; payload: { updates: Partial } }; +export type Action = ModuleActions | SectionActions | UpdateRoot | FullReset; export const rootReducer = ( state: ExamEditorStore, action: Action ): Partial => { - console.log(action.type); - if (MODULE_ACTIONS.includes(action.type as any)) { if (action.type === "REORDER_EXERCISES") { const updatedState = sectionReducer(state, action as SectionActions); - if (!updatedState.modules) return state; + if (!updatedState.modules) return state; return moduleReducer({ ...state, @@ -45,6 +51,19 @@ export const rootReducer = ( ...state, ...updates }; + case 'FULL_RESET': + return { + title: "", + currentModule: "reading", + speakingAvatars: [], + modules: { + reading: defaultModuleSettings("reading", 60), + writing: defaultModuleSettings("writing", 60), + speaking: defaultModuleSettings("speaking", 14), + listening: defaultModuleSettings("listening", 30), + level: defaultModuleSettings("level", 60) + }, + } default: return {}; } diff --git a/src/stores/examEditor/reorder/global.ts b/src/stores/examEditor/reorder/global.ts index 255ca251..12dbdff9 100644 --- a/src/stores/examEditor/reorder/global.ts +++ b/src/stores/examEditor/reorder/global.ts @@ -3,13 +3,6 @@ import { ModuleState } from "../types"; import ReorderResult from "./types"; const reorderFillBlanks = (exercise: FillBlanksExercise, startId: number): ReorderResult => { - let newSolutions = exercise.solutions - .sort((a, b) => parseInt(a.id) - parseInt(b.id)) - .map((solution, index) => ({ - ...solution, - id: (startId + index).toString() - })); - let idMapping = exercise.solutions .sort((a, b) => parseInt(a.id) - parseInt(b.id)) .reduce((acc, solution, index) => { @@ -17,20 +10,29 @@ const reorderFillBlanks = (exercise: FillBlanksExercise, startId: number): Reord return acc; }, {} as Record); + let newSolutions = exercise.solutions + .sort((a, b) => parseInt(a.id) - parseInt(b.id)) + .map((solution, index) => ({ + ...solution, + id: (startId + index).toString() + })); + let newText = exercise.text; Object.entries(idMapping).forEach(([oldId, newId]) => { const regex = new RegExp(`\\{\\{${oldId}\\}\\}`, 'g'); newText = newText.replace(regex, `{{${newId}}}`); }); - let newWords = exercise.words.map(word => { if (typeof word === 'string') { return word; } else if ('letter' in word && 'word' in word) { return word; - } else if ('options' in word) { - return word; + } else if ('options' in word && 'id' in word) { + return { + ...word, + id: idMapping[word.id] || word.id + }; } return word; }); @@ -50,7 +52,6 @@ const reorderFillBlanks = (exercise: FillBlanksExercise, startId: number): Reord }, lastId: startId + newSolutions.length }; - }; const reorderWriteBlanks = (exercise: WriteBlanksExercise, startId: number): ReorderResult => { diff --git a/src/stores/examEditor/types.ts b/src/stores/examEditor/types.ts index a97a377d..77494e66 100644 --- a/src/stores/examEditor/types.ts +++ b/src/stores/examEditor/types.ts @@ -22,7 +22,7 @@ export interface SectionSettings { export interface SpeakingSectionSettings extends SectionSettings { secondTopic?: string; - isGenerateAudio: boolean; + isGenerateAudioOpen: boolean; } export interface ReadingSectionSettings extends SectionSettings { @@ -34,6 +34,13 @@ export interface ListeningSectionSettings extends SectionSettings { isAudioGenerationOpen: boolean; } +export interface LevelSectionSettings extends SectionSettings { + readingDropdownOpen: boolean; + writingDropdownOpen: boolean; + speakingDropdownOpen: boolean; + listeningDropdownOpen: boolean; +} + export type Generating = "context" | "exercises" | "media" | undefined; export type Section = LevelPart | ReadingPart | ListeningPart | WritingExercise | SpeakingExercise | InteractiveSpeakingExercise; export type ExamPart = ListeningPart | ReadingPart | LevelPart;